From b65c82abf2f2998518ba5f5365a8d4e0a3645782 Mon Sep 17 00:00:00 2001 From: mycyg Date: Wed, 15 Jul 2026 10:48:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(qa):=20BUG-07=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=9F=BA=E5=BB=BA=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=BC=82=E7=A7=BB=E4=BF=AE=E5=A4=8D+=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E5=8F=AF=E5=A4=8D=E8=B7=91=E5=B9=B2=E5=87=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① r4-web-product-shell-baseline 的 fake client 补 listProjects()(home loader 已并行拉项目清单,缺失导致稳定误报 home error;照抄 routes.test.ts 的 fake), 双语外壳锚点更新到 Nav-v2 现行文案(提需求/总览、New request/Overview)。 ② qa:r5-10-real 适配 R10 option-first 首轮澄清契约:single_choice 时断言 ≥2 个 真实候选(id 唯一非空、label 非空)并选中推荐项应答;long_text 诚实退化时断言 0 选项;反模板门保留。报告新增 selected_option_id 证据。真 key 采样验证通过 (T1 succeeded,agent_step+review 齐,¥0.025)。 ③ R14 chat/search 真库 smoke 各自 CREATE 唯一命名 scratch 库、结束 DROP(FORCE) ——互不污染,「完播率」固定命中数断言不再受运行顺序影响。锚库命名门与 production 拒跑守卫原样保留(r14-smoke-db.ts)。 ④ 生成型审计产物(rust-system-i18n / live-route-interaction / product-shell- baseline)改落 gitignored artifacts/qa/,CI evidence 上传路径同步;verify 末尾 加 git diff --exit-code 保证跑完树干净。 Co-Authored-By: Claude Fable 5 --- .github/workflows/verify.yml | 6 +- .gitignore | 3 + apps/api/src/qa/r14-chat-smoke.ts | 19 +++--- apps/api/src/qa/r14-search-smoke.ts | 23 ++++---- apps/api/src/qa/r14-smoke-db.ts | 58 +++++++++++++++++++ .../qa/r5-10-real-key-evaluation-contract.ts | 9 ++- apps/api/src/qa/r5-10-real-key-evaluation.ts | 35 +++++++++-- apps/api/src/work-items-service.test.ts | 16 ++--- apps/web/qa/r4-web-live-route-interaction.ts | 11 +--- package.json | 2 +- scripts/qa/r4-rust-system-i18n.ts | 11 +--- scripts/qa/r4-web-product-shell-baseline.ts | 45 ++++++++++---- 12 files changed, 166 insertions(+), 72 deletions(-) create mode 100644 apps/api/src/qa/r14-smoke-db.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a208a8cf8..2e226af81 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -49,9 +49,9 @@ jobs: with: name: web-live-route-smoke-evidence path: | - docs/workhub/05-clients/assets/audit/2026-06-11-r4-web-live-route-interaction/contact-sheet.png - docs/workhub/05-clients/assets/audit/2026-06-11-r4-web-live-route-interaction/live-route-interaction-report.json - docs/workhub/05-clients/assets/audit/2026-06-11-r4-web-live-route-interaction/smoke-summary.md + artifacts/qa/r4-web-live-route-interaction/contact-sheet.png + artifacts/qa/r4-web-live-route-interaction/live-route-interaction-report.json + artifacts/qa/r4-web-live-route-interaction/smoke-summary.md retention-days: 14 rust-system-i18n: diff --git a/.gitignore b/.gitignore index 9eea8d865..2da62f3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # 参考代码:仅本地保留,绝不入库(当前项目源码 + opencode) /reference/ +# 生成型 QA 审计产物(报告/截图):可复跑生成,绝不入库——verify 末尾以 git diff --exit-code 保证干净 +/artifacts/ + # 依赖与构建产物 node_modules/ dist/ diff --git a/apps/api/src/qa/r14-chat-smoke.ts b/apps/api/src/qa/r14-chat-smoke.ts index e4051f32d..adb9ecc45 100644 --- a/apps/api/src/qa/r14-chat-smoke.ts +++ b/apps/api/src/qa/r14-chat-smoke.ts @@ -1,12 +1,12 @@ // R14 批 CHAT 真库冒烟(一次性可复跑验证脚本,照 r12-real-key-smoke.ts 的定位与防呆惯例): -// 在专用 scratch 库上跑完整迁移链后,走服务层把聊天完整度的全链路过一遍—— +// 在自建唯一命名的 scratch 库上跑完整迁移链后,走服务层把聊天完整度的全链路过一遍—— // 引用回复→编辑(含他人编辑 403)→reaction 幂等加减→置顶/取消→已读游标单调夹紧+receipts→ // 墓碑删除(含引用侧墓碑联动)→SSE 事件(recording bus 断言三个新事件都真的发布了)。 -// 不需要 LLM key。需要环境:DATABASE_URL(专用 scratch 库!命名必须匹配 workhub_r14_*smoke)。 +// 不需要 LLM key。需要环境:DATABASE_URL(workhub_r14_*smoke 命名的锚库,只用来建/删唯一库); +// smoke 每次 CREATE 唯一库、结束 DROP——与 search 等其他真库 smoke 互不污染。 import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { loadSettings } from "@workhub/config"; import { createConversationRepository, createDatabaseClient, @@ -21,16 +21,14 @@ import { } from "@workhub/db"; import { createConversationService } from "../services/conversations.js"; +import { withR14SmokeDatabase, type R14SmokeSettings } from "./r14-smoke-db.js"; async function main() { - const settings = loadSettings(process.env); - if (settings.appEnv === "production") { - throw new Error("Refusing to run the R14 chat smoke in production."); - } - if (!/workhub_r14_[a-z0-9_]*smoke/u.test(settings.databaseUrl)) { - throw new Error("R14 chat smoke requires a dedicated workhub_r14_*smoke scratch database."); - } + await withR14SmokeDatabase("chat", runChatSmoke); + process.exit(0); +} +async function runChatSmoke(settings: R14SmokeSettings) { await runMigrations(settings); const client = createDatabaseClient(settings); const db = client.db; @@ -222,7 +220,6 @@ async function main() { }) ); await client.close?.(); - process.exit(0); } main().catch((error) => { diff --git a/apps/api/src/qa/r14-search-smoke.ts b/apps/api/src/qa/r14-search-smoke.ts index 6aebb4a35..6f1ecdac0 100644 --- a/apps/api/src/qa/r14-search-smoke.ts +++ b/apps/api/src/qa/r14-search-smoke.ts @@ -1,12 +1,12 @@ // R14 批 SEARCH 真库冒烟(一次性可复跑验证脚本,照 r14-chat-smoke.ts / r12-real-key-smoke.ts 的定位与防呆惯例): -// 在专用 scratch 库上跑完整迁移链(含 0057 pg_trgm + 5 GIN 索引)后,直接种四数据源 + 一个「他人的个人空间 -// 项目」作围栏反例,再用 search 服务把四 scope 全过一遍——断言命中、墓碑滤除、个人空间围栏、assignee EXISTS、 -// CJK 2 字/≥3 字、has_more、LIKE 元字符转义、空结果诚实。不需要 LLM key。 -// 需要环境:DATABASE_URL(专用 scratch 库!命名必须匹配 workhub_r14_*smoke)。 +// 在自建唯一命名的 scratch 库上跑完整迁移链(含 0057 pg_trgm + 5 GIN 索引)后,直接种四数据源 + 一个「他人的 +// 个人空间项目」作围栏反例,再用 search 服务把四 scope 全过一遍——断言命中、墓碑滤除、个人空间围栏、assignee +// EXISTS、CJK 2 字/≥3 字、has_more、LIKE 元字符转义、空结果诚实。不需要 LLM key。 +// 需要环境:DATABASE_URL(workhub_r14_*smoke 命名的锚库,只用来建/删唯一库);smoke 每次 CREATE 唯一库、 +// 结束 DROP——chat smoke 先写过「完播率」消息也污染不到这里的固定命中数断言。 import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { loadSettings } from "@workhub/config"; import { conversationMessages, createDatabaseClient, @@ -28,16 +28,14 @@ import { import type { AuthActor } from "../middleware/auth.js"; import { createSearchService } from "../services/search.js"; +import { withR14SmokeDatabase, type R14SmokeSettings } from "./r14-smoke-db.js"; async function main() { - const settings = loadSettings(process.env); - if (settings.appEnv === "production") { - throw new Error("Refusing to run the R14 search smoke in production."); - } - if (!/workhub_r14_[a-z0-9_]*smoke/u.test(settings.databaseUrl)) { - throw new Error("R14 search smoke requires a dedicated workhub_r14_*smoke scratch database."); - } + await withR14SmokeDatabase("search", runSearchSmoke); + process.exit(0); +} +async function runSearchSmoke(settings: R14SmokeSettings) { await runMigrations(settings); const client = createDatabaseClient(settings); const db = client.db; @@ -239,7 +237,6 @@ async function main() { }) ); await client.close?.(); - process.exit(0); } main().catch((error) => { diff --git a/apps/api/src/qa/r14-smoke-db.ts b/apps/api/src/qa/r14-smoke-db.ts new file mode 100644 index 000000000..7927b70e5 --- /dev/null +++ b/apps/api/src/qa/r14-smoke-db.ts @@ -0,0 +1,58 @@ +// R14 真库冒烟的隔离库辅助:以操作者提供的 workhub_r14_*smoke 库为锚(只用来 CREATE/DROP,不写业务数据), +// 每次运行自建唯一命名的 scratch 库,跑完(无论成败)DROP 清理——chat/search 等多个 smoke 互不污染, +// 「固定命中数」类断言不再受运行顺序影响。 +// 守卫不降级:production 拒跑 + 锚库命名必须匹配 workhub_r14_*smoke,两条都保留在入口。 +import { randomUUID } from "node:crypto"; + +import { loadSettings } from "@workhub/config"; +import { createDatabaseClient } from "@workhub/db"; + +export type R14SmokeSettings = ReturnType; + +function swapDatabaseName(databaseUrl: string, databaseName: string) { + const url = new URL(databaseUrl); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +async function createUniqueDatabase(admin: ReturnType, name: string) { + // 并发 smoke 同时从 template1 建库会撞「source database is being accessed by other users」——短退避重试。 + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await admin.pool.query(`CREATE DATABASE "${name}"`); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1))); + } + } + throw lastError; +} + +export async function withR14SmokeDatabase( + smokeName: string, + run: (settings: R14SmokeSettings) => Promise +): Promise { + const anchorSettings = loadSettings(process.env); + if (anchorSettings.appEnv === "production") { + throw new Error(`Refusing to run the R14 ${smokeName} smoke in production.`); + } + if (!/workhub_r14_[a-z0-9_]*smoke/u.test(anchorSettings.databaseUrl)) { + throw new Error(`R14 ${smokeName} smoke requires a dedicated workhub_r14_*smoke scratch anchor database.`); + } + const uniqueName = `workhub_r14_${smokeName}_smoke_${randomUUID().replace(/-/gu, "").slice(0, 12)}`; + const admin = createDatabaseClient(anchorSettings); + try { + await createUniqueDatabase(admin, uniqueName); + try { + const derivedSettings = loadSettings({ ...process.env, DATABASE_URL: swapDatabaseName(anchorSettings.databaseUrl, uniqueName) }); + return await run(derivedSettings); + } finally { + // WITH (FORCE)(PG13+):断言失败时残留连接也不挡清理。 + await admin.pool.query(`DROP DATABASE IF EXISTS "${uniqueName}" WITH (FORCE)`); + } + } finally { + await admin.close(); + } +} diff --git a/apps/api/src/qa/r5-10-real-key-evaluation-contract.ts b/apps/api/src/qa/r5-10-real-key-evaluation-contract.ts index cb976baf8..ae7b3dfc2 100644 --- a/apps/api/src/qa/r5-10-real-key-evaluation-contract.ts +++ b/apps/api/src/qa/r5-10-real-key-evaluation-contract.ts @@ -110,8 +110,13 @@ export function createR5_10WorkItemServiceOptions( }; } -export function createR5_10ClarificationAnswerPayload(clarificationAnswer: string) { - return { free_text: clarificationAnswer }; +export function createR5_10ClarificationAnswerPayload(clarificationAnswer: string, selectedOptionId?: string) { + // R10 option-first 契约:有候选时选中一个真实候选——id 只能来自服务端问题卡,绝不是任务预设; + // free_text 始终携带确认语(诚实退化成 long_text 时它就是全部答案)。 + return { + ...(selectedOptionId ? { selected_option_ids: [selectedOptionId] } : {}), + free_text: clarificationAnswer + }; } export function selectR5_10TasksForRun(allTasks: readonly T[], rawTaskLimit: string | undefined) { diff --git a/apps/api/src/qa/r5-10-real-key-evaluation.ts b/apps/api/src/qa/r5-10-real-key-evaluation.ts index 7fbfd6401..bac673f56 100644 --- a/apps/api/src/qa/r5-10-real-key-evaluation.ts +++ b/apps/api/src/qa/r5-10-real-key-evaluation.ts @@ -98,6 +98,7 @@ type ClarificationQuestionEvidence = { body?: string; input_mode: string; options?: { id?: string; label?: string }[]; + recommended_option_ids?: string[]; }; type EvalClarificationFileContext = { @@ -193,6 +194,7 @@ type EvalTaskReport = { title: string; body: string | null; option_count: number; + selected_option_id: string | null; answer: string; }; artifacts: { @@ -317,11 +319,25 @@ function clarificationAnswerFor(task: EvalTask) { function assertRealClarificationQuestion(task: EvalTask, question: ClarificationQuestionEvidence) { const options = question.options ?? []; - if (question.input_mode !== "long_text") { - throw new Error(`${task.id} expected AI clarification to request free text, got ${question.input_mode}.`); - } - if (options.length > 0) { - throw new Error(`${task.id} expected AI clarification to avoid preset choices, got ${options.length} options.`); + // R10-0c(P1-1)option-first 契约:LLM 草稿带 ≥2 个候选答案时渲 single_choice 选项卡(自由文本折叠兜底); + // 没有可用候选时诚实退化为 long_text(不造假选项)。两种形态都必须是「真问题」,反模板门在下方保留。 + if (question.input_mode === "single_choice") { + if (options.length < 2) { + throw new Error(`${task.id} option-first clarification must offer at least 2 candidates, got ${options.length}.`); + } + const ids = options.map((option) => option.id ?? ""); + if (ids.some((id) => !id.trim()) || new Set(ids).size !== ids.length) { + throw new Error(`${task.id} option-first clarification has empty or duplicated option ids.`); + } + if (options.some((option) => !(option.label ?? "").trim())) { + throw new Error(`${task.id} option-first clarification has an empty option label.`); + } + } else if (question.input_mode === "long_text") { + if (options.length > 0) { + throw new Error(`${task.id} long_text degradation must not fabricate preset choices, got ${options.length} options.`); + } + } else { + throw new Error(`${task.id} expected single_choice (option-first) or long_text clarification, got ${question.input_mode}.`); } const combined = `${question.title}\n${question.body ?? ""}`.toLowerCase(); const genericPresetCount = [ @@ -889,10 +905,16 @@ async function main() { const clarificationQuestion = session.data.question; assertRealClarificationQuestion(task, clarificationQuestion); const clarificationAnswer = clarificationAnswerFor(task); + // option-first 时选中真实候选(优先推荐项,否则第一项);long_text 退化时只带 free_text。 + const clarificationOptions = clarificationQuestion.options ?? []; + const recommendedOptionId = clarificationQuestion.recommended_option_ids?.[0]; + const selectedClarificationOptionId = clarificationQuestion.input_mode === "single_choice" + ? clarificationOptions.find((option) => option.id === recommendedOptionId)?.id ?? clarificationOptions[0]?.id + : undefined; await requestJson( "POST", `/api/sessions/${session.data.session_id}/next-question`, - createR5_10ClarificationAnswerPayload(clarificationAnswer), + createR5_10ClarificationAnswerPayload(clarificationAnswer, selectedClarificationOptionId), 200 ); const createdWorkItem = await requestJson<{ data: { workitem: { id: string; status: string } } }>("POST", "/api/workitems", { @@ -1086,6 +1108,7 @@ async function main() { title: clarificationQuestion.title, body: clarificationQuestion.body ?? null, option_count: clarificationQuestion.options?.length ?? 0, + selected_option_id: selectedClarificationOptionId ?? null, answer: clarificationAnswer }, artifacts: { diff --git a/apps/api/src/work-items-service.test.ts b/apps/api/src/work-items-service.test.ts index 36339801a..482ec93dd 100644 --- a/apps/api/src/work-items-service.test.ts +++ b/apps/api/src/work-items-service.test.ts @@ -942,13 +942,15 @@ test("real-key evaluation wires the provider registry into WorkItem clarificatio assert.deepEqual(await options.projectFileContext?.({ intentText: "没有预置文件的任务" }), []); }); -test("real-key evaluation answers AI clarification with free text before applying task presets", () => { - const payload = createR5_10ClarificationAnswerPayload("请优先输出适合项目验收的要点。"); - - // R9.7: the old assertion grepped request-body source around `/next-question`. - // That was wrong because source text did not prove the clarification payload omits preset option ids. - assert.deepEqual(payload, { free_text: "请优先输出适合项目验收的要点。" }); - assert.equal(Object.hasOwn(payload, "selected_option_ids"), false); +test("real-key evaluation answers AI clarification per the R10 option-first contract", () => { + // long_text 诚实退化:只带 free_text,绝不携带任务预设 option id。 + const freeTextOnly = createR5_10ClarificationAnswerPayload("请优先输出适合项目验收的要点。"); + assert.deepEqual(freeTextOnly, { free_text: "请优先输出适合项目验收的要点。" }); + assert.equal(Object.hasOwn(freeTextOnly, "selected_option_ids"), false); + + // single_choice option-first:选中服务端问题卡给出的真实候选 id,free_text 仍携带确认语。 + const optionFirst = createR5_10ClarificationAnswerPayload("确认按该口径执行。", "option-2"); + assert.deepEqual(optionFirst, { selected_option_ids: ["option-2"], free_text: "确认按该口径执行。" }); }); test("real-key evaluation labels limited samples instead of applying full-suite gates", () => { diff --git a/apps/web/qa/r4-web-live-route-interaction.ts b/apps/web/qa/r4-web-live-route-interaction.ts index 84485ae46..fd125d83b 100644 --- a/apps/web/qa/r4-web-live-route-interaction.ts +++ b/apps/web/qa/r4-web-live-route-interaction.ts @@ -364,15 +364,8 @@ type RouteStatusProbe = { const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../../.."); const webRoot = path.join(repoRoot, "apps", "web"); -const defaultOutputDir = path.join( - repoRoot, - "docs", - "workhub", - "05-clients", - "assets", - "audit", - "2026-06-11-r4-web-live-route-interaction" -); +// 生成型审计产物落 gitignored artifacts 区:失败中断时删掉的只是生成物,tracked 历史证据不受影响。 +const defaultOutputDir = path.join(repoRoot, "artifacts", "qa", "r4-web-live-route-interaction"); const r4ReactComponentByRoute: Record = { home: "HomeRouteComponent", proposal: "ProposalRouteComponent", diff --git a/package.json b/package.json index 377d0c0f8..fc6b4b221 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "typecheck": "pnpm -r --if-present typecheck", "test": "pnpm -r --if-present test", "lint": "pnpm audit:portable-config && pnpm audit:target-paths && pnpm audit:migrations && pnpm qa:r2-release-gate && pnpm qa:r4-rust-system-i18n && pnpm qa:cuu-r3-launcher-smoke && pnpm qa:cuu-r3-dev-server-smoke && pnpm qa:cuu-r3-run-stream-smoke && pnpm qa:cuu-r3-run-failure-smoke && pnpm qa:cuu-r3-reload-restore-smoke && pnpm qa:cuu-r3-error-fault-smoke", - "verify": "pnpm typecheck && pnpm test && pnpm lint", + "verify": "pnpm typecheck && pnpm test && pnpm lint && git diff --exit-code", "qa:r1-route-visual": "tsx scripts/qa/r1-route-visual-qa.ts", "qa:r4-web-route-state-matrix": "tsx scripts/qa/r4-web-route-state-matrix.ts", "qa:r4-web-route-registry-loader": "tsx --tsconfig apps/web/tsconfig.json scripts/qa/r4-web-route-registry-loader.ts", diff --git a/scripts/qa/r4-rust-system-i18n.ts b/scripts/qa/r4-rust-system-i18n.ts index b19750be0..0991543ee 100644 --- a/scripts/qa/r4-rust-system-i18n.ts +++ b/scripts/qa/r4-rust-system-i18n.ts @@ -23,15 +23,8 @@ type Report = { }; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const outputDir = path.join( - repoRoot, - "docs", - "workhub", - "05-clients", - "assets", - "audit", - "2026-06-11-r4-rust-system-i18n" -); +// 生成型审计产物落 gitignored artifacts 区,不改写 tracked 文件——verify 末尾有 git diff --exit-code 门。 +const outputDir = path.join(repoRoot, "artifacts", "qa", "r4-rust-system-i18n"); const sources = { locale: path.join(repoRoot, "client-tauri", "src-tauri", "src", "locale.rs"), diff --git a/scripts/qa/r4-web-product-shell-baseline.ts b/scripts/qa/r4-web-product-shell-baseline.ts index ea05d09a6..ce6b364d9 100644 --- a/scripts/qa/r4-web-product-shell-baseline.ts +++ b/scripts/qa/r4-web-product-shell-baseline.ts @@ -11,6 +11,7 @@ import type { AttentionHomeVM, CostDashboardVM, GoldPathSurfaceVM, + ProjectListVM, ProposalDetailVM, ReplayTraceVM, WorkHubLocale, @@ -74,15 +75,8 @@ type ProductShellReport = { }; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const outputDir = path.join( - repoRoot, - "docs", - "workhub", - "05-clients", - "assets", - "audit", - "2026-06-11-r4-web-product-shell-baseline" -); +// 生成型审计产物落 gitignored artifacts 区,不改写 tracked 文件——可复跑且 git diff --exit-code 干净。 +const outputDir = path.join(repoRoot, "artifacts", "qa", "r4-web-product-shell-baseline"); const cases: ProductShellCase[] = [ { @@ -225,6 +219,35 @@ function fakeRouteClient(surface: GoldPathSurfaceVM) { return surface.page_vms.proposal; } }, + // home loader 与 attention 并行拉项目清单——fake 必须与生产 PageClient 契约一致(照抄 routes.test.ts 的 fake)。 + async listProjects(): Promise { + calls.push("listProjects"); + return { + generated_at: "2026-06-11T09:00:00.000Z", + projects: [ + { + id: "93000000-0000-4000-8000-000000000001", + name: "R5 Workspace", + slug: "r5-workspace", + owner_nickname: "owner", + archived: false, + created_at: "2026-06-11T08:00:00.000Z", + updated_at: "2026-06-11T09:00:00.000Z", + open_work_item_count: 1 + }, + { + id: "93000000-0000-4000-8000-000000000099", + name: "R5 Secondary", + slug: "r5-secondary", + owner_nickname: "owner", + archived: false, + created_at: "2026-06-10T08:00:00.000Z", + updated_at: "2026-06-10T09:00:00.000Z", + open_work_item_count: 0 + } + ] + }; + }, async listWorkItemConflicts(workItemId: string) { localeCall(`conflicts:${workItemId}`); return { conflicts: [], empty_state: "no_conflicts" as const }; @@ -323,8 +346,8 @@ ${input.html} cuuLeak: /\\bCuu\\b/i.test(text) || Boolean(document.querySelector("[data-cuu]")), kanbanLeak: /\\bkanban\\b/i.test(text), weeklyFixtureLeak: /客户周报|weekly report/i.test(text), - zhChrome: text.includes("工作入口") && text.includes("当前焦点"), - enChrome: text.includes("Work entry") && text.includes("Focus"), + zhChrome: text.includes("提需求") && text.includes("总览"), // Nav-v2(R10)后的固定外壳锚点:CTA+首个导航项 + enChrome: text.includes("New request") && text.includes("Overview"), clientWidth: document.documentElement.clientWidth, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), navClientWidth, From c98a71b2b51d6d53dfdf3c90ef530c7ee5026d2f Mon Sep 17 00:00:00 2001 From: mycyg Date: Wed, 15 Jul 2026 10:48:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(qa):=20BUG-09=20chrome-launch=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=AF=8A=E6=96=AD=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E5=90=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdio:"ignore" 丢 stderr 导致启动失败只剩泛化 fetch timeout。现在限长(16KiB) 保留 stderr 尾部,失败错误附完整诊断:启动参数、Chrome --version、退出 code/signal(stopChrome 前快照,不被自杀 SIGTERM 污染)、端口探测轨迹 (计数+末 10 条);子进程在 debug 端口起来前就退出时立即失败,不再傻等整个 CDP 超时窗。探测 fetch 加 2s AbortSignal 防挂死。新增单测覆盖 fail-fast 与 诊断内容,原有超时终止单测语义保留。 Co-Authored-By: Claude Fable 5 --- apps/web/src/chrome-launch.test.ts | 49 ++++++++++++++ apps/web/src/chrome-launch.ts | 102 ++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/chrome-launch.test.ts diff --git a/apps/web/src/chrome-launch.test.ts b/apps/web/src/chrome-launch.test.ts new file mode 100644 index 000000000..1027dfc45 --- /dev/null +++ b/apps/web/src/chrome-launch.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { launchChrome } from "./chrome-launch.js"; + +// BUG-09:Chrome 起不来时诊断不能被吞——stderr/退出状态/启动参数/版本/端口探测都要出现在错误报告里, +// 且子进程已退出时立即失败,不傻等整个 CDP 超时窗把真因冲淡成 fetch timeout。 +test("launchChrome fails fast with full diagnostics when Chrome exits before the debug port comes up", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "workhub-chrome-launch-diag-")); + try { + const fakeChromePath = path.join(tmp, "fake-chrome.sh"); + await writeFile( + fakeChromePath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then", + " echo \"FakeChrome 0.0.1\"", + " exit 0", + "fi", + "echo \"fake chrome cannot start: boom\" >&2", + "exit 3", + "" + ].join("\n"), + "utf8" + ); + await chmod(fakeChromePath, 0o755); + + const startedAt = Date.now(); + await assert.rejects( + launchChrome(fakeChromePath, 65533, path.join(tmp, "profile"), { debugTargetTimeoutMs: 20_000 }), + (error: Error) => { + assert.match(error.message, /Chrome exited \(code 3, signal null\) before the CDP debug port 65533 came up/u); + assert.match(error.message, /Chrome launch diagnostics:/u); + assert.match(error.message, /fake chrome cannot start: boom/u, "stderr tail is preserved"); + assert.match(error.message, /FakeChrome 0\.0\.1/u, "chrome --version is captured"); + assert.match(error.message, /--remote-debugging-port=65533/u, "launch args are reported"); + assert.match(error.message, /port_probes/u, "port probe trace is reported"); + return true; + } + ); + // 子进程秒退,不许烧完 20s 超时窗:快速失败是这条修复的一半。 + assert.ok(Date.now() - startedAt < 10_000, "fails fast instead of waiting out the CDP timeout"); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/chrome-launch.ts b/apps/web/src/chrome-launch.ts index 6dd6ebcc3..252c95155 100644 --- a/apps/web/src/chrome-launch.ts +++ b/apps/web/src/chrome-launch.ts @@ -83,19 +83,74 @@ async function stopChrome(child: ChildProcessWithoutNullStreams | undefined) { }); } -async function waitForDebugTarget(port: number, timeoutMs = 45_000) { - const deadline = Date.now() + timeoutMs; +// BUG-09:启动失败时不能只剩泛化 fetch 超时——限长保留 stderr 尾部与端口探测轨迹,报告里带全诊断。 +const STDERR_TAIL_LIMIT = 16 * 1024; +const PORT_PROBE_TAIL_LIMIT = 10; + +type ChromeExitInfo = { code: number | null; signal: NodeJS.Signals | null }; + +type PortProbe = { at_ms: number; note: string }; + +async function chromeVersion(chromePath: string) { + return new Promise((resolve) => { + let output = ""; + let child: ReturnType; + try { + child = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + resolve(null); + return; + } + const settle = () => { + clearTimeout(timer); + resolve(output.trim() || null); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + settle(); + }, 3_000); + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.on("error", settle); + child.on("exit", settle); + }); +} + +async function waitForDebugTarget(input: { + port: number; + timeoutMs: number; + probes: PortProbe[]; + probeCounter: { total: number }; + childExit: () => ChromeExitInfo | null; +}) { + const startedAt = Date.now(); + const deadline = startedAt + input.timeoutMs; + const record = (note: string) => { + input.probeCounter.total += 1; + input.probes.push({ at_ms: Date.now() - startedAt, note }); + if (input.probes.length > PORT_PROBE_TAIL_LIMIT) { + input.probes.shift(); + } + }; let lastError: unknown; while (Date.now() < deadline) { try { - const response = await fetch(`http://127.0.0.1:${port}/json/list`); + const response = await fetch(`http://127.0.0.1:${input.port}/json/list`, { signal: AbortSignal.timeout(2_000) }); const pages = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>; const page = pages.find((item) => item.type === "page" && item.webSocketDebuggerUrl); if (page?.webSocketDebuggerUrl) { return page.webSocketDebuggerUrl; } + record(`http ${response.status}: ${pages.length} targets, none is a page with webSocketDebuggerUrl`); } catch (error) { lastError = error; + record(String(error)); + } + const exit = input.childExit(); + if (exit) { + // Chrome 已经死了还傻等整个超时窗只会把真因冲淡成 fetch timeout——立即失败并报退出状态。 + throw new Error(`Chrome exited (code ${String(exit.code)}, signal ${String(exit.signal)}) before the CDP debug port ${input.port} came up`); } await new Promise((resolve) => setTimeout(resolve, 120)); } @@ -117,7 +172,7 @@ export async function launchChrome( ) { await rm(userDataDir, { recursive: true, force: true }); await mkdir(userDataDir, { recursive: true }); - const child = spawn(chromePath, [ + const args = [ "--headless=new", ...chromeExtraArgs(), "--disable-gpu", @@ -129,17 +184,48 @@ export async function launchChrome( `--user-data-dir=${userDataDir}`, "--window-size=1365,1100", "about:blank" - ], { stdio: "ignore" }) as ChildProcessWithoutNullStreams; + ]; + const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] }) as unknown as ChildProcessWithoutNullStreams; + let stderrTail = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL_LIMIT); + }); + let exitInfo: ChromeExitInfo | null = null; + child.once("exit", (code, signal) => { + exitInfo = { code, signal }; + }); + const probes: PortProbe[] = []; + const probeCounter = { total: 0 }; let cdp: CdpClient | undefined; try { - const websocketUrl = await waitForDebugTarget(debugPort, options.debugTargetTimeoutMs); + const websocketUrl = await waitForDebugTarget({ + port: debugPort, + timeoutMs: options.debugTargetTimeoutMs ?? 45_000, + probes, + probeCounter, + childExit: () => exitInfo + }); cdp = await CdpClient.connect(websocketUrl); await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); return { child, cdp }; } catch (error) { cdp?.close(); - await stopChrome(child); - throw error; + // 先快照退出状态再 stopChrome,否则诊断里的 exit 会被我们自己的 SIGTERM 污染。 + const exitBeforeStop: ChromeExitInfo | null = exitInfo; + const [version] = await Promise.all([chromeVersion(chromePath), stopChrome(child)]); + const diagnostics = { + chrome_path: chromePath, + chrome_version: version, + args, + exit: exitBeforeStop, + stderr_tail: stderrTail, + port_probes: { total: probeCounter.total, tail: probes } + }; + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `${message}\nChrome launch diagnostics: ${JSON.stringify(diagnostics, null, 2)}`, + error instanceof Error ? { cause: error } : undefined + ); } }