test(ysync): repro tests for review bugs 01-07 + v2 items-wire e2e port (miss 11)

The 2026-07-02 sync review (docs/features/ysync-review, ysync-review branch)
found 7 bugs and that the two-tab e2e only exercised the DEAD legacy scalar
wire. This lands plan doc 15 in full; results + empirical findings in doc 16.

- tests/collab/browser-entry-v2.ts (+build.mjs): the PRODUCTION v2 stack
  bundled for e2e (connectKicadDoc + attachKicadCollab, kdoc_* keys), with
  in-page renderActiveDoc/singleSeedRender/driftReport helpers and yjs forced
  to ONE copy (the two web pnpm workspaces otherwise bundle two
  instanceof-incompatible instances).
- tests/kicad/ysync-two-tab.spec.ts: pl_editor green baseline (A↔B edits,
  ITEM-level drift silence) + divergent-uuid adopt; bug-01 pcb/ee fresh-room
  repros (Chromium-only: two kicad_editor tabs exceed Firefox's per-process
  wasm budget); bug-06 concurrent-seed race; bug-03 Y-half.
- tests/kicad/ysync-repros-{pcbnew,eeschema}.spec.ts: bugs 02/03/05 + the
  bug-04 matrix (anchor-centred fp rotation, pad resize, endpoint drag,
  symbol rotation, Value-field edit), each with green landed-preconditions;
  the "local move emits" controls double as headless-emit probes — GREEN on
  both tools, so every emit-dependent repro is a live test.fail.
- wasm/bindings: 7 local-edit test hooks via real commits
  (CallAfter+COROUTINE) — TestRemoveItem/TestRotateItem (both tools,
  dispatched in the merged image), TestSetPadSize/TestMoveEndpoint (pcbnew),
  TestSetFieldText (eeschema).
- web/standalone ysync-repros.test.ts: bug-01 units (C++-faithful fake gating
  emit on ensureBridge) + bug-07a/b (stale DOWN hook, real sheet-manager gap).
- web/pcbjam-shared bump: bug-03/06 unit repros.

Convention: every repro asserts the CORRECT behavior and is expected-fail
(test.fail/it.fails) naming its bug doc; a fix flips it to "unexpected pass",
forcing marker removal — the repro becomes the regression test. Every
expected failure verified (JSON reporter) to fail at its documented assert.
Suite state: 39 passed / 0 failed / 0 flaky / 5 skipped (2 firefox guards,
2 pre-existing legacy two-tab skips, 1 pre-existing roundtrip fixme).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPfrVhfYgPPgtawjSssZfn
This commit is contained in:
Viktor Vaczi 2026-07-03 12:43:35 +02:00 • committed by Gergő Törcsvári
commit 4e92baf5d0
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 2030 additions and 1 deletions

View file

