mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
test(qa): exercise real cli channel picker (#99717)
This commit is contained in:
parent
66b2c5cfd8
commit
fab30c352e
3 changed files with 364 additions and 11 deletions
|
|
@ -1,23 +1,30 @@
|
|||
title: CLI channel picker evidence
|
||||
title: CLI channel picker
|
||||
|
||||
scenario:
|
||||
id: cli-channel-picker
|
||||
surface: cli-install-update-onboard-doctor
|
||||
category: cli-install-update-onboard-doctor.plugin-and-channel-setup
|
||||
coverage:
|
||||
secondary:
|
||||
primary:
|
||||
- cli.channel-picker
|
||||
objective: Link channel onboarding picker e2e coverage to CLI maturity accounting.
|
||||
objective: Drive the real interactive channel picker in an isolated OpenClaw home and verify its persisted configuration.
|
||||
successCriteria:
|
||||
- The channel picker remains usable with broken sibling registry diagnostics.
|
||||
- Hidden setup channels are omitted from selectable choices.
|
||||
- Configured and disabled channel states are presented with the expected actions.
|
||||
- The producer launches the compiled OpenClaw CLI through a real PTY with an isolated OPENCLAW_HOME.
|
||||
- The interaction selects Telegram, enters a test token, finishes the picker, and exits successfully.
|
||||
- The written config enables the Telegram plugin and channel, preserves the default group mention gate, and records configure wizard metadata.
|
||||
- Missing final output, a stalled picker, non-zero exit, or mismatched configuration produces failed evidence.
|
||||
docsRefs:
|
||||
- docs/channels/qa-channel.md
|
||||
- docs/channels/telegram.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/commands/onboard-channels.e2e.test.ts
|
||||
- test/e2e/qa-lab/config/cli-channel-picker.ts
|
||||
- scripts/e2e/lib/run-with-pty.mjs
|
||||
- src/flows/channel-setup.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/commands/onboard-channels.e2e.test.ts
|
||||
summary: Vitest e2e coverage for channel onboarding picker behavior.
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/config/cli-channel-picker.ts
|
||||
summary: Spawns the real channel picker under an isolated home, drives it through a PTY, asserts the config write, and emits QA evidence.
|
||||
timeoutMs: 180000
|
||||
args:
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
|
|
|
|||
46
test/e2e/qa-lab/config/cli-channel-picker.test.ts
Normal file
46
test/e2e/qa-lab/config/cli-channel-picker.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// CLI channel picker producer tests cover its unique config and redaction assertions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cliChannelPickerTestApi } from "./cli-channel-picker.js";
|
||||
|
||||
function validPickerConfig() {
|
||||
return {
|
||||
plugins: { entries: { telegram: { enabled: true } } },
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
botToken: cliChannelPickerTestApi.testBotToken,
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
wizard: { lastRunCommand: "configure", lastRunMode: "local" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("CLI channel picker producer", () => {
|
||||
it("accepts only the expected isolated Telegram configuration", () => {
|
||||
expect(cliChannelPickerTestApi.assertPickerConfig(validPickerConfig())).toMatchObject({
|
||||
channelEnabled: true,
|
||||
defaultGroupRequiresMention: true,
|
||||
pluginEnabled: true,
|
||||
selectedChannel: "telegram",
|
||||
wizardCommand: "configure",
|
||||
wizardMode: "local",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
cliChannelPickerTestApi.assertPickerConfig({
|
||||
...validPickerConfig(),
|
||||
channels: { telegram: { enabled: true, botToken: "wrong" } },
|
||||
}),
|
||||
).toThrow("entered Telegram bot token");
|
||||
});
|
||||
|
||||
it("removes the synthetic token and ANSI control sequences from evidence", () => {
|
||||
const sanitized = cliChannelPickerTestApi.sanitizePickerTranscript(
|
||||
`\u001b[31m${cliChannelPickerTestApi.testBotToken}\u001b[0m`,
|
||||
);
|
||||
|
||||
expect(sanitized).toBe("<test-token>");
|
||||
expect(sanitized).not.toContain("123456");
|
||||
});
|
||||
});
|
||||
300
test/e2e/qa-lab/config/cli-channel-picker.ts
Normal file
300
test/e2e/qa-lab/config/cli-channel-picker.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// CLI channel picker producer drives the real onboarding prompt in an isolated home.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { stripAnsiSequences } from "../../../../packages/terminal-core/src/ansi.js";
|
||||
import { createQaScriptEvidenceWriter } from "../runtime/script-evidence.js";
|
||||
|
||||
const SCENARIO_ID = "cli-channel-picker";
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/config/cli-channel-picker.ts";
|
||||
const TEST_BOT_TOKEN = "123456:QA_CHANNEL_PICKER_TEST_TOKEN_ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
type ProducerOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
function formatErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sanitizePickerTranscript(transcript: string) {
|
||||
return stripAnsiSequences(transcript).replaceAll(
|
||||
/123456(?:(?::|…)[A-Za-z0-9_…-]*)?/gu,
|
||||
"<test-token>",
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, label: string) {
|
||||
if (!/^[1-9]\d*$/.test(value)) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${label} must be a safe integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): ProducerOptions {
|
||||
let artifactBase: string | undefined;
|
||||
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--artifact-base") {
|
||||
artifactBase = args[++index];
|
||||
} else if (arg === "--timeout-ms") {
|
||||
timeoutMs = parsePositiveInt(args[++index] ?? "", "--timeout-ms");
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return { artifactBase: path.resolve(artifactBase), repoRoot: process.cwd(), timeoutMs };
|
||||
}
|
||||
|
||||
function buildCliStartup(repoRoot: string) {
|
||||
const result = spawnSync(process.execPath, ["scripts/build-all.mjs", "cliStartup"], {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`cliStartup build failed with exit code ${String(result.status)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRealPicker(options: ProducerOptions, openclawHome: string) {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + options.timeoutMs;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/e2e/lib/run-with-pty.mjs",
|
||||
path.join(openclawHome, "picker.raw.log"),
|
||||
process.execPath,
|
||||
"openclaw.mjs",
|
||||
"configure",
|
||||
"--section",
|
||||
"channels",
|
||||
],
|
||||
{
|
||||
cwd: options.repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: undefined,
|
||||
COLUMNS: "120",
|
||||
HOME: openclawHome,
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LINES: "40",
|
||||
OPENCLAW_CONFIG_PATH: undefined,
|
||||
OPENCLAW_HOME: openclawHome,
|
||||
OPENCLAW_LOCALE: "en",
|
||||
OPENCLAW_STATE_DIR: undefined,
|
||||
TELEGRAM_BOT_TOKEN: undefined,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let output = "";
|
||||
let exit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
let spawnError: Error | undefined;
|
||||
child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
||||
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
||||
child.on("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
exit = { code, signal };
|
||||
});
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
const waitFor = async (matcher: RegExp, fromIndex = 0) => {
|
||||
while (!matcher.test(stripAnsiSequences(output.slice(fromIndex)))) {
|
||||
if (spawnError) {
|
||||
throw spawnError;
|
||||
}
|
||||
if (exit) {
|
||||
throw new Error(
|
||||
`picker exited before output ${matcher}: code=${String(exit.code)} signal=${String(exit.signal)}`,
|
||||
);
|
||||
}
|
||||
if (remainingMs() === 0) {
|
||||
throw new Error(`picker timed out waiting for output: ${matcher}`);
|
||||
}
|
||||
await delay(Math.min(25, remainingMs()));
|
||||
}
|
||||
};
|
||||
const send = (input: string) => child.stdin.write(input);
|
||||
const sendAndWait = async (input: string, matcher: RegExp) => {
|
||||
const checkpoint = output.length;
|
||||
send(input);
|
||||
await waitFor(matcher, checkpoint);
|
||||
};
|
||||
|
||||
try {
|
||||
await waitFor(/Channel setup[\s\S]*Add or update channels/u);
|
||||
await sendAndWait("\r", /Select a channel/u);
|
||||
|
||||
for (let attempt = 0; attempt < 64; attempt += 1) {
|
||||
const checkpoint = output.length;
|
||||
send("\u001b[B");
|
||||
await waitFor(/●\s+[^\r\n]+/u, checkpoint);
|
||||
if (/●\s+Telegram \(Bot API\)/u.test(stripAnsiSequences(output.slice(checkpoint)))) {
|
||||
break;
|
||||
}
|
||||
if (attempt === 63) {
|
||||
throw new Error("Telegram was not reachable from the real channel picker");
|
||||
}
|
||||
}
|
||||
|
||||
await sendAndWait("\r", /How do you want to provide this Telegram bot token\?/u);
|
||||
await sendAndWait("\r", /◆\s+Enter Telegram bot token[\s\S]*│\s+_/u);
|
||||
await sendAndWait(`${TEST_BOT_TOKEN}\r`, /Telegram DM access warning[\s\S]*Select a channel/u);
|
||||
await sendAndWait("\u001b[A", /●\s+Finished \(Done\)/u);
|
||||
await sendAndWait("\r", /Configure DM access policies now\?/u);
|
||||
await sendAndWait("\r", /Configuration updated\./u);
|
||||
|
||||
while (!exit) {
|
||||
if (remainingMs() === 0) {
|
||||
throw new Error(`picker timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
await delay(Math.min(25, remainingMs()));
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
throw new Error(
|
||||
`picker exited unsuccessfully: code=${String(exit.code)} signal=${String(exit.signal)}`,
|
||||
);
|
||||
}
|
||||
return { durationMs: Math.max(1, Date.now() - startedAt), transcript: output };
|
||||
} catch (error) {
|
||||
if (!exit) {
|
||||
child.kill("SIGTERM");
|
||||
const cleanupDeadline = Date.now() + 5_000;
|
||||
while (!exit && Date.now() < cleanupDeadline) {
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertPickerConfig(config: unknown) {
|
||||
const value = config as {
|
||||
channels?: {
|
||||
telegram?: { botToken?: string; enabled?: boolean; groups?: Record<string, unknown> };
|
||||
};
|
||||
plugins?: { entries?: { telegram?: { enabled?: boolean } } };
|
||||
wizard?: { lastRunCommand?: string; lastRunMode?: string };
|
||||
};
|
||||
const telegram = value.channels?.telegram;
|
||||
const defaultGroup = telegram?.groups?.["*"] as { requireMention?: boolean } | undefined;
|
||||
if (value.plugins?.entries?.telegram?.enabled !== true) {
|
||||
throw new Error("picker did not enable the Telegram plugin");
|
||||
}
|
||||
if (telegram?.enabled !== true) {
|
||||
throw new Error("picker did not enable the Telegram channel");
|
||||
}
|
||||
if (telegram.botToken !== TEST_BOT_TOKEN) {
|
||||
throw new Error("picker did not write the entered Telegram bot token");
|
||||
}
|
||||
if (defaultGroup?.requireMention !== true) {
|
||||
throw new Error("picker did not write the Telegram default mention gate");
|
||||
}
|
||||
if (value.wizard?.lastRunCommand !== "configure" || value.wizard.lastRunMode !== "local") {
|
||||
throw new Error("picker did not persist configure wizard metadata");
|
||||
}
|
||||
return {
|
||||
channelEnabled: true,
|
||||
configPath: ".openclaw/openclaw.json",
|
||||
defaultGroupRequiresMention: true,
|
||||
pluginEnabled: true,
|
||||
selectedChannel: "telegram",
|
||||
wizardCommand: "configure",
|
||||
wizardMode: "local",
|
||||
};
|
||||
}
|
||||
|
||||
function createEvidenceWriter(options: ProducerOptions) {
|
||||
return createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "cli-channel-picker.log",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: SCENARIO_ID,
|
||||
title: "CLI channel picker",
|
||||
sourcePath: SOURCE_PATH,
|
||||
primaryCoverageIds: ["cli.channel-picker"],
|
||||
docsRefs: ["docs/channels/telegram.md", "docs/help/testing.md"],
|
||||
codeRefs: [SOURCE_PATH, "scripts/e2e/lib/run-with-pty.mjs", "src/flows/channel-setup.ts"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCliChannelPickerProducer(options: ProducerOptions) {
|
||||
const startedAt = Date.now();
|
||||
const writer = createEvidenceWriter(options);
|
||||
const workDir = path.join(options.artifactBase, ".work");
|
||||
const openclawHome = path.join(workDir, "openclaw-home");
|
||||
|
||||
try {
|
||||
await fs.rm(workDir, { force: true, recursive: true });
|
||||
await fs.mkdir(openclawHome, { recursive: true });
|
||||
buildCliStartup(options.repoRoot);
|
||||
const result = await runRealPicker(options, openclawHome);
|
||||
writer.appendLog(sanitizePickerTranscript(result.transcript));
|
||||
const configPath = path.join(openclawHome, ".openclaw", "openclaw.json");
|
||||
const assertion = assertPickerConfig(JSON.parse(await fs.readFile(configPath, "utf8")));
|
||||
await fs.writeFile(
|
||||
path.join(options.artifactBase, "config-assertion.json"),
|
||||
`${JSON.stringify(assertion, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return await writer.write({
|
||||
artifacts: [{ kind: "config-assertion", filePath: "config-assertion.json" }],
|
||||
details: "real channel picker completed and persisted isolated Telegram configuration",
|
||||
durationMs: result.durationMs,
|
||||
status: "pass",
|
||||
});
|
||||
} catch (error) {
|
||||
const details = formatErrorMessage(error);
|
||||
writer.appendLog(`\nfail: ${details}\n`);
|
||||
return await writer.write({
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(workDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const cliChannelPickerTestApi = {
|
||||
assertPickerConfig,
|
||||
sanitizePickerTranscript,
|
||||
testBotToken: TEST_BOT_TOKEN,
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runCliChannelPickerProducer(parseOptions(process.argv.slice(2)))
|
||||
.then((evidence) => {
|
||||
console.log(`CLI channel picker status: ${evidence.entries[0]?.result.status}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue