From 741a395a7791fdddd22417f867272db8ae8ff7a9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 04:23:59 +0800 Subject: [PATCH] fix: harden conversational rectification UI --- frontend/src/app/globals.css | 4 +- ...onversational-birth-time-rectification.tsx | 135 ++++-- .../hooks/use-conversational-rectification.ts | 83 +++- .../conversational-rectification/client.ts | 40 +- ...onversational-rectification-client.test.ts | 93 +++- ...ersational-rectification-component.test.ts | 430 +++++++++++++++++- ...rsational-rectification-controller.test.ts | 141 +++++- 7 files changed, 881 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b8ea60a5..24b96936 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -481,7 +481,9 @@ button:disabled { cursor: default; opacity: .45; } .conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } .conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; } .conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); } -.conversational-abandon-confirmation { border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); } +.conversational-abandon-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); } +.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); } +.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } .conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } .phrase-nowrap { white-space: nowrap; } diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index eca3905f..2b77ff4b 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,6 +1,11 @@ "use client"; -import { useRef, useState } from "react"; +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { ChatMessageContent } from "./chat-message-content.tsx"; import { useConversationalRectification, @@ -85,10 +90,41 @@ export function ConversationalRectificationSurface({ pendingConsultationQuestion, onContinueOriginalQuestion, }: SurfaceProps) { - const [abandonArmed, setAbandonArmed] = useState(false); + const [abandonArmedFor, setAbandonArmedFor] = useState(null); + const [localAnnouncement, setLocalAnnouncement] = useState | null>(null); const composer = useRef(null); + const abandonTrigger = useRef(null); + const abandonCancel = useRef(null); + const abandonConfirm = useRef(null); + const restoreAbandonFocus = useRef(false); const turn = controller.turn; const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; + const abandonIdentity = turn + ? `${turn.caseId}:${turn.turnVersion}:${turn.status}` + : null; + const canAbandon = Boolean( + turn?.actions.includes("abandon") + && turn.status !== "abandoned" + && turn.status !== "completed", + ); + const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity; + const statusAnnouncement = localAnnouncement?.identity === abandonIdentity + ? localAnnouncement.message + : ""; + + useEffect(() => { + if (abandonArmed) { + abandonCancel.current?.focus(); + return; + } + if (restoreAbandonFocus.current) { + restoreAbandonFocus.current = false; + abandonTrigger.current?.focus(); + } + }, [abandonArmed]); if (!turn) { return ( @@ -115,6 +151,34 @@ export function ConversationalRectificationSurface({ if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer()); }; const focusComposer = () => composer.current?.focus(); + const continueLocally = () => { + if (abandonIdentity) { + setLocalAnnouncement({ + identity: abandonIdentity, + message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。", + }); + } + focusComposer(); + }; + const closeAbandonDialog = () => { + restoreAbandonFocus.current = true; + setAbandonArmedFor(null); + }; + const handleAbandonDialogKey = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeAbandonDialog(); + return; + } + if (event.key !== "Tab") return; + if (event.shiftKey && document.activeElement === abandonCancel.current) { + event.preventDefault(); + abandonConfirm.current?.focus(); + } else if (!event.shiftKey && document.activeElement === abandonConfirm.current) { + event.preventDefault(); + abandonCancel.current?.focus(); + } + }; return (
本次校正已放弃,候选时间没有应用。

} {turn.status === "completed" && turn.candidate.status === "confirmed" &&

候选时间已经过你的明确确认。

} + {statusAnnouncement &&

{statusAnnouncement}

} {controller.error &&

{controller.error}

} @@ -264,7 +329,7 @@ export function ConversationalRectificationSurface({ className="button-secondary" disabled={controller.pending} type="button" - onClick={() => safely(controller.resume())} + onClick={continueLocally} > 继续校正 @@ -279,12 +344,16 @@ export function ConversationalRectificationSurface({ ) : null} - {turn.actions.includes("abandon") && !abandonArmed && ( + {canAbandon && !abandonArmed && ( @@ -292,27 +361,41 @@ export function ConversationalRectificationSurface({ {abandonArmed && ( -
-

放弃后会保留审计记录,但不会应用任何候选时间。确定继续吗?

-
- - -
-
+
+
+

确认放弃本次校正?

+

+ 放弃后会保留审计记录,但不会应用任何候选时间。 +

+
+ + +
+
+
)}
); diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts index fa20b592..0a5f0f5b 100644 --- a/frontend/src/hooks/use-conversational-rectification.ts +++ b/frontend/src/hooks/use-conversational-rectification.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { CONVERSATIONAL_RECTIFICATION_UNAVAILABLE, ConversationalRectificationRequestError, @@ -30,6 +30,7 @@ type MutationResult = Promise; export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{ getSnapshot(): ConversationalRectificationControllerSnapshot; subscribe(listener: () => void): () => void; + synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void; setDraft(value: string): void; selectDomain(domain: EvidenceDomain | null): void; start(pendingConsultationQuestion?: string | null): MutationResult; @@ -53,6 +54,21 @@ type Mutation = Readonly<{ clearDraftOnSuccess?: boolean; }>; +function createLatestControllerInput(initial: ControllerInput) { + let current = initial; + return { + update(next: ControllerInput) { + current = next; + }, + send(command: ConversationalRectificationCommand) { + return (current.send ?? sendConversationalRectificationCommand)(command); + }, + onTurn(turn: ConversationalRectificationTurn) { + current.onTurn?.(turn); + }, + }; +} + function displayError(error: unknown): string { return error instanceof ConversationalRectificationRequestError ? error.message @@ -79,6 +95,7 @@ export function createConversationalRectificationController( error: "", }; let activeMutation: MutationResult | null = null; + let caseContext = 0; const publish = (next: ConversationalRectificationControllerSnapshot) => { snapshot = next; @@ -90,13 +107,27 @@ export function createConversationalRectificationController( const acceptTurn = ( turn: ConversationalRectificationTurn, clearDraft: boolean, + expectedCaseContext: number, ) => { + const current = snapshot.turn; + if (caseContext !== expectedCaseContext) return turn; + if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn; + const selectedDomain = clearDraft + ? null + : snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) + ? snapshot.selectedDomain + : null; patch({ turn, error: "", - ...(clearDraft ? { draft: "", selectedDomain: null } : {}), + selectedDomain, + ...(clearDraft ? { draft: "" } : {}), }); - input.onTurn?.(turn); + try { + input.onTurn?.(turn); + } catch { + // A consumer callback is observational. It must never turn a durable success into a failure. + } return turn; }; const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({ @@ -114,15 +145,20 @@ export function createConversationalRectificationController( if (activeMutation) return activeMutation; patch({ pending: true, error: "" }); const turnAtStart = snapshot.turn; + const caseContextAtStart = caseContext; const operation = registry.run( mutation.identity, (actionId) => send(mutation.command(actionId)), - ).then((turn) => acceptTurn(turn, mutation.clearDraftOnSuccess === true)) + ).then((turn) => acceptTurn( + turn, + mutation.clearDraftOnSuccess === true, + caseContextAtStart, + )) .catch(async (error: unknown) => { if (turnAtStart && staleTurn(error)) { try { const recovered = await recoverLatest(turnAtStart); - return acceptTurn(recovered, false); + return acceptTurn(recovered, false, caseContextAtStart); } catch (recoveryError) { patch({ error: displayError(recoveryError) }); throw recoveryError; @@ -170,6 +206,29 @@ export function createConversationalRectificationController( listeners.add(listener); return () => listeners.delete(listener); }, + synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) { + const current = snapshot.turn; + if (turn === null) { + if (current === null) return; + caseContext += 1; + patch({ turn: null, draft: "", selectedDomain: null, error: "" }); + return; + } + if (current === null || current.caseId !== turn.caseId) { + caseContext += 1; + patch({ turn, draft: "", selectedDomain: null, error: "" }); + return; + } + if (turn.turnVersion <= current.turnVersion) return; + patch({ + turn, + error: "", + selectedDomain: snapshot.selectedDomain + && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) + ? snapshot.selectedDomain + : null, + }); + }, setDraft(value: string) { patch({ draft: value }); }, @@ -250,11 +309,23 @@ export function createConversationalRectificationController( export function useConversationalRectification( input: ControllerInput = {}, ): ConversationalRectificationController { - const [controller] = useState(() => createConversationalRectificationController(input)); + const [latestInput] = useState(() => createLatestControllerInput(input)); + const [controller] = useState(() => createConversationalRectificationController({ + initialTurn: input.initialTurn, + createActionId: input.createActionId, + send: latestInput.send, + onTurn: latestInput.onTurn, + })); const snapshot = useSyncExternalStore( controller.subscribe, controller.getSnapshot, controller.getSnapshot, ); + useLayoutEffect(() => { + latestInput.update(input); + }, [input, latestInput]); + useEffect(() => { + controller.synchronizeInitialTurn(input.initialTurn ?? null); + }, [controller, input.initialTurn]); return { ...controller, ...snapshot }; } diff --git a/frontend/src/lib/conversational-rectification/client.ts b/frontend/src/lib/conversational-rectification/client.ts index 9f25dc31..786ffd88 100644 --- a/frontend/src/lib/conversational-rectification/client.ts +++ b/frontend/src/lib/conversational-rectification/client.ts @@ -83,16 +83,46 @@ export type ConversationalRectificationActionRegistry = ReturnType< typeof createConversationalRectificationActionRegistry >; +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +function isRetryableTransportError(error: unknown): boolean { + return !isAbortError(error) && ( + error instanceof TypeError + || error instanceof SyntaxError + || (error instanceof DOMException && error.name === "SyntaxError") + ); +} + +async function postCommandWithOneReplay(body: string) { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const result = await postJson({ + url: "/api/birth-time-conversation", + body, + retryLostResponse: false, + }); + // postJson deliberately projects an unparseable non-ok body to null. Treating all null + // error payloads as replayable also covers proxies that mislabel HTML as application/json. + const nonJsonFailure = !result.response.ok && result.payload === null; + if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue; + return result; + } catch (error) { + if (attempt === 0 && isRetryableTransportError(error)) continue; + throw error; + } + } + throw new TypeError("unreachable conversational rectification replay state"); +} + export async function sendConversationalRectificationCommand( command: ConversationalRectificationCommand, ): Promise { const request = conversationalRectificationCommandSchema.parse(command); + const body = JSON.stringify(request); try { - const { response, payload } = await postJson({ - url: "/api/birth-time-conversation", - body: JSON.stringify(request), - retryLostResponse: true, - }); + const { response, payload } = await postCommandWithOneReplay(body); if (!response.ok) { const parsed = publicErrorSchema.safeParse(payload); const safeServerMessage = response.status < 500 && parsed.success diff --git a/frontend/tests/conversational-rectification-client.test.ts b/frontend/tests/conversational-rectification-client.test.ts index 370cbd80..07238101 100644 --- a/frontend/tests/conversational-rectification-client.test.ts +++ b/frontend/tests/conversational-rectification-client.test.ts @@ -75,6 +75,35 @@ test("action registry keeps one id for a failed canonical command and separates assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]); }); +test("action registry releases a successful identity but keeps it for a manual failure retry", async () => { + const ids = [firstActionId, secondActionId]; + const registry = createConversationalRectificationActionRegistry( + () => ids.shift() ?? assert.fail("unexpected action id allocation"), + ); + const identity = { + caseId, + turnVersion: 4, + operation: "pause", + payload: {}, + } as const; + const seen: string[] = []; + + await assert.rejects(registry.run(identity, async (actionId) => { + seen.push(actionId); + throw new TypeError("offline"); + })); + await registry.run(identity, async (actionId) => { + seen.push(actionId); + return turn; + }); + await registry.run(identity, async (actionId) => { + seen.push(actionId); + return turn; + }); + + assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]); +}); + test("client replays the exact action body after a lost response without adding a price", async (context) => { const bodies: string[] = []; let attempts = 0; @@ -103,11 +132,63 @@ test("client replays the exact action body after a lost response without adding assert.equal(Object.hasOwn(JSON.parse(bodies[0]) as object, "price"), false); }); -test("502 and non-JSON failures expose one stable Chinese message", async (context) => { - context.mock.method(globalThis, "fetch", async () => new Response("upstream html", { - status: 502, - headers: { "content-type": "text/html" }, - })); +test("a JSON 502 is retried once with the exact action body before succeeding", async (context) => { + const bodies: string[] = []; + context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + if (bodies.length === 1) { + return Response.json({ code: "service_unavailable", message: "internal detail" }, { status: 502 }); + } + return Response.json(turn); + }); + + const result = await sendConversationalRectificationCommand({ + type: "pause", + caseId, + actionId: firstActionId, + turnVersion: 4, + }); + + assert.deepEqual(result, turn); + assert.equal(bodies.length, 2); + assert.equal(bodies[0], bodies[1]); + assert.equal(JSON.parse(bodies[0] ?? "{}").actionId, firstActionId); +}); + +test("a non-ok non-JSON response is retried once even when a proxy mislabels it as JSON", async (context) => { + const bodies: string[] = []; + context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + if (bodies.length === 1) { + return new Response("proxy html", { + status: 503, + headers: { "content-type": "application/json" }, + }); + } + return Response.json(turn); + }); + + const result = await sendConversationalRectificationCommand({ + type: "pause", + caseId, + actionId: firstActionId, + turnVersion: 4, + }); + + assert.deepEqual(result, turn); + assert.equal(bodies.length, 2); + assert.equal(bodies[0], bodies[1]); +}); + +test("502 and non-JSON failures retry only once before one stable Chinese message", async (context) => { + const bodies: string[] = []; + context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + return new Response("upstream html", { + status: 502, + headers: { "content-type": "text/html" }, + }); + }); await assert.rejects( sendConversationalRectificationCommand({ @@ -120,4 +201,6 @@ test("502 and non-JSON failures expose one stable Chinese message", async (conte && error.status === 502 && error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE, ); + assert.equal(bodies.length, 2); + assert.equal(bodies[0], bodies[1]); }); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 08d65aca..bea2f2e4 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -1,6 +1,18 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { spawn, type ChildProcess } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { @@ -53,6 +65,7 @@ function controller(overrides: Partial = error: "", getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }), subscribe: () => () => undefined, + synchronizeInitialTurn: () => undefined, setDraft: () => undefined, selectDomain: () => undefined, start: async () => turn, @@ -132,3 +145,418 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/); assert.match(component, /确认放弃且不应用候选/); }); + +type CdpResponse = Readonly<{ + id?: number; + result?: unknown; + error?: Readonly<{ message?: string }>; +}>; + +class CdpSession { + private nextId = 0; + private readonly pending = new Map(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)) as CdpResponse; + if (message.id === undefined) return; + const request = this.pending.get(message.id); + if (!request) return; + this.pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed")); + else request.resolve(message.result); + }); + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url); + await new Promise((resolveConnection, rejectConnection) => { + socket.addEventListener("open", () => resolveConnection(), { once: true }); + socket.addEventListener("error", () => rejectConnection(new Error("Unable to connect to Chromium CDP")), { + once: true, + }); + }); + return new CdpSession(socket); + } + + async send(method: string, params: unknown = {}): Promise { + const id = ++this.nextId; + const response = new Promise((resolveResponse, rejectResponse) => { + this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse }); + }); + this.socket.send(JSON.stringify({ id, method, params })); + return response; + } + + async evaluate(expression: string): Promise { + const response = await this.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }) as { + result?: { value?: T }; + exceptionDetails?: { text?: string }; + }; + if (response.exceptionDetails) { + throw new Error(response.exceptionDetails.text ?? `Browser evaluation failed: ${expression}`); + } + return response.result?.value as T; + } + + close() { + this.socket.close(); + } +} + +function chromiumExecutable(): string { + if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH; + const systemCandidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]; + + const cacheRoots = [ + join(homedir(), "Library/Caches/ms-playwright"), + join(homedir(), ".cache/ms-playwright"), + ]; + for (const cacheRoot of cacheRoots) { + if (!existsSync(cacheRoot)) continue; + const headlessVersions = readdirSync(cacheRoot) + .filter((entry) => entry.startsWith("chromium_headless_shell-")) + .sort() + .reverse(); + for (const version of headlessVersions) { + const candidates = [ + join(cacheRoot, version, "chrome-headless-shell-mac-arm64/chrome-headless-shell"), + join(cacheRoot, version, "chrome-headless-shell-mac-x64/chrome-headless-shell"), + join(cacheRoot, version, "chrome-headless-shell-linux64/chrome-headless-shell"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + } + const versions = readdirSync(cacheRoot) + .filter((entry) => entry.startsWith("chromium-")) + .sort() + .reverse(); + for (const version of versions) { + const candidates = [ + join(cacheRoot, version, "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"), + join(cacheRoot, version, "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"), + join(cacheRoot, version, "chrome-linux/chrome"), + join(cacheRoot, version, "chrome-linux64/chrome"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + } + } + for (const candidate of systemCandidates) { + if (existsSync(candidate)) return candidate; + } + throw new Error("Chromium is required for the real DOM contract; set CHROME_PATH to its executable."); +} + +async function waitFor(probe: () => Promise, label: string): Promise { + const deadline = Date.now() + 8_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value !== null && value !== false) return value; + } catch (error) { + lastError = error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 30)); + } + throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`); +} + +async function pressEscape(cdp: CdpSession) { + await cdp.send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + nativeVirtualKeyCode: 27, + }); + await cdp.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + nativeVirtualKeyCode: 27, + }); +} + +async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{ + browser: ChildProcess; + cdp: CdpSession; +}> { + const executable = chromiumExecutable(); + const browser = spawn(executable, [ + executable.includes("chrome-headless-shell") ? "--headless" : "--headless=new", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-extensions", + "--disable-features=Translate", + "--disable-gpu", + "--disable-sync", + "--no-first-run", + "--no-sandbox", + "--remote-debugging-port=0", + `--user-data-dir=${userDataDirectory}`, + pathToFileURL(htmlPath).href, + ], { stdio: ["ignore", "pipe", "pipe"] }); + const activePort = join(userDataDirectory, "DevToolsActivePort"); + const port = await waitFor(async () => { + if (browser.exitCode !== null) { + throw new Error(`Chromium exited before CDP was ready (${browser.exitCode})`); + } + if (!existsSync(activePort)) return null; + return Number(readFileSync(activePort, "utf8").split("\n")[0]); + }, "Chromium DevTools port"); + const target = await waitFor(async () => { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + const targets = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>; + return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null; + }, "Chromium page target"); + return { browser, cdp: await CdpSession.connect(target) }; +} + +test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", async () => { + const frontendRoot = fileURLToPath(new URL("..", import.meta.url)); + const directory = mkdtempSync(join(tmpdir(), "rectification-browser-")); + const entryPath = join(directory, "fixture.tsx"); + const bundlePath = join(directory, "fixture.js"); + const htmlPath = join(directory, "fixture.html"); + const userDataDirectory = join(directory, "chrome-profile"); + const componentPath = join(frontendRoot, "src/components/conversational-birth-time-rectification.tsx"); + const hookPath = join(frontendRoot, "src/hooks/use-conversational-rectification.ts"); + const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8") + .replace(/^@import[^;]+;\s*/gm, ""); + const fixture = ` + import React, { useEffect, useState } from "react"; + import { createRoot } from "react-dom/client"; + import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)}; + import { useConversationalRectification } from ${JSON.stringify(hookPath)}; + + const caseA = "00000000-0000-4000-8000-000000000821"; + const caseB = "00000000-0000-4000-8000-000000000829"; + const longWord = "D9SENSITIVEREFERENCE".repeat(70); + const makeTurn = (caseId, turnVersion, status = "active") => ({ + caseId, + journeyProtocol: "conversational-evidence-v3", + status, + turnVersion, + narrative: "## 当前判断\\n\\n**05:18** 只是待验证候选。" + longWord, + candidate: { + status: "pending_validation", + representativeTime: "05:18", + rangeStart: "05:10", + rangeEnd: "05:26", + }, + technicalReceipt: { + calculationVersion: "rectification-technical-v1", + stableLayers: ["D1"], + sensitiveLayers: ["D9", "D10"], + candidateDifferenceRefs: ["consult-d9", "consult-d10"], + }, + evidenceRequest: status === "abandoned" ? null : { + domains: ["career", "education", "relocation"], + datePrecision: "month_preferred", + freeTextAllowed: true, + }, + evidenceRecap: [], + actions: status === "abandoned" ? [] : status === "paused" + ? ["answer", "abandon"] + : ["answer", "pause", "abandon"], + pendingConsultationQuestion: null, + }); + const turns = { + activeA1: makeTurn(caseA, 1), + activeA3: makeTurn(caseA, 3), + activeB1: makeTurn(caseB, 1), + abandonedB2: makeTurn(caseB, 2, "abandoned"), + }; + const events = []; + + function Harness() { + const [initialTurn, setInitialTurn] = useState(null); + const [transportLabel, setTransportLabel] = useState("first"); + const [callbackLabel, setCallbackLabel] = useState("first"); + const send = async (command) => { + events.push("send:" + transportLabel + ":" + command.type); + await new Promise((resolveSend) => setTimeout(resolveSend, 20)); + if (command.type === "pause") { + return makeTurn(command.caseId, command.turnVersion + 1, "paused"); + } + if (command.type === "abandon") { + return makeTurn(command.caseId, command.turnVersion + 1, "abandoned"); + } + return makeTurn(command.caseId ?? caseA, (command.turnVersion ?? 0) + 1); + }; + const controller = useConversationalRectification({ + initialTurn, + send, + onTurn: (next) => events.push("turn:" + callbackLabel + ":" + next.status), + }); + useEffect(() => { + globalThis.__rectificationHarness = { + events, + setCallbackLabel, + setTransportLabel, + setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); }, + }; + globalThis.__rectificationReady = true; + }); + return ; + } + createRoot(document.getElementById("root")).render(); + `; + let browser: ChildProcess | null = null; + let cdp: CdpSession | null = null; + try { + writeFileSync(entryPath, fixture); + await build({ + absWorkingDir: frontendRoot, + bundle: true, + define: { "process.env.NODE_ENV": '"test"' }, + entryPoints: [entryPath], + format: "iife", + jsx: "automatic", + logLevel: "silent", + nodePaths: [join(frontendRoot, "node_modules")], + outfile: bundlePath, + platform: "browser", + }); + writeFileSync(htmlPath, `
`); + + ({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory)); + await cdp.send("Runtime.enable"); + await cdp.send("Page.bringToFront"); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + }); + await waitFor( + () => cdp?.evaluate("globalThis.__rectificationReady === true") ?? Promise.resolve(false), + "React fixture readiness", + ); + await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')"); + await waitFor( + () => cdp?.evaluate("document.body.textContent.includes('当前判断')") ?? Promise.resolve(false), + "async initial turn", + ); + + const layout = await cdp.evaluate<{ + viewport: number; + scrollWidth: number; + surfaceWidth: number; + shortestButton: number; + }>(`(() => { + const buttons = [...document.querySelectorAll('.conversational-rectification button')]; + return { + viewport: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width, + shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)), + }; + })()`); + assert.equal(layout.viewport, 390); + assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`); + assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`); + assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`); + + await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()"); + await waitFor( + () => cdp?.evaluate("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false), + "domain-to-composer focus", + ); + + await cdp.evaluate("globalThis.__rectificationHarness.setTransportLabel('second'); globalThis.__rectificationHarness.setCallbackLabel('second')"); + await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('暂停,稍后继续')).click()"); + await waitFor( + () => cdp?.evaluate("document.body.textContent.includes('继续校正')") ?? Promise.resolve(false), + "paused response", + ); + assert.deepEqual( + await cdp.evaluate("globalThis.__rectificationHarness.events.slice()"), + ["send:second:pause", "turn:second:paused"], + ); + + const beforeContinue = await cdp.evaluate("globalThis.__rectificationHarness.events.length"); + await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('继续校正')).click()"); + await waitFor( + () => cdp?.evaluate("document.activeElement?.id === 'conversational-rectification-answer' && document.body.textContent.includes('现在可以继续填写')") ?? Promise.resolve(false), + "local paused continuation feedback", + ); + assert.equal(await cdp.evaluate("globalThis.__rectificationHarness.events.length"), beforeContinue); + + await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')"); + await waitFor( + () => cdp?.evaluate("document.body.textContent.includes('放弃本次校正') && !document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false), + "newer same-case initial turn", + ); + await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()"); + const dialog = await waitFor<{ title: string; description: string; active: string }>( + () => cdp!.evaluate(`(() => { + const dialog = document.querySelector('[role=alertdialog]'); + if (!dialog) return false; + return { + title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '', + description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '', + active: document.activeElement?.textContent?.trim() ?? '', + }; + })()`), + "abandon alertdialog", + ); + assert.match(dialog.title, /确认放弃/); + assert.match(dialog.description, /不会应用任何候选时间/); + assert.equal(dialog.active, "返回校正"); + + await pressEscape(cdp); + await waitFor( + () => cdp?.evaluate("!document.querySelector('[role=alertdialog]') && document.activeElement?.textContent?.includes('放弃本次校正')") ?? Promise.resolve(false), + "Escape close and trigger focus restoration", + ); + + await cdp.evaluate("document.activeElement.click()"); + await waitFor( + () => cdp?.evaluate("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false), + "reopened abandon dialog", + ); + await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeB1')"); + await waitFor( + () => cdp?.evaluate("!document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false), + "case-switch dialog reset", + ); + + await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()"); + await waitFor( + () => cdp?.evaluate("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false), + "case-B abandon dialog", + ); + await cdp.evaluate("globalThis.__rectificationHarness.setTurn('abandonedB2')"); + await waitFor( + () => cdp?.evaluate("!document.querySelector('[role=alertdialog]') && document.body.textContent.includes('本次校正已放弃')") ?? Promise.resolve(false), + "terminal dialog reset", + ); + } finally { + cdp?.close(); + browser?.kill("SIGKILL"); + browser?.stdout?.destroy(); + browser?.stderr?.destroy(); + browser?.unref(); + rmSync(directory, { force: true, recursive: true }); + } +}); diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts index c14f9203..2972c69f 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -13,6 +13,7 @@ import type { } from "../src/lib/conversational-rectification/contracts.ts"; const caseId = "00000000-0000-4000-8000-000000000811"; +const otherCaseId = "00000000-0000-4000-8000-000000000819"; const actionIds = [ "00000000-0000-4000-8000-000000000812", "00000000-0000-4000-8000-000000000813", @@ -129,16 +130,39 @@ test("a stale answer resumes the latest turn and returns recovered state without }, }); controller.setDraft("2022 年 3 月搬到另一座城市"); + controller.selectDomain("relocation"); - const result = await controller.answer("relocation"); + const result = await controller.answer(); assert.deepEqual(result, recovered); assert.deepEqual(commands.map((command) => command.type), ["answer", "resume"]); assert.equal(controller.getSnapshot().turn?.turnVersion, 5); assert.equal(controller.getSnapshot().draft, "2022 年 3 月搬到另一座城市"); + assert.equal(controller.getSnapshot().selectedDomain, null); assert.equal(controller.getSnapshot().error, ""); }); +test("a stale recovery retains the selected domain only while the recovered turn still requests it", async () => { + const recovered = activeTurn(5); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async (command) => { + if (command.type === "answer") { + throw new ConversationalRectificationRequestError(409, "stale_turn", "请加载最新进度后再试。"); + } + return recovered; + }, + }); + controller.setDraft("2021 年 7 月开始第一份工作"); + controller.selectDomain("career"); + + await controller.answer(); + + assert.equal(controller.getSnapshot().draft, "2021 年 7 月开始第一份工作"); + assert.equal(controller.getSnapshot().selectedDomain, "career"); +}); + test("a changed payload receives a different action id after a failed send", async () => { const commands: ConversationalRectificationCommand[] = []; const controller = createConversationalRectificationController({ @@ -157,3 +181,118 @@ test("a changed payload receives a different action id after a failed send", asy assert.notEqual(commands[0]?.actionId, commands[1]?.actionId); }); + +test("a successful turn stays successful when the consumer onTurn callback throws", async () => { + const next = activeTurn(3); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async () => next, + onTurn: () => { + throw new Error("consumer render side effect failed"); + }, + }); + controller.setDraft("2021 年 7 月开始第一份工作"); + + const result = await controller.answer("career"); + + assert.deepEqual(result, next); + assert.equal(controller.getSnapshot().turn?.turnVersion, 3); + assert.equal(controller.getSnapshot().draft, ""); + assert.equal(controller.getSnapshot().error, ""); +}); + +test("external initial turns adopt only newer same-case state", () => { + const controller = createConversationalRectificationController({ initialTurn: null }); + + controller.synchronizeInitialTurn(activeTurn(4)); + assert.equal(controller.getSnapshot().turn?.turnVersion, 4); + + controller.setDraft("仍在填写的经历"); + controller.selectDomain("career"); + controller.synchronizeInitialTurn(activeTurn(3)); + assert.equal(controller.getSnapshot().turn?.turnVersion, 4); + assert.equal(controller.getSnapshot().draft, "仍在填写的经历"); + assert.equal(controller.getSnapshot().selectedDomain, "career"); + + const newer = { + ...activeTurn(5), + evidenceRequest: { + domains: ["education", "relocation"], + datePrecision: "month_preferred", + freeTextAllowed: true, + }, + } satisfies ConversationalRectificationTurn; + controller.synchronizeInitialTurn(newer); + assert.equal(controller.getSnapshot().turn?.turnVersion, 5); + assert.equal(controller.getSnapshot().draft, "仍在填写的经历"); + assert.equal(controller.getSnapshot().selectedDomain, null); +}); + +test("switching cases synchronizes immediately and an old in-flight response cannot overwrite it", async () => { + let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async () => request, + }); + controller.setDraft("旧案例输入"); + const pending = controller.answer("career"); + const newCase = { ...activeTurn(1), caseId: otherCaseId }; + + controller.synchronizeInitialTurn(newCase); + + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().draft, ""); + resolveRequest?.(activeTurn(9)); + await pending; + + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().turn?.turnVersion, 1); + assert.equal(controller.getSnapshot().pending, false); +}); + +test("a newer external same-case turn wins over an older in-flight response", async () => { + let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async () => request, + }); + controller.setDraft("在途输入"); + const pending = controller.answer("career"); + + controller.synchronizeInitialTurn(activeTurn(5)); + resolveRequest?.(activeTurn(3)); + await pending; + + assert.equal(controller.getSnapshot().turn?.turnVersion, 5); +}); + +test("an external same-version turn is not replaced by a late response", async () => { + let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(4), + createActionId: idFactory(), + send: async () => request, + }); + controller.setDraft("在途输入"); + const pending = controller.answer("career"); + const external = { ...activeTurn(5), narrative: "外部已同步的最新轮次" }; + const lateResponse = { ...activeTurn(5), narrative: "较晚返回的旧请求" }; + + controller.synchronizeInitialTurn(external); + resolveRequest?.(lateResponse); + await pending; + + assert.equal(controller.getSnapshot().turn?.narrative, "外部已同步的最新轮次"); +});