CodeNomad/packages/ui/tests/browser/session-delete.test.ts
heunghingwan 2064682d40
test(ui): share one fixture daemon per run across the browser suite (#1146)
Browser tests that don't need their own server now borrow their fixture
page from one Vite server per run instead of each starting one.

run.mjs (local) and shard.mjs (CI) spawn the fixture daemon as a child. It
announces its endpoint and secret as one stdout line; the runner hands them
to the test processes through the environment and stops the daemon with an
authenticated request when its slice ends, failing the run when that
teardown fails or has to be forced. The daemon watches its owner's pid and
cleans up after an owner that ended without stopping it; on Windows it is
spawned detached so the owner's exit doesn't end it first. A test file run
on its own gets a daemon child of its own. fixture-borrow.ts registers
namespaced routes, fixture-exemptions.ts lists the files that keep their
own servers (stateful SSE middleware, custom transforms, Electron hosts),
and fixture-wiring.test.ts enforces borrow-or-exempt. AGENTS.md sanctions
the daemon's single-writer optimizer cache, and dev-docs/BROWSER_TESTING.md
documents the commands, the daemon model, logs and limits. Twenty owned
fixtures also dispose their cache when createServer() rejects.

Measured locally, 20 representative files run one at a time took 797 s
against 1,091 s on owned servers (about 27 % less); the CI shards' test
streams show no consistent change.

Validation: the daemon, run, borrow and wiring tests pass (11/11),
including split and noisy handshakes, a start that never announces,
pipelined shutdown requests, a forced stop seen through by a minimal
runner, and protected cleanup after a killed owner; the full suite ran
through npm run test:browser on Windows, and the four CI shards passed.
2026-10-27 02:11:40 +01:00

81 lines
4.2 KiB
TypeScript

import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { borrowFixture } from "./fixture-borrow"
import { chromium, webkit, type Browser } from "playwright"
let browser: Browser, url: string
before(async () => {
const fixture = await borrowFixture({
name: "session-delete",
routes: [
{ path: "/fixture", html: "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"></head><body><div id=\"root\"></div><script type=\"module\" src=\"/tests/browser/fixtures/header-windows.tsx\"></script></body></html>" },
],
})
try {
url = fixture.url
browser = await (process.env.CODENOMAD_TEST_BROWSER === "webkit" ? webkit : chromium)
.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
} catch (error) { throw error }
})
after(async () => { try { await browser?.close() } finally { } })
for (const { touch, width, name } of [
{ touch: true, width: 390, name: "phone tap" },
{ touch: false, width: 390, name: "narrow window mouse" },
{ touch: false, width: 1400, name: "desktop mouse" },
]) {
test(`session overflow Delete requires confirmation then removes the session (${name})`, async () => {
const context = await browser.newContext({ viewport: { width, height: 850 },
hasTouch: touch, isMobile: touch, locale: "en-US" })
const page = await context.newPage()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
// The fixture owns its session client; no real event stream may reconcile it.
await page.addInitScript(() => { window.EventSource = class extends EventTarget { close() {} } as any })
await page.route("**/api/**", route => route.fulfill({ json: {} }))
try {
await page.goto(url)
await page.waitForFunction(() => Boolean((window as any).fixture))
const sessionId = "a-long-session-title-that-needs-the-action-overflow-menu"
await page.evaluate(id => {
;(window as any).fixture.addSession(id)
;(window as any).fixture.selectSession(id)
}, sessionId)
const activate = async (locator: ReturnType<typeof page.locator>) => touch ? locator.tap() : locator.click()
const leftToggle = page.locator(".shell-panel-toggle--left button")
if (await leftToggle.getAttribute("aria-expanded") === "false") await activate(leftToggle)
const row = page.locator(`.session-item-base[data-session-id="${sessionId}"]`)
await row.waitFor()
const openDelete = async () => {
if (!touch) await row.hover()
await activate(row.locator(".action-overflow-trigger"))
await activate(page.getByRole("menuitem", { name: "Delete session", exact: true }))
await page.getByRole("dialog", { name: "Delete session", exact: true }).waitFor({ timeout: 5000 })
assert.deepEqual(await page.evaluate(() => (window as any).fixture.deletedSessions), [], "opening the dialog is not consent")
}
await openDelete()
await activate(page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }))
await page.getByRole("dialog").waitFor({ state: "detached" })
assert.deepEqual(await page.evaluate(() => (window as any).fixture.deletedSessions), [])
assert.equal(await row.count(), 1)
if (width === 390) {
// Ordinary outside gestures still close the floating panel.
const outside = (await page.locator("#outside").boundingBox())!
if (touch) await page.touchscreen.tap(outside.x + 5, outside.y + 5)
else await page.mouse.click(outside.x + 5, outside.y + 5)
await page.waitForFunction(() => document.querySelector('.shell-panel-toggle--left button')?.getAttribute("aria-expanded") === "false")
await activate(leftToggle)
}
await openDelete()
await activate(page.getByRole("dialog").getByRole("button", { name: "Delete", exact: true }))
await page.waitForFunction(() => (window as any).fixture.deletedSessions.length === 1)
assert.deepEqual(await page.evaluate(() => (window as any).fixture.deletedSessions), [sessionId])
await row.waitFor({ state: "detached" })
assert.deepEqual(errors, [])
} finally { await context.close() }
})
}