import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; /** * pcbnew selection soft-locks — C++ veto e2e (collab-presence 0007). * * Seeds the remote lock set directly via kicadCollabSetRemote (`locks` field) * and drives REAL input against it: * - a drag-move gesture starting on a locked footprint must not move it * (selectPoint's aOnDrag strip + the move request filter), while the SAME * gesture moves it once the lock clears (the control that proves the * gesture itself works); * - plain selection of a locked item stays possible (inspection semantics); * - kicadCollabReleaseSelection unselects exactly the contested uuids * (the tiebreak-loser path), leaving the rest selected; * - kicadCollabTestGetLocked mirrors the seeded set. * * The awareness/tiebreak loop lives in TS (unit-tested) and in the two-tab * tests/web/locks.spec.ts. */ const SEG1 = "44444444-0000-0000-0000-000000000001"; const FP1 = "66666666-0000-0000-0000-000000000001"; const SAMPLE_PCB = `(kicad_pcb \t(version 20241229) \t(generator "pcbnew") \t(generator_version "9.0") \t(general \t\t(thickness 1.6) \t) \t(paper "A4") \t(layers \t\t(0 "F.Cu" signal) \t\t(2 "B.Cu" signal) \t\t(37 "F.SilkS" user) \t\t(25 "Edge.Cuts" user) \t) \t(setup) \t(net 0 "") \t(footprint "TestLib:R" \t\t(layer "F.Cu") \t\t(uuid "${FP1}") \t\t(at 100 100) \t\t(attr smd) \t\t(fp_rect (start -2 -2) (end 2 2) (layer "F.SilkS") (stroke (width 0.3) (type solid)) (uuid "66666666-0000-0000-0000-0000000000cc")) \t\t(pad "1" smd rect \t\t\t(at 0 0) \t\t\t(size 3 3) \t\t\t(layers "F.Cu") \t\t\t(uuid "66666666-0000-0000-0000-0000000000dd") \t\t) \t) \t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}")) ) `; type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; type Mod = { kicadOpenFile(p: string): unknown; kicadCollabPresenceStart(): void; kicadCollabSetRemote(j: string): void; kicadCollabGetViewport(): string; kicadCollabGetSelection(): string; kicadCollabGetPos(id: string): string; kicadCollabReleaseSelection(uuidsJson: string, holder: string): void; kicadCollabTestGetLocked(): string; kicadCollabTestSelectComponent(): string; kicadCollabTestSelectFirst(): string; kicadCollabTestClearSelection(): boolean; }; type LocksWindow = { FS: FS; Module: Mod; kicadCollab?: Record; }; function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); } async function bootAndOpen(page: Page): Promise { await page.goto("/kicad/pcbnew-collab.html"); await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 }); await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); await page.waitForFunction( () => { const m = (window as unknown as { Module?: Partial }).Module; return ( typeof m?.kicadOpenFile === "function" && typeof m?.kicadCollabTestGetLocked === "function" ); }, null, { timeout: 90000 }, ); await page.waitForFunction( () => !!window.wxElementRegistry && window.wxElementRegistry .findAll({ visible: true }) .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), null, { timeout: 90000 }, ); await page.evaluate( ({ content }) => { const w = window as unknown as LocksWindow; const dir = "/home/kicad/documents"; try { w.FS.mkdirTree(dir); } catch { /* exists */ } const p = `${dir}/locks.kicad_pcb`; w.FS.writeFile(p, content); w.Module.kicadOpenFile(p); }, { content: SAMPLE_PCB }, ); await expect .poll(() => page.title(), { timeout: 60000, intervals: [500] }) .toMatch(/locks/i); await page.evaluate(() => { (window as unknown as LocksWindow).Module.kicadCollabPresenceStart(); }); } /** Seed (or clear) the remote lock set for FP1. */ function setLock(page: Page, locked: boolean): Promise { return page.evaluate( ({ fp, locked: isLocked }) => { const w = window as unknown as LocksWindow; w.Module.kicadCollabSetRemote( JSON.stringify({ peers: [], locks: isLocked ? [{ uuid: fp, name: "bob" }] : [], }), ); }, { fp: FP1, locked }, ); } /** The footprint's current screen position via the exported GAL transform. */ async function fpScreenPos(page: Page): Promise<{ x: number; y: number }> { const glId = await page.evaluate(() => { const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]')) .map((c) => c as HTMLCanvasElement) .find( (c) => window.getComputedStyle(c).display !== "none" && c.getBoundingClientRect().width > 0, ); return visible?.id ?? null; }); expect(glId).toBeTruthy(); const box = await page.locator(`#${glId}`).boundingBox(); expect(box).toBeTruthy(); const { vp, pos } = await page.evaluate(() => { const w = window as unknown as LocksWindow; return { vp: JSON.parse(w.Module.kicadCollabGetViewport()), pos: w.Module.kicadCollabGetPos("66666666-0000-0000-0000-000000000001"), }; }); const [wx, wy] = pos.split(",").map(Number); return { x: box!.x + (wx - vp.cx) * vp.scale + vp.w / 2, y: box!.y + (wy - vp.cy) * vp.scale + vp.h / 2, }; } const fpPos = (page: Page) => page.evaluate(() => (window as unknown as LocksWindow).Module.kicadCollabGetPos( "66666666-0000-0000-0000-000000000001", ), ); /** * The real pcbnew move flow: click-select the footprint, then the `M` hotkey * (pcbnew's default LEFT-DRAG gesture is rubber-band select, not move — the * eeschema spec covers the drag-gesture path where dragging DOES move). * `M` routes through EDIT_TOOL::Move → RequestSelection → the locked-items * client filter — exactly the veto under test. The drop is a click at the * offset position. */ async function moveViaHotkey(page: Page): Promise { const at = await fpScreenPos(page); await page.mouse.click(at.x, at.y); // select (allowed — inspection semantics) // The click routes through the real selection tool — wait for the selection to // actually land before arming the move (the bridge getter is the observable). // Depending on zoom the hit lands the footprint OR its pad — either is fine: // EDIT_TOOL::Move on a board pad substitutes the parent footprint anyway. await expect .poll( () => page.evaluate( () => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabGetSelection()) .length, ), { timeout: 10000, message: "click never landed a selection" }, ) .toBeGreaterThan(0); await page.mouse.move(at.x, at.y); await page.keyboard.press("m"); // Move-tool arming has no JS-observable (and in the locked case the request is // vetoed before any state changes — silence IS the expected outcome). await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: tool arming await page.mouse.move(at.x + 120, at.y + 80, { steps: 10 }); await page.mouse.click(at.x + 120, at.y + 80); // drop (no-op if move never started) // Bounded chance for a wrongful move to surface before the caller's negative // position assert; the unlocked control leg proves the gesture via its own poll. await page.waitForTimeout(800); // eslint-disable-line -- documented interaction dwell: negative-assert window await page.keyboard.press("Escape"); // leave no half-open tool between phases await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: tool teardown } test("seeded locks are probeable and a locked footprint resists a real move", async ({ page, testLogger, }) => { test.setTimeout(240000); await bootAndOpen(page); await setLock(page, true); await expect .poll(() => page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabTestGetLocked()), ), ) .toEqual([{ uuid: FP1, name: "bob" }]); // Veto: select + M + drop must leave the locked footprint in place. const before = await fpPos(page); expect(before).toBeTruthy(); await moveViaHotkey(page); expect(await fpPos(page)).toBe(before); // Control: the SAME flow moves it once the lock clears — proving the veto // (not a broken gesture) kept it in place above. await setLock(page, false); await expect .poll(() => page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabTestGetLocked()), ), ) .toEqual([]); await moveViaHotkey(page); await expect .poll(() => fpPos(page), { timeout: 10000, message: "control move never moved the unlocked footprint", }) .not.toBe(before); expect(hasAbort(testLogger)).toBe(false); }); test("a locked item stays selectable for inspection", async ({ page, testLogger }) => { test.setTimeout(240000); await bootAndOpen(page); await setLock(page, true); // Programmatic select drives the real selection tool (no drag involved). const id = await page.evaluate(() => (window as unknown as LocksWindow).Module.kicadCollabTestSelectComponent(), ); expect(id).toBe(FP1); await expect .poll(() => page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabGetSelection()), ), ) .toContain(FP1); expect(hasAbort(testLogger)).toBe(false); }); test("ReleaseSelection unselects exactly the contested uuids", async ({ page, testLogger, }) => { test.setTimeout(240000); await bootAndOpen(page); // Select the footprint plus (if distinct) the first top-level item through // the real tool — TestSelectFirst may pick the footprint itself, so assert // set-minus semantics rather than counts. await page.evaluate(() => { const w = window as unknown as LocksWindow; w.Module.kicadCollabTestSelectComponent(); w.Module.kicadCollabTestSelectFirst(); }); await expect .poll(() => page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabGetSelection()), ), ) .toContain(FP1); const preRelease: string[] = await page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabGetSelection()), ); // Lose the tiebreak on the footprint only. await page.evaluate( ({ fp }) => (window as unknown as LocksWindow).Module.kicadCollabReleaseSelection( JSON.stringify([fp]), "bob", ), { fp: FP1 }, ); // Exactly the contested uuid drops; anything else selected survives. await expect .poll(() => page.evaluate(() => JSON.parse((window as unknown as LocksWindow).Module.kicadCollabGetSelection()), ), ) .toEqual(preRelease.filter((u) => u !== FP1)); expect(hasAbort(testLogger)).toBe(false); });