feat: libs 0009-B — footprint serve + read wiring (GPL backend + standalone): extForKind .kicad_mod read/write, kind-filtered listLibs, LibsSource.listLibs(kind) threaded + boot per-tool kind; footprint-browse-remote e2e green (opens real capped 10.0.3 fp); bump pcbjam-shared

This commit is contained in:
Gergő Törcsvári 2026-06-14 07:55:24 +02:00
commit 2c52637b1a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
10 changed files with 163 additions and 26 deletions

View file

@ -0,0 +1,108 @@
import { test, expect, type Page } from '@playwright/test';
import { waitForRegistry } from '../e2e/utils/element-tracker';
/**
* 0009-B footprint READ path: does the footprint editor browse + LOAD a real,
* ingested KiCad-10.0.3 footprint through PCB_IO_PCBJAM_FP — i.e. does the fork's
* footprint parser accept the version-capped (20260206→20251028) body? This is
* the end-to-end proof of the 0009-A "version cap only, no token strip" finding.
*
* Boots the project-scoped footprint_editor against the GPL example backend
* (:3060) serving curated footprint fixtures (LIBS_DIR=/tmp/fp-fixtures). Boot
* generates the fp-lib-table from `listLibs?kind=footprint`; the test expands a
* lib in the tree (FootprintEnumerate) and opens a footprint (FootprintLoad →
* Parse). Pass: the footprint OPENS (title = lib:footprint) — which requires
* Parse to have succeeded; a FUTURE_FORMAT/token error would make FootprintLoad
* return null and nothing would open.
*
* The LIB_TREE's rendered dataviewitem Y is offset by a constant (the rows live
* below the column header); we calibrate that offset from the "Item" header +
* row pitch, then double-click true positions.
*/
const SHOT = (name: string) => `test-results/fpbrowse-${name}.png`;
const LIB = 'Resistor_SMD';
async function bootFootprintEditor(page: Page): Promise<void> {
await page.goto('/p/demo/footprint_editor/');
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
await waitForRegistry(page, 180000);
await page.waitForFunction(
() => !!window.wxElementRegistry && window.wxElementRegistry.findAll({}).length > 5,
null,
{ timeout: 180000 },
);
await page.waitForFunction(() => !!(window as any).kicadLibs, null, { timeout: 60000 });
await page.waitForTimeout(2000);
}
/** Map a rendered tree row's offset Y to its true screen Y (see header note). */
async function treeGeom(page: Page) {
return page.evaluate(() => {
const rd = window.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;
// True center of the first visible row sits just below the header.
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;
}
test('footprint editor browses + loads a real ingested footprint (read path / version cap)', async ({ page }) => {
const logs: string[] = [];
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`));
await bootFootprintEditor(page);
await page.screenshot({ path: SHOT('01-boot'), scale: 'css' });
const geom0 = await treeGeom(page);
logs.push(`[spec] tree geom: ${JSON.stringify(geom0)}`);
// Expand the footprint lib (FootprintEnumerate) by double-clicking its row.
const libClicked = await dblclickRow(page, new RegExp(`^${LIB}$`));
logs.push(`[spec] expanded lib: ${libClicked}`);
await page.waitForTimeout(2000);
await page.screenshot({ path: SHOT('02-expanded'), scale: 'css' });
// Open a footprint child (FootprintLoad → Parse). SMD fixtures carry "Metric".
const fpClicked = await dblclickRow(page, /Metric/);
logs.push(`[spec] opened footprint: ${fpClicked}`);
await page.waitForTimeout(2500);
await page.screenshot({ path: SHOT('03-loaded'), scale: 'css' });
const title = await page.title();
logs.push(`[spec] title after open: ${title}`);
// Diagnostics BEFORE assertions so a failure stays legible.
console.log('--- console + spec log ---\n' + logs.join('\n'));
// fp-lib-table worked: the footprint origin is in the tree.
expect(geom0?.rows.some((r) => r.label === LIB), 'footprint lib row present').toBe(true);
// Enumerate worked end-to-end through the fork: a footprint child appeared.
expect(fpClicked, 'footprint row present after expand (FootprintEnumerate)').toBeTruthy();
// Parse SUCCEEDED: the editor opened a footprint from the capped 10.0.3 data.
expect(title, 'a footprint opened (FootprintLoad + Parse on capped data)').toContain(LIB);
expect(title, 'opened footprint name in title').toMatch(/Metric/);
// No parser rejection of the capped data, no wedge.
expect(logs.some((l) => /FUTURE_FORMAT|too recent|Expecting\(|Unexpected/i.test(l)), 'no parse/format error').toBe(false);
expect(logs.some((l) => l.includes('Aborted(')), 'no WASM abort').toBe(false);
expect(new URL(page.url()).searchParams.get('oomRetry'), 'no OOM respawn').toBeNull();
});

View file

@ -31,6 +31,13 @@ function safeLibDir(root: string, lib: string): string | null {
return path.join(root, lib); return path.join(root, lib);
} }
/** Per-kind body file extension (one file per item), or null for unknown kinds. */
export function extForKind(kind: string): string | null {
if (kind === "symbol") return ".kicad_sym";
if (kind === "footprint") return ".kicad_mod";
return null;
}
interface IndexFile { interface IndexFile {
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[]; items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
description?: string | null; description?: string | null;
@ -44,7 +51,10 @@ async function readIndex(dir: string): Promise<IndexFile | null> {
} }
} }
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> { export async function listLibs(
cfg: LibsConfig,
kind?: string,
): Promise<Lib[]> {
if (!cfg.dir) return []; if (!cfg.dir) return [];
let entries: import("node:fs").Dirent[]; let entries: import("node:fs").Dirent[];
try { try {
@ -56,6 +66,9 @@ export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
for (const e of entries) { for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith(".")) continue; if (!e.isDirectory() || e.name.startsWith(".")) continue;
const idx = await readIndex(path.join(cfg.dir, e.name)); const idx = await readIndex(path.join(cfg.dir, e.name));
// Filter origins by item kind when requested (a footprint tool shouldn't
// list symbol-only origins, and vice versa).
if (kind && !(idx?.items ?? []).some((i) => i.kind === kind)) continue;
libs.push({ libs.push({
id: e.name, id: e.name,
name: e.name, name: e.name,
@ -92,14 +105,15 @@ export function itemBodyPath(
kind: string, kind: string,
name: string, name: string,
): string | null { ): string | null {
if (!cfg.dir || kind !== "symbol") return null; const ext = extForKind(kind);
if (!cfg.dir || !ext) return null;
const dir = safeLibDir(cfg.dir, lib); const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null; if (!dir) return null;
// Symbol names allow a wide charset but never path separators. // Item names allow a wide charset but never path separators.
if (name.includes("/") || name.includes("\\") || name.includes("..")) { if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return null; return null;
} }
return path.join(dir, `${name}.kicad_sym`); return path.join(dir, `${name}${ext}`);
} }
export function streamBody(absPath: string) { export function streamBody(absPath: string) {

View file

@ -177,10 +177,12 @@ async function main(): Promise<void> {
} }
return { status: 200 as const, body: await walk(PROJECT_DIR) }; return { status: 200 as const, body: await walk(PROJECT_DIR) };
}, },
listLibs: async ({ headers }) => { listLibs: async ({ headers, query }) => {
const owner = ownerOf(headers); const owner = ownerOf(headers);
// Origins filtered by item kind (?kind); user libs are kind-agnostic
// containers → always listed.
const [origins, user] = await Promise.all([ const [origins, user] = await Promise.all([
listLibs(libs), listLibs(libs, query.kind),
listUserLibs(userLibs, owner), listUserLibs(userLibs, owner),
]); ]);
return { status: 200 as const, body: [...origins, ...user] }; return { status: 200 as const, body: [...origins, ...user] };

View file

@ -5,16 +5,19 @@
// plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout // plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout
// the read side uses: // the read side uses:
// //
// <USER_LIBS_DIR>/<owner>/<libId>/index.json { items: [...], type, name } // <USER_LIBS_DIR>/<owner>/<libId>/index.json { items: [...], type, name }
// <USER_LIBS_DIR>/<owner>/<libId>/<Symbol>.kicad_sym the saved body (verbatim) // <USER_LIBS_DIR>/<owner>/<libId>/<Symbol>.kicad_sym a saved symbol body
// <USER_LIBS_DIR>/<owner>/<libId>/<Footprint>.kicad_mod a saved footprint body
// //
// No parsing: the editor sends fork-native kicad_symbol_lib bytes, stored as-is // No parsing: the editor sends fork-native bytes, stored as-is (decision:
// (decision: user-saved bodies round-trip without a version shim). Owner comes // user-saved bodies round-trip without a version shim). A user lib is a
// from OWNER_HEADER; absent ⇒ "default". // kind-agnostic container (symbols + footprints can coexist). Owner comes from
// OWNER_HEADER; absent ⇒ "default".
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import type { Lib, LibItem } from "@pcbjam/shared"; import type { Lib, LibItem } from "@pcbjam/shared";
import { extForKind } from "./libs.js";
export const DEFAULT_OWNER = "default"; export const DEFAULT_OWNER = "default";
@ -161,11 +164,12 @@ export function userItemBodyPath(
name: string, name: string,
): string | null { ): string | null {
const dir = libDir(cfg, owner, lib); const dir = libDir(cfg, owner, lib);
if (!dir || kind !== "symbol" || !safeSeg(name)) return null; const ext = extForKind(kind);
return path.join(dir, `${name}.kicad_sym`); if (!dir || !ext || !safeSeg(name)) return null;
return path.join(dir, `${name}${ext}`);
} }
/** Write one symbol body into a user lib + index it. */ /** Write one item body (symbol or footprint) into a user lib + index it. */
export async function writeUserItem( export async function writeUserItem(
cfg: UserLibsConfig, cfg: UserLibsConfig,
owner: string, owner: string,
@ -174,13 +178,14 @@ export async function writeUserItem(
name: string, name: string,
body: string, body: string,
): Promise<LibItem> { ): Promise<LibItem> {
if (kind !== "symbol") throw new UserLibError(400, "only symbols for now"); const ext = extForKind(kind);
if (!ext) throw new UserLibError(400, `unsupported kind "${kind}"`);
const dir = libDir(cfg, owner, lib); const dir = libDir(cfg, owner, lib);
if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name"); if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name");
const idx = await readIndex(dir); const idx = await readIndex(dir);
if (!idx) throw new UserLibError(404, `user library "${lib}" not found`); if (!idx) throw new UserLibError(404, `user library "${lib}" not found`);
await fs.writeFile(path.join(dir, `${name}.kicad_sym`), body, "utf8"); await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8");
const items = idx.items ?? []; const items = idx.items ?? [];
const existing = items.find((i) => i.kind === kind && i.name === name); const existing = items.find((i) => i.kind === kind && i.name === name);

@ -1 +1 @@
Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288 Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4

View file

@ -121,7 +121,8 @@ async function doBoot(opts: BootOptions): Promise<void> {
installLibsProvider(libsSource, log); installLibsProvider(libsSource, log);
try { try {
// Ensure the owner has at least one writable user lib to save items into. // Ensure the owner has at least one writable user lib to save items into.
let libsList = await libsSource.listLibs(); // Pass the tool's kind so origins are filtered to the right domain.
let libsList = await libsSource.listLibs(libKind);
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) { if (libsSource.createLib && !libsList.some((l) => l.type === "user")) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME); const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
if (created) { if (created) {

View file

@ -30,8 +30,8 @@ export function remoteLibsSource(
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`; `${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
return { return {
async listLibs(): Promise<LibInfo[]> { async listLibs(kind?: string): Promise<LibInfo[]> {
const res = await client.listLibs(); const res = await client.listLibs({ query: { kind } });
if (res.status !== 200) return []; if (res.status !== 200) return [];
return res.body.map((l) => ({ return res.body.map((l) => ({
id: l.id, id: l.id,

View file

@ -21,8 +21,13 @@ export interface LibItemInfo {
} }
export interface LibsSource { export interface LibsSource {
/** Libraries to expose to the editor (one sym-lib-table row each). */ /**
listLibs(): Promise<LibInfo[]>; * Libraries to expose to the editor (one lib-table row each). `kind` (the
* current tool's item kind, "symbol" | "footprint") filters origins to those
* holding that kind; user libs are kind-agnostic containers, always listed.
* Omitted ⇒ all libs.
*/
listLibs(kind?: string): Promise<LibInfo[]>;
/** Items in a library (by lib id). */ /** Items in a library (by lib id). */
listItems(libId: string): Promise<LibItemInfo[]>; listItems(libId: string): Promise<LibItemInfo[]>;
/** /**

View file

@ -36,16 +36,17 @@ function withSpikeKindLib(
const isSpike = (libId: string) => libId === spec.id; const isSpike = (libId: string) => libId === spec.id;
return { return {
async listLibs(): Promise<LibInfo[]> { async listLibs(kind?: string): Promise<LibInfo[]> {
// Resilient to a missing backend: the spike must boot standalone (the // Resilient to a missing backend: the spike must boot standalone (the
// writable lib is in-memory), so an unreachable inner source just yields // writable lib is in-memory), so an unreachable inner source just yields
// no origins rather than failing the whole table. // no origins rather than failing the whole table.
let base: LibInfo[] = []; let base: LibInfo[] = [];
try { try {
base = inner ? await inner.listLibs() : []; base = inner ? await inner.listLibs(kind) : [];
} catch (e) { } catch (e) {
log(`[libs] spike: inner listLibs failed, origins omitted: ${String(e)}`); log(`[libs] spike: inner listLibs failed, origins omitted: ${String(e)}`);
} }
// The spike lib is the writable target for its kind (mimics a user lib).
return [...base, { id: spec.id, name: spec.name, type: "user" }]; return [...base, { id: spec.id, name: spec.name, type: "user" }];
}, },

View file

@ -58,8 +58,9 @@ const SYMBOLS: Record<string, string> = {
export function staticLibsSource(): LibsSource { export function staticLibsSource(): LibsSource {
return { return {
async listLibs(): Promise<LibInfo[]> { async listLibs(kind?: string): Promise<LibInfo[]> {
return [STATIC_LIB]; // The built-in example lib is symbols-only.
return !kind || kind === "symbol" ? [STATIC_LIB] : [];
}, },
async listItems(libId: string): Promise<LibItemInfo[]> { async listItems(libId: string): Promise<LibItemInfo[]> {
if (libId !== STATIC_LIB.id) return []; if (libId !== STATIC_LIB.id) return [];