| 1 | // Real-page gate for the turn-fork entry: it loads the shipped application |
| 2 | // against the browser dev mock and drives the entry a user clicks, so the |
| 3 | // fixture data, the app wiring, and the notice channel are all exercised |
| 4 | // together. Browsers resolve from the default Playwright cache. |
| 5 | import assert from "node:assert/strict"; |
| 6 | import path from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { startPreviewServer } from "./vite-preview-server.mjs"; |
| 9 | |
| 10 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 11 | const { chromium } = await import("playwright"); |
| 12 | const port = Number(process.env.REASONIX_FORK_APP_PORT ?? 4659); |
| 13 | const server = await startPreviewServer(frontendDir, port); |
| 14 | const browser = await chromium.launch({ headless: true }); |
| 15 | const base = `http://127.0.0.1:${port}/`; |
| 16 | const report = { browser: browser.version(), platform: process.platform, arch: process.arch }; |
| 17 | |
| 18 | const ENTRY = ".chat-actions button.chat-action-icon:not(.copybtn)"; |
| 19 | const settle = (page) => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); |
| 20 | const entry = (page) => page.locator(ENTRY).last(); |
| 21 | const labels = (page) => page.locator(".workbench-dock__tab-label").allTextContents(); |
| 22 | |
| 23 | async function openApp(page, query) { |
| 24 | await page.goto(`${base}${query}`); |
| 25 | await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); |
| 26 | await page.locator(".chat-column .chat-turn-tail").last().waitFor(); |
| 27 | await settle(page); |
| 28 | } |
| 29 | |
| 30 | try { |
| 31 | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); |
| 32 | const errors = []; |
| 33 | page.on("pageerror", (error) => errors.push(error.message)); |
| 34 | |
| 35 | // 1. The dev fixture offers a forkable turn on the page a developer opens. |
| 36 | await openApp(page, "?mock=1"); |
| 37 | await entry(page).waitFor(); |
| 38 | // The entry exists before the target read lands; wait for it to be enabled and |
| 39 | // labelled rather than sampling a loading state. |
| 40 | await page.waitForFunction((selector) => { |
| 41 | const nodes = document.querySelectorAll(selector); |
| 42 | const button = nodes[nodes.length - 1]; |
| 43 | return Boolean(button) && button.getAttribute("aria-disabled") === null |
| 44 | && button.getAttribute("aria-label") === "Branch into a new conversation"; |
| 45 | }, ENTRY, { timeout: 10_000 }); |
| 46 | const label = await entry(page).getAttribute("aria-label"); |
| 47 | assert.equal(await entry(page).getAttribute("aria-disabled"), null, "the running dev app renders an enabled fork entry"); |
| 48 | assert.equal(await entry(page).getAttribute("data-unavailable"), null, "the enabled dev entry is not marked unavailable"); |
| 49 | await entry(page).focus(); |
| 50 | await page.waitForFunction(() => document.querySelector('[role="tooltip"]')?.textContent === "Branch into a new conversation", null, { timeout: 10_000 }); |
| 51 | assert.equal(await page.locator('[role="tooltip"]').textContent(), "Branch into a new conversation", "the dev entry explains itself with the branch label"); |
| 52 | report.devEntry = { label, tooltip: "Branch into a new conversation" }; |
| 53 | |
| 54 | // 2. Clicking it creates the child through the shipped controller and adopts its tab. |
| 55 | const childTitle = () => page.evaluate(() => document.body.innerText.match(/\S[^\n]*\(1\)[^\n]*/)?.[0]?.trim() ?? ""); |
| 56 | assert.equal(await childTitle(), "", "no forked child exists before the click"); |
| 57 | await entry(page).click(); |
| 58 | await page.waitForFunction(() => /\(1\)/.test(document.body.innerText)); |
| 59 | await settle(page); |
| 60 | const adopted = await childTitle(); |
| 61 | assert.match(adopted, /\(1\)$/, `the created child opens with the mock's fork title (${adopted})`); |
| 62 | report.adoptedTab = adopted; |
| 63 | |
| 64 | // 3. A child whose tab cannot open surfaces the localized recovery notice, and |
| 65 | // a second click on the same turn reuses that child instead of creating another. |
| 66 | await openApp(page, "?mock=1&fork-attach-failure=1"); |
| 67 | await entry(page).click(); |
| 68 | const warnNotices = () => page.locator('.chat-notice[data-level="warn"]'); |
| 69 | await page.waitForFunction(() => document.querySelectorAll('.chat-notice[data-level="warn"]').length === 1, null, { timeout: 10_000 }); |
| 70 | const first = await warnNotices().last().textContent(); |
| 71 | assert.match(first ?? "", /The branched session mock-fork-/, "the recovery notice names the created child"); |
| 72 | await entry(page).click(); |
| 73 | // The repeated fork reports the child again; wait for the rendered notice |
| 74 | // instead of assuming one frame is enough. |
| 75 | await page.waitForFunction(() => document.querySelectorAll('.chat-notice[data-level="warn"]').length === 2, null, { timeout: 10_000 }); |
| 76 | const second = await warnNotices().last().textContent(); |
| 77 | assert.equal(await warnNotices().count(), 2, "a repeated fork reports the child again"); |
| 78 | assert.ok(second?.includes(first?.match(/mock-fork-\S+/)?.[0] ?? "child"), "the repeated fork names the same child, never a second one"); |
| 79 | report.recovery = { first, second }; |
| 80 | |
| 81 | await page.screenshot({ path: path.join(process.env.REASONIX_FORK_EVIDENCE ?? "/tmp", "fork-targets-app.png") }); |
| 82 | assert.deepEqual(errors, []); |
| 83 | console.log(JSON.stringify(report, null, 2)); |
| 84 | } finally { |
| 85 | await browser.close(); |
| 86 | await server.httpServer.close(); |
| 87 | } |
| 88 |