// worker-comms.spec.ts - Full-stack worker communication integration tests. // // Runs against the full bldr dev server (bun run start:web:wasm). // Verifies the complete plugin lifecycle: WASM runtime boot, worker // creation, plugin startup, and absence of eager SAB bus registration. import { test, expect } from '@playwright/test' import type { Browser, ConsoleMessage, Page } from '@playwright/test' import { startupMarkEvent } from '../web/bldr/startup-marks.js' const workerCommsTimeoutMs = 300_000 interface StartupMark { name: string label: string sequence: number timestampMs?: number detail: Record } interface StartupMarkGlobal { __swStartupMarks?: StartupMark[] } interface StartupMarkEventPayload { name?: string detail?: Record } // Collect console messages matching a pattern within a timeout. async function waitForConsole( page: Page, pattern: string | RegExp, timeoutMs = workerCommsTimeoutMs, ): Promise { const { promise, resolve, reject } = Promise.withResolvers() const timer = setTimeout( () => reject(new Error(`timeout waiting for console: ${pattern}`)), timeoutMs, ) const handler = (msg: ConsoleMessage) => { const text = msg.text() const matches = typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text) if (matches) { clearTimeout(timer) page.removeListener('console', handler) resolve(text) } } page.on('console', handler) return promise } interface StartupMarkWaiter { label: string resolve: (mark: StartupMark) => void } interface StartupMarkCollector { count(label: string): number marks(): StartupMark[] dump(): string wait(label: string, timeoutMs?: number): Promise } let startupMarkCollectorID = 0 function formatStartupMarkDump(marks: StartupMark[]): string { if (!marks.length) { return '(none)' } return marks .map((mark, index) => { const sequence = mark.sequence > 0 ? mark.sequence.toString() : (index + 1).toString() const timestamp = typeof mark.timestampMs === 'number' ? `${mark.timestampMs.toFixed(3)}ms` : 'unknown-ms' return `${sequence}. ${mark.label} @ ${timestamp}` }) .join('\n') } function expectStartupMarksInOrder( marks: StartupMark[], expectedLabels: string[], ) { let searchFrom = 0 for (const label of expectedLabels) { const index = marks.findIndex( (mark, markIndex) => markIndex >= searchFrom && mark.label === label, ) expect( index, `missing startup mark in order: ${label}`, ).toBeGreaterThanOrEqual(searchFrom) searchFrom = index + 1 } } async function createStartupMarkCollector( page: Page, ): Promise { const marks: StartupMark[] = [] const waiters: StartupMarkWaiter[] = [] const callbackName = `__bldrE2EStartupMark${++startupMarkCollectorID}` const removeWaiter = (waiter: StartupMarkWaiter) => { const index = waiters.indexOf(waiter) if (index >= 0) { waiters.splice(index, 1) } } const recordMark = (mark: StartupMark) => { marks.push(mark) for (const waiter of waiters.slice()) { if (waiter.label === mark.label) { waiter.resolve(mark) } } } await page.exposeFunction(callbackName, recordMark) await page.addInitScript( ({ callbackName, startupMarkEvent }) => { const startupGlobal = globalThis as typeof globalThis & StartupMarkGlobal & Record const pushMark = (mark: StartupMark) => { const callback = startupGlobal[callbackName] if (typeof callback !== 'function') { return } if (typeof performance === 'undefined') { void (callback as (mark: StartupMark) => void)(mark) return } void (callback as (mark: StartupMark) => void)({ ...mark, timestampMs: performance.now(), }) } for (const mark of startupGlobal.__swStartupMarks ?? []) { pushMark(mark) } globalThis.addEventListener(startupMarkEvent, (event: Event) => { const payload = (event as CustomEvent).detail const detail = payload?.detail ?? {} const label = detail.label if (typeof label !== 'string') { return } pushMark({ name: payload?.name ?? '', label, sequence: Number(detail.sequence ?? 0), detail, }) }) }, { callbackName, startupMarkEvent }, ) return { count(label: string) { return marks.filter((mark) => mark.label === label).length }, marks() { return [...marks] }, dump() { return formatStartupMarkDump(marks) }, wait(label: string, timeoutMs = workerCommsTimeoutMs) { const existing = marks.find((mark) => mark.label === label) if (existing) { return Promise.resolve(existing) } const { promise, resolve, reject } = Promise.withResolvers() const waiter: StartupMarkWaiter = { label, resolve: (mark: StartupMark) => { clearTimeout(timer) removeWaiter(waiter) resolve(mark) }, } const timer = setTimeout(() => { removeWaiter(waiter) reject( new Error( `timeout waiting for startup mark: ${label}\nCollected startup marks:\n${formatStartupMarkDump(marks)}`, ), ) }, timeoutMs) waiters.push(waiter) return promise }, } } test.describe.configure({ mode: 'serial', timeout: 360_000 }) // TIER: pr test.describe('worker communication lifecycle', () => { test('detects worker comms config', async ({ page }) => { const configPromise = waitForConsole(page, 'worker-comms: detected config') await page.goto('/#/') const msg = await configPromise // Should detect a valid config (A, B, C, or F). expect(msg).toMatch(/detected config [ABCF]/) }) test('does not eagerly register plugins on SAB bus', async ({ page }) => { const busMessages: string[] = [] page.on('console', (msg) => { const text = msg.text() if ( text.includes('SAB bus') || text.includes('registered on SAB bus') || text.includes('SabBus: max readers') ) { busMessages.push(text) } }) await page.goto('/#/') await expect(page.locator('#bldr-root')).toBeVisible() expect(busMessages).toEqual([]) }) test('plugin starts native worker', async ({ page }) => { const startupMarks = await createStartupMarkCollector(page) await page.goto('/#/') const startMark = await startupMarks.wait('plugin.script-import-start') expect(startMark.detail).toMatchObject({ plugin: true }) }) test('full lifecycle: detect, bus, plugin, render', async ({ page }) => { const startupMarks = await createStartupMarkCollector(page) const errors: string[] = [] page.on('pageerror', (err) => { if (err.message.includes('cache disabled')) return errors.push(err.message) }) const forbiddenBusMessages: string[] = [] page.on('console', (msg) => { const text = msg.text() if ( text.includes('SAB bus') || text.includes('registered on SAB bus') || text.includes('SabBus: max readers') ) { forbiddenBusMessages.push(text) } }) await page.goto('/#/') const detectMark = await startupMarks.wait('worker-comms.detected') expect(detectMark.detail).toMatchObject({ source: 'browser' }) const startMark = await startupMarks.wait('plugin.script-import-start') expect(startMark.detail).toMatchObject({ plugin: true }) await waitForBldrRootRender(page) expect(forbiddenBusMessages).toEqual([]) // No uncaught errors. expect(errors).toEqual([]) }) }) test.describe('cross-tab communication', () => { test('two tabs establish cross-tab channels', async ({ context }) => { // Open two pages in the same browser context (shared ServiceWorker). const [pageA, pageB] = await Promise.all([ context.newPage(), context.newPage(), ]) // Collect ALL console messages for debugging. const allA: string[] = [] const allB: string[] = [] pageA.on('console', (msg) => allA.push(msg.text())) pageB.on('console', (msg) => allB.push(msg.text())) // Navigate both pages to the app. await pageA.goto('/#/') await pageA.waitForSelector('#bldr-root', { timeout: 60_000 }) await pageB.goto('/#/') await pageB.waitForSelector('#bldr-root', { timeout: 60_000 }) // Wait for cross-tab system to initialize on both pages. // Cross-tab messages include "cross-tab comms" and "cross-tab transport". await expect(async () => { const all = [...allA, ...allB] const hasCrossTab = all.some((m) => m.includes('cross-tab')) expect(hasCrossTab).toBe(true) }).toPass({ timeout: 30_000 }) }) }) // Create a browser context where SharedWorker is unavailable, // forcing DedicatedWorker runtime mode with Web Lock singleton. async function newDedicatedRuntimeContext(browser: Browser) { const context = await browser.newContext() await context.addInitScript(() => { Object.defineProperty(globalThis, 'SharedWorker', { value: undefined, configurable: true, }) }) return context } async function waitForBldrRootRender(page: Page) { const root = page.locator('#bldr-root') await expect(root).toBeVisible() await expect( page.locator('#bldr-root #bldr-initial-loading-shell'), ).toHaveCount(0, { timeout: 120_000 }) await expect(async () => { const text = await root.evaluate((el) => el.textContent ?? '') expect(text).not.toContain('Downloading the app bundle') }).toPass({ timeout: 120_000 }) } test.describe('singleton coordinator (no SharedWorker)', () => { test('only one tab runs plugins', async ({ browser }) => { const context = await newDedicatedRuntimeContext(browser) const [pageA, pageB] = await Promise.all([ context.newPage(), context.newPage(), ]) const pageAMarks = await createStartupMarkCollector(pageA) const pageBMarks = await createStartupMarkCollector(pageB) // Page A loads and acquires the singleton plugin lock. await pageA.goto('/#/') await pageAMarks.wait('singleton-lock.acquired') await pageAMarks.wait('plugin.script-import-start') // Page B loads without becoming another plugin host. await pageB.goto('/#/') await pageBMarks.wait('worker-comms.detected') await waitForBldrRootRender(pageB) expect(pageBMarks.count('plugin.script-import-start')).toBe(0) await context.close() }) test('singleton handoff on tab close', async ({ browser }) => { const context = await newDedicatedRuntimeContext(browser) const [pageA, pageB] = await Promise.all([ context.newPage(), context.newPage(), ]) const pageAMarks = await createStartupMarkCollector(pageA) const pageBMarks = await createStartupMarkCollector(pageB) // Page A acquires the singleton. await pageA.goto('/#/') await pageAMarks.wait('plugin.script-import-start') // Page B attaches to the existing runtime without starting plugins. await pageB.goto('/#/') await pageBMarks.wait('worker-comms.detected') await waitForBldrRootRender(pageB) expect(pageBMarks.count('plugin.script-import-start')).toBe(0) const pageBLock = pageBMarks.wait('singleton-lock.acquired') const pageBStart = pageBMarks.wait('plugin.script-import-start') // Close page A, releasing the singleton lock. await pageA.close() try { // Page B should acquire the lock and start plugins. await pageBLock const startMark = await pageBStart expect(startMark.detail).toMatchObject({ plugin: true }) expectStartupMarksInOrder(pageBMarks.marks(), [ 'dedicated-host.promoted', 'runtime.worker-created', 'runtime.client-channel-acked', 'runtime.connected', 'web-document.resume-ready', 'worker.create-request-received', 'worker.init-message-received', 'plugin.script-import-start', ]) } catch (err) { const message = err instanceof Error ? err.message : String(err) throw new Error( `${message}\nPage A startup marks:\n${pageAMarks.dump()}\nPage B startup marks:\n${pageBMarks.dump()}`, { cause: err }, ) } await context.close() }) })