| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import path from "node:path"; |
| 4 | import { fileURLToPath } from "node:url"; |
| 5 | import { startPreviewServer } from "./vite-preview-server.mjs"; |
| 6 | import { newSessionButton, selectSession } from "./app-page-actions.mjs"; |
| 7 | |
| 8 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 9 | process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" |
| 10 | ? path.join(frontendDir, ".pw-browsers") |
| 11 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 12 | // Playwright reads PLAYWRIGHT_BROWSERS_PATH at module evaluation; import it |
| 13 | // only after the path normalization above. |
| 14 | const { chromium } = await import("playwright"); |
| 15 | const port = Number(process.env.REASONIX_APP_BROWSER_PORT ?? 4657); |
| 16 | const preview = await startPreviewServer(frontendDir, port); |
| 17 | const browser = await chromium.launch({ headless: true }); |
| 18 | |
| 19 | function assert(condition, message) { |
| 20 | if (!condition) throw new Error(message); |
| 21 | process.stdout.write(` PASS ${message}\n`); |
| 22 | } |
| 23 | |
| 24 | async function settle(page, frames = 5) { |
| 25 | await page.evaluate((count) => new Promise((resolve) => { |
| 26 | const tick = () => --count <= 0 ? resolve() : requestAnimationFrame(tick); |
| 27 | requestAnimationFrame(tick); |
| 28 | }), frames); |
| 29 | } |
| 30 | |
| 31 | try { |
| 32 | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); |
| 33 | const pageErrors = []; |
| 34 | page.on("pageerror", (error) => pageErrors.push(error.message)); |
| 35 | await page.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1&app-lifecycle-probe=1`, { waitUntil: "domcontentloaded" }); |
| 36 | await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); |
| 37 | await page.locator(".project-tree").first().waitFor(); |
| 38 | await page.evaluate(() => { |
| 39 | window.__appBrowserIdentity = { |
| 40 | composer: document.querySelector("textarea.composer__input:not([aria-hidden=true])"), |
| 41 | sidebar: document.querySelector(".sidebar"), |
| 42 | }; |
| 43 | }); |
| 44 | const composer = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 45 | await selectSession(page, "bench:small-6t"); |
| 46 | await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE')); |
| 47 | await composer.fill("layout-owned draft"); |
| 48 | // Raw Markdown fallbacks become parsed DOM asynchronously. History preservation |
| 49 | // means stable node identity, not identical transient textContent. |
| 50 | const transcriptIdentity = () => { |
| 51 | const nodes = [...document.querySelectorAll('[data-chat-anchor-key]')]; |
| 52 | window.__modelTranscriptNodes ??= nodes; |
| 53 | return nodes.map((node, index) => ({ key: node.dataset.chatAnchorKey, kind: node.dataset.chatKind, |
| 54 | sameHost: node === window.__modelTranscriptNodes[index] })); |
| 55 | }; |
| 56 | const transcriptBeforeModel = await page.evaluate(transcriptIdentity); |
| 57 | assert(transcriptBeforeModel.length > 0, 'model replay starts with hydrated transcript blocks'); |
| 58 | await page.locator('.modelsw__trigger:not(.effortsw__trigger)').click(); |
| 59 | const nextModel = page.locator('.modelsw__item[role="option"]:not([aria-selected="true"])').first(); |
| 60 | const nextModelName = await nextModel.locator('.modelsw__model').textContent(); |
| 61 | await nextModel.click(); |
| 62 | await page.waitForFunction(name => document.querySelector('.modelsw__label')?.textContent?.includes(name), nextModelName); |
| 63 | await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false |
| 64 | && window.__reasonixAppLifecycle?.snapshot().activeOperations === 0); |
| 65 | const transcriptAfterModel = await page.evaluate(transcriptIdentity); |
| 66 | const draftAfterModel = await composer.inputValue(); |
| 67 | assert(draftAfterModel === 'layout-owned draft' && JSON.stringify(transcriptAfterModel) === JSON.stringify(transcriptBeforeModel), |
| 68 | 'real model selection preserves source transcript, Composer draft and writable readiness'); |
| 69 | // A fresh session now seeds Overview in an expanded empty dock. Add Files |
| 70 | // from the tab menu so the rest of the browser fixture can exercise the |
| 71 | // workspace tree and preview. |
| 72 | await page.getByRole('tab', { name: 'Overview', exact: true }).waitFor(); |
| 73 | assert(await page.getByRole('tab', { name: 'Overview', exact: true }).count() === 1, |
| 74 | 'fresh expanded workspace dock defaults to Overview'); |
| 75 | if (await page.getByRole('tab', { name: 'Files', exact: true }).count() === 0) { |
| 76 | await page.locator('.workbench-dock__tab-add').click(); |
| 77 | await page.locator('.tab-add-menu__item', { hasText: 'Files' }).first().click(); |
| 78 | } |
| 79 | await page.getByRole('tab', { name: 'Files', exact: true }).click(); |
| 80 | await page.locator('[data-workspace-path="README.md"]').click(); |
| 81 | await page.waitForFunction(() => document.querySelector('.workspace-preview__body')?.textContent?.includes('Browser-dev workspace preview.')); |
| 82 | await page.evaluate(() => { |
| 83 | Object.assign(window.__appBrowserIdentity, { |
| 84 | workspace: document.querySelector('.workspace-panel'), |
| 85 | workspaceTree: document.querySelector('.workspace-tree'), |
| 86 | preview: document.querySelector('.workspace-preview__body'), |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | assert(await page.locator(".app.app--workbench").count() === 1, "workbench layout renders from the authoritative startup snapshot"); |
| 91 | |
| 92 | const identities = await page.evaluate(() => ({ |
| 93 | composer: window.__appBrowserIdentity.composer === document.querySelector("textarea.composer__input:not([aria-hidden=true])"), |
| 94 | sidebar: window.__appBrowserIdentity.sidebar === document.querySelector(".sidebar"), |
| 95 | workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'), |
| 96 | workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'), |
| 97 | preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'), |
| 98 | })); |
| 99 | assert(Object.values(identities).every(Boolean), "management-page visits retain Sidebar, Composer, actual WorkspacePanel/tree and file preview identity"); |
| 100 | |
| 101 | const terminalToggle = page.getByRole("button", { name: "Terminal", exact: true }).first(); |
| 102 | await terminalToggle.click(); |
| 103 | await page.locator('.terminal-drawer[aria-hidden="false"]').waitFor(); |
| 104 | assert(await page.locator('.terminal-drawer-resizer[tabindex="0"]').count() === 1, "open terminal drawer exposes one keyboard resizer"); |
| 105 | assert(await page.locator(".footer.footer--compact").count() === 1, "open terminal compacts the shared footer without remounting Composer"); |
| 106 | assert(await composer.inputValue() === "layout-owned draft", "terminal drawer lifecycle preserves the Composer draft"); |
| 107 | await terminalToggle.click(); |
| 108 | const closedTerminal = page.locator('.terminal-drawer[aria-hidden="true"][inert]'); |
| 109 | await closedTerminal.waitFor({ state: "attached" }); |
| 110 | await closedTerminal.waitFor({ state: "hidden" }); |
| 111 | assert(await closedTerminal.count() === 1, "closed warm terminal remains mounted and hidden"); |
| 112 | assert(await page.locator('.terminal-drawer-resizer[tabindex="-1"]').count() === 1, "closed warm terminal is inert and leaves keyboard navigation"); |
| 113 | |
| 114 | await selectSession(page, "bench:geometry"); |
| 115 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete.")); |
| 116 | await selectSession(page, "bench:small-6t"); |
| 117 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); |
| 118 | const afterSwitch = await page.evaluate(() => ({ |
| 119 | workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'), |
| 120 | workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'), |
| 121 | preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'), |
| 122 | selectedFile: document.querySelector('.workspace-tree__row--active')?.getAttribute('data-workspace-path'), |
| 123 | subscriptions: window.__reasonixAppLifecycle?.snapshot().activeSubscriptions, |
| 124 | operations: window.__reasonixAppLifecycle?.snapshot().activeOperations, |
| 125 | })); |
| 126 | assert(afterSwitch.workspace && afterSwitch.workspaceTree && afterSwitch.preview && afterSwitch.selectedFile === 'README.md', |
| 127 | 'same-project session switching preserves actual WorkspacePanel, tree, preview DOM and selected file'); |
| 128 | assert(afterSwitch.subscriptions === 6, `the six AppRuntimeEffects subscriptions remain singular (${afterSwitch.subscriptions})`); |
| 129 | assert(afterSwitch.operations === 0, "instrumented operation owners report zero active operations (not yet all App operations)"); |
| 130 | |
| 131 | // Browser-mock local switches exercise the renderer latency contract. Use |
| 132 | // the navigation surface's paint receipt (the same click-to-first-paint |
| 133 | // milestone reported in diagnostics), not completion of deferred Markdown |
| 134 | // or lazy-content expansion after the first screen is already visible. |
| 135 | const switchSamples = []; |
| 136 | for (let index = 0; index < 20; index += 1) { |
| 137 | const geometry = index % 2 === 0; |
| 138 | const label = geometry ? "bench:geometry" : "bench:small-6t"; |
| 139 | // The latency gate stops at the first readable inline body. The full |
| 140 | // ASYNC marker intentionally lives beyond the lazy-content preview and |
| 141 | // is validated above; including its simulated 1.5s body fetch here would |
| 142 | // benchmark deferred expansion rather than first readable paint. |
| 143 | const marker = geometry ? "Geometry contract fixture complete." : "Asynchronously hydrated verification appendix"; |
| 144 | const previousIntent = await page.evaluate(() => window.__reasonixPerf?.stats().navigation?.intent ?? -1); |
| 145 | await selectSession(page, label); |
| 146 | await page.waitForFunction((text) => document.querySelector(".transcript")?.textContent?.includes(text), marker); |
| 147 | await page.waitForFunction((intent) => { |
| 148 | const navigation = window.__reasonixPerf?.stats().navigation; |
| 149 | return navigation?.intent !== intent && navigation?.clickToFirstPaintMs !== undefined; |
| 150 | }, previousIntent); |
| 151 | switchSamples.push(await page.evaluate(() => window.__reasonixPerf.stats().navigation.clickToFirstPaintMs)); |
| 152 | } |
| 153 | switchSamples.sort((a, b) => a - b); |
| 154 | const localSwitchP95 = switchSamples[Math.ceil(switchSamples.length * 0.95) - 1]; |
| 155 | assert(localSwitchP95 <= 300, |
| 156 | `browser-mock local click-to-first-paint P95 <= 300ms (${localSwitchP95.toFixed(1)}ms; samples=${switchSamples.map(value => value.toFixed(1)).join(",")})`); |
| 157 | |
| 158 | await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click(); |
| 159 | await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click(); |
| 160 | await page.locator('.remote-surface--ready').waitFor(); |
| 161 | await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); |
| 162 | assert((await page.locator('.topicbar').textContent()).includes('Remote demo session'), "remote project selection adopts its source workspace and authoritative hydrated surface"); |
| 163 | await newSessionButton(page).click(); |
| 164 | await page.waitForFunction(() => document.querySelector('.topicbar')?.textContent?.includes('New session')); |
| 165 | await page.locator('.remote-surface--ready').waitFor(); |
| 166 | await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); |
| 167 | assert(await page.locator('.remote-surface').count() === 1, "global New Session stays on the remote workspace instead of opening a local blank"); |
| 168 | assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), "local/remote navigation and remote New Session preserve the Composer DOM identity"); |
| 169 | await selectSession(page, "bench:geometry"); |
| 170 | await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('Geometry contract fixture complete.')); |
| 171 | assert(await page.locator('.remote-surface').count() === 0, "subsequent local navigation owns the surface; remote events do not reclaim it"); |
| 172 | const sentText = 'App source-bound submission fixture'; |
| 173 | await composer.fill(sentText); |
| 174 | await composer.press('Enter'); |
| 175 | await page.locator('[data-chat-kind="user"]').filter({ hasText: sentText }).waitFor(); |
| 176 | await page.locator('.composer__btn--stop').click(); |
| 177 | await page.locator('.composer__btn--stop').waitFor({ state: 'hidden' }); |
| 178 | await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); |
| 179 | assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), |
| 180 | 'ordinary source-bound send and native Stop preserve Composer identity and restore writable readiness'); |
| 181 | assert(pageErrors.length === 0, `layout replay emits no page errors (${pageErrors.length})`); |
| 182 | |
| 183 | process.stdout.write("app browser lifecycle gate passed\n"); |
| 184 | } finally { |
| 185 | await browser.close(); |
| 186 | await preview.close(); |
| 187 | } |
| 188 |