diff --git a/kicad b/kicad index ac7d733..8192a71 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit ac7d733787000f7988a122559888bc8d76924315 +Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86 diff --git a/tests/playwright-web.config.ts b/tests/playwright-web.config.ts index 0e72895..d4f2d84 100644 --- a/tests/playwright-web.config.ts +++ b/tests/playwright-web.config.ts @@ -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///, * 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', + }, }, }); diff --git a/tests/web/global-setup-web.ts b/tests/web/global-setup-web.ts index c973761..c2628cb 100644 --- a/tests/web/global-setup-web.ts +++ b/tests/web/global-setup-web.ts @@ -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 { 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 { - 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 { + 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 { await waitForApi(); - await ensureDemoProject(); + await verifyDemoProject(); + console.log(`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" — OK`); } diff --git a/tests/web/tool-switch.spec.ts b/tests/web/tool-switch.spec.ts new file mode 100644 index 0000000..84158d1 --- /dev/null +++ b/tests/web/tool-switch.spec.ts @@ -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//). 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 { + 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 { + 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', + }); + }); +}); diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 68288ff..48ab112 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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(["pl_editor", "eeschema", "pcbnew"]); +const LEGACY_EXTENSION_TOOL: Record = { + ".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 (
diff --git a/web/standalone/src/wasm/global.d.ts b/web/standalone/src/wasm/global.d.ts index b47a25a..690eade 100644 --- a/web/standalone/src/wasm/global.d.ts +++ b/web/standalone/src/wasm/global.d.ts @@ -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`