pcb-schema switch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f936eb17b0
commit
ee451c9de2
6 changed files with 303 additions and 56 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit ac7d733787000f7988a122559888bc8d76924315
|
||||
Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
87
tests/web/tool-switch.spec.ts
Normal file
87
tests/web/tool-switch.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
1
web/standalone/src/wasm/global.d.ts
vendored
1
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue