import assert from "node:assert/strict"; 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 { ConversationalRectificationSurface, } from "../src/components/conversational-birth-time-rectification.tsx"; import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts"; import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts"; // The repository keeps JSX in preserve mode for Next.js; direct Node SSR needs the classic global. Object.assign(globalThis, { React }); const turn: ConversationalRectificationTurn = { caseId: "00000000-0000-4000-8000-000000000821", journeyProtocol: "conversational-evidence-v3", status: "confirming", turnVersion: 6, narrative: "## 当前判断\n\n**05:18** 只是待验证候选;D9 与 D10 仍需真实经历交叉验证。", candidate: { status: "ready_for_confirmation", representativeTime: "05:18", rangeStart: "05:16", rangeEnd: "05:20", }, technicalReceipt: { calculationVersion: "rectification-technical-v1", stableLayers: ["D1"], sensitiveLayers: ["D9", "D10"], candidateDifferenceRefs: ["consult-d9", "consult-d10"], }, evidenceRequest: { domains: ["relationship", "career", "relocation"], datePrecision: "month_preferred", freeTextAllowed: true, }, evidenceRecap: [{ id: "00000000-0000-4000-8000-000000000822", summary: "开始第一份长期工作", dateLabel: "2021 年 7 月", }], actions: ["answer", "pause", "abandon", "confirm"], pendingConsultationQuestion: "我适合什么时候换工作?", }; function controller(overrides: Partial = {}): ConversationalRectificationController { return { turn, draft: "", selectedDomain: null, pending: false, error: "", getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }), subscribe: () => () => undefined, synchronizeInitialTurn: () => undefined, setDraft: () => undefined, selectDomain: () => undefined, start: async () => turn, resume: async () => turn, answer: async () => turn, pause: async () => turn, abandon: async () => turn, confirm: async () => turn, ...overrides, }; } test("rich narrative precedes 2–4 domain choices while free text remains available", () => { const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, { controller: controller() }, )); assert.match(markup, /

