fix(ysync): wire dialect == file dialect, uuid churn, drift noise
The Y.Doc is the source of truth for the FILE, but both live wires wrote KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every difference was permanent, unfixable drift. - pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version) inside every (footprint …). Keep (locked yes). - eeschema: aForClipboard=false — clipboard mode collapsed every symbol's (instances … (path "/sheet")) to (path ""). - Re-supply (version) at PARSE time only (withFootprintVersion): the token is invalid file content but load-bearing on decode — without it the parser starts at m_requiredVersion=0 and stamps (hide yes) on every mandatory field. - FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator= keeps the target's const m_Uuid, so Clone() rerolled all four). - drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders legitimately; excluded from counts, report-worthiness and dedupe hashes. Migration 0017. - fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree — FACE_PCB starts lazily there and never preloaded its libraries. Guards: wire-vs-file round-trip tests (pcbnew + eeschema), fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
This commit is contained in:
parent
a35eeb7e8b
commit
f92266fcee
8 changed files with 528 additions and 44 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
||||||
Subproject commit 92f18ef4ed83f085dd63b3513372c07b0e428c86
|
Subproject commit a005a34be0e0bc074c4d98bbaea51f59c0f7661f
|
||||||
|
|
@ -37,6 +37,14 @@ interface ToolCfg {
|
||||||
empty: string;
|
empty: string;
|
||||||
/** Known-volatile top-level tokens to ignore (serializer nondeterminism). */
|
/** Known-volatile top-level tokens to ignore (serializer nondeterminism). */
|
||||||
ignoreTokens?: string[];
|
ignoreTokens?: string[];
|
||||||
|
/**
|
||||||
|
* uuids that live in the FILE but are legitimately absent from the items wire,
|
||||||
|
* because they identify the document rather than an item — e.g. a schematic's
|
||||||
|
* root `(uuid …)`, which travels in kdoc_meta/kdoc_layout, not kdoc_items.
|
||||||
|
* Listed explicitly per fixture so `expectWireMatchesFile` can subtract them
|
||||||
|
* without blanket-ignoring "the wire dropped an item", which is a real defect.
|
||||||
|
*/
|
||||||
|
wireOmitsUuids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mod = {
|
type Mod = {
|
||||||
|
|
@ -161,6 +169,62 @@ async function roundTrip(
|
||||||
return { orig, regen };
|
return { orig, regen };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One boot: the file the tool would SAVE, and the per-item blobs it would put on
|
||||||
|
* the collab WIRE, taken from the same model at the same moment.
|
||||||
|
*/
|
||||||
|
async function fileAndWire(
|
||||||
|
context: BrowserContext,
|
||||||
|
cfg: ToolCfg,
|
||||||
|
): Promise<{ file: string; wire: string }> {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await bootOpen(page, cfg, cfg.fixture, "rt");
|
||||||
|
const file = await saveRead(page, cfg, "orig_dump");
|
||||||
|
const snap = await page.evaluate(() => window.Module.kicadCollabSnapshotItems());
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
// Splice the blobs into one synthetic document so sexprDiff can index them by
|
||||||
|
// uuid. Non-footprint blobs already arrive wrapped in their own `(kicad_pcb …)`
|
||||||
|
// envelope; nesting is harmless — only uuid-bearing forms are compared, and the
|
||||||
|
// envelope's layers/version carry none.
|
||||||
|
const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr);
|
||||||
|
return { file, wire: `(${cfg.tool}_wire ${blobs.join("\n")})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE invariant this suite was missing. `roundTrip` compares two FILE saves, so a
|
||||||
|
* wire blob written in a different dialect than the file writer round-trips
|
||||||
|
* perfectly and still corrupts the Y.Doc — which is exactly what happened:
|
||||||
|
*
|
||||||
|
* - pcbnew serialized wire footprints through CLIPBOARD_IO (CTL_FOR_CLIPBOARD),
|
||||||
|
* which unlike CTL_FOR_BOARD emits `(version …) (generator …)
|
||||||
|
* (generator_version …)` INSIDE every `(footprint …)`. Every footprint of
|
||||||
|
* every board drifted, permanently.
|
||||||
|
* - eeschema serialized symbols with `aForClipboard=true`, whose
|
||||||
|
* `MakeRelativeTo(currentSheet)` collapses `(instances … (path "/<sheet>" …))`
|
||||||
|
* to `(path "")` — so materializing the Y.Doc would strip every symbol's sheet
|
||||||
|
* path, reference and unit.
|
||||||
|
*
|
||||||
|
* The Y.Doc is the source of truth for the FILE, so the two must agree token for
|
||||||
|
* token. NOTE: no `ignoreTokens` here on purpose — `generator_version` is exactly
|
||||||
|
* one of the tokens the pcbnew bug leaked, and ignoring it would re-mask it. (The
|
||||||
|
* fixture-vs-build version mismatch that `ignoreTokens` exists for lives on the
|
||||||
|
* ROOT form, which carries no uuid and is therefore never compared.)
|
||||||
|
*/
|
||||||
|
async function expectWireMatchesFile(context: BrowserContext, cfg: ToolCfg): Promise<void> {
|
||||||
|
const { file, wire } = await fileAndWire(context, cfg);
|
||||||
|
const raw = sexprDiff(file, wire);
|
||||||
|
const omitted = new Set(cfg.wireOmitsUuids ?? []);
|
||||||
|
const diff = {
|
||||||
|
...raw,
|
||||||
|
removed: raw.removed.filter((u) => !omitted.has(u)),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0,
|
||||||
|
`wire blobs disagree with the file save:\n${JSON.stringify(diff, null, 2)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PL: ToolCfg = {
|
const PL: ToolCfg = {
|
||||||
|
|
@ -215,6 +279,68 @@ const SCH: ToolCfg = {
|
||||||
ignoreTokens: ["generator_version"],
|
ignoreTokens: ["generator_version"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A schematic carrying a real SYMBOL with `(instances …)`. The wire-vs-file test
|
||||||
|
// below needs one: the eeschema clipboard dialect rewrote the instance path
|
||||||
|
// relative to the current sheet, collapsing it to `(path "")`. A wire-only
|
||||||
|
// fixture (SCH above) cannot catch that — only symbols have instances.
|
||||||
|
const SCH_SYM: ToolCfg = {
|
||||||
|
...SCH,
|
||||||
|
// The schematic's own root uuid is document identity (kdoc_meta/kdoc_layout),
|
||||||
|
// never an item on the items wire — so the file has it and the wire does not.
|
||||||
|
wireOmitsUuids: ["11111111-1111-1111-1111-111111111111"],
|
||||||
|
fixture: `(kicad_sch
|
||||||
|
(version 20250114)
|
||||||
|
(generator "eeschema")
|
||||||
|
(generator_version "9.0")
|
||||||
|
(uuid "11111111-1111-1111-1111-111111111111")
|
||||||
|
(paper "A4")
|
||||||
|
(lib_symbols
|
||||||
|
(symbol "Device:R"
|
||||||
|
(pin_numbers (hide yes))
|
||||||
|
(pin_names (offset 0))
|
||||||
|
(exclude_from_sim no)
|
||||||
|
(in_bom yes)
|
||||||
|
(on_board yes)
|
||||||
|
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
|
||||||
|
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
|
||||||
|
(symbol "R_0_1"
|
||||||
|
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||||
|
(stroke (width 0.254) (type default)) (fill (type none)))
|
||||||
|
)
|
||||||
|
(symbol "R_1_1"
|
||||||
|
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27)))))
|
||||||
|
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "2" (effects (font (size 1.27 1.27)))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(symbol
|
||||||
|
(lib_id "Device:R")
|
||||||
|
(at 100 100 0)
|
||||||
|
(unit 1)
|
||||||
|
(exclude_from_sim no)
|
||||||
|
(in_bom yes)
|
||||||
|
(on_board yes)
|
||||||
|
(dnp no)
|
||||||
|
(uuid "33333333-0000-0000-0000-000000000001")
|
||||||
|
(property "Reference" "R1" (at 102 99 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||||
|
(property "Value" "R" (at 102 101 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||||
|
(pin "1" (uuid "33333333-0000-0000-0000-0000000000a1"))
|
||||||
|
(pin "2" (uuid "33333333-0000-0000-0000-0000000000a2"))
|
||||||
|
(instances
|
||||||
|
(project "rt"
|
||||||
|
(path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(sheet_instances (path "/" (page "1")))
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
const PCB: ToolCfg = {
|
const PCB: ToolCfg = {
|
||||||
tool: "pcbnew",
|
tool: "pcbnew",
|
||||||
// pcbnew-collab.html seeds kicad_common.json to skip the first-run setup wizard;
|
// pcbnew-collab.html seeds kicad_common.json to skip the first-run setup wizard;
|
||||||
|
|
@ -357,6 +483,26 @@ test.describe("round trip: file → yjs → file", () => {
|
||||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The wire dialect must BE the file dialect — see expectWireMatchesFile. These
|
||||||
|
// two would both have failed before the CTL_FOR_BOARD / aForClipboard=false fix:
|
||||||
|
// pcbnew on the `(version)(generator)(generator_version)` triple inside every
|
||||||
|
// footprint, eeschema on `(instances … (path ""))`.
|
||||||
|
test("pcbnew wire blobs match the file save token for token", async ({
|
||||||
|
context,
|
||||||
|
testLogger,
|
||||||
|
}) => {
|
||||||
|
await expectWireMatchesFile(context, PCB_FP);
|
||||||
|
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eeschema wire blobs keep the symbol's instance path", async ({
|
||||||
|
context,
|
||||||
|
testLogger,
|
||||||
|
}) => {
|
||||||
|
await expectWireMatchesFile(context, SCH_SYM);
|
||||||
|
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
|
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
|
||||||
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
|
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
|
||||||
// parse, the codebase's documented asyncify-fragile path (even a verbatim
|
// parse, the codebase's documented asyncify-fragile path (even a verbatim
|
||||||
|
|
|
||||||
138
tests/web/fpedit-from-eeschema.spec.ts
Normal file
138
tests/web/fpedit-from-eeschema.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { clickByTooltip, stableShot, waitForWxApp } from '../e2e/utils/element-tracker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footprint Editor opened FROM a schematic session must show its libraries.
|
||||||
|
*
|
||||||
|
* Regression guard for the ysync-0010 §4 bug: `single_top.cpp` preloads libraries
|
||||||
|
* only for the face the app booted into. Booting `--frame=fpedit` (what
|
||||||
|
* `-/footprint_editor` does, and what every other fp-editor spec exercises)
|
||||||
|
* preloads FACE_PCB and works. But the eeschema toolbar button routes through
|
||||||
|
* `Kiway().Player( FRAME_FOOTPRINT_EDITOR )`, which starts FACE_PCB lazily at
|
||||||
|
* click time and never preloads — so nothing had walked the footprint libraries,
|
||||||
|
* `LIBRARY_MANAGER_ADAPTER::GetLibraryNames()` reported only rows whose status is
|
||||||
|
* LOADED (i.e. none), and `FP_TREE_SYNCHRONIZING_ADAPTER::Sync` skipped
|
||||||
|
* everything. The editor opened with a completely empty library tree.
|
||||||
|
*
|
||||||
|
* Fixed by `AsyncLoad()` + `BlockUntilLoaded()` in
|
||||||
|
* `FOOTPRINT_EDIT_FRAME::initLibraryTree()`.
|
||||||
|
*
|
||||||
|
* Two things this spec must NOT do, both learned the hard way:
|
||||||
|
*
|
||||||
|
* - Don't assert on `document.title`. It stays "… — Schematic Editor" even after
|
||||||
|
* a second editor frame opens (verified with Symbol Editor too), so a title
|
||||||
|
* poll reports failure on a working transition. The wx FRAME LIST is the
|
||||||
|
* signal: `ModEditFrame` appearing IS the Footprint Editor opening.
|
||||||
|
* - Don't use the `demo.kicad_sch` route. It boots its document from the Y.Doc
|
||||||
|
* and needs apps/sync (:3055), which this suite's stack does not run; the doc
|
||||||
|
* stalls at "untitled [Unsaved]" and FACE_PCB never starts. The fileless
|
||||||
|
* `-/eeschema` route needs no sync, and nothing here needs a loaded schematic —
|
||||||
|
* the bug is about which kiface preloaded its libraries.
|
||||||
|
*
|
||||||
|
* The tree assertion is a DELTA, not an absolute count: the schematic editor
|
||||||
|
* already renders its own dataviewitem rows (symbol libs), so "> 0" would pass
|
||||||
|
* even with a totally empty footprint tree. Growth past that baseline is exactly
|
||||||
|
* "the footprint tree got rows".
|
||||||
|
*
|
||||||
|
* Determinism: no blind waits — readiness comes from waitForWxApp, the app's own
|
||||||
|
* `kicadLibs` observable, and polling for the state each step produces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPE = 'default';
|
||||||
|
const BOOT_TIMEOUT = 180000;
|
||||||
|
|
||||||
|
/** Names of the live wx top-level frames. */
|
||||||
|
function frameNames(page: Page): Promise<string[]> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
(window as unknown as { wxElementRegistry: any }).wxElementRegistry
|
||||||
|
.findAll({})
|
||||||
|
.filter((e: any) => /Frame$/.test(e.typeName || ''))
|
||||||
|
.map((e: any) => e.name as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rendered library-tree rows across whatever frames are up. */
|
||||||
|
function treeRowCount(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { wxElementRegistry: any }).wxElementRegistry.findAllRendered({
|
||||||
|
elementType: 'dataviewitem',
|
||||||
|
}).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('footprint editor reached from eeschema', () => {
|
||||||
|
test.describe.configure({ timeout: 420000 });
|
||||||
|
|
||||||
|
test('opens with a populated library tree', async ({ page }) => {
|
||||||
|
const aborts: string[] = [];
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (/Aborted\(/i.test(m.text())) aborts.push(m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) Boot the SCHEMATIC editor — the face whose preload does not cover footprints.
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/-/eeschema`);
|
||||||
|
await waitForWxApp(page, { timeout: BOOT_TIMEOUT });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.title(), { message: 'schematic editor up', timeout: BOOT_TIMEOUT })
|
||||||
|
.toMatch(/Schematic Editor/i);
|
||||||
|
|
||||||
|
// The libs bridge must exist before we cross over, or a miss below would be an
|
||||||
|
// app-plumbing failure rather than the kiface-preload bug under test.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !!(window as unknown as { kicadLibs?: unknown }).kicadLibs,
|
||||||
|
null,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await frameNames(page)).toEqual(['SchematicFrame']);
|
||||||
|
|
||||||
|
// Baseline: the schematic editor's OWN tree rows (symbol libs) — see header.
|
||||||
|
// It must be SETTLED before we read it: those rows render asynchronously, and
|
||||||
|
// a baseline sampled too early (0) would weaken the delta below into "> 0",
|
||||||
|
// which the schematic's own rows could satisfy on their own. Two consecutive
|
||||||
|
// equal reads = settled.
|
||||||
|
let baseline = -1;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const n = await treeRowCount(page);
|
||||||
|
const settled = n === baseline;
|
||||||
|
baseline = n;
|
||||||
|
return settled;
|
||||||
|
},
|
||||||
|
{ message: 'schematic tree row count settled', timeout: 60000, intervals: [1000] },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// 2) Cross to the Footprint Editor the way a user does: the toolbar button
|
||||||
|
// (ACTIONS::showFootprintEditor → Kiway().Player( FRAME_FOOTPRINT_EDITOR )).
|
||||||
|
expect(
|
||||||
|
await clickByTooltip(page, 'Create, delete and edit board footprints', {
|
||||||
|
elementType: 'tool',
|
||||||
|
}),
|
||||||
|
'Footprint Editor toolbar button found and clicked',
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// FACE_PCB starts lazily here — this is the first time the pcbnew kiface runs.
|
||||||
|
await expect
|
||||||
|
.poll(() => frameNames(page), {
|
||||||
|
message: 'Footprint Editor frame (ModEditFrame) opened',
|
||||||
|
timeout: BOOT_TIMEOUT,
|
||||||
|
})
|
||||||
|
.toContain('ModEditFrame');
|
||||||
|
|
||||||
|
// 3) THE ASSERTION: its library tree is populated. Before the fix the frame
|
||||||
|
// opened exactly like this and the tree stayed empty, so the row count
|
||||||
|
// never moved off the schematic editor's baseline.
|
||||||
|
await expect
|
||||||
|
.poll(() => treeRowCount(page), {
|
||||||
|
message: `footprint library tree rows rendered (baseline was ${baseline})`,
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(baseline);
|
||||||
|
|
||||||
|
await stableShot(page, 'fpedit-from-eeschema-tree.png');
|
||||||
|
expect(aborts, 'no WASM abort').toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
tests/web/symedit-from-eeschema.spec.ts
Normal file
112
tests/web/symedit-from-eeschema.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { clickByTooltip, waitForWxApp } from '../e2e/utils/element-tracker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol Editor opened from a schematic session must show its libraries.
|
||||||
|
*
|
||||||
|
* Companion to `fpedit-from-eeschema.spec.ts`, and deliberately the WEAKER of the
|
||||||
|
* two: this path is expected to be green already. It is here because
|
||||||
|
* `SYMBOL_EDIT_FRAME::SyncLibraries()` carries the same latent defect shape the
|
||||||
|
* footprint side had —
|
||||||
|
*
|
||||||
|
* SYMBOL_LIBRARY_ADAPTER* adapter = PROJECT_SCH::SymbolLibAdapter( &Prj() );
|
||||||
|
* adapter->BlockUntilLoaded(); // ← no AsyncLoad() first
|
||||||
|
*
|
||||||
|
* — which `FOOTPRINT_LIST_IMPL::ReadFootprintFiles()` explicitly warns against
|
||||||
|
* ("AsyncLoad() must be called before BlockUntilLoaded() to ensure library
|
||||||
|
* loading is started"). `BlockUntilLoaded()` on a face that never preloaded
|
||||||
|
* returns immediately with nothing loaded, and the tree comes up empty.
|
||||||
|
*
|
||||||
|
* It cannot fail TODAY because every route to the Symbol Editor already has
|
||||||
|
* FACE_SCH running: `--frame=symedit` preloads it, and the only UI entry point is
|
||||||
|
* eeschema's own toolbar (`ACTIONS::showSymbolEditor` is appended solely by
|
||||||
|
* toolbars_sch_editor.cpp:201 — nothing in pcbnew/ references it). So this spec
|
||||||
|
* pins the currently-working behaviour; it turns into a real regression guard the
|
||||||
|
* moment a cross-kiface entry point is added.
|
||||||
|
*
|
||||||
|
* Same two traps as the footprint spec: assert on the wx FRAME LIST, not
|
||||||
|
* `document.title` (which stays "… — Schematic Editor" even after a second editor
|
||||||
|
* opens), and use the fileless `-/eeschema` route so no apps/sync is required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPE = 'default';
|
||||||
|
const BOOT_TIMEOUT = 180000;
|
||||||
|
|
||||||
|
function frameNames(page: Page): Promise<string[]> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
(window as unknown as { wxElementRegistry: any }).wxElementRegistry
|
||||||
|
.findAll({})
|
||||||
|
.filter((e: any) => /Frame$/.test(e.typeName || ''))
|
||||||
|
.map((e: any) => e.name as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeRowCount(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { wxElementRegistry: any }).wxElementRegistry.findAllRendered({
|
||||||
|
elementType: 'dataviewitem',
|
||||||
|
}).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('symbol editor reached from eeschema', () => {
|
||||||
|
test.describe.configure({ timeout: 420000 });
|
||||||
|
|
||||||
|
test('opens with a populated library tree', async ({ page }) => {
|
||||||
|
const aborts: string[] = [];
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (/Aborted\(/i.test(m.text())) aborts.push(m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/-/eeschema`);
|
||||||
|
await waitForWxApp(page, { timeout: BOOT_TIMEOUT });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.title(), { message: 'schematic editor up', timeout: BOOT_TIMEOUT })
|
||||||
|
.toMatch(/Schematic Editor/i);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !!(window as unknown as { kicadLibs?: unknown }).kicadLibs,
|
||||||
|
null,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await frameNames(page)).toEqual(['SchematicFrame']);
|
||||||
|
|
||||||
|
// Settle the baseline before reading it — see the footprint spec's header.
|
||||||
|
let baseline = -1;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const n = await treeRowCount(page);
|
||||||
|
const settled = n === baseline;
|
||||||
|
baseline = n;
|
||||||
|
return settled;
|
||||||
|
},
|
||||||
|
{ message: 'schematic tree row count settled', timeout: 60000, intervals: [1000] },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await clickByTooltip(page, 'Create, delete and edit schematic symbols', {
|
||||||
|
elementType: 'tool',
|
||||||
|
}),
|
||||||
|
'Symbol Editor toolbar button found and clicked',
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => frameNames(page), {
|
||||||
|
message: 'Symbol Editor frame (LibeditFrame) opened',
|
||||||
|
timeout: BOOT_TIMEOUT,
|
||||||
|
})
|
||||||
|
.toContain('LibeditFrame');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => treeRowCount(page), {
|
||||||
|
message: `symbol library tree rows rendered (baseline was ${baseline})`,
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(baseline);
|
||||||
|
|
||||||
|
expect(aborts, 'no WASM abort').toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -355,10 +355,22 @@ void emitSheetChanged()
|
||||||
}, s.c_str() );
|
}, s.c_str() );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize one live schematic item to its native s-expr via the clipboard
|
// Serialize one live schematic item to its native s-expr via a one-item
|
||||||
// formatter (the exact path Ctrl-C uses: a one-item SCH_SELECTION through
|
// SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also
|
||||||
// SCH_IO_KICAD_SEXPR::Format). For a symbol the output also carries its
|
// carries its (lib_symbols …) definition (that prelude is emitted for any symbol
|
||||||
// (lib_symbols …) definition, just like a copy does.
|
// in the selection, independent of aForClipboard).
|
||||||
|
//
|
||||||
|
// aForClipboard MUST be false. Clipboard mode is a LOSSY, paste-oriented dialect:
|
||||||
|
// it rewrites `(instances (project … (path …)))` relative to aRelativePath — so a
|
||||||
|
// symbol on the current sheet collapses to `(path "")` — takes the REFERENCE field
|
||||||
|
// from the per-sheet instance instead of the ordinal one, and keeps orphaned
|
||||||
|
// instance data (sch_io_kicad_sexpr.cpp saveSymbol: ~758-766, ~791-806, ~903).
|
||||||
|
// The Y.Doc is the source of truth for the FILE, so a wire blob must be byte-equal
|
||||||
|
// to that item's subtree in a full file save; clipboard form would silently strip
|
||||||
|
// every symbol's sheet path and unit/reference on materialize.
|
||||||
|
//
|
||||||
|
// aRelativePath is still required (Format wxCHECKs it non-null) but is unread on
|
||||||
|
// the aForClipboard=false path.
|
||||||
std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
|
std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
|
||||||
{
|
{
|
||||||
SCH_SELECTION sel;
|
SCH_SELECTION sel;
|
||||||
|
|
@ -368,7 +380,7 @@ std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
|
||||||
STRING_FORMATTER fmt;
|
STRING_FORMATTER fmt;
|
||||||
SCH_IO_KICAD_SEXPR plugin;
|
SCH_IO_KICAD_SEXPR plugin;
|
||||||
plugin.Format( &sel, &aFrame->GetCurrentSheet(), aFrame->Schematic(), &fmt,
|
plugin.Format( &sel, &aFrame->GetCurrentSheet(), aFrame->Schematic(), &fmt,
|
||||||
/*aForClipboard*/ true );
|
/*aForClipboard*/ false );
|
||||||
return fmt.GetString();
|
return fmt.GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,27 +294,55 @@ json itemToJson( BOARD_ITEM* aItem )
|
||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── s-expr clipboard blob (the generic `added` mechanism) ────────────────────────────────────
|
// ── s-expr item blob (the generic `added` mechanism) ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// For added items beyond the natively-reconstructed PCB_TRACK (footprints, vias, zones, graphic
|
// For added items beyond the natively-reconstructed PCB_TRACK (footprints, vias, zones, graphic
|
||||||
// shapes/text…), reuse KiCad's own copy/paste serializer, CLIPBOARD_IO. It Format()s a one-item
|
// shapes/text…), serialize one item with the BOARD writer's control set: a bare `(footprint …)`
|
||||||
// selection exactly as Ctrl-C does — a bare `(footprint …)` for a footprint, or a fake
|
// for a footprint, or a fake `(kicad_pcb … <layers> <item>)` envelope for everything else (the
|
||||||
// `(kicad_pcb … <layers> <item>)` envelope for everything else (the bare item tokens like
|
// bare item tokens like `(segment`/`(via`/`(zone` are NOT accepted by the parser top-level, so
|
||||||
// `(segment`/`(via`/`(zone` are NOT accepted by the parser top-level, so the envelope is
|
// the envelope is required).
|
||||||
// required). CLIPBOARD_IO normally talks to the system clipboard; SetWriter/SetReader redirect
|
//
|
||||||
// it to a string so it works headless / in wasm.
|
// The envelope still comes from CLIPBOARD_IO::SaveSelection, which is also what supplies the
|
||||||
|
// `(layers …)` block the parser needs; CLIPBOARD_IO normally talks to the system clipboard, so
|
||||||
|
// SetWriter/SetReader redirect it to a string to work headless / in wasm. Footprints take the
|
||||||
|
// dedicated path below instead — see blobForItem for why the clipboard dialect is wrong here.
|
||||||
|
|
||||||
// Serialize one live board item to a clipboard blob (used only for `added` payloads — NOT the
|
// A board writer we can point at a BOARD. PCB_IO_KICAD_SEXPR's default control set is
|
||||||
|
// CTL_FOR_BOARD — exactly what a .kicad_pcb save uses — but only CLIPBOARD_IO exposes a
|
||||||
|
// public SetBoard(); m_board is protected on PCB_IO, so a two-line subclass gets us the
|
||||||
|
// file writer without a fork change.
|
||||||
|
class WIRE_BOARD_IO : public PCB_IO_KICAD_SEXPR
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit WIRE_BOARD_IO( BOARD* aBoard ) : PCB_IO_KICAD_SEXPR( CTL_FOR_BOARD )
|
||||||
|
{
|
||||||
|
m_board = aBoard;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize one live board item to a wire blob (used only for `added` payloads — NOT the
|
||||||
// diff unit, so `changed`/`removed` stay light and the blob never drives change detection).
|
// diff unit, so `changed`/`removed` stay light and the blob never drives change detection).
|
||||||
//
|
//
|
||||||
// Footprints DON'T go through SaveSelection: its "make safe to transfer" step copies the
|
// The blob MUST be byte-equal to that item's subtree in a full .kicad_pcb save: the Y.Doc is
|
||||||
|
// the source of truth for the FILE, so any writer difference is a permanent, unfixable drift
|
||||||
|
// and corrupts what a server-side materialize writes.
|
||||||
|
//
|
||||||
|
// That is why footprints use CTL_FOR_BOARD and not CLIPBOARD_IO's CTL_FOR_CLIPBOARD. The two
|
||||||
|
// differ by exactly CTL_OMIT_FOOTPRINT_VERSION (pcb_io_kicad_sexpr.h), so clipboard form emits
|
||||||
|
// (footprint "Lib:C1206" (version 20260206) (generator "pcbnew") (generator_version "10.0") …)
|
||||||
|
// — three tokens a board-embedded footprint never has (pcb_io_kicad_sexpr.cpp ~1201). Every
|
||||||
|
// footprint of every board drifted on those three lines. For non-footprints the two control
|
||||||
|
// sets are identical (the bit only gates footprint output), so the SaveSelection path below
|
||||||
|
// is already file-equivalent and keeps its `(kicad_pcb … <layers> <item>)` envelope, which the
|
||||||
|
// parser requires — bare `(segment`/`(via`/`(zone` are not accepted at top level.
|
||||||
|
//
|
||||||
|
// Footprints also DON'T go through SaveSelection: its "make safe to transfer" step copies the
|
||||||
// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's
|
// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's
|
||||||
// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's
|
// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's
|
||||||
// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob,
|
// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob,
|
||||||
// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round
|
// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round
|
||||||
// trips lose the field uuids). Instead we make the same safety copy ourselves, RESTORE the
|
// trips lose the field uuids). The copy ctor now restores those uuids itself (footprint.cpp),
|
||||||
// mandatory-field uuids from the source, and Format it directly — the same Format machinery
|
// but we keep making the safety copy here so the live item is never mutated.
|
||||||
// SaveSelection uses internally, so asyncify behavior is identical.
|
|
||||||
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
||||||
{
|
{
|
||||||
if( aItem->Type() == PCB_FOOTPRINT_T )
|
if( aItem->Type() == PCB_FOOTPRINT_T )
|
||||||
|
|
@ -322,26 +350,17 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
||||||
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
|
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
|
||||||
FOOTPRINT copy( *src );
|
FOOTPRINT copy( *src );
|
||||||
|
|
||||||
for( PCB_FIELD* field : copy.GetFields() )
|
|
||||||
{
|
|
||||||
if( field->IsMandatory() )
|
|
||||||
{
|
|
||||||
if( const PCB_FIELD* srcField = src->GetField( field->GetId() ) )
|
|
||||||
const_cast<KIID&>( field->m_Uuid ) = srcField->m_Uuid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
|
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
|
||||||
// (the wire carries absolute positions) and minus SetNetCode(0): zeroing pad
|
// (the wire carries absolute positions) and minus SetNetCode(0): zeroing pad
|
||||||
// nets is a paste-into-FOREIGN-board safety, but collab peers edit the SAME
|
// nets is a paste-into-FOREIGN-board safety, but collab peers edit the SAME
|
||||||
// board — nets must survive the wire. KiCad 10 formats pad nets by NAME and
|
// board — nets must survive the wire. KiCad 10 formats pad nets by NAME and
|
||||||
// the parser resolves by name against the receiver's board (creating the net
|
// the parser resolves by name against the receiver's board (creating the net
|
||||||
// if missing), so no code remapping is needed on apply.
|
// if missing), so no code remapping is needed on apply.
|
||||||
copy.SetLocked( false );
|
//
|
||||||
|
// NOTE: unlike SaveSelection we do NOT SetLocked( false ) — `(locked yes)` is
|
||||||
CLIPBOARD_IO io;
|
// real file content and dropping it would drift against the save.
|
||||||
|
WIRE_BOARD_IO io( aBoard );
|
||||||
STRING_FORMATTER fmt;
|
STRING_FORMATTER fmt;
|
||||||
io.SetBoard( aBoard );
|
|
||||||
io.SetOutputFormatter( &fmt );
|
io.SetOutputFormatter( &fmt );
|
||||||
io.Format( © );
|
io.Format( © );
|
||||||
|
|
||||||
|
|
@ -365,15 +384,58 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct a board item from a clipboard blob. Parse() returns a bare FOOTPRINT*, or a BOARD*
|
// A bare `(footprint …)` blob carries no `(version …)`, but the parser NEEDS one: it starts at
|
||||||
|
// m_requiredVersion = 0, and several format decisions are gated on it — most visibly
|
||||||
|
// `if( m_requiredVersion < 20230620 ) field->SetVisible( false )` in the T_property case
|
||||||
|
// (pcb_io_kicad_sexpr_parser.cpp), which silently stamps `(hide yes)` onto every mandatory
|
||||||
|
// field of an applied footprint.
|
||||||
|
//
|
||||||
|
// The clipboard dialect got this for free because CTL_FOR_CLIPBOARD emits the version INSIDE
|
||||||
|
// the footprint form — but that token is not valid board-file content (see blobForItem), so we
|
||||||
|
// can't keep it in the Y.Doc. Instead re-supply it here, at parse time only: splice
|
||||||
|
// `(version N)` in right after `(footprint "<lib id>"`, which is exactly where the clipboard
|
||||||
|
// writer put it. The Y.Doc body stays byte-identical to the file; only the wire→model decode
|
||||||
|
// sees the token.
|
||||||
|
static std::string withFootprintVersion( const std::string& aBlob )
|
||||||
|
{
|
||||||
|
static const std::string kHead = "(footprint";
|
||||||
|
|
||||||
|
if( aBlob.compare( 0, kHead.size(), kHead ) != 0 )
|
||||||
|
return aBlob; // envelope blob — its (kicad_pcb …) carries a version
|
||||||
|
|
||||||
|
if( aBlob.find( "(version " ) != std::string::npos )
|
||||||
|
return aBlob; // already versioned (older peer, clipboard dialect)
|
||||||
|
|
||||||
|
// Skip the quoted lib id that follows the head keyword, then inject.
|
||||||
|
size_t open = aBlob.find( '"', kHead.size() );
|
||||||
|
|
||||||
|
if( open == std::string::npos )
|
||||||
|
return aBlob;
|
||||||
|
|
||||||
|
size_t close = open + 1;
|
||||||
|
|
||||||
|
while( close < aBlob.size() && aBlob[close] != '"' )
|
||||||
|
close += ( aBlob[close] == '\\' ) ? 2 : 1;
|
||||||
|
|
||||||
|
if( close >= aBlob.size() )
|
||||||
|
return aBlob;
|
||||||
|
|
||||||
|
return aBlob.substr( 0, close + 1 )
|
||||||
|
+ " (version " + std::to_string( SEXPR_BOARD_FILE_VERSION ) + ")"
|
||||||
|
+ aBlob.substr( close + 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct a board item from a wire blob. Parse() returns a bare FOOTPRINT*, or a BOARD*
|
||||||
// (the `(kicad_pcb …)` envelope) holding the single item — in which case detach that item from
|
// (the `(kicad_pcb …)` envelope) holding the single item — in which case detach that item from
|
||||||
// the throw-away board and hand back ownership. Returns nullptr on a parse failure (Parse catches
|
// the throw-away board and hand back ownership. Returns nullptr on a parse failure (Parse catches
|
||||||
// internally) or if no item is found. Runs inside the apply COROUTINE.
|
// internally) or if no item is found. Runs inside the apply COROUTINE.
|
||||||
BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob )
|
BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlobIn )
|
||||||
{
|
{
|
||||||
if( aBlob.empty() )
|
if( aBlobIn.empty() )
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|
||||||
|
const std::string aBlob = withFootprintVersion( aBlobIn );
|
||||||
|
|
||||||
CLIPBOARD_IO io;
|
CLIPBOARD_IO io;
|
||||||
io.SetBoard( &aBoard );
|
io.SetBoard( &aBoard );
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit db9529d92f9b7a9014c52373c75462e7e5e51e3a
|
Subproject commit e044c86ea538a1ec6dd2120d1f85fcf9da6b8aa2
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
* looks like a user save (no upload, no peer-tab dirty flag).
|
* looks like a user save (no upload, no peer-tab dirty flag).
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
docDelta,
|
compareSlots,
|
||||||
type DriftReportBody,
|
type DriftReportBody,
|
||||||
|
driftDocDelta,
|
||||||
fileToDoc,
|
fileToDoc,
|
||||||
isEmptyKicadDelta,
|
isEmptyKicadDelta,
|
||||||
type Tool,
|
type Tool,
|
||||||
|
|
@ -58,10 +59,17 @@ const DEFAULT_EVERY_N = 50;
|
||||||
/** Reports per session cap — a real reconciler bug must not flood the backend. */
|
/** Reports per session cap — a real reconciler bug must not flood the backend. */
|
||||||
const MAX_REPORTS_PER_SESSION = 20;
|
const MAX_REPORTS_PER_SESSION = 20;
|
||||||
|
|
||||||
/** djb2 over the drift-defining JSON — dedupe key, not a security hash. */
|
/**
|
||||||
|
* djb2 over the drift-defining JSON — dedupe key, not a security hash.
|
||||||
|
*
|
||||||
|
* `diff.reordered` and `layoutReordered` are excluded on purpose: they are not
|
||||||
|
* drift, and v2's order churn would otherwise change the key on every pass and
|
||||||
|
* defeat the "report a stable divergence once" rule.
|
||||||
|
*/
|
||||||
function driftKey(body: DriftReportBody): string {
|
function driftKey(body: DriftReportBody): string {
|
||||||
|
const { reordered: _reordered, ...diff } = body.diff;
|
||||||
const canon = JSON.stringify({
|
const canon = JSON.stringify({
|
||||||
diff: body.diff,
|
diff,
|
||||||
layoutChanged: body.layoutChanged,
|
layoutChanged: body.layoutChanged,
|
||||||
metaChanged: body.metaChanged,
|
metaChanged: body.metaChanged,
|
||||||
});
|
});
|
||||||
|
|
@ -132,10 +140,13 @@ export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
|
||||||
|
|
||||||
const wasmDoc = fileToDoc(text);
|
const wasmDoc = fileToDoc(text);
|
||||||
const ydocDoc = yToDoc(opts.doc);
|
const ydocDoc = yToDoc(opts.doc);
|
||||||
const diff = docDelta(ydocDoc, wasmDoc);
|
// Order-only differences go to `diff.reordered` / `layoutReordered`: y-sexpr
|
||||||
// docDelta covers items only; flag layout/preamble divergence separately.
|
// v2 reorders legitimately, so they are noise, not divergence (kicad-delta.ts).
|
||||||
const layoutChanged =
|
const diff = driftDocDelta(ydocDoc, wasmDoc);
|
||||||
JSON.stringify(ydocDoc.layout) !== JSON.stringify(wasmDoc.layout);
|
// driftDocDelta covers items only; flag layout/preamble divergence separately.
|
||||||
|
const layoutRelation = compareSlots(ydocDoc.layout, wasmDoc.layout);
|
||||||
|
const layoutChanged = layoutRelation === "different";
|
||||||
|
const layoutReordered = layoutRelation === "reordered";
|
||||||
const metaChanged = ydocDoc.root !== wasmDoc.root;
|
const metaChanged = ydocDoc.root !== wasmDoc.root;
|
||||||
if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null;
|
if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null;
|
||||||
|
|
||||||
|
|
@ -145,6 +156,7 @@ export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
|
||||||
ydocDoc,
|
ydocDoc,
|
||||||
diff,
|
diff,
|
||||||
layoutChanged,
|
layoutChanged,
|
||||||
|
layoutReordered,
|
||||||
metaChanged,
|
metaChanged,
|
||||||
// The docs above are version-blind (yToDoc normalizes v1/v2) — this is
|
// The docs above are version-blind (yToDoc normalizes v1/v2) — this is
|
||||||
// the only signal of which storage encoding the Y.Doc actually used.
|
// the only signal of which storage encoding the Y.Doc actually used.
|
||||||
|
|
@ -166,7 +178,9 @@ export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
|
||||||
if (body) {
|
if (body) {
|
||||||
log(
|
log(
|
||||||
`[drift] ${opts.targetPath}: +${body.diff.added.length} ~${body.diff.updated.length} -${body.diff.removed.length}` +
|
`[drift] ${opts.targetPath}: +${body.diff.added.length} ~${body.diff.updated.length} -${body.diff.removed.length}` +
|
||||||
`${body.layoutChanged ? " layout" : ""}${body.metaChanged ? " meta" : ""}`,
|
`${body.diff.reordered.length ? ` (${body.diff.reordered.length} reordered, not drift)` : ""}` +
|
||||||
|
`${body.layoutChanged ? " layout" : ""}${body.layoutReordered ? " layout-reordered" : ""}` +
|
||||||
|
`${body.metaChanged ? " meta" : ""}`,
|
||||||
);
|
);
|
||||||
await reportDrift(opts.slug, body);
|
await reportDrift(opts.slug, body);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue