libs 0017: sync overrides indexed + stale-lib session menu + Cmd+S DOM-focus fix + WasmTool split
- kicadLibsFootprintUsage + kicadUpdateFromLibrary embinds (result via pcbjam:lib-update-done — runOnCoroutine is deferred) - standalone: stale-lib FAB triangle + session-menu Update-from-library row, save busy notice names the item, footprint placed-usage in the toast - WasmTool.tsx split: module helpers → components/wasm-tool/ - specs: save-cmd-key (Meta+S, mac UA), fpedit-cmd-save (DOM-focus repro) - wxwidgets → cdd5a5c (wxDomBlurActive) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
This commit is contained in:
parent
9a19b96b6c
commit
866db5888c
18 changed files with 2084 additions and 1092 deletions
187
tests/kicad/save-cmd-key.spec.ts
Normal file
187
tests/kicad/save-cmd-key.spec.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Cmd+S (Meta+S — the Mac save chord) must save exactly like Ctrl+S does.
|
||||
*
|
||||
* save-hook.spec pins Ctrl+S → window.kicadCollab.onSave for every frame; every
|
||||
* other spec in the tree presses "Control+s" too, so the Meta chord had no
|
||||
* coverage at all. A Mac user reported "Cmd+S didn't save, the Save icon did"
|
||||
* (libs 0017). This spec is the red side of that report: same boot / edit /
|
||||
* hook recipe as save-hook.spec, chord swapped for Meta+S. A Ctrl+S control
|
||||
* run in the same session guards against blaming the chord for a boot
|
||||
* problem.
|
||||
*
|
||||
* wx side: keyboard.cpp SetKeyboardModifiers maps metaKey → ControlDown only
|
||||
* when wxGetOsVersion() reports a Mac (wx.js platformInfo from the UA), and
|
||||
* app.cpp TranslateMenuAccel matches "\tCtrl+S" on ControlDown — so the Meta
|
||||
* chord has two places to fall through.
|
||||
*/
|
||||
|
||||
interface ToolCfg {
|
||||
html: string;
|
||||
ext: string;
|
||||
modify: { fn: string; args: (string | number)[] };
|
||||
fixture: string;
|
||||
}
|
||||
|
||||
type Mod = Record<string, (...a: (string | number)[]) => unknown>;
|
||||
type FS = {
|
||||
mkdirTree(p: string): void;
|
||||
writeFile(p: string, d: string): void;
|
||||
};
|
||||
type HookWindow = Window & {
|
||||
FS: FS;
|
||||
Module: Mod;
|
||||
kicadCollab?: Record<string, unknown>;
|
||||
__savedPaths: string[];
|
||||
};
|
||||
|
||||
const BOOT_TIMEOUT = 150000;
|
||||
const NAME = "savecmd";
|
||||
|
||||
async function bootOpen(page: Page, cfg: ToolCfg): Promise<string> {
|
||||
await page.goto(`/kicad/${cfg.html}`);
|
||||
await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
|
||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT });
|
||||
await page.waitForFunction(
|
||||
(modFn) => {
|
||||
const m = (window as unknown as { Module?: Mod }).Module;
|
||||
return typeof m?.kicadOpenFile === "function" && typeof m?.[modFn] === "function";
|
||||
},
|
||||
cfg.modify.fn,
|
||||
{ timeout: BOOT_TIMEOUT },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!!window.wxElementRegistry &&
|
||||
window.wxElementRegistry
|
||||
.findAll({ visible: true })
|
||||
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
|
||||
null,
|
||||
{ timeout: BOOT_TIMEOUT },
|
||||
);
|
||||
const abs = `/home/kicad/documents/${NAME}.${cfg.ext}`;
|
||||
await page.evaluate(
|
||||
({ content, abs }) => {
|
||||
const w = window as unknown as HookWindow;
|
||||
try {
|
||||
w.FS.mkdirTree("/home/kicad/documents");
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
w.FS.writeFile(abs, content);
|
||||
w.Module.kicadOpenFile(abs);
|
||||
},
|
||||
{ content: cfg.fixture, abs },
|
||||
);
|
||||
await expect.poll(() => page.title(), { timeout: BOOT_TIMEOUT, intervals: [300] }).toMatch(new RegExp(NAME, "i"));
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function focusCanvas(page: Page): Promise<void> {
|
||||
const box = await page.locator("#canvas").boundingBox();
|
||||
expect(box, "#canvas has a bounding box").not.toBeNull();
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.waitForTimeout(300); // eslint-disable-line -- focus click settle, no JS signal
|
||||
}
|
||||
|
||||
/** Edit, press `chord`, return the number of onSave calls it produced within 10 s. */
|
||||
async function savesAfter(page: Page, cfg: ToolCfg, chord: string): Promise<number> {
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as HookWindow).__savedPaths = [];
|
||||
});
|
||||
await page.evaluate(
|
||||
({ fn, args }) => (window as unknown as HookWindow).Module[fn](...args),
|
||||
cfg.modify,
|
||||
);
|
||||
await page.waitForTimeout(500); // eslint-disable-line -- dirty flag settle (save-hook.spec)
|
||||
await focusCanvas(page);
|
||||
await page.keyboard.press(chord);
|
||||
await page
|
||||
.waitForFunction(() => (window as unknown as HookWindow).__savedPaths.length > 0, null, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return page.evaluate(() => (window as unknown as HookWindow).__savedPaths.length);
|
||||
}
|
||||
|
||||
async function expectMetaSaves(page: Page, cfg: ToolCfg): Promise<void> {
|
||||
await bootOpen(page, cfg);
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as HookWindow;
|
||||
w.__savedPaths = [];
|
||||
w.kicadCollab = { ...w.kicadCollab, onSave: (p: string) => w.__savedPaths.push(p) };
|
||||
});
|
||||
// Control: the Ctrl chord saves in this very session (else the boot is the
|
||||
// problem, not the chord).
|
||||
expect(await savesAfter(page, cfg, "Control+s"), "Ctrl+S control save").toBeGreaterThan(0);
|
||||
// The claim: Cmd+S saves too.
|
||||
expect(await savesAfter(page, cfg, "Meta+s"), "Meta+S (Cmd+S) save").toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const SCH: ToolCfg = {
|
||||
html: "eeschema.html",
|
||||
ext: "kicad_sch",
|
||||
modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] },
|
||||
fixture: `(kicad_sch
|
||||
(version 20250114)
|
||||
(generator "eeschema")
|
||||
(generator_version "9.0")
|
||||
(uuid "11111111-1111-1111-1111-111111111111")
|
||||
(paper "A4")
|
||||
(lib_symbols)
|
||||
(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))
|
||||
(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
`,
|
||||
};
|
||||
|
||||
const PCB: ToolCfg = {
|
||||
html: "pcbnew-collab.html",
|
||||
ext: "kicad_pcb",
|
||||
modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] },
|
||||
fixture: `(kicad_pcb
|
||||
(version 20241229)
|
||||
(generator "pcbnew")
|
||||
(generator_version "9.0")
|
||||
(general (thickness 1.6))
|
||||
(paper "A4")
|
||||
(layers
|
||||
(0 "F.Cu" signal)
|
||||
(2 "B.Cu" signal)
|
||||
(37 "F.SilkS" user)
|
||||
(25 "Edge.Cuts" user)
|
||||
)
|
||||
(setup)
|
||||
(net 0 "")
|
||||
(footprint "TestLib:R"
|
||||
(layer "F.Cu")
|
||||
(uuid "66666666-0000-0000-0000-000000000001")
|
||||
(at 100 100)
|
||||
(attr smd)
|
||||
(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
|
||||
)
|
||||
)
|
||||
`,
|
||||
};
|
||||
|
||||
test.describe("Cmd+S (Meta+S) saves like Ctrl+S", () => {
|
||||
test.describe.configure({ timeout: 300000 });
|
||||
// The Playwright device presets carry a WINDOWS user agent; wx.js derives the
|
||||
// OS from the UA and only maps the Meta key to wx's ControlDown on a Mac.
|
||||
// Pin a macOS UA so the chord is judged the way a Mac browser sends it.
|
||||
test.use({
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0",
|
||||
});
|
||||
|
||||
test("eeschema: Meta+S → onSave", async ({ page, testLogger }) => {
|
||||
void testLogger;
|
||||
await expectMetaSaves(page, SCH);
|
||||
});
|
||||
|
||||
test("pcbnew: Meta+S → onSave", async ({ page, testLogger }) => {
|
||||
void testLogger;
|
||||
await expectMetaSaves(page, PCB);
|
||||
});
|
||||
});
|
||||
217
tests/web/fpedit-cmd-save.spec.ts
Normal file
217
tests/web/fpedit-cmd-save.spec.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { clickByTooltip, stableShot, waitForWxApp } from '../e2e/utils/element-tracker';
|
||||
|
||||
/**
|
||||
* Cmd+S in a CHILD editor (Footprint Editor opened from a board session) must
|
||||
* save the open footprint through the lib write bridge, exactly like the
|
||||
* toolbar Save icon does.
|
||||
*
|
||||
* Report (libs 0017): on a Mac, a footprint edited in the editor opened from
|
||||
* pcbnew saved from the Save icon but not from Cmd+S. tests/kicad/save-cmd-key
|
||||
* proves the Meta chord saves in the MAIN frames under a macOS UA, so the gap
|
||||
* is child-frame specific. Recipe: board session → toolbar "Create, delete and
|
||||
* edit board footprints" → expand Resistor_SMD → open a footprint → dirty it
|
||||
* (select-all + Delete) → Cmd+S → expect one `save` request on
|
||||
* window.kicadLibs.request. The Save toolbar button is the control.
|
||||
*
|
||||
* UA: the Playwright device presets say Windows; wx.js derives the OS from the
|
||||
* UA and maps the Meta key to ControlDown only on a Mac — so pin a Mac UA.
|
||||
*
|
||||
* Root cause + fix (2026-08-25): the lib-tree filter <input> kept BROWSER focus
|
||||
* after a canvas click (the wasm mouse callback preventDefaults mousedown), so
|
||||
* every key stayed with the input. wxWindowWasm::SetFocus now blurs the active
|
||||
* wx-dom control when a canvas-drawn window takes wx focus (wxDomBlurActive).
|
||||
* The from-pcbnew variant needs the board route (collab); a local
|
||||
* web/standalone/.env pointing VITE_YJS_* at :3055 breaks that route locally.
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
const BOOT_TIMEOUT = 180000;
|
||||
const LIB = 'Resistor_SMD';
|
||||
|
||||
type SpyWindow = Window & {
|
||||
kicadLibs?: { request: (...a: unknown[]) => Promise<unknown> };
|
||||
__libOps: { op: string; arg: string }[];
|
||||
};
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a rendered tree row's offset Y to its true screen Y (footprint-browse-remote). */
|
||||
async function treeGeom(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const rd = (window as any).wxElementRegistry.findAllRendered({});
|
||||
const hdr = rd.find((e: any) => e.elementType === 'columnheader' && e.label === 'Item');
|
||||
const rows = rd
|
||||
.filter((e: any) => e.elementType === 'dataviewitem')
|
||||
.sort((a: any, b: any) => a.centerY - b.centerY);
|
||||
if (!hdr || rows.length === 0) return null;
|
||||
const pitch = rows.length > 1 ? rows[1].centerY - rows[0].centerY : 17;
|
||||
const firstTrue = hdr.centerY + hdr.height / 2 + pitch / 2;
|
||||
const offset = firstTrue - rows[0].centerY;
|
||||
return {
|
||||
offset,
|
||||
rows: rows.map((r: any) => ({ label: r.label, cx: r.centerX, cy: r.centerY })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function dblclickRow(page: Page, re: RegExp): Promise<string | null> {
|
||||
const geom = await treeGeom(page);
|
||||
if (!geom) return null;
|
||||
const row = geom.rows.find((r) => re.test(r.label || ''));
|
||||
if (!row) return null;
|
||||
await page.mouse.dblclick(row.cx, row.cy + geom.offset);
|
||||
return row.label;
|
||||
}
|
||||
|
||||
/** Count of `save` ops the lib bridge received. */
|
||||
function saveOps(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickCanvasCenter(page: Page): Promise<void> {
|
||||
const box = await page.locator('canvas').first().boundingBox();
|
||||
expect(box, 'canvas box').not.toBeNull();
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
}
|
||||
|
||||
test.describe('footprint editor from pcbnew: Cmd+S saves', () => {
|
||||
test.describe.configure({ timeout: 480000 });
|
||||
test.use({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
async function run(page: Page, entry: 'from-pcbnew' | 'standalone'): Promise<void> {
|
||||
const logs: string[] = [];
|
||||
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||
page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`));
|
||||
|
||||
if (entry === 'from-pcbnew') {
|
||||
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb`);
|
||||
await waitForWxApp(page, { timeout: BOOT_TIMEOUT });
|
||||
await expect
|
||||
.poll(() => page.title(), { message: 'board editor up', timeout: BOOT_TIMEOUT })
|
||||
.toMatch(/PCB Editor/i);
|
||||
} else {
|
||||
await page.goto(`/${SCOPE}/projects/demo/-/footprint_editor`);
|
||||
await waitForWxApp(page, { timeout: BOOT_TIMEOUT });
|
||||
}
|
||||
await page.waitForFunction(() => !!(window as unknown as SpyWindow).kicadLibs, null, {
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// Spy on the lib bridge: every op the editor asks for, in order.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as SpyWindow;
|
||||
w.__libOps = [];
|
||||
const real = w.kicadLibs!.request;
|
||||
w.kicadLibs!.request = (...a: unknown[]) => {
|
||||
// request(op, "/mnt/pcbjam/<id>", arg, kind)
|
||||
w.__libOps.push({ op: String(a[0]), arg: String(a[2] ?? '').slice(0, 60) });
|
||||
return real.apply(w.kicadLibs, a);
|
||||
};
|
||||
});
|
||||
|
||||
if (entry === 'from-pcbnew') {
|
||||
expect(await frameNames(page)).toEqual(['PcbFrame']);
|
||||
expect(
|
||||
await clickByTooltip(page, 'Create, delete and edit board footprints', { elementType: 'tool' }),
|
||||
'Footprint Editor toolbar button clicked',
|
||||
).toBe(true);
|
||||
}
|
||||
await expect
|
||||
.poll(() => frameNames(page), { message: 'ModEditFrame opened', timeout: BOOT_TIMEOUT })
|
||||
.toContain('ModEditFrame');
|
||||
await expect
|
||||
.poll(() => treeGeom(page).then((g) => g?.rows.some((r) => r.label === LIB) ?? false), {
|
||||
message: `${LIB} row in the footprint tree`,
|
||||
timeout: 120000,
|
||||
})
|
||||
.toBe(true);
|
||||
await stableShot(page, 'fpedit-cmd-save-01-tree.png');
|
||||
|
||||
expect(await dblclickRow(page, new RegExp(`^${LIB}$`)), 'lib expanded').toBeTruthy();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
(window as any).wxElementRegistry
|
||||
.findAllRendered({ elementType: 'dataviewitem' })
|
||||
.some((e: any) => /Metric/.test(e.label || '')),
|
||||
),
|
||||
{ message: 'footprint child rows after enumerate', timeout: 60000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const opened = await dblclickRow(page, /Metric/);
|
||||
expect(opened, 'a footprint row opened').toBeTruthy();
|
||||
await expect
|
||||
.poll(() => page.title(), { message: 'footprint loaded (title)', timeout: 60000 })
|
||||
.toContain(LIB);
|
||||
await stableShot(page, 'fpedit-cmd-save-02-loaded.png');
|
||||
|
||||
// Dirty the footprint from the canvas: select all + rotate (R). Delete is
|
||||
// refused on the mandatory ${REFERENCE} field, so it doesn't dirty anything.
|
||||
await clickCanvasCenter(page);
|
||||
await page.waitForTimeout(300); // eslint-disable-line -- focus settle, no JS signal
|
||||
await page.keyboard.press('Meta+a');
|
||||
await page.keyboard.press('r');
|
||||
await page.waitForTimeout(500); // eslint-disable-line -- modified flag settle, no JS signal
|
||||
await stableShot(page, 'fpedit-cmd-save-03-dirty.png');
|
||||
logs.push(
|
||||
`[spec] before chord: title="${await page.title()}" activeElement=${await page.evaluate(
|
||||
() => `${document.activeElement?.tagName}#${(document.activeElement as HTMLElement)?.id}`,
|
||||
)}`,
|
||||
);
|
||||
|
||||
const before = await saveOps(page);
|
||||
await page.keyboard.press('Meta+s');
|
||||
await page
|
||||
.waitForFunction(
|
||||
(n) => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length > n,
|
||||
before,
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.catch(() => undefined);
|
||||
const afterCmd = await saveOps(page);
|
||||
logs.push(`[spec] saves before=${before} afterCmd=${afterCmd}`);
|
||||
await stableShot(page, 'fpedit-cmd-save-04-after-cmd-s.png');
|
||||
|
||||
// Control: the toolbar Save icon (what the user fell back to).
|
||||
if (afterCmd === before) {
|
||||
await clickByTooltip(page, 'Save', { elementType: 'tool' });
|
||||
await page
|
||||
.waitForFunction(
|
||||
(n) => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length > n,
|
||||
before,
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.catch(() => undefined);
|
||||
logs.push(`[spec] saves after toolbar Save=${await saveOps(page)}`);
|
||||
}
|
||||
logs.push(
|
||||
`[spec] lib ops: ${JSON.stringify(await page.evaluate(() => (window as unknown as SpyWindow).__libOps))}`,
|
||||
);
|
||||
console.log('--- spec log ---\n' + logs.filter((l) => /\[spec\]|\[libs\]|save|accel/i.test(l)).join('\n'));
|
||||
|
||||
expect(logs.some((l) => l.includes('Aborted(')), 'no WASM abort').toBe(false);
|
||||
expect(afterCmd, 'Cmd+S produced a lib save').toBeGreaterThan(before);
|
||||
}
|
||||
|
||||
test('standalone footprint editor: Cmd+S on a dirty footprint reaches the lib save bridge', async ({ page }) => {
|
||||
await run(page, 'standalone');
|
||||
});
|
||||
|
||||
test('from pcbnew: Cmd+S on a dirty footprint reaches the lib save bridge', async ({ page }) => {
|
||||
await run(page, 'from-pcbnew');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue