| 1 | import assert from "node:assert/strict"; |
| 2 | import { mkdir } from "node:fs/promises"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import path from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { createServer } from "vite"; |
| 7 | import { newSessionButton } from "./app-page-actions.mjs"; |
| 8 | |
| 9 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 10 | process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(root, ".pw-browsers"); |
| 11 | const { chromium } = await import("playwright"); |
| 12 | const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 0 } }); |
| 13 | await server.listen(); |
| 14 | let browser; |
| 15 | try { |
| 16 | browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE }); |
| 17 | const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" }); |
| 18 | const errors = []; |
| 19 | page.on("pageerror", error => errors.push(error.message)); |
| 20 | const address = server.httpServer.address(); |
| 21 | await page.goto(`http://127.0.0.1:${address.port}/?mock=bench&bench=1`); |
| 22 | const input = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 23 | await input.waitFor(); |
| 24 | await page.evaluate(async () => { |
| 25 | const { app } = await import("/src/lib/bridge.ts"); |
| 26 | const { DESKTOP_COMMANDS } = await import("/src/generated/desktopContract.generated.ts"); |
| 27 | const { installDesktopHostStub } = await import("/src/__tests__/desktopHostStub.ts"); |
| 28 | const fallback = Object.fromEntries(DESKTOP_COMMANDS.map(key => [key, app[key]])); |
| 29 | const operations = new Map(); |
| 30 | const observedReady = new Set(); |
| 31 | const reads = new Map(); |
| 32 | const operationFor = meta => [...operations.values()].find(op => op.ref.sessionId === (meta.session?.sessionId || meta.sessionId)); |
| 33 | const startup = meta => { |
| 34 | const operation = operationFor(meta); |
| 35 | return operation?.phase === "starting" ? { ...meta, ready: false } : meta; |
| 36 | }; |
| 37 | installDesktopHostStub({ ...fallback, |
| 38 | BeginManualSessionCreation: async request => { |
| 39 | // The mock creates the durable identity; hold runtime readiness behind |
| 40 | // an explicit fixture completion, just like the host's reserved tab. |
| 41 | const result = await fallback.BeginManualSessionCreation(request); |
| 42 | const operation = { ...result, phase: "starting", surfaceReady: true }; |
| 43 | operations.set(request.operationId, operation); |
| 44 | return structuredClone(operation); |
| 45 | }, |
| 46 | GetManualSessionCreation: async id => { |
| 47 | reads.set(id, (reads.get(id) || 0) + 1); |
| 48 | const operation = operations.get(id); |
| 49 | if (operation.phase === "ready") observedReady.add(id); |
| 50 | return structuredClone(operation); |
| 51 | }, |
| 52 | ListManualSessionCreations: async () => [...operations.values()].filter(op => op.phase !== "ready"), |
| 53 | ListTabs: async () => (await fallback.ListTabs()).map(startup), |
| 54 | MetaForTab: async id => startup(await fallback.MetaForTab(id)), |
| 55 | }); |
| 56 | window.manualCreationFixture = { |
| 57 | operations: () => [...operations.values()], |
| 58 | finish: id => { operations.get(id).phase = "ready"; }, |
| 59 | observedReady: id => observedReady.has(id), |
| 60 | reads: id => reads.get(id) || 0, |
| 61 | active: async () => (await app.ListTabs()).find(tab => tab.active)?.session?.sessionId, |
| 62 | }; |
| 63 | }); |
| 64 | await input.fill("Existing session input"); |
| 65 | await newSessionButton(page).click(); |
| 66 | await page.waitForFunction(() => window.manualCreationFixture.operations().length === 1); |
| 67 | await input.waitFor({ state: "visible" }); |
| 68 | await page.waitForFunction(() => { |
| 69 | const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])"); |
| 70 | return input && !input.closest("[inert]") && !input.disabled && input.value === ""; |
| 71 | }); |
| 72 | const first = await page.evaluate(() => window.manualCreationFixture.operations()[0]); |
| 73 | assert.equal(first.phase, "starting"); |
| 74 | await input.fill("First new session input while starting"); |
| 75 | assert.equal(await page.locator(".composer__btn--send").isDisabled(), true, "typing never sends before the new controller is ready"); |
| 76 | const notice = page.getByTestId("creation-notice"); |
| 77 | await notice.getByText(/You can type now/).waitFor(); |
| 78 | assert.equal(await notice.locator("button").count(), 0, "preparation does not ask for any user choice"); |
| 79 | const noticeBox = await notice.boundingBox(); |
| 80 | const inputBox = await input.boundingBox(); |
| 81 | assert.ok(noticeBox.y + noticeBox.height <= inputBox.y, "preparation explanation is adjacent to and above the composer"); |
| 82 | const evidence = process.env.REASONIX_CREATION_EVIDENCE ?? path.join(tmpdir(), "reasonix-manual-creation-evidence"); |
| 83 | await mkdir(evidence, { recursive: true }); |
| 84 | await page.screenshot({ path: path.join(evidence, "starting-editable.png") }); |
| 85 | |
| 86 | await newSessionButton(page).click(); |
| 87 | await page.waitForFunction(() => window.manualCreationFixture.operations().length === 2); |
| 88 | await page.waitForFunction(() => { |
| 89 | const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])"); |
| 90 | return input && !input.closest("[inert]") && input.value === ""; |
| 91 | }); |
| 92 | const second = await page.evaluate(() => window.manualCreationFixture.operations()[1]); |
| 93 | assert.notEqual(second.ref.sessionId, first.ref.sessionId, "each explicit new action allocates its own session"); |
| 94 | await input.fill("Second new session input while starting"); |
| 95 | const firstReads = await page.evaluate(id => window.manualCreationFixture.reads(id), first.operationId); |
| 96 | await page.evaluate(id => window.manualCreationFixture.finish(id), first.operationId); |
| 97 | assert.equal(await page.evaluate(() => window.manualCreationFixture.active()), second.ref.sessionId); |
| 98 | assert.equal(await input.inputValue(), "Second new session input while starting"); |
| 99 | await page.evaluate(id => window.manualCreationFixture.finish(id), second.operationId); |
| 100 | await page.waitForFunction(id => window.manualCreationFixture.observedReady(id), second.operationId); |
| 101 | await page.waitForFunction(() => !document.querySelector(".composer__btn--send")?.disabled); |
| 102 | await notice.waitFor({ state: "detached" }); |
| 103 | assert.equal(await input.inputValue(), "Second new session input while starting", "runtime completion preserves typed input"); |
| 104 | assert.equal(await page.evaluate(() => window.manualCreationFixture.active()), second.ref.sessionId, "out-of-order startup never changes the user's selection"); |
| 105 | assert.equal(await page.evaluate(id => window.manualCreationFixture.reads(id), first.operationId), firstReads, "leaving a starting session stops its request polling while the host continues"); |
| 106 | assert.deepEqual(errors, []); |
| 107 | await page.screenshot({ path: path.join(evidence, "ready-input-preserved.png") }); |
| 108 | console.log(`PASS manual creation: editable starting input, isolated new identities, send readiness and out-of-order completion. Evidence: ${evidence}`); |
| 109 | } finally { |
| 110 | await browser?.close(); |
| 111 | await server.close(); |
| 112 | } |
| 113 |