@ -0,0 +1,154 @@
// Browser bundle entry for the V2 "items" collab e2e — the PRODUCTION stack
// (ysync 0008): startKicadCollab → connectKicadDoc + bindKicadCollab over
// moduleItemsBridge, Y keys kdoc_*, C++ exports kicadCollabSnapshotItems /
// kicadCollabApplyItems / window.kicadCollab.onItems.
//
// The sibling browser-entry.ts drives the LEGACY scalar wire (startCollab /
// onDelta), which is DEAD in production — nothing registers onDelta; WasmTool
// binds onItems only (ysync-review miss 11). New collab e2e must load THIS
// bundle; the legacy one stays only until the scalar wire is deleted.
//
// Build: npm run build:collab (tests/) → tests/apps/kicad/collab-bundle-v2.js
//
// IMPORTANT (build.mjs): yjs is aliased to ONE physical copy. web/standalone and
// web/pcbjam-shared are separate pnpm workspaces, so without the alias the
// bundle carries two yjs instances (the legacy bundle demonstrably does) — and
// the v2 path breaks on that (Y types are instanceof-checked singletons; same
// reason the standalone vitest config sets `dedupe: ["yjs"]`).
import * as Y from "yjs";
import {
docDelta,
docToFile,
docToY,
fileToDoc,
isEmptyKicadDelta,
yToDoc,
} from "@pcbjam/shared";
import {
attachKicadCollab,
connectKicadDoc,
type KicadCollabHandle,
type KicadItemsModule,
type KicadItemsWindow,
} from "../../web/standalone/src/wasm/collab/index";
interface StartOpts {
room: string;
/** BroadcastChannel settle window before the seed-vs-adopt decision. */
settleMs?: number;
/**
* Full file text; when set and the room is EMPTY, the Y.Doc is file-seeded
* from fileToDoc(seedText) — the production first-tab path (and the branch
* bug 01 lives in). Omit to exercise the editor-snapshot / adopt branches.
*/
seedText?: string;
/**
* The ydoc-load path: the editor opened exactly this doc's content, so seed
* only baselines the wasm differ instead of running the adopt apply.
*/
editorMatchesDoc?: boolean;
}
/** Start the v2 stack; the handle lands on window.__collabV2 for in-page asserts. */
async function start(
mod: KicadItemsModule,
win: KicadItemsWindow,
opts: StartOpts,
): Promise<void> {
// startKicadCollab's body, split so editorMatchesDoc is reachable (the
// production WasmTool uses the same connect + attach pair for ydoc mode).
const session = await connectKicadDoc({
provider: { kind: "broadcastchannel", settleMs: opts.settleMs ?? 400 },
room: opts.room,
});
const h = attachKicadCollab(mod, win, session, {
seedDoc: opts.seedText ? fileToDoc(opts.seedText) : undefined,
editorMatchesDoc: opts.editorMatchesDoc,
});
(window as unknown as { __collabV2?: KicadCollabHandle }).__collabV2 = h;
}
function handle(): KicadCollabHandle {
const h = (window as unknown as { __collabV2?: KicadCollabHandle }).__collabV2;
if (!h) throw new Error("KicadCollabV2: start() has not completed");
return h;
}
/** docToFile of the live room doc — THROWS if the doc stopped materializing. */
function renderActiveDoc(): string {
return docToFile(yToDoc(handle().doc));
}
/** What ONE seeder would materialize — the bug-06 reference rendering. */
function singleSeedRender(seedText: string): string {
const ydoc = new Y.Doc();
try {
docToY(fileToDoc(seedText), ydoc);
return docToFile(yToDoc(ydoc));
} finally {
ydoc.destroy();
}
}
export interface DriftSummary {
added: string[];
updated: string[];
removed: string[];
layoutChanged: boolean;
metaChanged: boolean;
}
/**
* The drift-detect convergence oracle, replicating computeDrift's core
* (web/standalone/src/wasm/collab/drift-detect.ts:94-118) from @pcbjam/shared
* primitives only — drift-detect itself pulls `@/lib/api`, so it can't be
* bundled here. Serializes the live model via the tool's save fn, diffs it
* against the room doc; null means editor ≡ doc.
*/
function driftReport(saveFn: string, scratchPath: string): DriftSummary | null {
const w = window as unknown as {
Module: Record<string, (p: string) => void>;
FS: {
readFile(p: string, o: { encoding: "utf8" }): string;
unlink(p: string): void;
};
};
w.Module[saveFn]!(scratchPath);
let text: string;
try {
text = w.FS.readFile(scratchPath, { encoding: "utf8" });
} finally {
try {
w.FS.unlink(scratchPath);
} catch {
/* scratch cleanup is best-effort */
}
}
const wasmDoc = fileToDoc(text);
const ydocDoc = yToDoc(handle().doc);
const diff = docDelta(ydocDoc, wasmDoc);
const layoutChanged =
JSON.stringify(ydocDoc.layout) !== JSON.stringify(wasmDoc.layout);
const metaChanged = ydocDoc.root !== wasmDoc.root;
if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null;
return {
added: diff.added.map((i) => i.uuid),
updated: diff.updated.map((i) => i.uuid),
removed: diff.removed,
layoutChanged,
metaChanged,
};
}
declare global {
interface Window {
KicadCollabV2?: {
start: typeof start;
renderActiveDoc: typeof renderActiveDoc;
singleSeedRender: typeof singleSeedRender;
driftReport: typeof driftReport;
};
}
}
window.KicadCollabV2 = { start, renderActiveDoc, singleSeedRender, driftReport };

View file

@ -23,3 +23,28 @@ await build({
});
console.log("collab bundle built → apps/kicad/collab-bundle.js");
// The V2 ("items") bundle — the PRODUCTION collab stack (see browser-entry-v2.ts).
await build({
entryPoints: [path.join(testsDir, "collab/browser-entry-v2.ts")],
bundle: true,
format: "iife",
outfile: path.join(testsDir, "apps/kicad/collab-bundle-v2.js"),
nodePaths: [path.join(testsDir, "node_modules")],
external: ["y-partyserver/provider", "@hocuspocus/provider"],
alias: {
// web/standalone and web/pcbjam-shared are separate pnpm workspaces, so
// their `yjs` imports resolve to two physical copies. The v2 binding hands
// Y types across that boundary (instanceof-checked), so force ONE copy —
// the tests devDep — exactly like the standalone vitest `dedupe: ["yjs"]`.
yjs: path.join(testsDir, "node_modules/yjs"),
// Lets this entry (which lives under tests/, outside the web workspaces)
// import the shared lib by name, resolving to the SAME source instance the
// standalone collab modules bundle.
"@pcbjam/shared": path.join(testsDir, "../web/pcbjam-shared/src/index.ts"),
},
logLevel: "info",
target: "es2020",
});
console.log("collab v2 bundle built → apps/kicad/collab-bundle-v2.js");