pcb-schema switch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-06-11 12:22:34 +02:00
commit ee451c9de2
6 changed files with 303 additions and 56 deletions

2
kicad

@ -1 +1 @@
Subproject commit ac7d733787000f7988a122559888bc8d76924315
Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86

View file

@ -1,21 +1,20 @@
import { defineConfig, devices } from '@playwright/test';
/**
* E2E config for the React WEB APP (apps/frontend at :3048 + apps/server :3050),
* as opposed to playwright-kicad.config.ts which drives the standalone tool
* harness HTMLs under tests/apps/kicad.
* E2E config for the React WEB APP (web/standalone editor at :3048 + the
* @pcbjam/backend-example reference backend at :3060), as opposed to
* playwright-kicad.config.ts which drives the standalone tool harness HTMLs
* under tests/apps/kicad.
*
* These tests exercise the real web open paths: navigate to /p/<project>/<tool>/<file>,
* let WasmTool boot the tool in-document (boot.ts), drive the project into MEMFS
* and auto-open the file (open-flow.ts via Module.kicadOpenFile), and assert the
* editor loaded wizard-free.
*
* PREREQUISITE: the web stack must be running. From web/:
* pnpm db:up && pnpm db:migrate && pnpm dev
* (db:migrate seeds the "demo" project; global-setup-web.ts re-seeds it through
* the API if missing.) The webServer block below reuses an already-running stack
* and, if absent, best-effort starts `turbo dev` but that still needs Postgres
* up (pnpm db:up) and migrated first.
* The backend serves a single project off the local filesystem no DB, no
* seeding. The webServer block reuses an already-running stack (`pnpm dev` from
* web/) or cold-starts it with the env below, pointing PROJECT_DIR at the
* committed tests/fixtures/demo project (slug "demo").
*
* Firefox is the reliable headless target on ARM Mac (Chromium SwiftShader WebGL
* bug); use --project=chromium (system Chrome) for headed debugging.
@ -54,11 +53,18 @@ export default defineConfig({
webServer: {
// Best-effort: reuse the dev stack if it's already up (the common case);
// otherwise start turbo dev. Postgres (pnpm db:up) + db:migrate must already
// have run — turbo dev does not provision the database.
// otherwise cold-start turbo dev with the env a fresh checkout needs
// (turbo passes these through, so no hand-copied .env files required).
command: 'pnpm --dir ../web dev',
url: FRONTEND_URL,
reuseExistingServer: true,
timeout: 120000,
env: {
...process.env,
// resolved against web/backend/ (the backend's cwd)
PROJECT_DIR: '../../tests/fixtures/demo',
VITE_API_BASE_URL: 'http://localhost:3060',
CORS_ORIGIN: 'http://localhost:3048',
},
},
});

View file

@ -1,20 +1,16 @@
import * as fs from 'fs';
import * as path from 'path';
import type { FullConfig } from '@playwright/test';
/**
* Global setup for the web-app e2e suite.
*
* The tests drive the real React app at :3048 and open files from the committed
* "demo" project. That project is normally created by `pnpm db:migrate`
* (seedDemoProject). This setup makes the suite self-sufficient: it waits for
* the API to be reachable and, if the demo project is missing, recreates it by
* uploading the committed seed-data files through the public API the same
* bytes db:seed uses. Idempotent.
* The stack is the standalone editor (:3048) plus the reference backend
* (:3060, @pcbjam/backend-example), which serves a single project off the
* local filesystem PROJECT_DIR=tests/fixtures/demo, so the slug is "demo"
* (basename of PROJECT_DIR). Nothing to seed: just wait for the backend and
* verify it serves the committed demo files the specs open.
*/
const API_BASE = process.env.VITE_API_BASE_URL ?? 'http://localhost:3050';
const SEED_DIR = path.resolve(__dirname, '../../web/apps/server/seed-data');
const API_BASE = process.env.BACKEND_URL ?? 'http://localhost:3060';
const DEMO_SLUG = 'demo';
const DEMO_FILES = ['demo.kicad_sch', 'demo.kicad_pcb', 'demo.kicad_wks'];
@ -32,43 +28,30 @@ async function waitForApi(timeoutMs = 60000): Promise<void> {
await new Promise((res) => setTimeout(res, 1000));
}
throw new Error(
`API not reachable at ${API_BASE} (${lastErr}). Start the web stack first: ` +
`from web/ run \`pnpm db:up && pnpm db:migrate && pnpm dev\`.`
`backend not reachable at ${API_BASE} (${lastErr}). Start the web stack first: ` +
`from web/ run \`pnpm dev\` (PROJECT_DIR defaults to tests/fixtures/demo via ` +
`backend/.env or the playwright webServer env).`
);
}
async function ensureDemoProject(): Promise<void> {
const existing = await fetch(`${API_BASE}/api/projects/${DEMO_SLUG}`);
if (existing.ok) {
const body = (await existing.json()) as { files: { path: string }[] };
const have = new Set(body.files.map((f) => f.path));
if (DEMO_FILES.every((f) => have.has(f))) return;
} else {
// Create the project (ignore 409 if a race created it).
const created = await fetch(`${API_BASE}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Demo Project', slug: DEMO_SLUG }),
});
if (!created.ok && created.status !== 409) {
throw new Error(`failed to create demo project: HTTP ${created.status}`);
}
async function verifyDemoProject(): Promise<void> {
const r = await fetch(`${API_BASE}/api/projects/${DEMO_SLUG}`);
if (!r.ok) {
throw new Error(
`GET /api/projects/${DEMO_SLUG} -> HTTP ${r.status}; ` +
`is the backend's PROJECT_DIR pointing at tests/fixtures/demo?`
);
}
// Upload the committed seed bytes (multipart field-name = project-relative path).
const form = new FormData();
for (const name of DEMO_FILES) {
const buf = fs.readFileSync(path.join(SEED_DIR, name));
form.append(name, new Blob([buf]), name);
const body = (await r.json()) as { files: { path: string }[] };
const have = new Set(body.files.map((f) => f.path));
const missing = DEMO_FILES.filter((f) => !have.has(f));
if (missing.length) {
throw new Error(`demo project missing files: ${missing.join(', ')}`);
}
const up = await fetch(`${API_BASE}/api/projects/${DEMO_SLUG}/files`, {
method: 'POST',
body: form,
});
if (!up.ok) throw new Error(`failed to seed demo files: HTTP ${up.status}`);
}
export default async function globalSetup(_config: FullConfig): Promise<void> {
await waitForApi();
await ensureDemoProject();
await verifyDemoProject();
console.log(`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" — OK`);
}

View file

@ -0,0 +1,87 @@
import { test, expect, type Page } from '@playwright/test';
import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker';
/**
* Tool-switch e2e: eeschema Tools "Switch to PCB Editor" (and the reverse)
* must navigate the browser to the other tool's URL.
*
* Native KiCad spawns a process for this via ExecuteFile (common/gestfich.cpp);
* the WASM build delegates to window.kicadWebOpenTool (WasmTool.tsx), which
* maps the MEMFS file path to the project-relative file and calls
* location.assign(/p/demo/<tool>/<file>). Each direction is a full page
* navigation followed by a fresh wasm boot hence the generous timeouts.
*/
async function waitForToolReady(page: Page, titleRe: RegExp): Promise<void> {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), {
message: `editor never reached title ${titleRe}`,
timeout: 120000,
intervals: [1000],
})
.toMatch(titleRe);
// The menu helpers drive the rendered-element registry.
await page.waitForFunction(
() =>
!!(window as unknown as { wxElementRegistry?: { findAllRendered?: unknown } })
.wxElementRegistry,
null,
{ timeout: 30000 }
);
}
async function switchTool(
page: Page,
menuLabel: string,
expectedUrl: RegExp,
expectedTitle: RegExp
): Promise<void> {
expect(await clickMenuBarItem(page, 'Tools'), 'Tools menubar item clickable').toBe(true);
await page.waitForTimeout(400); // popup render settle, same as tests/kicad specs
expect(await clickMenuItem(page, menuLabel), `"${menuLabel}" menu item clickable`).toBe(true);
await page.waitForURL(expectedUrl, { timeout: 30000 });
await waitForToolReady(page, expectedTitle);
}
test.describe('web app — tool switching', () => {
test('eeschema → Switch to PCB Editor navigates to pcbnew', async ({ page }) => {
test.setTimeout(420000); // two full wasm boots
await page.goto('/p/demo/eeschema/demo.kicad_sch');
await waitForToolReady(page, /demo — Schematic Editor/i);
await switchTool(
page,
'Switch to PCB Editor',
/\/p\/demo\/pcbnew\/demo\.kicad_pcb/,
/demo — PCB Editor/i
);
await page.screenshot({
path: 'test-results/web-switch-sch-to-pcb.png',
scale: 'device',
});
});
test('pcbnew → Switch to Schematic Editor navigates to eeschema', async ({ page }) => {
test.setTimeout(420000);
await page.goto('/p/demo/pcbnew/demo.kicad_pcb');
await waitForToolReady(page, /demo — PCB Editor/i);
await switchTool(
page,
'Switch to Schematic Editor',
/\/p\/demo\/eeschema\/demo\.kicad_sch/,
/demo — Schematic Editor/i
);
await page.screenshot({
path: 'test-results/web-switch-pcb-to-sch.png',
scale: 'device',
});
});
});

