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:
Gergő Törcsvári 2026-08-25 17:58:54 +02:00
commit 866db5888c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
18 changed files with 2084 additions and 1092 deletions

View 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');
});
});