From 2c52637b1a749481fdeae5b04802ab22bffdc0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sun, 14 Jun 2026 07:55:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20libs=200009-B=20=E2=80=94=20footprint?= =?UTF-8?q?=20serve=20+=20read=20wiring=20(GPL=20backend=20+=20standalone)?= =?UTF-8?q?:=20extForKind=20.kicad=5Fmod=20read/write,=20kind-filtered=20l?= =?UTF-8?q?istLibs,=20LibsSource.listLibs(kind)=20threaded=20+=20boot=20pe?= =?UTF-8?q?r-tool=20kind;=20footprint-browse-remote=20e2e=20green=20(opens?= =?UTF-8?q?=20real=20capped=2010.0.3=20fp);=20bump=20pcbjam-shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/web/footprint-browse-remote.spec.ts | 108 ++++++++++++++++++ web/backend/src/libs.ts | 22 +++- web/backend/src/server.ts | 6 +- web/backend/src/user-libs.ts | 25 ++-- web/pcbjam-shared | 2 +- web/standalone/src/wasm/boot.ts | 3 +- web/standalone/src/wasm/libs/remote-source.ts | 4 +- web/standalone/src/wasm/libs/source.ts | 9 +- .../src/wasm/libs/spike-writable.ts | 5 +- web/standalone/src/wasm/libs/static-source.ts | 5 +- 10 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 tests/web/footprint-browse-remote.spec.ts diff --git a/tests/web/footprint-browse-remote.spec.ts b/tests/web/footprint-browse-remote.spec.ts new file mode 100644 index 0000000..ba562d2 --- /dev/null +++ b/tests/web/footprint-browse-remote.spec.ts @@ -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 { + 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 { + 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(); +}); diff --git a/web/backend/src/libs.ts b/web/backend/src/libs.ts index 63d4c32..0b87021 100644 --- a/web/backend/src/libs.ts +++ b/web/backend/src/libs.ts @@ -31,6 +31,13 @@ function safeLibDir(root: string, lib: string): string | null { 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 { items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[]; description?: string | null; @@ -44,7 +51,10 @@ async function readIndex(dir: string): Promise { } } -export async function listLibs(cfg: LibsConfig): Promise { +export async function listLibs( + cfg: LibsConfig, + kind?: string, +): Promise { if (!cfg.dir) return []; let entries: import("node:fs").Dirent[]; try { @@ -56,6 +66,9 @@ export async function listLibs(cfg: LibsConfig): Promise { for (const e of entries) { if (!e.isDirectory() || e.name.startsWith(".")) continue; 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({ id: e.name, name: e.name, @@ -92,14 +105,15 @@ export function itemBodyPath( kind: string, name: string, ): 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); 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("..")) { return null; } - return path.join(dir, `${name}.kicad_sym`); + return path.join(dir, `${name}${ext}`); } export function streamBody(absPath: string) { diff --git a/web/backend/src/server.ts b/web/backend/src/server.ts index a7016ea..9bf3d66 100644 --- a/web/backend/src/server.ts +++ b/web/backend/src/server.ts @@ -177,10 +177,12 @@ async function main(): Promise { } return { status: 200 as const, body: await walk(PROJECT_DIR) }; }, - listLibs: async ({ headers }) => { + listLibs: async ({ headers, query }) => { const owner = ownerOf(headers); + // Origins filtered by item kind (?kind); user libs are kind-agnostic + // containers → always listed. const [origins, user] = await Promise.all([ - listLibs(libs), + listLibs(libs, query.kind), listUserLibs(userLibs, owner), ]); return { status: 200 as const, body: [...origins, ...user] }; diff --git a/web/backend/src/user-libs.ts b/web/backend/src/user-libs.ts index e2a7fc6..e0cdc3e 100644 --- a/web/backend/src/user-libs.ts +++ b/web/backend/src/user-libs.ts @@ -5,16 +5,19 @@ // plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout // the read side uses: // -// ///index.json { items: [...], type, name } -// ///.kicad_sym the saved body (verbatim) +// ///index.json { items: [...], type, name } +// ///.kicad_sym a saved symbol body +// ///.kicad_mod a saved footprint body // -// No parsing: the editor sends fork-native kicad_symbol_lib bytes, stored as-is -// (decision: user-saved bodies round-trip without a version shim). Owner comes -// from OWNER_HEADER; absent ⇒ "default". +// No parsing: the editor sends fork-native bytes, stored as-is (decision: +// user-saved bodies round-trip without a version shim). A user lib is a +// kind-agnostic container (symbols + footprints can coexist). Owner comes from +// OWNER_HEADER; absent ⇒ "default". import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { Lib, LibItem } from "@pcbjam/shared"; +import { extForKind } from "./libs.js"; export const DEFAULT_OWNER = "default"; @@ -161,11 +164,12 @@ export function userItemBodyPath( name: string, ): string | null { const dir = libDir(cfg, owner, lib); - if (!dir || kind !== "symbol" || !safeSeg(name)) return null; - return path.join(dir, `${name}.kicad_sym`); + const ext = extForKind(kind); + 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( cfg: UserLibsConfig, owner: string, @@ -174,13 +178,14 @@ export async function writeUserItem( name: string, body: string, ): Promise { - 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); if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name"); const idx = await readIndex(dir); 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 existing = items.find((i) => i.kind === kind && i.name === name); diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 3cb0a2f..f21b6ea 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288 +Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4 diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index d622c89..3bcd390 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -121,7 +121,8 @@ async function doBoot(opts: BootOptions): Promise { installLibsProvider(libsSource, log); try { // 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")) { const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME); if (created) { diff --git a/web/standalone/src/wasm/libs/remote-source.ts b/web/standalone/src/wasm/libs/remote-source.ts index a467286..a54c9b7 100644 --- a/web/standalone/src/wasm/libs/remote-source.ts +++ b/web/standalone/src/wasm/libs/remote-source.ts @@ -30,8 +30,8 @@ export function remoteLibsSource( `${encodeURIComponent(kind)}/${encodeURIComponent(name)}`; return { - async listLibs(): Promise { - const res = await client.listLibs(); + async listLibs(kind?: string): Promise { + const res = await client.listLibs({ query: { kind } }); if (res.status !== 200) return []; return res.body.map((l) => ({ id: l.id, diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts index 4a8b6aa..d1460ae 100644 --- a/web/standalone/src/wasm/libs/source.ts +++ b/web/standalone/src/wasm/libs/source.ts @@ -21,8 +21,13 @@ export interface LibItemInfo { } export interface LibsSource { - /** Libraries to expose to the editor (one sym-lib-table row each). */ - listLibs(): Promise; + /** + * 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; /** Items in a library (by lib id). */ listItems(libId: string): Promise; /** diff --git a/web/standalone/src/wasm/libs/spike-writable.ts b/web/standalone/src/wasm/libs/spike-writable.ts index 7e80387..089c526 100644 --- a/web/standalone/src/wasm/libs/spike-writable.ts +++ b/web/standalone/src/wasm/libs/spike-writable.ts @@ -36,16 +36,17 @@ function withSpikeKindLib( const isSpike = (libId: string) => libId === spec.id; return { - async listLibs(): Promise { + async listLibs(kind?: string): Promise { // Resilient to a missing backend: the spike must boot standalone (the // writable lib is in-memory), so an unreachable inner source just yields // no origins rather than failing the whole table. let base: LibInfo[] = []; try { - base = inner ? await inner.listLibs() : []; + base = inner ? await inner.listLibs(kind) : []; } catch (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" }]; }, diff --git a/web/standalone/src/wasm/libs/static-source.ts b/web/standalone/src/wasm/libs/static-source.ts index b7bbc98..5ef173a 100644 --- a/web/standalone/src/wasm/libs/static-source.ts +++ b/web/standalone/src/wasm/libs/static-source.ts @@ -58,8 +58,9 @@ const SYMBOLS: Record = { export function staticLibsSource(): LibsSource { return { - async listLibs(): Promise { - return [STATIC_LIB]; + async listLibs(kind?: string): Promise { + // The built-in example lib is symbols-only. + return !kind || kind === "symbol" ? [STATIC_LIB] : []; }, async listItems(libId: string): Promise { if (libId !== STATIC_LIB.id) return [];