| 1 | #!/usr/bin/env node |
| 2 | // Real Chromium UI with controlled runtime frames; backend ownership is covered |
| 3 | // separately by the controller/Serve/remote HTTP regression tests. |
| 4 | import path from "node:path"; |
| 5 | import fs from "node:fs/promises"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import { createServer } from "vite"; |
| 8 | import { selectSession } from "./app-page-actions.mjs"; |
| 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: 4668, strictPort: true } }); |
| 13 | await server.listen(); |
| 14 | const browser = await chromium.launch({ headless: true }); |
| 15 | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); |
| 16 | const errors = []; |
| 17 | page.on("pageerror", error => errors.push(error.message)); |
| 18 | const check = (yes, message) => { if (!yes) throw new Error(message); console.log("PASS " + message); }; |
| 19 | try { |
| 20 | await page.goto("http://127.0.0.1:4668/?mock=bench&bench=1"); |
| 21 | const input = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 22 | await input.waitFor(); |
| 23 | await selectSession(page, "bench:small-6t"); |
| 24 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); |
| 25 | await page.evaluate(async () => { |
| 26 | const { app, onRemoteTabOpened, onRemoteTabUpdated } = await import("/src/lib/bridge.ts"); |
| 27 | const { runtimeStateStore } = await import("/src/lib/runtimeStateStore.ts"); |
| 28 | const { acceptRuntimeState } = await import("/src/lib/runtimeStateReducer.ts"); |
| 29 | const { installDesktopHostStub } = await import("/src/__tests__/desktopHostStub.ts"); |
| 30 | const { DESKTOP_COMMANDS } = await import("/src/generated/desktopContract.generated.ts"); |
| 31 | const fallback = Object.fromEntries(DESKTOP_COMMANDS.map(key => [key, app[key]])); |
| 32 | const tabs = await app.ListTabs(); |
| 33 | const tree = { topics: [] }; |
| 34 | const selected = tabs.find(tab => tab.sessionPath?.includes("small")) ?? tabs[0]; |
| 35 | window.__runtimeFixture = { tab: selected, revision: 0, calls: [], queries: [], fail: true, accept: (...args) => acceptRuntimeState(runtimeStateStore, ...args), topics: tree.topics }; |
| 36 | onRemoteTabOpened(tab => { window.__runtimeFixture.tab = tab; }); |
| 37 | onRemoteTabUpdated(tab => { window.__runtimeFixture.tab = tab; }); |
| 38 | const host = installDesktopHostStub(new Proxy(fallback, { get(_target, key) { |
| 39 | if (key === "CaptureInboxTarget") return async (tabId, sessionPath) => { |
| 40 | if (!sessionPath || sessionPath !== window.__runtimeFixture.tab.sessionPath) throw new Error("Composer did not bind its selected session path: " + JSON.stringify({ tabId, sessionPath, expected: window.__runtimeFixture.tab.sessionPath })); |
| 41 | const remote = window.__runtimeFixture.tab.remote; |
| 42 | return { tabId, sessionPath, generation: 1, selection: 0, remote: Boolean(remote), hostId: remote?.hostId, workspace: remote?.workspace }; |
| 43 | }; |
| 44 | if (key === "LookupInboxFollowupForTarget") return async (...args) => { |
| 45 | window.__runtimeFixture.queries.push(args); |
| 46 | if (window.__runtimeFixture.fail) throw new Error("receipt unavailable"); |
| 47 | return { itemId: "runtime-queued", disposition: "idempotent_hit", position: 0, paused: false }; |
| 48 | }; |
| 49 | if (key === "EnqueueInboxFollowupForTarget") return async (...args) => { |
| 50 | window.__runtimeFixture.calls.push(args); |
| 51 | if (window.__runtimeFixture.fail) throw new Error("fixture enqueue unavailable"); |
| 52 | return { itemId: "runtime-queued", disposition: "queued", position: 1, paused: false }; |
| 53 | }; |
| 54 | const value = fallback[key]; |
| 55 | if (key === "OpenRemoteProjectTab") return async (...args) => { const tab = await value(...args); window.__runtimeFixture.tab = tab; return tab; }; |
| 56 | return value; |
| 57 | } })); |
| 58 | window.__runtimeFixture.emit = (tabId, channel, payload) => host.emit(`remote-tab:${tabId}:${channel}`, payload); |
| 59 | }); |
| 60 | const publish = async (phase, extra = {}, remote = false) => page.evaluate(({ phase, extra, remote }) => { |
| 61 | const f = window.__runtimeFixture; |
| 62 | const tab = f.tab; |
| 63 | const state = { schemaVersion: 1, runtimeEpoch: "fixture-controller", revision: ++f.revision, phase, |
| 64 | running: phase === "executing" || phase === "finishing", turnId: "fixture-turn", turnStatus: phase === "executing" ? "in_progress" : "completed", |
| 65 | turnEventSeq: 1, pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: phase === "executing" ? "thinking" : "", ...extra }; |
| 66 | return f.accept({ epoch: "fixture-app", revision: f.revision, topics: f.topics, sessions: [{ |
| 67 | tabId: tab.id, scope: tab.scope ?? "project", workspaceRoot: tab.workspaceRoot, topicId: tab.topicId ?? "", |
| 68 | sessionPath: tab.sessionPath ?? "", sessionGeneration: 1, open: true, remote, |
| 69 | hostId: tab.remote?.hostId, freshness: extra.freshness ?? "synced", state, |
| 70 | }] }, true); |
| 71 | }, { phase, extra, remote }); |
| 72 | await publish("finishing"); |
| 73 | await page.locator(".composer-run-strip").filter({ hasText: /Finishing|正在收尾/ }).waitFor(); |
| 74 | check(await page.locator(".composer__btn--stop").count() === 0, "finishing hides Stop"); |
| 75 | check(await page.locator(".composer-card--running,.composer-run-strip__dot").count() === 0, "finishing has no animated run marker"); |
| 76 | await input.fill("durable next turn"); |
| 77 | await input.press("Enter"); |
| 78 | await page.waitForFunction(() => window.__runtimeFixture.calls.length === 1); |
| 79 | check(await input.inputValue() === "durable next turn", "failed enqueue preserves draft"); |
| 80 | await fs.mkdir("/tmp/reasonix-runtime-evidence", { recursive: true }); |
| 81 | await page.screenshot({ path: "/tmp/reasonix-runtime-evidence/pending-followup.png" }); |
| 82 | await publish("idle"); |
| 83 | check(await page.locator(".composer__btn--send").getAttribute("aria-label") === "Check send result", "phase transition keeps receipt confirmation action"); |
| 84 | await page.evaluate(() => { window.__runtimeFixture.fail = false; }); |
| 85 | await input.press("Enter"); |
| 86 | await page.waitForFunction(() => document.querySelector("textarea.composer__input:not([aria-hidden=true])")?.value === ""); |
| 87 | const calls = await page.evaluate(() => window.__runtimeFixture.calls); |
| 88 | const queries = await page.evaluate(() => window.__runtimeFixture.queries); |
| 89 | check(calls.length === 1 && queries.length === 1 && calls[0].at(-1) === queries[0].at(-1), "retry only queries the original durable idempotency key"); |
| 90 | await publish("idle", { backgroundJobs: 2 }); |
| 91 | await page.locator(".composer-run-strip").filter({ hasText: /2/ }).waitFor(); |
| 92 | await page.locator(".runtime-activity-indicator:not(.runtime-activity-indicator--static)").first().waitFor(); |
| 93 | check(await page.locator(".runtime-activity-indicator:not(.runtime-activity-indicator--static)").count() > 0, "background jobs keep project activity visible"); |
| 94 | await publish("idle"); |
| 95 | await page.locator(".composer-run-strip").waitFor({ state: "hidden" }); |
| 96 | // The invariant is that no activity indicator stays visible once the last |
| 97 | // job completes. Sidebar surfaces settle asynchronously, so wait for the |
| 98 | // settled state and name whatever stayed lit if it never arrives. |
| 99 | const lingering = await page.evaluate(async () => { |
| 100 | const visible = () => [...document.querySelectorAll(".runtime-activity-indicator")].filter(el => el.getClientRects().length > 0); |
| 101 | const deadline = Date.now() + 15000; |
| 102 | while (visible().length && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 50)); |
| 103 | return visible().map(el => `${el.closest("[class]")?.className ?? "?"}: ${el.getAttribute("aria-label") ?? el.className}`); |
| 104 | }); |
| 105 | check(lingering.length === 0, `last job completion clears project activity (lingering: ${lingering.join(" | ") || "none"})`); |
| 106 | await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click(); |
| 107 | await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click(); |
| 108 | await page.locator(".remote-surface--ready").waitFor(); |
| 109 | await publish("finishing", {}, true); |
| 110 | await page.locator(".composer-run-strip").filter({ hasText: /Finishing|正在收尾/ }).waitFor(); |
| 111 | await input.fill("remote durable next turn"); |
| 112 | await input.press("Enter"); |
| 113 | await page.waitForFunction(() => window.__runtimeFixture.calls.length === 2); |
| 114 | check(await input.inputValue() === "", "remote finishing queues the next input and clears it after receipt"); |
| 115 | await publish("executing", { freshness: "unknown" }, true); |
| 116 | await page.locator(".composer-run-strip").filter({ hasText: /sync|同步/i }).waitFor(); |
| 117 | check(await input.isDisabled(), "remote disconnect blocks send while preserving unknown state"); |
| 118 | check(await page.locator(".composer__btn--stop").count() === 0, "unknown remote state hides Stop"); |
| 119 | await fs.mkdir("/tmp/reasonix-runtime-evidence", { recursive: true }); |
| 120 | await page.screenshot({ path: "/tmp/reasonix-runtime-evidence/remote-unknown.png" }); |
| 121 | await publish("executing", {}, true); |
| 122 | await page.locator(".composer__btn--stop").waitFor(); |
| 123 | check(!(await input.isDisabled()), "remote reconnect restores authoritative execution controls"); |
| 124 | await page.evaluate(async () => { |
| 125 | const __emitMockRemoteTab = window.__runtimeFixture.emit; |
| 126 | const tabId = window.__runtimeFixture.tab.id; |
| 127 | __emitMockRemoteTab(tabId, "event", { kind: "turn_started", turnId: "fixture-turn" }); |
| 128 | __emitMockRemoteTab(tabId, "event", { kind: "text", text: "runtime missing completion fixture" }); |
| 129 | }); |
| 130 | await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).waitFor(); |
| 131 | await publish("idle", {}, true); |
| 132 | await page.locator(".composer-run-strip").waitFor({ state: "hidden" }); |
| 133 | check(await page.locator(".composer__btn--stop").count() === 0, "remote completion removes the run control"); |
| 134 | check(await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).count() === 1, |
| 135 | "ancillary idle cannot erase output before transcript v2 confirms completion"); |
| 136 | await selectSession(page, "bench:geometry"); |
| 137 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete.")); |
| 138 | check(await page.locator(".remote-surface").count() === 0, "local switch retains ownership after remote runtime frames"); |
| 139 | check(errors.length === 0, "runtime scenarios produce no browser errors: " + errors.join("; ")); |
| 140 | } catch (error) { |
| 141 | console.error("Runtime fixture toasts:", await page.locator(".toast__text").allTextContents()); |
| 142 | throw error; |
| 143 | } finally { await browser.close(); await server.close(); } |
| 144 |