import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; import { loadBoard } from "./utils/threed-viewer"; import { waitForPcbnew } from "./utils/pcbnew-ready"; import { clickMenuBarItem, clickMenuItemByText } from "../e2e/utils/element-tracker"; /** * JSPI coroutine lifecycle in the REAL editor — successor to * fiber-resume-park.spec.ts (which pinned the retired asyncify rewind guard; * that guard's refused-resume beacon string no longer exists, which made the * old spec's engagement assert vacuous). * * The prod-shaped gate for the August 2026 ownership bug: coroutine.h's * ~CALL_CONTEXT released a BORROWED context record (the live enterer of a * nested dispatch); the JSPI backend read that as destroy-while-parked, * killed the record, and the poisoned caller slot made PCB_SELECTION_TOOL's * first Wait() dereference the raw -1 refusal: "coroutine 1 entry REJECTED: * memory access out of bounds" at boot, then a "[wx-scheduler] job tick * error" per tool activation and no dialog ever opening. This walks * boot → board load → Place Footprints → chooser opens → Cancel → chooser * closes, asserting a zero ghost/deadParked census and no rejection/trap * lines at every stage. Deterministically RED on the pre-fix build. * * (The old spec's mid-park-resume staging lives on as the jspi-coroutine * standalone harness, 18 protocol cases in both engines — the doc-15 * refusal, destroy-while-parked containment, and the phantom-release * refusals are pinned there, against the same libcontext.cpp.) */ const TRAP_SIGNATURE = /Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds|entry REJECTED|job tick error/; interface CoroCensus { ghosts: number; deadParked: number; refusedResumes: number; quarantines: number; parkedCoroutines: string[]; } function census(page: Page): Promise { return page.evaluate(() => { const L = (globalThis as any).__libctxJspi; const S = (globalThis as any).__wxScheduler; const ring: [number, string, string, number][] = S?._ring ?? []; return { ghosts: L?.ghosts ?? -1, deadParked: L?.deadParked ?? -1, refusedResumes: ring.filter((r) => r[1] === "libctxRefusedResume").length, quarantines: ring.filter((r) => r[1] === "libctxQuarantine").length, parkedCoroutines: [...(S?._suspended?.keys?.() ?? [])].filter((k: string) => String(k).startsWith("lc"), ), }; }); } // The footprint chooser is a wxFrame, not a wxDialog — count top-level frames // (same detection as footprint-chooser-close.spec.ts). function frameCount(page: Page): Promise { return page.evaluate( () => (window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) => /Frame$/.test(e.typeName || ""), ).length, ); } // Synthetic emscripten mouse events on the canvas: a Playwright click is // intercepted by the wx scrollbar overlay (same helper as // footprint-chooser-close.spec.ts). async function synthClick(page: Page, x: number, y: number): Promise { await page.evaluate( ([cx, cy]) => { const c = document.querySelector("#canvas") as HTMLCanvasElement; const opt = (b: number) => ({ clientX: cx, clientY: cy, bubbles: true, cancelable: true, view: window, button: 0, buttons: b, }); c.dispatchEvent(new MouseEvent("mousemove", opt(0))); c.dispatchEvent(new MouseEvent("mousedown", opt(1))); c.dispatchEvent(new MouseEvent("mouseup", opt(0))); c.dispatchEvent(new MouseEvent("click", opt(0))); }, [x, y], ); } test.describe("JSPI coroutine lifecycle (prod-shaped)", () => { test("boot, board load and tool activation stay coroutine-clean; chooser opens and cancels", async ({ page, testLogger, }) => { test.setTimeout(240000); await page.goto("/kicad/pcbnew.html"); await waitForPcbnew(page); const atBoot = await census(page); console.log(`[TEST] boot census: ${JSON.stringify(atBoot)}`); expect(atBoot.ghosts, "no ghost/refused transitions at boot").toBe(0); expect(atBoot.deadParked, "no coroutine died parked at boot").toBe(0); expect( atBoot.parkedCoroutines.length, "the always-on selection tool is parked in Wait()", ).toBeGreaterThan(0); // The board load is the historical trigger surface (SetBoard → // ResetTools(MODEL_RELOAD) ×2 → wake/dispatch storm over the parked // selection tool — where the phantom release fired). await loadBoard(page, testLogger); const afterLoad = await census(page); console.log(`[TEST] after-load census: ${JSON.stringify(afterLoad)}`); expect(afterLoad.ghosts, "no ghost transitions across the board load").toBe(0); expect(afterLoad.deadParked, "no coroutine died across the board load").toBe(0); // Place Footprints via the menu, then a canvas click opens the chooser // (the pre-fix build swallowed the activation: no dialog, job tick OOB). const framesBefore = await frameCount(page); expect(await clickMenuBarItem(page, "Place"), "Place menu findable").toBe(true); await clickMenuItemByText(page, "Place Footprints"); const canvas = (await page.locator("#canvas").boundingBox())!; await synthClick(page, Math.round(canvas.width * 0.35), Math.round(canvas.height * 0.45)); await expect .poll(() => frameCount(page), { timeout: 40000, intervals: [200] }) .toBeGreaterThan(framesBefore); const chooserOpen = await census(page); console.log(`[TEST] chooser-open census: ${JSON.stringify(chooserOpen)}`); expect(chooserOpen.ghosts, "no ghost transitions opening the chooser").toBe(0); expect(chooserOpen.deadParked, "no coroutine died opening the chooser").toBe(0); // Cancel the quasimodal. NOTE: in the harness the chooser's library // enumeration stays parked on its `fp-lib` bridge wait forever (the page // installs no `window.kicadLibs` provider), so the CLOSE itself is not // assertable here — the same reason footprint-chooser-close.spec.ts // soft-waits it. The live app (with a provider) closes on Cancel — probed // as part of the 2026-08-13 fix verification. What IS assertable, and // what the pre-fix build fails: the census stays clean and the app stays // responsive across the whole exercise. const cancel = await page.evaluate(() => { const btn = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find( (e) => /Button/i.test(e.typeName || "") && /cancel/i.test(e.label || ""), ); return btn ? { x: btn.centerX, y: btn.centerY } : null; }); expect(cancel, "Cancel button found in the chooser").not.toBeNull(); await synthClick(page, cancel!.x, cancel!.y); await expect .poll(() => frameCount(page), { timeout: 15000, intervals: [200] }) .toBe(framesBefore) .catch(() => console.log( "[TEST] chooser close not observable without a lib provider (fp-lib park) — soft", ), ); // Responsiveness after the cancel: a wx timer still gets delivered (the // doc-19 dead-app class froze the loop here). const firedBefore = await page.evaluate(() => { try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; } catch { return -1; } }); await page.evaluate(() => (window as any).Module.kicadTestArmTimerPark(30, 0)); await expect .poll( () => page.evaluate(() => { try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; } catch { return -1; } }), { timeout: 10000, intervals: [100] }, ) .toBeGreaterThan(firedBefore); const afterCancel = await census(page); console.log(`[TEST] after-cancel census: ${JSON.stringify(afterCancel)}`); expect(afterCancel.ghosts, "cancel left no ghost transitions").toBe(0); expect(afterCancel.deadParked, "cancel killed no coroutine").toBe(0); const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => TRAP_SIGNATURE.test(l), ); expect(trapLines, "no trap/rejection signature anywhere in the run").toEqual([]); }); });