| 1 | import assert from "node:assert/strict"; |
| 2 | import path from "node:path"; |
| 3 | import { fileURLToPath } from "node:url"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { startPreviewServer } from "./vite-preview-server.mjs"; |
| 6 | import { 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 | const { chromium } = await import("playwright"); |
| 13 | const preview = process.env.REASONIX_DOCK_URL ? null : await startPreviewServer(frontendDir, 4663); |
| 14 | const url = process.env.REASONIX_DOCK_URL ?? "http://127.0.0.1:4663"; |
| 15 | const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE }); |
| 16 | try { |
| 17 | const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: "en-US" }); |
| 18 | await context.addInitScript(() => localStorage.setItem("reasonix-lang", "en")); |
| 19 | const page = await context.newPage(); |
| 20 | const errors = []; |
| 21 | page.on("pageerror", error => errors.push(error.message)); |
| 22 | await page.goto(url + "/?mock=bench&bench=1&app-lifecycle-probe=1"); |
| 23 | await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); |
| 24 | await selectSession(page, "bench:small-6t"); |
| 25 | // The benchmark fixture hydrates this session asynchronously. Wait for the |
| 26 | // authoritative session snapshot before mutating dock state, otherwise the |
| 27 | // late hydrate can replace the empty-state picker while Playwright clicks it. |
| 28 | await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE')); |
| 29 | const tabs = page.locator('.workbench-dock__tabs [role="tab"]'); |
| 30 | const overview = page.getByRole("tab", { name: "Overview", exact: true }); |
| 31 | await overview.waitFor(); |
| 32 | assert.equal(await overview.count(), 1, "fresh expanded workspace dock defaults to Overview"); |
| 33 | await overview.locator(".workbench-dock__tab-close").click(); |
| 34 | await page.locator(".workbench-dock").waitFor({ state: "detached" }); |
| 35 | assert.equal(await tabs.count(), 0, "closing the last tab removes the dock"); |
| 36 | await page.locator(".topicbar__chrome-btn--workspace[aria-pressed=false]").click(); |
| 37 | await page.locator(".tab-picker__item").filter({ hasText: /^Files$/ }).click(); |
| 38 | await page.locator('[data-workspace-path="README.md"]').click(); |
| 39 | await page.waitForFunction(() => document.querySelector(".workspace-preview__body")?.textContent?.includes("Browser-dev workspace preview.")); |
| 40 | await page.locator('[data-workspace-path="go.mod"]').click(); |
| 41 | await page.locator('[data-workspace-path="README.md"]').click(); |
| 42 | await page.locator(".workbench-dock__tab-add").click(); |
| 43 | await page.locator(".tab-add-menu__item").filter({ hasText: /^Files$/ }).click(); |
| 44 | assert.equal(await tabs.count(), 2); |
| 45 | assert.equal(await page.locator(".workspace-panel").count(), 1, "only the active view mounts"); |
| 46 | assert.equal(await page.locator(".workspace-preview__body").count(), 0, "duplicate view starts empty"); |
| 47 | await page.locator('[data-workspace-path="go.mod"]').click(); |
| 48 | const selected = () => document.querySelector(".workspace-tree__row--active")?.getAttribute("data-workspace-path"); |
| 49 | await page.waitForFunction(selected => document.querySelector(".workspace-tree__row--active")?.getAttribute("data-workspace-path") === selected, "go.mod"); |
| 50 | await tabs.nth(0).click(); |
| 51 | assert.equal(await page.evaluate(selected), "README.md", "first view restores selection"); |
| 52 | await page.locator('.workspace-iconbtn:has(svg.lucide-x)').click(); |
| 53 | assert.equal(await page.evaluate(selected), "go.mod", "first view restores its inner open-file stack"); |
| 54 | await page.locator('[data-workspace-path="README.md"]').click(); |
| 55 | await tabs.nth(1).click(); |
| 56 | assert.equal(await page.evaluate(selected), "go.mod", "second view restores selection"); |
| 57 | await page.getByPlaceholder("Filter files…", { exact: true }).fill("mod"); |
| 58 | await tabs.nth(0).click(); |
| 59 | assert.equal(await page.getByPlaceholder("Filter files…", { exact: true }).inputValue(), ""); |
| 60 | await tabs.nth(1).click(); |
| 61 | assert.equal(await page.getByPlaceholder("Filter files…", { exact: true }).inputValue(), "mod"); |
| 62 | await tabs.nth(1).locator(".workbench-dock__tab-close").click(); |
| 63 | await page.locator(".workbench-dock__tab-overview").click(); |
| 64 | await page.getByRole("menuitem", { name: /^Files Closed/ }).click(); |
| 65 | assert.equal(await page.evaluate(selected), "go.mod", "reopen restores the original view"); |
| 66 | assert.equal(await page.getByPlaceholder("Filter files…", { exact: true }).inputValue(), "mod", "reopen restores filtering"); |
| 67 | await page.reload(); |
| 68 | await page.locator('[data-workspace-path="go.mod"]').waitFor(); |
| 69 | assert.equal(await tabs.count(), 2, "restart restores both views"); |
| 70 | assert.equal(await page.evaluate(selected), "go.mod", "restart restores the active view"); |
| 71 | assert.equal(await page.getByPlaceholder("Filter files…", { exact: true }).inputValue(), "mod", "restart restores filtering"); |
| 72 | await tabs.nth(0).click(); |
| 73 | assert.equal(await page.evaluate(selected), "README.md", "restart keeps the other view independent"); |
| 74 | await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click(); |
| 75 | await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click(); |
| 76 | await page.locator(".remote-surface--ready").waitFor(); |
| 77 | assert.equal(await tabs.count(), 0, "other project does not inherit local dock tabs"); |
| 78 | await selectSession(page, "bench:small-6t"); |
| 79 | await page.locator('[data-workspace-path="README.md"]').waitFor(); |
| 80 | assert.equal(await tabs.count(), 2); |
| 81 | assert.equal(await page.evaluate(selected), "README.md", "returning to the project restores its view"); |
| 82 | |
| 83 | // Native inert behavior, including hit-testing without the management overlay. |
| 84 | await page.evaluate(() => { |
| 85 | window.dockBackground = document.querySelector(".topicbar__chrome-btn--workspace"); |
| 86 | window.dockBackgroundClicks = 0; |
| 87 | window.dockBackground.addEventListener("click", () => window.dockBackgroundClicks++); |
| 88 | }); |
| 89 | await page.locator("button:has(svg.lucide-settings)").last().click(); |
| 90 | await page.locator(".settings-screen").waitFor(); |
| 91 | assert(await page.evaluate(() => Boolean(window.dockBackground.closest("[inert]")))); |
| 92 | assert(await page.evaluate(() => { |
| 93 | window.dockBackground.focus(); |
| 94 | return document.activeElement !== window.dockBackground; |
| 95 | }), "ancestor inert prevents background focus"); |
| 96 | for (let i = 0; i < 6; i++) { |
| 97 | await page.keyboard.press("Tab"); |
| 98 | assert(await page.evaluate(() => !document.activeElement?.closest("[inert]")), "Tab stays outside inert controls"); |
| 99 | } |
| 100 | const point = await page.evaluate(() => { |
| 101 | document.querySelector(".settings-screen").style.pointerEvents = "none"; |
| 102 | const rect = window.dockBackground.getBoundingClientRect(); |
| 103 | return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
| 104 | }); |
| 105 | await page.mouse.click(point.x, point.y); |
| 106 | assert.equal(await page.evaluate(() => window.dockBackgroundClicks), 0, "inert prevents background interaction without relying on an overlay"); |
| 107 | await page.evaluate(() => { document.querySelector(".settings-screen").style.pointerEvents = ""; }); |
| 108 | await page.locator(".settings-screen .management-screen__back").click(); |
| 109 | assert(await page.evaluate(() => !window.dockBackground.closest("[inert]"))); |
| 110 | assert.equal(await page.evaluate(selected), "README.md", "management preserves panel state"); |
| 111 | await tabs.nth(0).click({ button: "right" }); |
| 112 | assert.equal(await page.locator('.context-menu [role="menuitem"]').count(), 3, "outer menu has only tab actions"); |
| 113 | await page.keyboard.press("Escape"); |
| 114 | await tabs.nth(0).focus(); |
| 115 | await page.keyboard.press("Shift+F10"); |
| 116 | assert.equal(await page.locator('.context-menu [role="menuitem"]').count(), 3); |
| 117 | assert(await page.locator(".context-menu").evaluate(menu => { |
| 118 | const r = menu.getBoundingClientRect(); |
| 119 | return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight; |
| 120 | }), "flat keyboard menu stays inside the viewport"); |
| 121 | await page.keyboard.press("Escape"); |
| 122 | await page.locator(".topicbar__chrome-btn--launcher").click(); |
| 123 | await page.locator(".dock-launcher").waitFor(); |
| 124 | await page.locator(".topicbar__chrome-btn--launcher").click(); |
| 125 | await page.locator(".dock-launcher").waitFor({ state: "detached" }); |
| 126 | await page.screenshot({ path: path.join(tmpdir(), "reasonix-dock-views-desktop.png") }); |
| 127 | await page.setViewportSize({ width: 960, height: 720 }); |
| 128 | await page.screenshot({ path: path.join(tmpdir(), "reasonix-dock-views-narrow.png") }); |
| 129 | assert.deepEqual(errors, []); |
| 130 | console.log("PASS independent file views, close/reopen, restart, tab-only menu and management inert behavior"); |
| 131 | } finally { |
| 132 | await browser.close(); |
| 133 | await preview?.httpServer.close(); |
| 134 | } |
| 135 |