| 1 | #!/usr/bin/env node |
| 2 | // Real app UI and Web Audio, with controlled background runtime snapshots. |
| 3 | import assert from "node:assert/strict"; |
| 4 | import path from "node:path"; |
| 5 | import fs from "node:fs/promises"; |
| 6 | import { existsSync } from "node:fs"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { createServer } from "vite"; |
| 9 | import { selectSession, readActiveSessionLabel } from "./app-page-actions.mjs"; |
| 10 | |
| 11 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 12 | if (process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" || |
| 13 | (!process.env.PLAYWRIGHT_BROWSERS_PATH && existsSync(path.join(root, ".pw-browsers")))) { |
| 14 | process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(root, ".pw-browsers"); |
| 15 | } |
| 16 | const { chromium } = await import("playwright"); |
| 17 | const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 0 } }); |
| 18 | await server.listen(); |
| 19 | const url = server.resolvedUrls.local[0]; |
| 20 | console.log("Attention browser fixture: " + url); |
| 21 | let browser; |
| 22 | let releaseNotificationModule; |
| 23 | try { |
| 24 | browser = await chromium.launch({ headless: true }); |
| 25 | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); |
| 26 | const errors = []; |
| 27 | const notificationModuleGate = new Promise(resolve => { releaseNotificationModule = resolve; }); |
| 28 | await page.route("**/src/lib/runtimeNotifications.ts", async route => { |
| 29 | await notificationModuleGate; |
| 30 | await route.continue(); |
| 31 | }); |
| 32 | page.on("pageerror", error => errors.push(error.message)); |
| 33 | page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); |
| 34 | await page.goto(url + "?mock=bench&bench=1", { waitUntil: "domcontentloaded" }); |
| 35 | const input = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 36 | await input.waitFor(); |
| 37 | await selectSession(page, "bench:small-6t"); |
| 38 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); |
| 39 | await input.fill("B draft remains here while A asks"); |
| 40 | await input.focus(); |
| 41 | const selected = await readActiveSessionLabel(page); |
| 42 | await page.evaluate(async () => { |
| 43 | const { runtimeStateStore } = await import("/src/lib/runtimeStateStore.ts"); |
| 44 | const { setAttentionPreference } = await import("/src/lib/sound.ts"); |
| 45 | setAttentionPreference("synth"); |
| 46 | window.__attentionContexts = []; |
| 47 | const NativeAudioContext = window.AudioContext; |
| 48 | window.AudioContext = class extends NativeAudioContext { |
| 49 | constructor(...args) { super(...args); window.__attentionContexts.push(this); } |
| 50 | }; |
| 51 | let revision = 0; |
| 52 | window.__publishAttention = (kind = "ask", turnId = "turn-background-A") => { |
| 53 | const state = { schemaVersion: 1, runtimeEpoch: "runtime-A", activityRevision: 1, revision: ++revision, |
| 54 | phase: "executing", running: true, turnId, turnStatus: "in_progress", turnEventSeq: 1, pendingPrompt: true, |
| 55 | pendingInteractions: [{ requestId: "1", kind, turnId, runtimeEpoch: "runtime-A", headId: "head-A" }], |
| 56 | cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "" }; |
| 57 | runtimeStateStore.commit({ epoch: "attention-fixture", revision, sessions: [{ tabId: "detached:A", scope: "global", |
| 58 | workspaceRoot: "/fixture", topicId: "topic-A", sessionId: "session-A", sessionPath: "/fixture/a", sessionGeneration: 1, |
| 59 | open: false, remote: false, freshness: "synced", state }], topics: [{ scope: "global", node: { |
| 60 | key: "session-B", topicId: "topic-A", kind: "global_session", label: "Conversation B", |
| 61 | session: { hostId: "local", sessionId: "session-B" }, |
| 62 | } }, { scope: "global", node: { |
| 63 | key: "topic-A", topicId: "topic-A", kind: "global_session", label: "Conversation A", status: "waiting_confirmation", |
| 64 | session: { hostId: "local", sessionId: "session-A" }, |
| 65 | } }] }); |
| 66 | }; |
| 67 | window.__publishAttention(); |
| 68 | }); |
| 69 | assert.equal(await page.evaluate(() => window.__attentionContexts.length), 0, "fixture holds the notification module until a background prompt is pending"); |
| 70 | releaseNotificationModule(); |
| 71 | await page.getByText("Conversation A is waiting for your answer", { exact: true }).waitFor(); |
| 72 | assert.equal(await input.inputValue(), "B draft remains here while A asks"); |
| 73 | assert.equal(await readActiveSessionLabel(page), selected); |
| 74 | assert.equal(await input.evaluate(element => element === document.activeElement), true, "notification cannot steal focus"); |
| 75 | assert.equal(await page.evaluate(() => window.__attentionContexts.length), 1); |
| 76 | assert.equal(await page.evaluate(() => window.__attentionContexts[0].state === "suspended"), false, "Web Audio must not wait for a tab switch"); |
| 77 | const evidence = process.env.REASONIX_ATTENTION_EVIDENCE ?? "/tmp/reasonix-attention-evidence"; |
| 78 | await fs.mkdir(evidence, { recursive: true }); |
| 79 | await page.screenshot({ path: path.join(evidence, "background-ask.png") }); |
| 80 | await page.evaluate(() => window.__publishAttention()); |
| 81 | assert.equal(await page.evaluate(() => window.__attentionContexts.length), 1, "repeated snapshot is silent"); |
| 82 | await page.evaluate(() => window.__publishAttention("approval", "turn-background-approval")); |
| 83 | await page.getByText("Conversation A is waiting for your approval", { exact: true }).waitFor(); |
| 84 | assert.equal(await page.evaluate(() => window.__attentionContexts.length), 2); |
| 85 | await selectSession(page, "bench:geometry"); |
| 86 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete.")); |
| 87 | await selectSession(page, "bench:small-6t"); |
| 88 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); |
| 89 | assert.equal(await input.inputValue(), "B draft remains here while A asks", "draft survives real navigation"); |
| 90 | await page.evaluate(() => window.__publishAttention("approval", "turn-background-approval")); |
| 91 | assert.equal(await page.evaluate(() => window.__attentionContexts.length), 2, "navigation must not reset notification identity"); |
| 92 | assert.deepEqual(errors, []); |
| 93 | console.log("PASS background Ask and approval, immediate Web Audio, visible toast, focus/draft preservation, navigation and dedupe"); |
| 94 | } finally { |
| 95 | releaseNotificationModule?.(); |
| 96 | await browser?.close(); |
| 97 | await server.close(); |
| 98 | } |
| 99 |