View file

@ -1,14 +1,171 @@
import * as React from "react";
import { collabRoomId, type Tool } from "@pcbjam/shared";
import {
collabRoomId,
EXTENSION_TOOL,
FILELESS_TOOLS,
toolSchema,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
".sch": "eeschema",
".brd": "pcbnew",
};
let activeToolNavigationHook:
| ((toolName: string, fileName: string) => boolean)
| undefined;
const toolNavigationDispatcher = (toolName: string, fileName: string) =>
activeToolNavigationHook?.(toolName, fileName) ?? false;
function ensureToolNavigationDispatcher(win: ToolWindow): boolean {
if (win.kicadWebOpenTool === toolNavigationDispatcher) return true;
try {
Object.defineProperty(win, "kicadWebOpenTool", {
configurable: true,
value: toolNavigationDispatcher,
});
return true;
} catch {
return false;
}
}
if (typeof window !== "undefined") {
ensureToolNavigationDispatcher(window as ToolWindow);
}
function normalizeToolName(rawName: string): Tool | null {
const basename = rawName.replace(/\\/g, "/").split("/").pop() ?? rawName;
const withoutExe = basename.replace(/\.exe$/i, "");
const toolName = withoutExe === "pcb_calculator" ? "calculator" : withoutExe;
const parsed = toolSchema.safeParse(toolName);
return parsed.success ? parsed.data : null;
}
function relativeProjectPath(slug: string, path: string): string | undefined {
if (!path) return undefined;
const normalized = path.replace(/\\/g, "/");
const prefix = `${memfsProjectDir(slug)}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
const marker = `/projects/${slug}/`;
const markerIndex = normalized.indexOf(marker);
if (markerIndex >= 0) return normalized.slice(markerIndex + marker.length);
return normalized.startsWith("/") ? undefined : normalized;
}
function fileStem(path: string): string {
const name = path.replace(/\\/g, "/").split("/").pop() ?? path;
return name.replace(/\.[^.]+$/, "");
}
function fileTool(path: string): Tool | undefined {
const lower = path.toLowerCase();
for (const [extension, mappedTool] of Object.entries({
...EXTENSION_TOOL,
...LEGACY_EXTENSION_TOOL,
})) {
if (lower.endsWith(extension)) return mappedTool;
}
return undefined;
}
function chooseToolFile(
files: ToolFile[],
nextTool: Tool,
requestedPath?: string,
currentPath?: string,
): string | undefined {
if (requestedPath && files.some((file) => file.path === requestedPath)) {
return requestedPath;
}
const candidates = files.filter((file) => fileTool(file.path) === nextTool);
const preferredStem = requestedPath
? fileStem(requestedPath)
: currentPath
? fileStem(currentPath)
: undefined;
if (preferredStem) {
const matchingStem = candidates.find(
(file) => fileStem(file.path) === preferredStem,
);
if (matchingStem) return matchingStem.path;
}
return candidates[0]?.path;
}
function encodeRelPath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
function installToolNavigationHook(
win: ToolWindow,
opts: {
slug: string;
files: ToolFile[];
targetPath?: string;
log: (m: string) => void;
},
): () => void {
const hook = (rawToolName: string, rawFileName: string): boolean => {
const nextTool = normalizeToolName(rawToolName);
if (!nextTool) {
opts.log(`[nav] unsupported KiCad tool: ${rawToolName}`);
return false;
}
const requestedPath = relativeProjectPath(opts.slug, rawFileName);
const nextPath = FILELESS_TOOLS.has(nextTool)
? undefined
: chooseToolFile(opts.files, nextTool, requestedPath, opts.targetPath);
if (!FILELESS_TOOLS.has(nextTool) && !nextPath) {
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
return false;
}
const url =
`/p/${encodeURIComponent(opts.slug)}/${nextTool}` +
(nextPath ? `/${encodeRelPath(nextPath)}` : "") +
win.location.search;
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
win.location.assign(url);
return true;
};
if (!ensureToolNavigationDispatcher(win)) {
opts.log("[nav] unable to install KiCad tool navigation hook");
}
activeToolNavigationHook = hook;
return () => {
if (activeToolNavigationHook === hook) activeToolNavigationHook = undefined;
};
}
/**
* Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the
@ -104,6 +261,21 @@ export function WasmTool({
const [showLog, setShowLog] = React.useState(false);
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
const append = React.useCallback(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
[],
);
React.useEffect(() => {
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
slug,
files,
targetPath,
log: append,
});
return () => removeNavigationHook();
}, [slug, files, targetPath, append]);
React.useEffect(() => {
// Guard re-entry: the WASM runtime is process-global and must boot exactly
@ -117,8 +289,6 @@ export function WasmTool({
return;
}
const append = (msg: string) =>
setLogs((prev) => [...prev.slice(-800), msg]);
const win = window as ToolWindow;
void (async () => {
@ -142,7 +312,7 @@ export function WasmTool({
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
// they don't retrigger a (rejected) second boot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tool, slug, base]);
}, [tool, slug, base, append]);
return (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">

View file

@ -44,6 +44,7 @@ declare global {
Module?: any;
FS?: EmscriptenFS;
wxElementRegistry?: WxElementRegistry;
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean;
}
// The browsing-context window the tool runs in — now the top-level `window`