diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml new file mode 100644 index 0000000..6ce312d --- /dev/null +++ b/.github/workflows/codex-pr-review.yml @@ -0,0 +1,254 @@ +name: Codex PR Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + evidence: + name: Collect review evidence + if: github.event_name == 'pull_request' && !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + unit_tests: ${{ steps.unit_tests.outcome }} + interactive_tests: ${{ steps.interactive_tests.outcome }} + steps: + - name: Checkout PR merge commit + uses: actions/checkout@v6 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run unit tests + id: unit_tests + continue-on-error: true + shell: bash + run: | + set -o pipefail + pnpm test 2>&1 | tee codex-unit-test.log + + - name: Run interactive terminal check + id: interactive_tests + continue-on-error: true + shell: bash + env: + CODEX_INTERACTIVE_REPORT: codex-interactive-report.md + run: | + set -o pipefail + pnpm test:interactive 2>&1 | tee codex-interactive-test.log + + - name: Upload review evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: codex-review-evidence + path: | + codex-unit-test.log + codex-interactive-test.log + codex-interactive-report.md + if-no-files-found: ignore + + review: + name: Review PR + if: github.event_name == 'pull_request' && !github.event.pull_request.draft + needs: evidence + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + final_message: ${{ steps.codex.outputs.final-message }} + steps: + - name: Checkout PR merge commit + uses: actions/checkout@v6 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Fetch PR base and head + run: | + git fetch --no-tags origin \ + ${{ github.event.pull_request.base.ref }} \ + +refs/pull/${{ github.event.pull_request.number }}/head + + - name: Download review evidence + uses: actions/download-artifact@v6 + with: + name: codex-review-evidence + + - name: Run Codex PR review + id: codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.QNAIGC_API_KEY }} + responses-api-endpoint: https://api.qnaigc.com/bypass/openai/v1/responses + model: openai/gpt-5.5 + effort: medium + sandbox: read-only + safety-strategy: read-only + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Before reviewing, read CLAUDE.md from the checked-out workspace and follow its project-specific instructions. + + 请使用中文完成整段评审,包括结论、问题、验证说明和无问题时的说明。 + 只评审这个 PR 引入的变更,不要修改文件。 + 优先关注具体 bug、行为回归、安全风险、数据丢失风险、并发问题、缺失测试,以及 CLI 交互体验问题。 + + 这个仓库是交互式 CLI 工具。请先根据 PR diff 判断本次改动影响了哪些命令、向导、菜单、工具适配器或配置写入路径,再重点审阅对应交互场景是否正常。不要只检查 Codex 工具,必须从整个项目角度评估交互风险。 + + 请重点审阅 PR 是否可能导致以下问题: + - 进入菜单后菜单一直闪烁或重复刷新。 + - 方向键无法上下选择选项。 + - 回车无法确认选项。 + - 菜单卡住、无法退出,或终端输出不可读。 + - 配置写入污染真实 HOME,而不是测试隔离目录。 + + Workflow 已经在你之前运行了测试,请读取这些文件作为证据: + - codex-unit-test.log + - codex-interactive-test.log + - codex-interactive-report.md + + codex-interactive-report.md 会列出实际覆盖的真实伪终端场景。交互检查脚本会从当前构建产物中的 toolManager 动态发现已注册工具,并为主菜单、工具选择器以及每个工具菜单生成键盘导航场景。 + + 如果 PR 改动了某个具体工具、菜单、向导 flow、i18n 文案、配置模型状态或终端渲染逻辑,请优先检查报告中对应场景的终端 transcript,并结合 diff 判断是否存在用户可见交互回归。 + + 测试步骤结果: + - pnpm test: ${{ needs.evidence.outputs.unit_tests }} + - pnpm test:interactive: ${{ needs.evidence.outputs.interactive_tests }} + + Use this comparison range: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + git diff --stat ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + PR 标题: + ${{ github.event.pull_request.title }} + + PR 描述: + ---- + ${{ github.event.pull_request.body }} + + 返回一段简洁的中文 Markdown 评审,格式要求: + - 顶部第一行必须且只能使用以下结论之一: + - 结论:通过 + - 结论:需要关注 + - 先列出发现的问题,按严重程度排序,尽可能包含文件路径和行号。 + - 如果交互测试失败,请说明失败的用户可见表现,以及它和本次 PR 改动的关系。 + - 最后包含一个简短的测试/验证小节,必须提到 pnpm test 和 pnpm test:interactive 的结果。 + + feedback: + name: Comment and label PR + if: github.event_name == 'pull_request' && !github.event.pull_request.draft && always() + needs: review + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + issues: write + pull-requests: write + steps: + - name: Comment and label + uses: actions/github-script@v8 + env: + CODEX_FINAL_MESSAGE: ${{ needs.review.outputs.final_message }} + CODEX_REVIEW_RESULT: ${{ needs.review.result }} + with: + github-token: ${{ github.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = context.payload.pull_request.number; + const result = process.env.CODEX_REVIEW_RESULT; + const message = process.env.CODEX_FINAL_MESSAGE || ""; + + const attentionLabel = "codex: needs attention"; + const failedLabel = "codex: failed"; + const managedLabels = [attentionLabel, failedLabel]; + + const existing = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number, + }); + const codexLabels = existing + .filter((label) => managedLabels.includes(label.name)) + .map((label) => label.name); + for (const name of codexLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + let selected = failedLabel; + if (result === "success") { + selected = /^(结论:通过|Verdict:\s*clean\b)/im.test(message) + ? "" + : attentionLabel; + } + if (selected) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: selected, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + owner, + repo, + name: selected, + color: selected === failedLabel ? "d73a4a" : "fbca04", + description: selected === failedLabel + ? "Codex PR review failed to generate usable feedback" + : "Codex PR review found issues that need attention", + }); + } + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [selected], + }); + } + + const body = result === "success" && message.trim() + ? `## Codex 评审\n\n${message.trim()}` + : [ + "## Codex 评审", + "", + "Codex 评审未生成反馈,请查看 workflow 日志了解详情。", + ].join("\n"); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); diff --git a/.gitignore b/.gitignore index df76046..1458f47 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ # Logs *.log npm-debug.log* +codex-interactive-report.md # Runtime data *.pid diff --git a/package.json b/package.json index c1965b0..153fedd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "tsc && rm -rf dist/locales && cp -r src/locales dist/locales", "test": "pnpm build && node --test tests/*.test.mjs", + "test:interactive": "pnpm build && node scripts/codex-interactive-check.mjs", "dev": "tsc --watch", "start": "node dist/cli.js", "clean": "rm -rf dist", @@ -58,6 +59,7 @@ "@types/inquirer": "^9.0.7", "@types/js-yaml": "^4.0.9", "@types/node": "^20.11.0", + "node-pty": "^1.1.0", "typescript": "^5.3.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c19353..ed68d6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@types/node': specifier: ^20.11.0 version: 20.19.35 + node-pty: + specifier: ^1.1.0 + version: 1.1.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -278,6 +281,12 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -595,6 +604,12 @@ snapshots: mute-stream@2.0.0: {} + node-addon-api@7.1.1: {} + + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 diff --git a/scripts/codex-interactive-check.mjs b/scripts/codex-interactive-check.mjs new file mode 100644 index 0000000..b9c1905 --- /dev/null +++ b/scripts/codex-interactive-check.mjs @@ -0,0 +1,582 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_REPORT_PATH = 'codex-interactive-report.md'; +const DISABLE_FETCH_PRELOAD = 'disable-fetch.cjs'; +const DISABLE_FETCH_PRELOAD_SOURCE = [ + "globalThis.fetch = async () => {", + " throw new Error('Network fetch is disabled during interactive checks.');", + '};', + '', +].join('\n'); + +export function stripAnsi(value) { + return value.replace(/\u001B(?:[@-Z\\-_a-z]|\[[0-?]*[ -/]*[@-~])/g, ''); +} + +export function analyzeTranscript(transcript, options = {}) { + const clean = stripAnsi(transcript); + const failures = []; + const expectedPatterns = options.expectedPatterns ?? []; + + for (const expected of expectedPatterns) { + if (!clean.includes(expected)) { + failures.push(`Expected terminal output did not include "${expected}".`); + } + } + + const pattern = options.repeatedRenderPattern; + const count = pattern ? clean.split(pattern).length - 1 : 0; + const maxRepeatedRenders = options.maxRepeatedRenders ?? Number.POSITIVE_INFINITY; + if (pattern && count > maxRepeatedRenders) { + failures.push( + `Repeated render pattern "${pattern}" appeared ${count} times, exceeding threshold ${maxRepeatedRenders}.`, + ); + } + + return { + ok: failures.length === 0, + failures, + repeatedRenderCount: count, + summary: failures.length ? failures.join('\n') : 'Interactive terminal check passed.', + }; +} + +export function updateTranscriptCursor(transcript, cursor, expected) { + const clean = stripAnsi(transcript.join('')); + const slice = clean.slice(cursor); + const index = slice.indexOf(expected); + if (index === -1) { + return null; + } + return cursor + index + expected.length; +} + +export function hasSelectedOption(transcript, optionText) { + return updateSelectedOptionCursor([transcript], 0, optionText) !== null; +} + +export function updateSelectedOptionCursor(transcript, cursor, optionText) { + const clean = stripAnsi(transcript.join('')); + const lines = clean.slice(cursor).match(/[^\n]*(?:\n|$)/g) ?? []; + let offset = cursor; + + for (const lineWithBreak of lines) { + if (!lineWithBreak) break; + const line = lineWithBreak.endsWith('\n') ? lineWithBreak.slice(0, -1) : lineWithBreak; + if (/^\s*(❯|>)\s/.test(line) && line.includes(optionText)) { + return offset + line.length; + } + offset += lineWithBreak.length; + } + + return null; +} + +export function hasScenarioExitStep(scenario) { + return scenario.steps.some((step) => step.expectExit); +} + +export function buildCliArgs(scenarioArgs, preloadPath) { + return ['--require', preloadPath, 'dist/cli.js', ...scenarioArgs]; +} + +export function resolveNodePtySpawnHelperPath(packageMainPath, platform = process.platform, arch = process.arch) { + return path.join(path.dirname(packageMainPath), '..', 'prebuilds', `${platform}-${arch}`, 'spawn-helper'); +} + +function ensureNodePtySpawnHelperExecutable() { + const require = createRequire(import.meta.url); + try { + const helperPath = resolveNodePtySpawnHelperPath(require.resolve('node-pty')); + if (fs.existsSync(helperPath)) { + fs.chmodSync(helperPath, 0o755); + } + } catch { + // 只读 node_modules 或受限 CI 环境下 chmod 失败时,继续交互检查。 + } +} + +async function runScenario(pty, scenario, homeDir) { + const transcript = []; + const preloadPath = ensureDisableFetchPreload(homeDir); + const processState = { exited: false, code: null, signal: null }; + let exitSubscription; + const child = pty.spawn(process.execPath, buildCliArgs(scenario.args, preloadPath), { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + LANG: 'zh_CN.UTF-8', + TERM: 'xterm-256color', + NO_COLOR: '1', + }, + }); + + child.onData((data) => { + transcript.push(data); + }); + exitSubscription = child.onExit(({ exitCode, signal }) => { + processState.exited = true; + processState.code = exitCode; + processState.signal = signal; + }); + + let cursor = 0; + let exitResult = { timedOut: false, code: null, signal: null }; + let scenarioError = null; + + try { + try { + for (const step of scenario.steps) { + if (processState.exited && !step.expectExit) { + throw new Error(`Process exited before scenario step completed with code ${processState.code ?? 'null'}.`); + } + if (step.waitFor) { + cursor = await waitForNewTranscript( + transcript, + cursor, + step.waitFor, + step.timeoutMs || DEFAULT_TIMEOUT_MS, + processState, + ); + } + if (step.input) { + child.write(step.input); + } + if (step.waitAfter) { + cursor = await waitForNewTranscript( + transcript, + cursor, + step.waitAfter, + step.timeoutMs || DEFAULT_TIMEOUT_MS, + processState, + ); + } + if (step.pauseMs) { + await delay(step.pauseMs); + } + if (step.expectSelected) { + cursor = await waitForSelectedOption( + transcript, + cursor, + step.expectSelected, + step.timeoutMs || DEFAULT_TIMEOUT_MS, + processState, + ); + } + if (step.expectExit) { + exitResult = await waitForExitOrKill(child, processState, step.timeoutMs || scenario.exitTimeoutMs || 5000); + cursor = stripAnsi(transcript.join('')).length; + } + } + if (!scenario.steps.some((step) => step.expectExit)) { + exitResult = await waitForExitOrKill(child, processState, scenario.exitTimeoutMs || 5000); + } + } catch (error) { + scenarioError = error; + } + } finally { + exitSubscription?.dispose(); + if (!processState.exited) { + safeKill(child); + } + } + + if (scenarioError) { + return createScenarioErrorResult(scenario, transcript, scenarioError); + } + + const output = transcript.join(''); + const analysis = analyzeTranscript(output, scenario.analysis); + if (exitResult.timedOut) { + analysis.failures.push(`Process did not exit within ${scenario.exitTimeoutMs || 5000}ms after scenario input completed.`); + analysis.ok = false; + analysis.summary = analysis.failures.join('\n'); + } + if (exitResult.code !== null && exitResult.code !== 0) { + analysis.failures.push(`Process exited with non-zero code ${exitResult.code}.`); + analysis.ok = false; + analysis.summary = analysis.failures.join('\n'); + } + if (exitResult.signal) { + analysis.failures.push(`Process exited after receiving signal ${exitResult.signal}.`); + analysis.ok = false; + analysis.summary = analysis.failures.join('\n'); + } + return { + name: scenario.name, + output, + analysis, + }; +} + +export function createScenarioErrorResult(scenario, transcript, error) { + const output = transcript.join(''); + const analysis = analyzeTranscript(output, scenario.analysis); + const message = error instanceof Error ? error.message : String(error); + analysis.failures.push(message); + analysis.ok = false; + analysis.summary = analysis.failures.join('\n'); + return { + name: scenario.name, + output, + analysis, + }; +} + +function waitForSelectedOption(transcript, cursor, optionText, timeoutMs, processState = null) { + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + const nextCursor = updateSelectedOptionCursor(transcript, cursor, optionText); + if (nextCursor !== null) { + clearInterval(timer); + resolve(nextCursor); + return; + } + if (processState?.exited) { + clearInterval(timer); + reject(new Error(`Process exited before selecting option "${optionText}" with code ${processState.code ?? 'null'}.`)); + return; + } + if (Date.now() - startedAt > timeoutMs) { + clearInterval(timer); + reject(new Error(`Timed out waiting for selected option after cursor ${cursor}: ${optionText}`)); + } + }, 50); + }); +} + +function waitForNewTranscript(transcript, cursor, expected, timeoutMs, processState = null) { + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + const nextCursor = updateTranscriptCursor(transcript, cursor, expected); + if (nextCursor !== null) { + clearInterval(timer); + resolve(nextCursor); + return; + } + if (processState?.exited) { + clearInterval(timer); + reject(new Error(`Process exited before terminal output appeared with code ${processState.code ?? 'null'}: ${expected}`)); + return; + } + if (Date.now() - startedAt > timeoutMs) { + clearInterval(timer); + reject(new Error(`Timed out waiting for new terminal output after cursor ${cursor}: ${expected}`)); + } + }, 50); + }); +} + +function waitForExitOrKill(child, processState, timeoutMs) { + return new Promise((resolve) => { + if (processState.exited) { + resolve({ timedOut: false, code: processState.code, signal: processState.signal }); + return; + } + + let exitSubscription; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + safeKill(child); + settled = true; + exitSubscription?.dispose(); + resolve({ timedOut: true, code: null, signal: null }); + } + }, timeoutMs); + + exitSubscription = child.onExit(({ exitCode, signal }) => { + if (!settled) { + clearTimeout(timer); + settled = true; + exitSubscription?.dispose(); + resolve({ timedOut: false, code: exitCode, signal }); + } + }); + }); +} + +function safeKill(child) { + try { + child.kill(); + } catch { + // 进程可能已经退出,node-pty 在这种情况下可能抛 ESRCH。 + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createHomeFixtureConfig() { + return [ + 'lang: zh_CN', + 'endpoint: china', + 'api_key: qiniu-test-token', + 'claudeCode:', + ' haikuModel: claude-3-5-haiku-latest', + ' sonnetModel: claude-sonnet-4-5', + ' opusModel: claude-opus-4-5', + ' subagentModel: claude-sonnet-4-5', + ' useDefaultModels: false', + 'codexModel: openai/gpt-5.5', + 'codeBuddyModels:', + ' - openai/gpt-5.5', + 'workbuddyModels:', + ' - openai/gpt-5.5', + 'hermesModel: openai/gpt-5.5', + '', + ].join('\n'); +} + +function createHomeFixture() { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coding-helper-interactive-')); + try { + const configDir = path.join(homeDir, '.coding-helper'); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(configDir, 'config.yaml'), createHomeFixtureConfig(), { mode: 0o600 }); + return homeDir; + } catch (error) { + fs.rmSync(homeDir, { recursive: true, force: true }); + throw error; + } +} + +function ensureDisableFetchPreload(homeDir) { + const preloadPath = path.join(homeDir, DISABLE_FETCH_PRELOAD); + if (!fs.existsSync(preloadPath)) { + fs.writeFileSync(preloadPath, DISABLE_FETCH_PRELOAD_SOURCE, { mode: 0o600 }); + } + return preloadPath; +} + +const ZH_LOCALE = JSON.parse(fs.readFileSync(new URL('../src/locales/zh_CN.json', import.meta.url), 'utf8')); +const MAIN_MENU_OPTIONS = [ + ZH_LOCALE.menu_configure_language, + ZH_LOCALE.menu_configure_endpoint, + ZH_LOCALE.menu_configure_apikey, + ZH_LOCALE.menu_configure_tools, + ZH_LOCALE.menu_clear_config, + ZH_LOCALE.menu_update_helper, + ZH_LOCALE.menu_exit, +]; +const TOOL_MENU_OPTIONS = [ + ZH_LOCALE.tool_configure_models, + ZH_LOCALE.tool_load_config, + ZH_LOCALE.tool_unload_config, + ZH_LOCALE.tool_launch, + ZH_LOCALE.tool_update, + ZH_LOCALE.tool_view_config, + ZH_LOCALE.tool_back, +]; + +function downToOption(options, fromOption, toOption) { + const fromIndex = options.indexOf(fromOption); + const toIndex = options.indexOf(toOption); + if (fromIndex === -1 || toIndex === -1 || toIndex < fromIndex) { + throw new Error(`Cannot navigate from "${fromOption}" to "${toOption}".`); + } + return '\u001B[B'.repeat(toIndex - fromIndex); +} + +function createToolMenuScenario(tool) { + const title = tool.displayName; + return { + name: `${title} tool menu keyboard navigation`, + args: ['enter', tool.name], + steps: [ + { waitFor: ZH_LOCALE.tool_configure_models, input: '\u001B[B', expectSelected: ZH_LOCALE.tool_load_config }, + { input: '\u001B[A', expectSelected: ZH_LOCALE.tool_configure_models }, + { + input: downToOption(TOOL_MENU_OPTIONS, ZH_LOCALE.tool_configure_models, ZH_LOCALE.tool_back), + expectSelected: ZH_LOCALE.tool_back, + }, + { input: '\r', expectExit: true }, + ], + analysis: { + repeatedRenderPattern: ZH_LOCALE.menu_select_action, + maxRepeatedRenders: 20, + expectedPatterns: [ + ZH_LOCALE.tool_menu_title.replace('{{tool}}', title), + ZH_LOCALE.tool_configure_models, + ZH_LOCALE.tool_load_config, + ZH_LOCALE.tool_back, + ], + }, + }; +} + +export function buildScenarios(tools) { + const toolNames = tools.map((tool) => tool.displayName); + const toolSelectorTarget = tools[1] || tools[0]; + const toolSelectorSteps = toolSelectorTarget + ? [ + { + waitFor: ZH_LOCALE.menu_main_title, + input: `${downToOption(MAIN_MENU_OPTIONS, ZH_LOCALE.menu_configure_language, ZH_LOCALE.menu_configure_tools)}\r`, + pauseMs: 200, + }, + { waitFor: ZH_LOCALE.menu_configure_tools, pauseMs: 200 }, + ] + : [ + { + waitFor: ZH_LOCALE.menu_main_title, + input: downToOption(MAIN_MENU_OPTIONS, ZH_LOCALE.menu_configure_language, ZH_LOCALE.menu_exit), + expectSelected: ZH_LOCALE.menu_exit, + }, + { input: '\r', expectExit: true }, + ]; + + if (toolSelectorTarget) { + const downCount = tools.findIndex((tool) => tool.name === toolSelectorTarget.name); + toolSelectorSteps.push({ + input: '\u001B[B'.repeat(Math.max(0, downCount)), + pauseMs: 200, + expectSelected: toolSelectorTarget.displayName, + }); + toolSelectorSteps.push({ + input: '\r', + waitAfter: ZH_LOCALE.tool_configure_models, + }); + toolSelectorSteps.push({ + input: downToOption(TOOL_MENU_OPTIONS, ZH_LOCALE.tool_configure_models, ZH_LOCALE.tool_back), + expectSelected: ZH_LOCALE.tool_back, + }); + toolSelectorSteps.push({ + input: '\r', + waitAfter: ZH_LOCALE.menu_main_title, + }); + toolSelectorSteps.push({ + input: downToOption(MAIN_MENU_OPTIONS, ZH_LOCALE.menu_configure_language, ZH_LOCALE.menu_exit), + expectSelected: ZH_LOCALE.menu_exit, + }); + toolSelectorSteps.push({ + input: '\r', + expectExit: true, + }); + } + + return [ + { + name: 'main menu keyboard navigation', + args: ['enter'], + steps: [ + { waitFor: ZH_LOCALE.menu_main_title, input: '\u001B[B', expectSelected: ZH_LOCALE.menu_configure_endpoint }, + { input: '\u001B[A', expectSelected: ZH_LOCALE.menu_configure_language }, + { + input: downToOption(MAIN_MENU_OPTIONS, ZH_LOCALE.menu_configure_language, ZH_LOCALE.menu_exit), + expectSelected: ZH_LOCALE.menu_exit, + }, + { input: '\r', expectExit: true }, + ], + analysis: { + repeatedRenderPattern: ZH_LOCALE.menu_select_action, + maxRepeatedRenders: 20, + expectedPatterns: [ + ZH_LOCALE.menu_main_title, + ZH_LOCALE.menu_configure_language, + ZH_LOCALE.menu_configure_endpoint, + ZH_LOCALE.menu_exit, + ], + }, + }, + { + name: 'tool selector keyboard navigation', + args: ['enter'], + steps: toolSelectorSteps, + analysis: { + repeatedRenderPattern: ZH_LOCALE.menu_select_action, + maxRepeatedRenders: 30, + expectedPatterns: [ZH_LOCALE.menu_configure_tools, ...toolNames], + }, + }, + ...tools.map((tool) => createToolMenuScenario(tool)), + ]; +} + +export function formatReport(results) { + const lines = ['# Codex Interactive Terminal Check', '']; + for (const result of results) { + lines.push(`## ${result.name}`, ''); + lines.push(result.analysis.ok ? 'Status: passed' : 'Status: failed', ''); + if (result.analysis.failures.length) { + lines.push('Failures:'); + for (const failure of result.analysis.failures) { + lines.push(`- ${failure}`); + } + lines.push(''); + } + lines.push(`Repeated render count: ${result.analysis.repeatedRenderCount}`, ''); + lines.push('Terminal transcript:'); + lines.push('```text'); + lines.push(stripAnsi(result.output).trimEnd()); + lines.push('```', ''); + } + return `${lines.join('\n')}\n`; +} + +async function main() { + ensureNodePtySpawnHelperExecutable(); + const homeDir = createHomeFixture(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const results = []; + + try { + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + const { default: pty } = await import('node-pty'); + const { toolManager } = await import('../dist/lib/tool-manager.js'); + const tools = toolManager.getAll().map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + })); + + for (const scenario of buildScenarios(tools)) { + results.push(await runScenario(pty, scenario, homeDir)); + } + } finally { + restoreEnvValue('HOME', originalHome); + restoreEnvValue('USERPROFILE', originalUserProfile); + fs.rmSync(homeDir, { recursive: true, force: true }); + } + + const reportPath = process.env.CODEX_INTERACTIVE_REPORT || DEFAULT_REPORT_PATH; + fs.writeFileSync(reportPath, formatReport(results)); + + const failures = results.flatMap((result) => result.analysis.failures.map((failure) => `${result.name}: ${failure}`)); + if (failures.length) { + console.error(failures.join('\n')); + process.exit(1); + } +} + +function restoreEnvValue(name, value) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); + }); +} diff --git a/tests/codex-interactive-check.test.mjs b/tests/codex-interactive-check.test.mjs new file mode 100644 index 0000000..295ce8c --- /dev/null +++ b/tests/codex-interactive-check.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + analyzeTranscript, + buildCliArgs, + buildScenarios, + createHomeFixtureConfig, + createScenarioErrorResult, + formatReport, + hasSelectedOption, + hasScenarioExitStep, + resolveNodePtySpawnHelperPath, + updateSelectedOptionCursor, + updateTranscriptCursor, + stripAnsi, +} from '../scripts/codex-interactive-check.mjs'; + +test('analyzeTranscript flags excessive repeated menu renders', () => { + const transcript = Array.from({ length: 12 }, () => '? Select action\n> Configure language\n Exit').join('\n'); + + const result = analyzeTranscript(transcript, { + repeatedRenderPattern: 'Select action', + maxRepeatedRenders: 5, + expectedPatterns: [], + }); + + assert.equal(result.ok, false); + assert.match(result.summary, /repeated/i); + assert.deepEqual(result.failures, [ + 'Repeated render pattern "Select action" appeared 12 times, exceeding threshold 5.', + ]); +}); + +test('analyzeTranscript reports missing expected interactive states', () => { + const result = analyzeTranscript('Main menu\n> Configure tools\n', { + repeatedRenderPattern: 'Select action', + maxRepeatedRenders: 5, + expectedPatterns: ['Codex', 'Tool menu'], + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.failures, [ + 'Expected terminal output did not include "Codex".', + 'Expected terminal output did not include "Tool menu".', + ]); +}); + +test('analyzeTranscript tolerates omitted analysis options', () => { + const result = analyzeTranscript('Main menu\n'); + + assert.equal(result.ok, true); + assert.deepEqual(result.failures, []); +}); + +test('stripAnsi removes terminal control sequences before analysis', () => { + assert.equal(stripAnsi('\u001bc\u001b[2J\u001b[32mCodex\u001b[39m'), 'Codex'); +}); + +test('resolveNodePtySpawnHelperPath points from node-pty main file to platform helper', () => { + const helperPath = resolveNodePtySpawnHelperPath('/repo/node_modules/node-pty/lib/index.js', 'darwin', 'arm64'); + + assert.equal(helperPath.replace(/\\/g, '/'), '/repo/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper'); +}); + +test('buildCliArgs uses Node 18 compatible CommonJS preload', () => { + assert.deepEqual(buildCliArgs(['enter', 'codex'], '/tmp/disable-fetch.cjs'), [ + '--require', + '/tmp/disable-fetch.cjs', + 'dist/cli.js', + 'enter', + 'codex', + ]); +}); + +test('buildScenarios covers dynamically discovered tools', () => { + const tools = [ + { name: 'alpha', displayName: 'Alpha Tool' }, + { name: 'beta', displayName: 'Beta Tool' }, + ]; + + assert.deepEqual( + buildScenarios(tools).map((scenario) => scenario.name), + [ + 'main menu keyboard navigation', + 'tool selector keyboard navigation', + 'Alpha Tool tool menu keyboard navigation', + 'Beta Tool tool menu keyboard navigation', + ], + ); +}); + +test('createHomeFixtureConfig includes model state for every current tool config family', () => { + const config = createHomeFixtureConfig(); + + assert.match(config, /claudeCode:/); + assert.match(config, /codexModel:/); + assert.match(config, /codeBuddyModels:/); + assert.match(config, /workbuddyModels:/); + assert.match(config, /hermesModel:/); +}); + +test('updateTranscriptCursor only matches output appended after the previous cursor', () => { + const transcript = ['Main Menu\nConfigure tools\n']; + const cursor = stripAnsi(transcript.join('')).length; + + assert.equal(updateTranscriptCursor(transcript, cursor, 'Configure tools'), null); + + transcript.push('Tool selector\nCodex\n'); + assert.equal(updateTranscriptCursor(transcript, cursor, 'Codex'), `${transcript[0]}Tool selector\nCodex`.length); +}); + +test('updateTranscriptCursor preserves later output in the same appended chunk', () => { + const transcript = ['Tool selector\nAlpha Tool\nBeta Tool\n']; + + const nextCursor = updateTranscriptCursor(transcript, 0, 'Alpha Tool'); + + assert.equal(nextCursor, 'Tool selector\nAlpha Tool'.length); + assert.equal(updateTranscriptCursor(transcript, nextCursor, 'Beta Tool'), 'Tool selector\nAlpha Tool\nBeta Tool'.length); +}); + +test('hasSelectedOption requires the pointer to be on the expected option', () => { + assert.equal(hasSelectedOption('❯ ◆ 配置语言\n ◇ 配置线路', '配置语言'), true); + assert.equal(hasSelectedOption('> ◆ 配置语言\n ◇ 配置线路', '配置语言'), true); + assert.equal(hasSelectedOption(' ◆ 配置语言\n❯ ◇ 配置线路', '配置语言'), false); +}); + +test('updateSelectedOptionCursor preserves later output in the same appended chunk', () => { + const transcript = ['❯ ◆ 配置语言\n ◇ 配置线路\n主菜单\n']; + + const nextCursor = updateSelectedOptionCursor(transcript, 0, '配置语言'); + + assert.equal(nextCursor, '❯ ◆ 配置语言'.length); + assert.equal(updateTranscriptCursor(transcript, nextCursor, '主菜单'), '❯ ◆ 配置语言\n ◇ 配置线路\n主菜单'.length); +}); + +test('buildScenarios marks exit steps explicitly', () => { + const scenarios = buildScenarios([{ name: 'alpha', displayName: 'Alpha Tool' }]); + + assert.equal(scenarios.every(hasScenarioExitStep), true); +}); + +test('buildScenarios keeps an exit path when no tools are registered', () => { + const scenarios = buildScenarios([]); + + assert.equal(scenarios.every(hasScenarioExitStep), true); + assert.deepEqual( + scenarios.map((scenario) => scenario.name), + ['main menu keyboard navigation', 'tool selector keyboard navigation'], + ); +}); + +test('createScenarioErrorResult preserves transcript and failure reason in report', () => { + const result = createScenarioErrorResult( + { name: 'broken scenario', analysis: { repeatedRenderPattern: 'Select action', maxRepeatedRenders: 5, expectedPatterns: [] } }, + ['Main Menu\n❯ Exit\n'], + new Error('Timed out waiting for selected option'), + ); + + assert.equal(result.analysis.ok, false); + assert.match(result.analysis.failures[0], /Timed out waiting/); + const report = formatReport([result]); + assert.match(report, /broken scenario/); + assert.match(report, /Main Menu/); + assert.match(report, /Timed out waiting/); +});