| 1 | // Browser gate for the transcript's turn-fork entry. It builds the bench page |
| 2 | // with the real Transcript and drives it in Chromium, reading the rendered |
| 3 | // states, labels, and notices out of the DOM. |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { mkdir, mkdtemp, rm } from "node:fs/promises"; |
| 6 | import { tmpdir } from "node:os"; |
| 7 | import path from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { build, preview, loadConfigFromFile } from "vite"; |
| 10 | |
| 11 | // Browsers resolve from the default Playwright cache; this gate never pins a |
| 12 | // worktree-local path. |
| 13 | const { chromium } = await import("playwright"); |
| 14 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 15 | const outDir = await mkdtemp(path.join(tmpdir(), "reasonix-fork-build-")); |
| 16 | const evidence = process.env.REASONIX_FORK_EVIDENCE ?? path.join(tmpdir(), "reasonix-fork-evidence"); |
| 17 | await mkdir(evidence, { recursive: true }); |
| 18 | const loaded = await loadConfigFromFile({ command: "build", mode: "production" }, path.join(root, "vite.config.ts")); |
| 19 | const config = loaded.config; |
| 20 | await build({ ...config, configFile: false, root, logLevel: "error", |
| 21 | plugins: config.plugins.filter(plugin => !["archive-hidden-sourcemaps", "keep-dist-placeholder"].includes(plugin?.name)), |
| 22 | build: { ...config.build, outDir, sourcemap: false, |
| 23 | rolldownOptions: { ...config.build.rolldownOptions, input: path.join(root, "bench/fork-targets.html") } } }); |
| 24 | const server = await preview({ configFile: false, root, logLevel: "error", build: { outDir }, preview: { host: "127.0.0.1", port: 0 } }); |
| 25 | const address = server.httpServer.address(); |
| 26 | const base = `http://127.0.0.1:${address.port}/bench/fork-targets.html`; |
| 27 | const browser = await chromium.launch({ headless: true }); |
| 28 | const report = { browser: browser.version(), platform: process.platform, arch: process.arch, states: {}, clicks: [], notices: [] }; |
| 29 | |
| 30 | // Selectors: the transcript's action row and the branch entry are the shipped |
| 31 | // ones; the bench adds only the fixture control surface. |
| 32 | const ENTRY = ".chat-actions button.chat-action-icon:not(.copybtn)"; |
| 33 | const entry = page => page.locator(ENTRY).last(); |
| 34 | const settle = page => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); |
| 35 | const described = (page, selector) => page.evaluate((sel) => { |
| 36 | // The rendered entry is the LAST turn's, matching the locator used above. |
| 37 | const button = Array.from(document.querySelectorAll(sel)).at(-1); |
| 38 | const id = button?.getAttribute("aria-describedby"); |
| 39 | return id ? document.getElementById(id)?.textContent ?? "" : ""; |
| 40 | }, selector); |
| 41 | const state = async (page, name) => { |
| 42 | const button = entry(page); |
| 43 | await button.waitFor(); |
| 44 | return { name, disabled: await button.getAttribute("aria-disabled"), unavailable: await button.getAttribute("data-unavailable"), |
| 45 | label: await button.getAttribute("aria-label"), described: await described(page, ENTRY) }; |
| 46 | }; |
| 47 | // Locale dictionaries load on demand, and until the requested one arrives the |
| 48 | // provider renders its English fallback. Every read of localized copy therefore |
| 49 | // waits for the text itself, so a green run never depends on load timing. |
| 50 | const waitForLabel = (page, expected) => page.waitForFunction(({ selector, expected }) => { |
| 51 | const nodes = document.querySelectorAll(selector); |
| 52 | return nodes.length > 0 && nodes[nodes.length - 1].getAttribute("aria-label") === expected; |
| 53 | }, { selector: ENTRY, expected }, { timeout: 10_000 }); |
| 54 | const waitForDescribed = (page, expected) => page.waitForFunction(({ selector, expected }) => { |
| 55 | const nodes = document.querySelectorAll(selector); |
| 56 | const id = nodes.length ? nodes[nodes.length - 1].getAttribute("aria-describedby") : null; |
| 57 | return Boolean(id) && document.getElementById(id)?.textContent === expected; |
| 58 | }, { selector: ENTRY, expected }, { timeout: 10_000 }); |
| 59 | |
| 60 | try { |
| 61 | const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); |
| 62 | const errors = []; |
| 63 | page.on("pageerror", (error) => errors.push(error.message)); |
| 64 | await page.goto(base); |
| 65 | await page.locator(".chat-column .chat-turn-tail").last().waitFor(); |
| 66 | await settle(page); |
| 67 | |
| 68 | // A completed turn offers the persisted boundary of its answer. |
| 69 | await waitForLabel(page, "Branch into a new conversation"); |
| 70 | const completed = await state(page, "completed"); |
| 71 | await entry(page).focus(); |
| 72 | await page.waitForFunction((expected) => (document.querySelector('[role="tooltip"]')?.textContent ?? "") === expected, |
| 73 | "Branch into a new conversation", { timeout: 10_000 }); |
| 74 | const tooltip = await page.locator('[role="tooltip"]').textContent(); |
| 75 | assert.equal(completed.disabled, null, "a completed turn renders an enabled fork entry"); |
| 76 | assert.equal(completed.unavailable, null, "the enabled entry is not marked unavailable"); |
| 77 | assert.equal(tooltip, completed.label, "the focused entry explains itself with the branch label"); |
| 78 | report.states.completed = { ...completed, tooltip }; |
| 79 | |
| 80 | // The open turn has no boundary yet. |
| 81 | await page.evaluate(() => window.forkFixture.source("open")); |
| 82 | await waitForDescribed(page, "This turn has not finished yet, so it has no boundary to branch from."); |
| 83 | const open = await state(page, "open"); |
| 84 | assert.equal(open.disabled, "true", "an unfinished turn renders a disabled fork entry"); |
| 85 | assert.match(open.described, /has not finished yet/, "the unfinished turn names its reason"); |
| 86 | report.states.open = open; |
| 87 | |
| 88 | // A source that keeps no turn records proves no boundary. |
| 89 | await page.evaluate(() => window.forkFixture.source("recordless")); |
| 90 | await waitForDescribed(page, "This turn has no verifiable branch boundary in the session's records."); |
| 91 | const recordless = await state(page, "recordless"); |
| 92 | assert.equal(recordless.disabled, "true", "a recordless source renders a disabled fork entry"); |
| 93 | assert.match(recordless.described, /no verifiable branch boundary/, "the recordless source names its reason"); |
| 94 | report.states.recordless = recordless; |
| 95 | |
| 96 | // Clicking the enabled entry creates the child through the real binding. |
| 97 | await page.evaluate(() => window.forkFixture.source("completed")); |
| 98 | await waitForLabel(page, "Branch into a new conversation"); |
| 99 | await entry(page).click(); |
| 100 | await entry(page).click(); |
| 101 | await page.waitForFunction(() => window.forkFixture.calls().length === 2, null, { timeout: 10_000 }); |
| 102 | await page.waitForFunction(() => document.querySelectorAll("[data-fork-call]").length === 2, null, { timeout: 10_000 }); |
| 103 | const calls = await page.evaluate(() => window.forkFixture.calls()); |
| 104 | const operations = await page.locator("[data-fork-call]").evaluateAll((nodes) => nodes.map((node) => node.textContent ?? "")); |
| 105 | assert.equal(calls.length, 2, "each click dispatches one create"); |
| 106 | assert.deepEqual(calls.map((call) => call.turnId), ["turn-2", "turn-2"], "the click carries the turn identity"); |
| 107 | assert.ok(calls.every((call) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(call.operationId)), |
| 108 | "each acknowledged host operation has an id"); |
| 109 | assert.notEqual(calls[0].operationId, calls[1].operationId, "the second acknowledged click is a new operation, not a replay"); |
| 110 | assert.deepEqual(operations, calls.map((call) => call.operationId), "the dispatched operation ids are the rendered ones"); |
| 111 | report.clicks = calls; |
| 112 | |
| 113 | // A created child whose tab did not open reaches the user as the recovery notice. |
| 114 | await page.goto(`${base}?fork-attach-failure=1`); |
| 115 | await page.locator(".chat-column .chat-turn-tail").last().waitFor(); |
| 116 | await waitForLabel(page, "Branch into a new conversation"); |
| 117 | await entry(page).click(); |
| 118 | await page.waitForFunction(() => (document.querySelector("[data-fork-notice]")?.textContent ?? "").includes("mock-fork-turn-"), null, { timeout: 10_000 }); |
| 119 | await entry(page).click(); |
| 120 | await page.waitForFunction(() => window.forkFixture.calls().length === 2, null, { timeout: 10_000 }); |
| 121 | const notice = await page.locator("[data-fork-notice]").textContent(); |
| 122 | const failed = await page.evaluate(() => window.forkFixture.calls()); |
| 123 | assert.equal(failed[1].operationId, failed[0].operationId, "an unacknowledged attach failure reuses the host operation"); |
| 124 | assert.ok(notice?.includes(failed[0].operationId), "the recovery notice names the child the failed attach produced"); |
| 125 | assert.match(notice ?? "", /could not open/, "the notice explains what happened to the child"); |
| 126 | assert.equal(await page.locator("[data-fork-notice]").count(), 1, "the failure is surfaced, never swallowed"); |
| 127 | report.notices.push(notice ?? ""); |
| 128 | |
| 129 | // The same states and labels read in the second shipped locale. The zh-CN |
| 130 | // dictionary arrives on demand, so each read waits for the localized text |
| 131 | // the switch is expected to produce. |
| 132 | await page.goto(base); |
| 133 | await page.locator(".chat-column .chat-turn-tail").last().waitFor(); |
| 134 | await settle(page); |
| 135 | const localeStartedAt = Date.now(); |
| 136 | await page.evaluate(() => window.forkFixture.locale("zh")); |
| 137 | await waitForLabel(page, "在新对话中分支"); |
| 138 | // Recorded, not asserted: the provider loads a locale dictionary on demand and |
| 139 | // renders its English fallback until that chunk lands. |
| 140 | report.localeApplyMs = Date.now() - localeStartedAt; |
| 141 | const zhCompleted = await state(page, "zh-completed"); |
| 142 | await page.evaluate(() => window.forkFixture.source("open")); |
| 143 | await waitForDescribed(page, "该轮次尚未结束,还没有可供分支的边界。"); |
| 144 | const zhOpen = await state(page, "zh-open"); |
| 145 | await page.evaluate(() => window.forkFixture.source("recordless")); |
| 146 | await waitForDescribed(page, "该轮次在会话记录中没有可确认的分支边界。"); |
| 147 | const zhRecordless = await state(page, "zh-recordless"); |
| 148 | assert.equal(zhCompleted.label, "在新对话中分支", "the enabled entry keeps the branch label in zh-CN"); |
| 149 | assert.equal(zhCompleted.disabled, null, "a completed turn stays forkable in zh-CN"); |
| 150 | assert.match(zhOpen.described, /该轮次尚未结束/, "the unfinished-turn reason is localized"); |
| 151 | assert.match(zhRecordless.described, /没有可确认的分支边界/, "the unverifiable reason is localized"); |
| 152 | report.states["zh-CN"] = { completed: zhCompleted, open: zhOpen, recordless: zhRecordless }; |
| 153 | |
| 154 | await page.screenshot({ path: path.join(evidence, "fork-targets.png") }); |
| 155 | assert.deepEqual(errors, []); |
| 156 | console.log(JSON.stringify(report, null, 2)); |
| 157 | } finally { |
| 158 | await browser.close(); |
| 159 | await server.httpServer.close(); |
| 160 | await rm(outDir, { recursive: true, force: true }); |
| 161 | } |
| 162 |