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:
parent
19fce866c1
commit
2c52637b1a
10 changed files with 163 additions and 26 deletions
108
tests/web/footprint-browse-remote.spec.ts
Normal file
108
tests/web/footprint-browse-remote.spec.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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<IndexFile | null> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
|
||||
export async function listLibs(
|
||||
cfg: LibsConfig,
|
||||
kind?: string,
|
||||
): Promise<Lib[]> {
|
||||
if (!cfg.dir) return [];
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
|
|
@ -56,6 +66,9 @@ export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -177,10 +177,12 @@ async function main(): Promise<void> {
|
|||
}
|
||||
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] };
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@
|
|||
// plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout
|
||||
// the read side uses:
|
||||
//
|
||||
// <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>/index.json { items: [...], type, name }
|
||||
// <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
|
||||
// (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<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);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288
|
||||
Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4
|
||||
|
|
@ -121,7 +121,8 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export function remoteLibsSource(
|
|||
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
|
||||
|
||||
return {
|
||||
async listLibs(): Promise<LibInfo[]> {
|
||||
const res = await client.listLibs();
|
||||
async listLibs(kind?: string): Promise<LibInfo[]> {
|
||||
const res = await client.listLibs({ query: { kind } });
|
||||
if (res.status !== 200) return [];
|
||||
return res.body.map((l) => ({
|
||||
id: l.id,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,13 @@ export interface LibItemInfo {
|
|||
}
|
||||
|
||||
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). */
|
||||
listItems(libId: string): Promise<LibItemInfo[]>;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -36,16 +36,17 @@ function withSpikeKindLib(
|
|||
const isSpike = (libId: string) => libId === spec.id;
|
||||
|
||||
return {
|
||||
async listLibs(): Promise<LibInfo[]> {
|
||||
async listLibs(kind?: string): Promise<LibInfo[]> {
|
||||
// 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" }];
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ const SYMBOLS: Record<string, string> = {
|
|||
|
||||
export function staticLibsSource(): LibsSource {
|
||||
return {
|
||||
async listLibs(): Promise<LibInfo[]> {
|
||||
return [STATIC_LIB];
|
||||
async listLibs(kind?: string): Promise<LibInfo[]> {
|
||||
// The built-in example lib is symbols-only.
|
||||
return !kind || kind === "symbol" ? [STATIC_LIB] : [];
|
||||
},
|
||||
async listItems(libId: string): Promise<LibItemInfo[]> {
|
||||
if (libId !== STATIC_LIB.id) return [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue