pcbjam/tests/web/cross-probe.spec.ts
Gergő Törcsvári 3186986a0a
feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)
Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.

- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
  full PresenceState at selection rate (cursor always null); peers() = other-
  TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
  xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
  cross peers appended to the kicadCollabSetRemote snapshot as
  {id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
  and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
  ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
  room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
  patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
  TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
  merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
  13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
  partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
  (the whole-window #canvas compare flaked on the auto-dismissing version
  infobar — also fixes the long-known presence-eeschema restore flake);
  cross-app + presence-kicad vitest suites

Spec: docs/features/collab-presence/0006 (closed repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
2026-07-07 21:09:23 +02:00

128 lines
4.6 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
/**
* Cross-app selection e2e (collab-presence 0006): one tab on the demo
* SCHEMATIC and one on the demo BOARD of the same project share the
* project-wide presence room over the default BroadcastChannel provider.
* Selecting a symbol in eeschema must ghost-highlight the linked footprint in
* pcbnew (footprint `(path …)` tail = symbol uuid — the demo fixtures are
* linked), and vice versa. Assertions use the deterministic
* `kicadCollabTestGetCrossMapped()` probe (what the ghost render draws from)
* rather than pixel diffs — the render path itself is covered by the
* tests/kicad specs.
*
* This is also the single-user cross-probe test by construction: both tabs
* run as the same `?user=` in one browser context and still map each other
* (cross-app peers are keyed by client, not user).
*/
const SCOPE = 'default';
type Mod = {
kicadCollabTestSelectComponent(): string;
kicadCollabTestClearSelection(): boolean;
kicadCollabTestGetCrossMapped(): string;
kicadCollabGetSelectionFull(): string;
};
type W = { Module: Mod };
async function bootTool(
page: Page,
route: string,
titleRe: RegExp,
user: string,
): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${route}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), {
message: `${route}: editor never reached the expected title`,
timeout: 120000,
intervals: [1000],
})
.toMatch(titleRe);
await page.waitForFunction(
() => {
const m = (window as unknown as Partial<W>).Module;
return (
typeof m?.kicadCollabTestSelectComponent === 'function' &&
typeof m?.kicadCollabTestGetCrossMapped === 'function'
);
},
null,
{ timeout: 60000 },
);
}
const crossMapped = (page: Page) =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabTestGetCrossMapped()),
);
test('selecting a symbol in eeschema ghost-highlights the footprint in pcbnew, and back', async ({
page,
context,
}) => {
test.setTimeout(480000); // two full editor boots (eeschema + pcbnew wasm)
const sch = page;
await bootTool(sch, 'demo.kicad_sch', /demo — Schematic Editor/i, 'alice');
const pcb = await context.newPage();
await bootTool(pcb, 'demo.kicad_pcb', /demo — PCB Editor/i, 'bob');
// ── eeschema → pcbnew ──────────────────────────────────────────────────────
const symId = await sch.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(symId, 'demo schematic should contain a symbol').toBeTruthy();
// The board tab maps the symbol to ≥1 footprint (reused sheets map to more).
await expect
.poll(() => crossMapped(pcb), {
timeout: 20000,
message: 'pcbnew never received/mapped the eeschema selection',
})
.not.toEqual([]);
// The selection emit carried the symbol uuid across (sanity on the wire).
const schSel = await sch.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
);
expect(schSel.uuids).toContain(symId);
// Clearing the schematic selection clears the board ghosts.
await sch.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestClearSelection(),
);
await expect
.poll(() => crossMapped(pcb), { timeout: 20000 })
.toEqual([]);
// ── pcbnew → eeschema ──────────────────────────────────────────────────────
const fpId = await pcb.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(fpId, 'demo board should contain a footprint').toBeTruthy();
// The footprint's path tail resolves to a symbol on the schematic's sheet.
await expect
.poll(() => crossMapped(sch), {
timeout: 20000,
message: 'eeschema never received/mapped the pcbnew selection',
})
.not.toEqual([]);
// …and the emit carried the footprint's schematic path.
const pcbSel = await pcb.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
);
expect(pcbSel.uuids).toContain(fpId);
expect(pcbSel.fpPaths.length).toBeGreaterThan(0);
await pcb.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestClearSelection(),
);
await expect
.poll(() => crossMapped(sch), { timeout: 20000 })
.toEqual([]);
});