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
This commit is contained in:
Gergő Törcsvári 2026-07-07 19:30:18 +02:00
commit 3186986a0a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 1276 additions and 48 deletions

View file

@ -21,6 +21,9 @@ import { test, expect } from "./fixtures";
const SEG1 = "44444444-0000-0000-0000-000000000001";
const FP1 = "66666666-0000-0000-0000-000000000001";
// The footprint's schematic link (0006): its (path …) ends in the symbol uuid.
const SYM1 = "aaaaaaaa-0000-0000-0000-000000000001";
const FP1_PATH = `/${SYM1}`;
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
@ -41,6 +44,7 @@ const SAMPLE_PCB = `(kicad_pcb
\t\t(layer "F.Cu")
\t\t(uuid "${FP1}")
\t\t(at 100 100)
\t\t(path "${FP1_PATH}")
\t\t(attr smd)
\t\t(property "Reference" "R1"
\t\t\t(at 0 -4.2 0)
@ -72,14 +76,18 @@ type Mod = {
kicadCollabSetRemote(j: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
kicadCollabGetSelectionFull(): string;
kicadCollabTestGetCrossMapped(): string;
kicadCollabTestSelectFirst(): string;
kicadCollabTestClearSelection(): boolean;
};
type SelPayload = { uuids: string[]; fpPaths: string[] };
type PresenceWindow = {
FS: FS;
Module: Mod;
kicadCollab?: Record<string, unknown>;
__selEmits?: string[][];
__selPayloads?: SelPayload[];
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
};
@ -134,14 +142,24 @@ async function bootAndOpen(page: Page): Promise<void> {
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
.toMatch(/presence/i);
// Install the capture hooks + start the presence input bindings.
// Install the capture hooks + start the presence input bindings. Since 0006
// pcbnew emits `{uuids, fpPaths}` — capture the uuid arrays (the pre-0006
// assertions) AND the full payloads (the cross-app ones).
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.__selEmits = [];
w.__selPayloads = [];
w.__cursorEmits = [];
w.kicadCollab = {
...w.kicadCollab,
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
onSelection: (json: string) => {
const parsed = JSON.parse(json) as SelPayload | string[];
const payload: SelPayload = Array.isArray(parsed)
? { uuids: parsed, fpPaths: [] }
: parsed;
w.__selEmits!.push(payload.uuids);
w.__selPayloads!.push(payload);
},
onCursor: (x: number, y: number, active: number) =>
w.__cursorEmits!.push({ x, y, active }),
};
@ -188,6 +206,49 @@ test("selection emit: programmatic select/clear reaches onSelection with uuids",
expect(hasAbort(testLogger)).toBe(false);
});
test("selection emit carries the footprint's schematic path (0006)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
// TestSelectFirst picks the first top-level item — the fixture's only
// footprint sorts first, but assert by payload rather than by luck: select,
// then find the emit whose uuids contain the footprint.
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
);
await expect
.poll(
() =>
page.evaluate(() => {
const w = window as unknown as PresenceWindow;
return w.__selPayloads!.at(-1) ?? null;
}),
{ timeout: 10000 },
)
.not.toBeNull();
const payload = await page.evaluate(
() => (window as unknown as PresenceWindow).__selPayloads!.at(-1)!,
);
if (payload.uuids.includes(FP1)) {
expect(payload.fpPaths).toEqual([FP1_PATH]);
} else {
// A non-footprint got selected first — the paths list must then be empty.
expect(payload.fpPaths).toEqual([]);
}
// The pull export mirrors the emit payload (cross-app seed at attach).
const full = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelectionFull()),
);
expect(full.uuids).toEqual(payload.uuids);
expect(full.fpPaths).toEqual(payload.fpPaths);
expect(hasAbort(testLogger)).toBe(false);
});
test("selection emit: a real canvas click drives the wx-layer trigger", async ({
page,
testLogger,
@ -352,6 +413,107 @@ test("remote render paints the overlay without touching local selection", async
expect(hasAbort(testLogger)).toBe(false);
});
test("cross-app ghost render: an eeschema peer's symbol uuid maps to the footprint (0006)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const canvas = page.locator("#canvas");
const before = await canvas.screenshot();
// An eeschema peer snapshot entry: no same-doc selection, xsel = symbol uuid.
await page.evaluate(
({ sym }) => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "bob#x7",
name: "bob · sch",
color: "#d946ef",
cursor: null,
selection: [],
xsel: [sym],
},
],
}),
);
},
{ sym: SYM1 },
);
// The suffix match resolves the symbol to the fixture footprint.
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([FP1]);
// …and paints the ghost outline (pixels change), with no selection leak.
await expect
.poll(
async () => !(await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
const localSel = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelection()),
);
expect(localSel).toEqual([]);
// An unknown symbol uuid maps to nothing; clearing restores the pixels.
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "bob#x7",
name: "bob · sch",
color: "#d946ef",
cursor: null,
selection: [],
xsel: ["99999999-0000-0000-0000-000000000099"],
},
],
}),
);
});
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([]);
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
JSON.stringify({ peers: [] }),
),
);
await expect
.poll(
async () => (await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page);