当前判断<\/h2>/); assert.match(markup, /05:18<\/strong>/); assert.ok(markup.indexOf("当前判断") < markup.indexOf("重要关系")); assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3); assert.match(markup, /]+id="conversational-rectification-answer"/); assert.match(markup, /Ctrl\/⌘ \+ Enter/); assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/); }); test("evidence is correctable, technical receipts stay visible, and confirmation is explicit", () => { const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, { controller: controller() }, )); assert.match(markup, /已记录的真实经历/); assert.match(markup, /2021 年 7 月/); assert.match(markup, /更正这条经历:开始第一份长期工作/); assert.match(markup, /本轮技术回执/); assert.match(markup, /rectification-technical-v1/); assert.match(markup, /consult-d9/); assert.match(markup, /待确认 · 未验证/); assert.match(markup, /确认将 05:18 设为当前排盘时间/); assert.match(markup, /不会自动采用/); assert.match(markup, /暂停,稍后继续/); assert.match(markup, /放弃本次校正/); }); test("pending markup and responsive CSS expose accessibility contracts", () => { const pendingController = controller({ pending: true, draft: "保留中的文字", getSnapshot: () => ({ turn, draft: "保留中的文字", selectedDomain: "career", pending: true, error: "", }), }); const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, { controller: pendingController }, )); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const component = readFileSync( new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8", ); assert.match(markup, /aria-busy="true"/); assert.match(markup, /]+disabled=""[^>]*>保留中的文字<\/textarea>/); assert.match(markup, /aria-label="生时校正对话"/); assert.match(markup, /role="alert"|aria-live="polite"/); assert.match(css, /\.conversational-rectification[\s\S]*min-width:\s*0/); assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/); assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/); assert.match(css, /\.conversational-rectification[^}]*:focus-visible/); assert.match(css, /summary, \.conversational-status\):focus-visible/); 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 }>; }>; const CDP_CONNECT_TIMEOUT_MS = 3_000; const CDP_COMMAND_TIMEOUT_MS = 3_000; const EXTERNAL_PROBE_TIMEOUT_MS = 1_000; async function withHardDeadline( operation: Promise, timeoutMs: number, label: string, onTimeout?: () => void, ): Promise { let timeout: ReturnType | undefined; const deadline = new Promise((_resolve, reject) => { timeout = setTimeout(() => { try { onTimeout?.(); } catch { // Timeout still rejects even if the best-effort cancellation hook itself fails. } reject(new Error(`Timed out waiting for ${label}`)); }, timeoutMs); }); try { return await Promise.race([operation, deadline]); } finally { if (timeout) clearTimeout(timeout); } } class CdpSession { private nextId = 0; private readonly pending = new Map; }>(); private closedError: Error | null = null; private constructor(private readonly socket: WebSocket) { socket.addEventListener("message", (event) => { let message: CdpResponse; try { message = JSON.parse(String(event.data)) as CdpResponse; } catch { this.rejectPending(new Error("Chromium CDP returned malformed JSON")); return; } if (message.id === undefined) return; const request = this.pending.get(message.id); if (!request) return; this.pending.delete(message.id); clearTimeout(request.timeout); if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed")); else request.resolve(message.result); }); socket.addEventListener("close", () => { this.rejectPending(new Error("Chromium CDP socket closed")); }); socket.addEventListener("error", () => { this.rejectPending(new Error("Chromium CDP socket failed")); }); } static async connect(url: string): Promise { const socket = new WebSocket(url); await withHardDeadline(new Promise((resolveConnection, rejectConnection) => { const connected = () => { cleanup(); resolveConnection(); }; const failed = () => { cleanup(); rejectConnection(new Error("Unable to connect to Chromium CDP")); }; const closed = () => { cleanup(); rejectConnection(new Error("Chromium CDP closed before connecting")); }; const cleanup = () => { socket.removeEventListener("open", connected); socket.removeEventListener("error", failed); socket.removeEventListener("close", closed); }; socket.addEventListener("open", connected, { once: true }); socket.addEventListener("error", failed, { once: true }); socket.addEventListener("close", closed, { once: true }); }), CDP_CONNECT_TIMEOUT_MS, "Chromium CDP connection", () => socket.close()); return new CdpSession(socket); } async send(method: string, params: unknown = {}): Promise { if (this.closedError) throw this.closedError; if (this.socket.readyState !== WebSocket.OPEN) { throw new Error("Chromium CDP socket is not open"); } const id = ++this.nextId; const response = new Promise((resolveResponse, rejectResponse) => { const timeout = setTimeout(() => { if (!this.pending.delete(id)) return; rejectResponse(new Error(`Timed out waiting for CDP command ${method}`)); }, CDP_COMMAND_TIMEOUT_MS); this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse, timeout }); }); try { this.socket.send(JSON.stringify({ id, method, params })); } catch (error) { const request = this.pending.get(id); if (request) { this.pending.delete(id); clearTimeout(request.timeout); request.reject(error instanceof Error ? error : new Error(String(error))); } } 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.rejectPending(new Error("Chromium CDP session closed")); if (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN) { this.socket.close(); } } private rejectPending(error: Error) { this.closedError ??= error; for (const request of this.pending.values()) { clearTimeout(request.timeout); request.reject(error); } this.pending.clear(); } } 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 remaining = deadline - Date.now(); const value = await withHardDeadline( Promise.resolve().then(probe), Math.max(1, Math.min(EXTERNAL_PROBE_TIMEOUT_MS, remaining)), `${label} probe`, ); if (value !== null && value !== false) return value; } catch (error) { lastError = error; } const remaining = deadline - Date.now(); if (remaining > 0) { await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(30, remaining))); } } throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`); } async function fetchJsonWithDeadline(url: string): Promise { const abort = new AbortController(); try { const response = await withHardDeadline( fetch(url, { signal: abort.signal }), EXTERNAL_PROBE_TIMEOUT_MS, `fetch ${url}`, () => abort.abort(), ); if (!response.ok) throw new Error(`Chromium target list returned HTTP ${response.status}`); return await withHardDeadline( response.json() as Promise, EXTERNAL_PROBE_TIMEOUT_MS, `JSON body from ${url}`, () => abort.abort(), ); } finally { abort.abort(); } } async function terminateChildProcess(browser: ChildProcess): Promise { try { const alreadyExited = browser.exitCode !== null || browser.signalCode !== null; if (!alreadyExited) { const closed = new Promise((resolveClosed) => { const done = () => { browser.removeListener("close", done); browser.removeListener("error", done); resolveClosed(); }; browser.once("close", done); browser.once("error", done); }); browser.kill("SIGKILL"); await withHardDeadline(closed, 3_000, "Chromium process exit"); } } finally { browser.stdout?.destroy(); browser.stderr?.destroy(); } } 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", "--remote-debugging-port=0", `--user-data-dir=${userDataDirectory}`, pathToFileURL(htmlPath).href, ], { stdio: ["ignore", "pipe", "pipe"] }); let launchError: Error | null = null; let cdp: CdpSession | null = null; browser.on("error", (error) => { launchError = error; }); browser.stdout?.resume(); browser.stderr?.resume(); try { const activePort = join(userDataDirectory, "DevToolsActivePort"); const port = await waitFor(async () => { if (launchError) throw launchError; if (browser.exitCode !== null || browser.signalCode !== null) { throw new Error( `Chromium exited before CDP was ready (${browser.exitCode ?? browser.signalCode})`, ); } if (!existsSync(activePort)) return null; const parsed = Number(readFileSync(activePort, "utf8").split("\n")[0]); if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) { throw new Error("Chromium wrote an invalid DevTools port"); } return parsed; }, "Chromium DevTools port"); const target = await waitFor(async () => { const targets = await fetchJsonWithDeadline>(`http://127.0.0.1:${port}/json/list`); return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null; }, "Chromium page target"); cdp = await CdpSession.connect(target); return { browser, cdp }; } catch (error) { cdp?.close(); let cleanupError: unknown; try { await terminateChildProcess(browser); } catch (caught) { cleanupError = caught; } finally { rmSync(userDataDirectory, { force: true, recursive: true }); } if (cleanupError) { throw new AggregateError([error, cleanupError], "Chromium fixture launch and cleanup failed"); } throw error; } } test("Chromium harness keeps the browser sandbox and bounds every external wait", () => { const source = readFileSync(fileURLToPath(import.meta.url), "utf8"); const unsafeSandboxFlag = ["--no", "sandbox"].join("-"); assert.equal(source.includes(`"${unsafeSandboxFlag}"`), false); assert.match(source, /withHardDeadline/); assert.match(source, /rejectPending/); assert.match(source, /terminateChildProcess/); assert.match(source, /fetchJsonWithDeadline/); }); test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", { timeout: 30_000, }, 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 withHardDeadline(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", }), 10_000, "browser fixture bundle"); 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("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('确认放弃且不应用候选')).click()"); await waitFor( () => cdp?.evaluate(`(() => { const status = document.querySelector('.conversational-status'); return !document.querySelector('[role=alertdialog]') && document.body.textContent.includes('本次校正已放弃') && status?.tabIndex === -1 && document.activeElement === status; })()`) ?? Promise.resolve(false), "terminal dialog close and terminal status focus", ); } finally { try { cdp?.close(); if (browser) await terminateChildProcess(browser); } finally { rmSync(directory, { force: true, recursive: true }); } } });