Trio harness (standalone-hardening 0008 phase A): tests/kicad/utils/trio.ts (A=seeder/B=editor/C=observer, explicit-uuid fixtures, settleConverged + oracleSweep) + drift-trio.spec.ts — pl_editor plumbing baseline, eeschema and pcbnew S1, segment change-path regression (4/4 Chromium; firefox skipped by wasm budget). browser-entry-v2 driftReport upgraded to the production comparator (driftDocDelta + compareSlots, reordered/layoutReordered). Bumps web/pcbjam-shared for the deltaToItemsWire root-lift fix the harness surfaced (doc §10 ledger). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5cAM9M6q34n5X4dbrfVvi
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
// 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 {
|
|
compareSlots,
|
|
docToFile,
|
|
docToY,
|
|
driftDocDelta,
|
|
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[];
|
|
/** Order-only item churn — surfaced for triage, NOT drift (kicad-delta.ts). */
|
|
reordered: string[];
|
|
layoutChanged: boolean;
|
|
/** Order-only layout churn — likewise not report-worthy on its own. */
|
|
layoutReordered: boolean;
|
|
metaChanged: boolean;
|
|
}
|
|
|
|
/**
|
|
* The drift-detect convergence oracle, replicating computeDrift's core
|
|
* (web/standalone/src/wasm/collab/drift-detect.ts) 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 with the PRODUCTION comparator (driftDocDelta +
|
|
* compareSlots — order-only churn goes to reordered/layoutReordered and does
|
|
* not make a report, matching ysync 0010); 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 = driftDocDelta(ydocDoc, wasmDoc);
|
|
const layoutRelation = compareSlots(ydocDoc.layout, wasmDoc.layout);
|
|
const layoutChanged = layoutRelation === "different";
|
|
const metaChanged = ydocDoc.root !== wasmDoc.root;
|
|
// layoutReordered deliberately outside the gate — same as computeDrift.
|
|
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,
|
|
reordered: diff.reordered.map((i) => i.uuid),
|
|
layoutChanged,
|
|
layoutReordered: layoutRelation === "reordered",
|
|
metaChanged,
|
|
};
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
KicadCollabV2?: {
|
|
start: typeof start;
|
|
renderActiveDoc: typeof renderActiveDoc;
|
|
singleSeedRender: typeof singleSeedRender;
|
|
driftReport: typeof driftReport;
|
|
};
|
|
}
|
|
}
|
|
|
|
window.KicadCollabV2 = { start, renderActiveDoc, singleSeedRender, driftReport };
|