diff --git a/qa/scenarios/config/cli-channel-picker.yaml b/qa/scenarios/config/cli-channel-picker.yaml index 569227c4df3..e5f0cbf6899 100644 --- a/qa/scenarios/config/cli-channel-picker.yaml +++ b/qa/scenarios/config/cli-channel-picker.yaml @@ -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} diff --git a/test/e2e/qa-lab/config/cli-channel-picker.test.ts b/test/e2e/qa-lab/config/cli-channel-picker.test.ts new file mode 100644 index 00000000000..19ba9cdd070 --- /dev/null +++ b/test/e2e/qa-lab/config/cli-channel-picker.test.ts @@ -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(""); + expect(sanitized).not.toContain("123456"); + }); +}); diff --git a/test/e2e/qa-lab/config/cli-channel-picker.ts b/test/e2e/qa-lab/config/cli-channel-picker.ts new file mode 100644 index 00000000000..c9fec12f82c --- /dev/null +++ b/test/e2e/qa-lab/config/cli-channel-picker.ts @@ -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, + "", + ); +} + +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 }; + }; + 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; + }); +}