| 1 | import assert from "node:assert/strict"; |
| 2 | import { mkdtemp, copyFile, writeFile, mkdir, rm } from "node:fs/promises"; |
| 3 | import { writeFileSync } from "node:fs"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import path from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { createServer } from "vite"; |
| 9 | import { verifyNativeReaderInput } from "./submission-native-input.mjs"; |
| 10 | |
| 11 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 12 | process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(root, ".pw-browsers"); |
| 13 | const { chromium, _electron } = await import("playwright"); |
| 14 | const electron = process.argv.includes("--electron"); |
| 15 | const nativeInput = process.argv.includes("--native-input"); |
| 16 | assert.ok(!nativeInput || electron, "native input requires Electron"); |
| 17 | const evidence = process.env.REASONIX_HANDOFF_EVIDENCE ?? path.join(tmpdir(), "reasonix-handoff-evidence"); |
| 18 | await mkdir(evidence, { recursive: true }); |
| 19 | const server = await createServer({ root, server: { host: "127.0.0.1", port: 0 } }); |
| 20 | await server.listen(); |
| 21 | const url = `http://127.0.0.1:${server.httpServer.address().port}/bench/submission-handoff.html`; |
| 22 | console.log(url); |
| 23 | const host = await mkdtemp(path.join(tmpdir(), "reasonix-handoff-host-")); |
| 24 | let browser, app; |
| 25 | const report = { host: electron ? "electron" : "chromium", actions: [], rounds: [], errors: [], nativeInput: "not_run", complete: false }; |
| 26 | try { |
| 27 | let page; |
| 28 | if (electron) { |
| 29 | await copyFile(path.join(root, nativeInput ? "bench/submission-native-electron.cjs" : "bench/transcript-layout-electron.cjs"), path.join(host, "main.cjs")); |
| 30 | app = await _electron.launch({ executablePath: createRequire(path.join(root, "../electron/package.json"))("electron"), args: [path.join(host, "main.cjs")], env: { ...process.env, REASONIX_LAYOUT_URL: url } }); |
| 31 | page = await app.firstWindow(); |
| 32 | } else { |
| 33 | browser = await chromium.launch({ headless: true }); |
| 34 | page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); |
| 35 | } |
| 36 | page.on("pageerror", error => report.errors.push(error.message)); |
| 37 | page.on("console", message => { if (message.type() === "error" && /same key|unique.*key|Maximum update|Uncaught/i.test(message.text())) report.errors.push(message.text()); }); |
| 38 | await page.addInitScript(() => { window.handoffWrites = []; window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = write => window.handoffWrites.push(write); }); |
| 39 | await page.goto(url); |
| 40 | await page.waitForFunction(() => Boolean(window.handoff)); |
| 41 | const frame = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); |
| 42 | await page.locator('[data-chat-kind="user"]').first().waitFor(); |
| 43 | await frame(); |
| 44 | const input = page.locator("textarea.composer__input:not(.composer__input--measure)"); |
| 45 | await input.fill("handoff question"); await input.press("Enter"); |
| 46 | report.actions.push("composer-submit", "live-output", "rpc-accepted", "output-completed", "expand-process", "batched-identity-and-message-only-record"); |
| 47 | await page.waitForFunction(() => window.handoff.inspect().locals === 1); |
| 48 | await page.evaluate(() => window.handoff.output()); await frame(); |
| 49 | await page.evaluate(() => window.handoff.finish()); await frame(); |
| 50 | const disclosure = page.locator('[data-chat-kind="process"]').last().locator('button[aria-expanded]'); |
| 51 | await disclosure.click(); |
| 52 | await frame(); |
| 53 | assert.equal(await disclosure.getAttribute("aria-expanded"), "true"); |
| 54 | await page.evaluate(() => { |
| 55 | window.beforeHandoff = Object.fromEntries(["user", "process", "tail"].map(kind => [kind, [...document.querySelectorAll(`[data-chat-kind="${kind}"]`)].at(-1)])); |
| 56 | const text = window.beforeHandoff.user.querySelector(".msg__body"); |
| 57 | if (text?.firstChild) { const range = document.createRange(); range.selectNodeContents(text); getSelection().removeAllRanges(); getSelection().addRange(range); } |
| 58 | window.selectionBefore = getSelection().toString(); |
| 59 | window.focusBefore = document.activeElement; |
| 60 | window.writeCountBefore = window.handoffWrites.length; |
| 61 | window.handoff.batched(); |
| 62 | }); |
| 63 | await page.waitForFunction(() => window.handoff.inspect().locals === 0); await frame(); |
| 64 | report.handoff = await page.evaluate(() => ({ |
| 65 | sameNodes: Object.fromEntries(Object.entries(window.beforeHandoff).map(([kind, node]) => [kind, Boolean(node && node === [...document.querySelectorAll(`[data-chat-kind="${kind}"]`)].at(-1))])), |
| 66 | selectionPreserved: getSelection().toString() === window.selectionBefore, |
| 67 | focusPreserved: document.activeElement === window.focusBefore, |
| 68 | processExpanded: [...document.querySelectorAll('[data-chat-kind="process"]')].at(-1)?.querySelector('button')?.getAttribute("aria-expanded"), |
| 69 | newWrites: window.handoffWrites.slice(window.writeCountBefore), ...window.handoff.inspect(), |
| 70 | })); |
| 71 | assert.ok(Object.values(report.handoff.sameNodes).every(Boolean), JSON.stringify(report.handoff.sameNodes)); |
| 72 | assert.ok(report.handoff.selectionPreserved); |
| 73 | assert.ok(report.handoff.focusPreserved); |
| 74 | assert.equal(report.handoff.processExpanded, "true"); |
| 75 | assert.equal(report.handoff.ids.filter(id => id === "m:sent-0").length, 1); |
| 76 | await page.evaluate(() => window.handoff.finish()); await frame(); |
| 77 | |
| 78 | for (let round = 0; round < 20; round++) { |
| 79 | report.actions.push({ round, direction: "older", pages: 4 }); |
| 80 | await page.evaluate(async () => { for (let step = 0; step < 4; step++) await window.handoff.page("older"); }); await frame(); |
| 81 | let sample = await page.evaluate(() => window.handoff.inspect()); |
| 82 | assert.ok(sample.stats.residentWindowEntries <= 96); |
| 83 | assert.ok(sample.handoffs <= sample.users); |
| 84 | assert.ok(sample.presentation.messages <= sample.users); |
| 85 | assert.equal(sample.locals, 0); |
| 86 | assert.equal(sample.ids.includes("m:sent-0"), false); |
| 87 | if (round === 0) { |
| 88 | report.actions.push("reader-wheel", "offscreen-submit", "offscreen-record"); |
| 89 | if (nativeInput) { |
| 90 | report.nativeInput = {}; |
| 91 | const saveProgress = () => writeFileSync(path.join(evidence, "native-input-progress.json"), JSON.stringify(report.nativeInput, null, 2)); |
| 92 | const progress = setInterval(saveProgress, 2000); |
| 93 | const deadline = setTimeout(() => { |
| 94 | report.nativeInput.timeout = true; |
| 95 | saveProgress(); |
| 96 | app.process().kill("SIGKILL"); |
| 97 | }, 90_000); |
| 98 | try { await verifyNativeReaderInput(page, report.nativeInput); } |
| 99 | catch (error) { |
| 100 | await page.screenshot({ path: path.join(evidence, "native-input-failure.png"), timeout: 5000 }).catch(() => {}); |
| 101 | throw error; |
| 102 | } finally { |
| 103 | clearInterval(progress); clearTimeout(deadline); saveProgress(); |
| 104 | } |
| 105 | } |
| 106 | else { |
| 107 | await page.locator(".chat-flow-scroll").hover(); |
| 108 | await page.mouse.wheel(0, -300); |
| 109 | } |
| 110 | await page.waitForFunction(() => document.querySelector(".chat-flow-scroll")?.getAttribute("data-scroll-mode") === "reader"); |
| 111 | await frame(); |
| 112 | await page.evaluate(() => { window.handoff.send("offscreen question"); window.readerIds = window.handoff.inspect().ids; }); await frame(); |
| 113 | await page.evaluate(() => { |
| 114 | const row = document.querySelector('[data-chat-kind="user"]'); |
| 115 | window.readerNode = row; window.readerTop = row.getBoundingClientRect().top; |
| 116 | window.writeCountBefore = window.handoffWrites.length; |
| 117 | window.handoff.record(); |
| 118 | }); |
| 119 | await page.waitForFunction(() => window.handoff.inspect().locals === 0); await frame(); |
| 120 | report.offscreen = await page.evaluate(() => ({ sameIds: JSON.stringify(window.readerIds) === JSON.stringify(window.handoff.inspect().ids), |
| 121 | sameNode: window.readerNode.isConnected, drift: Math.abs(window.readerNode.getBoundingClientRect().top - window.readerTop), |
| 122 | newWrites: window.handoffWrites.slice(window.writeCountBefore) })); |
| 123 | assert.ok(report.offscreen.sameIds && report.offscreen.sameNode); |
| 124 | assert.ok(report.offscreen.drift <= 1); |
| 125 | assert.ok(report.offscreen.newWrites.every(write => write.owner === "restore" && write.outcome === "no-op"), "confirmation must not scroll the reader or request tail-follow"); |
| 126 | await page.evaluate(() => window.handoff.finish()); |
| 127 | } |
| 128 | report.actions.push({ round, direction: "newer", pages: 4 }); |
| 129 | await page.evaluate(async () => { for (let step = 0; step < 4; step++) await window.handoff.page("newer"); }); await frame(); |
| 130 | sample = await page.evaluate(() => window.handoff.inspect()); |
| 131 | assert.ok(sample.stats.residentWindowEntries <= 96); |
| 132 | assert.ok(sample.handoffs <= sample.users); |
| 133 | assert.ok(sample.presentation.messages <= sample.users); |
| 134 | assert.ok(sample.presentation.submissions <= sample.users + sample.locals); |
| 135 | assert.equal(new Set(sample.ids).size, sample.ids.length); |
| 136 | assert.equal(sample.ids.filter(id => id === "m:sent-0").length, 1, "reclaimed message must reload exactly once"); |
| 137 | assert.equal(await page.locator('[data-chat-kind="user"]').filter({ hasText: "handoff question" }).count(), 1); |
| 138 | assert.equal(await page.locator('[data-chat-kind="user"]').filter({ hasText: "offscreen question" }).count(), 1); |
| 139 | assert.equal(sample.locals, 0); |
| 140 | report.rounds.push(sample); |
| 141 | } |
| 142 | assert.ok(report.rounds.at(-1).stats.reclaimedPages > 0); |
| 143 | assert.deepEqual(report.errors, []); |
| 144 | report.complete = true; |
| 145 | console.log(JSON.stringify({ host: report.host, complete: true, rounds: report.rounds.length, handoff: report.handoff.sameNodes, offscreen: report.offscreen, stats: report.rounds.at(-1).stats })); |
| 146 | } finally { |
| 147 | await writeFile(path.join(evidence, `${report.host}.json`), JSON.stringify(report, null, 2)); |
| 148 | await app?.close(); await browser?.close(); await server.close(); await rm(host, { recursive: true, force: true }); |
| 149 | } |
| 150 |