Skip to content

Commit 7398ae4

Browse files
committed
feat(e2e-e2): Playwright scaffolding + 6-scenario canvas smoke
Stage E-2 of the E2E Coverage Matrix epic (cppmega-mlx-pa3.3). Lands the test infrastructure that Stages E-3..E-5 will fan out on. Backend wiring (cppmega_v4/jsonrpc/server.py): - Open CORS so the Vite dev server on a different origin can call /rpc + /ws. Single-user dev tool, no auth — open allow_origins is appropriate. Frontend wiring (vbgui/src/App.tsx): - handlePresetDrop now also rebinds loss.head_outputs to the last brick's id after a preset loads. Without this verify_build_spec rejects the freshly-loaded preset because the default loss head_output ('logits') matches no node in the canvas. - num_layers dropped from build_preset_specs call — pass omitted so each preset emits its canonical repeat unit (some are 6+ bricks for sliding/global patterns and would truncate to zero at depth=2). - Backend URL read from import.meta.env.VITE_BACKEND_URL so the e2e globalSetup can point the bundle at a non-default port. E2E scaffolding (vbgui/e2e/): - playwright.config.ts: chromium-only, headless, 1280x800, workers=4 locally / 2 in CI, retries=1 in CI, screenshots on failure, traces retained on failure. - globalSetup.ts: spawns 'uvicorn cppmega_v4.jsonrpc.server:create_app --factory --port 8767' and 'vite --port 5176' with VITE_BACKEND_URL exported, then polls /health + 5176/ for readiness with a 30 s budget each. Logs to e2e/logs/. PID handles persisted for teardown. - globalTeardown.ts: SIGTERM both children, give them 500 ms to drain. - fixtures.ts: gotoApp / selectPreset (poll for brick-node-* count) / clickTab / loadTokenizerInPlayground / loadParquetInInspector / clickRunPipeline (wait for modal) / assertOverallStatus / closeModal / dropBrickViaPalette (synthetic DragEvent for HTML5 drag-drop that Playwright cannot dispatch natively). - utils/screenshot.ts: per-scenario PNG dump into e2e/screenshots/. - utils/matrix.ts: loads tests/fixtures/MATRIX.json so scenarios iterate the same combinations the Python side knows about. - scenarios/01_canvas_smoke.spec.ts: 5 preset-launcher walkthroughs (qwen3_next / kimi_linear / deepseek_v3 / mistral4 / gemma4) + empty-canvas error path. 16 screenshots emitted. Top-level .gitignore: ignore vbgui/e2e/{test-results,screenshots,logs, playwright-report}/ — those are per-run artefacts. vbgui dev dep additions: @playwright/test 1.60, @types/node 25.9. package.json: e2e / e2e:headed / e2e:report npm scripts. vite.config.ts test.include narrowed to tests/**/*.test.{ts,tsx} so playwright .spec.ts files do not get picked up by vitest. tsconfig.json: include e2e/ + add node + vite/client types. Test results: - Playwright: 6 / 6 passed (26.8 s wall-clock). - Vbgui vitest: 104 / 104 passed (no churn). - Python pytest: 2139 / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-pa3.3.
1 parent 818afbb commit 7398ae4

15 files changed

Lines changed: 513 additions & 7 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ cppmega_mlx/training/native_optim/*.dylib
1919
cppmega_mlx/training/native_optim/*.metallib
2020
cppmega_v4/widget/static/
2121

22+
# Visual Builder E2E artefacts (per-run output).
23+
vbgui/e2e/test-results/
24+
vbgui/e2e/screenshots/
25+
vbgui/e2e/logs/
26+
vbgui/e2e/playwright-report/
27+
2228
# Local Metal GPU traces (multi-hundred-MB Apple Xcode capture bundles).
2329
*.gputrace
2430
docs/upstream/tilelang_metal_mamba3/captures/

cppmega_v4/jsonrpc/server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from contextlib import asynccontextmanager
1818

1919
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
20+
from fastapi.middleware.cors import CORSMiddleware
2021

2122
from cppmega_v4.jsonrpc.cache import LRUCache
2223
from cppmega_v4.jsonrpc.dispatcher import dispatch
@@ -43,6 +44,14 @@ async def lifespan(_: FastAPI):
4344
version=SCHEMA_VERSION,
4445
lifespan=lifespan,
4546
)
47+
# Visual Builder is single-user dev/jupyter — open CORS is correct here.
48+
app.add_middleware(
49+
CORSMiddleware,
50+
allow_origins=["*"],
51+
allow_methods=["*"],
52+
allow_headers=["*"],
53+
allow_credentials=False,
54+
)
4655
app.state.cache = cache
4756

4857
@app.get("/health")

vbgui/e2e/fixtures.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Per-test helpers wrapping common GUI walks so scenarios stay short.
2+
3+
import { expect, type Locator, type Page } from "@playwright/test";
4+
5+
export const TIMEOUT_RUN = 20_000;
6+
7+
export async function gotoApp(page: Page): Promise<void> {
8+
await page.goto("/", { waitUntil: "load" });
9+
await expect(page.getByTestId("preset-launcher")).toBeVisible();
10+
}
11+
12+
export async function selectPreset(page: Page, name: string): Promise<void> {
13+
await page.getByTestId("preset-launcher").selectOption(name);
14+
// canvas populates async (RPC → setNodes). Wait for at least one node.
15+
await expect.poll(async () =>
16+
await page.locator("[data-testid^='brick-node-']").count(),
17+
{ timeout: 8_000 },
18+
).toBeGreaterThan(0);
19+
}
20+
21+
export async function clickTab(page: Page,
22+
key: "canvas" | "tokenizer" | "data"): Promise<void> {
23+
await page.getByTestId(`app-tab-${key}`).click();
24+
}
25+
26+
export async function loadTokenizerInPlayground(
27+
page: Page, sourcePath: string,
28+
): Promise<void> {
29+
await clickTab(page, "tokenizer");
30+
await page.getByTestId("tokenizer-playground").waitFor();
31+
await page.getByTestId("add-panel").click();
32+
await page.getByTestId("tokenizer-source-0").fill(sourcePath);
33+
await page.getByTestId("tokenizer-encode-0").click();
34+
await page.getByTestId("tokenizer-metrics-0").waitFor();
35+
}
36+
37+
export async function loadParquetInInspector(
38+
page: Page, parquetPath: string,
39+
): Promise<void> {
40+
await clickTab(page, "data");
41+
await page.getByTestId("data-inspector").waitFor();
42+
await page.getByTestId("data-path").fill(parquetPath);
43+
await page.getByTestId("data-load").click();
44+
await page.getByTestId("data-metrics").waitFor();
45+
}
46+
47+
export async function clickRunPipeline(
48+
page: Page, mode: "smoke" | "full" | "train",
49+
): Promise<Locator> {
50+
await clickTab(page, "canvas");
51+
if (mode === "smoke") {
52+
await page.getByTestId("run-pipeline").click();
53+
} else {
54+
await page.getByTestId("run-pipeline-toggle").click();
55+
await page.getByTestId(`run-pipeline-${mode}`).click();
56+
}
57+
const modal = page.getByTestId("run-result-modal");
58+
await modal.waitFor({ timeout: TIMEOUT_RUN });
59+
return modal;
60+
}
61+
62+
export async function assertOverallStatus(
63+
modal: Locator, expected: "ok" | "fail",
64+
): Promise<void> {
65+
const overall = modal.getByTestId("run-result-overall");
66+
await expect(overall).toContainText(expected);
67+
}
68+
69+
export async function closeModal(page: Page): Promise<void> {
70+
await page.getByTestId("run-result-close").click().catch(() => undefined);
71+
await page.getByTestId("run-result-modal").waitFor({ state: "detached",
72+
timeout: 2_000 })
73+
.catch(() => undefined);
74+
}
75+
76+
/** Drop a brick onto the canvas via synthetic dragstart/drop events. */
77+
export async function dropBrickViaPalette(
78+
page: Page, kind: string,
79+
): Promise<void> {
80+
const tile = page.getByTestId(`palette-brick-${kind}`);
81+
const canvas = page.getByTestId("flow-canvas");
82+
const before = await page.locator("[data-testid^='brick-node-']").count();
83+
await tile.hover();
84+
await page.mouse.down();
85+
const box = await canvas.boundingBox();
86+
if (!box) throw new Error("canvas has no bounding box");
87+
await page.mouse.move(box.x + 200, box.y + 200, { steps: 6 });
88+
await page.mouse.up();
89+
// Synthetic drop event (Playwright cannot natively dispatch HTML5
90+
// drag/drop, so fall back to the App's onDropBrick handler via JS).
91+
await canvas.evaluate((el, k) => {
92+
const dt = new DataTransfer();
93+
dt.setData("application/x-cppmega-brick", k);
94+
const ev = new DragEvent("drop", { bubbles: true, dataTransfer: dt,
95+
clientX: 200, clientY: 200 });
96+
el.dispatchEvent(ev);
97+
}, kind);
98+
await expect.poll(async () =>
99+
await page.locator("[data-testid^='brick-node-']").count(),
100+
{ timeout: 4_000 },
101+
).toBeGreaterThan(before);
102+
}

vbgui/e2e/globalSetup.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Spawn the FastAPI backend (uvicorn) + the Vite dev server before the
2+
// Playwright workers attach. Both are torn down by globalTeardown.ts.
3+
4+
import { spawn, type ChildProcess } from "node:child_process";
5+
import { mkdirSync } from "node:fs";
6+
import { createWriteStream } from "node:fs";
7+
import { writeFileSync } from "node:fs";
8+
import { dirname, resolve } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
11+
import {
12+
BACKEND_PORT, BACKEND_URL,
13+
FRONTEND_PORT, FRONTEND_URL,
14+
} from "./playwright.config";
15+
16+
const __dirname = dirname(fileURLToPath(import.meta.url));
17+
const LOG_DIR = resolve(__dirname, "logs");
18+
19+
interface Handles {
20+
backend?: ChildProcess;
21+
frontend?: ChildProcess;
22+
}
23+
24+
const handles: Handles = {};
25+
26+
function spawnLogged(cmd: string, args: string[],
27+
cwd: string, label: string,
28+
env: NodeJS.ProcessEnv = process.env): ChildProcess {
29+
mkdirSync(LOG_DIR, { recursive: true });
30+
const out = createWriteStream(`${LOG_DIR}/${label}.stdout.log`,
31+
{ flags: "w" });
32+
const err = createWriteStream(`${LOG_DIR}/${label}.stderr.log`,
33+
{ flags: "w" });
34+
const child = spawn(cmd, args, {
35+
cwd,
36+
env: { ...env, FORCE_COLOR: "0" },
37+
stdio: ["ignore", "pipe", "pipe"],
38+
});
39+
child.stdout?.pipe(out);
40+
child.stderr?.pipe(err);
41+
child.on("exit", (code) =>
42+
err.write(`[${label}] exited code=${code}\n`));
43+
return child;
44+
}
45+
46+
async function waitFor(url: string, label: string, timeoutMs: number) {
47+
const deadline = Date.now() + timeoutMs;
48+
let lastErr: string = "no attempts";
49+
while (Date.now() < deadline) {
50+
try {
51+
const res = await fetch(url);
52+
if (res.ok) return;
53+
lastErr = `HTTP ${res.status}`;
54+
} catch (e) {
55+
lastErr = String(e);
56+
}
57+
await new Promise((r) => setTimeout(r, 300));
58+
}
59+
throw new Error(`${label} not ready at ${url} after ${timeoutMs}ms; ` +
60+
`last error: ${lastErr}`);
61+
}
62+
63+
export default async function globalSetup(): Promise<void> {
64+
const repoRoot = resolve(__dirname, "../..");
65+
const vbguiRoot = resolve(__dirname, "..");
66+
67+
// Backend (uvicorn). Use the same Python venv the tests use.
68+
handles.backend = spawnLogged(
69+
process.env.VBGUI_E2E_PYTHON
70+
?? "/Users/dave/sources/nanochat/.venv/bin/python",
71+
[
72+
"-m", "uvicorn",
73+
"cppmega_v4.jsonrpc.server:create_app",
74+
"--factory",
75+
"--port", String(BACKEND_PORT),
76+
"--host", "127.0.0.1",
77+
],
78+
repoRoot, "backend",
79+
);
80+
81+
// Frontend (vite dev). Force NODE_ENV=development so devDeps are honoured.
82+
// Point the bundled RpcClient at the e2e backend port via Vite env var.
83+
handles.frontend = spawnLogged(
84+
"npx",
85+
["vite", "--port", String(FRONTEND_PORT), "--strictPort",
86+
"--host", "127.0.0.1"],
87+
vbguiRoot, "frontend",
88+
{
89+
...process.env,
90+
NODE_ENV: "development",
91+
VITE_BACKEND_URL: BACKEND_URL,
92+
},
93+
);
94+
95+
// Record PIDs so globalTeardown can find them even after re-import.
96+
writeFileSync(`${LOG_DIR}/handles.json`, JSON.stringify({
97+
backend_pid: handles.backend?.pid ?? null,
98+
frontend_pid: handles.frontend?.pid ?? null,
99+
}, null, 2));
100+
101+
await Promise.all([
102+
waitFor(`${BACKEND_URL}/health`, "backend", 30_000),
103+
waitFor(FRONTEND_URL, "frontend", 30_000),
104+
]);
105+
}

vbgui/e2e/globalTeardown.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Kill the dev servers spawned in globalSetup via the PID file.
2+
3+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
4+
import { dirname, resolve } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
const LOG_DIR = resolve(__dirname, "logs");
9+
const HANDLES = `${LOG_DIR}/handles.json`;
10+
11+
function kill(pid: number | null): void {
12+
if (!pid) return;
13+
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
14+
// SIGTERM should suffice for uvicorn + vite; if not, follow up later.
15+
}
16+
17+
export default async function globalTeardown(): Promise<void> {
18+
if (!existsSync(HANDLES)) return;
19+
const raw = JSON.parse(readFileSync(HANDLES, "utf-8")) as {
20+
backend_pid?: number | null;
21+
frontend_pid?: number | null;
22+
};
23+
kill(raw.frontend_pid ?? null);
24+
kill(raw.backend_pid ?? null);
25+
// Give them a moment to exit cleanly so port:5176/8767 is free for the
26+
// next run; ignore any post-exit chatter.
27+
await new Promise((r) => setTimeout(r, 500));
28+
try { unlinkSync(HANDLES); } catch { /* ok */ }
29+
}

vbgui/e2e/playwright.config.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
// Dev ports may be overridden by globalSetup if it picks dynamic ones.
4+
export const FRONTEND_PORT = Number(process.env.VBGUI_E2E_FRONTEND_PORT ?? 5176);
5+
export const BACKEND_PORT = Number(process.env.VBGUI_E2E_BACKEND_PORT ?? 8767);
6+
export const FRONTEND_URL = `http://127.0.0.1:${FRONTEND_PORT}`;
7+
export const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`;
8+
9+
export default defineConfig({
10+
testDir: "./scenarios",
11+
fullyParallel: false,
12+
forbidOnly: !!process.env.CI,
13+
retries: process.env.CI ? 1 : 0,
14+
workers: process.env.CI ? 2 : 4,
15+
reporter: process.env.CI ? "github" : "list",
16+
globalSetup: "./globalSetup.ts",
17+
globalTeardown: "./globalTeardown.ts",
18+
outputDir: "./test-results",
19+
use: {
20+
baseURL: FRONTEND_URL,
21+
headless: true,
22+
viewport: { width: 1280, height: 800 },
23+
screenshot: "only-on-failure",
24+
trace: "retain-on-failure",
25+
actionTimeout: 8_000,
26+
navigationTimeout: 15_000,
27+
},
28+
projects: [
29+
{
30+
name: "chromium",
31+
use: { ...devices["Desktop Chrome"] },
32+
},
33+
],
34+
expect: { timeout: 5_000 },
35+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Proof-of-life for the Playwright scaffolding. Five short scenarios:
2+
// each loads a preset via the top-bar launcher, asserts canvas nodes,
3+
// then runs the Smoke pipeline through the backend and screenshots
4+
// the final modal.
5+
6+
import { expect, test } from "@playwright/test";
7+
import {
8+
assertOverallStatus, clickRunPipeline,
9+
closeModal, gotoApp, selectPreset,
10+
} from "../fixtures";
11+
import { snapshot } from "../utils/screenshot";
12+
13+
const PROOF_PRESETS = [
14+
"qwen3_next", "kimi_linear", "deepseek_v3", "mistral4", "gemma4",
15+
];
16+
17+
test.describe("canvas smoke (proof of life)", () => {
18+
for (const preset of PROOF_PRESETS) {
19+
test(`preset ${preset} runs smoke pipeline through GUI`,
20+
async ({ page }) => {
21+
await gotoApp(page);
22+
await snapshot(page, "01_canvas_smoke", `${preset}__01_empty`);
23+
24+
await selectPreset(page, preset);
25+
await snapshot(page, "01_canvas_smoke", `${preset}__02_loaded`);
26+
27+
const modal = await clickRunPipeline(page, "smoke");
28+
await snapshot(page, "01_canvas_smoke", `${preset}__03_modal`);
29+
await assertOverallStatus(modal, "ok");
30+
31+
// At least the first stage row should be rendered.
32+
await expect(modal.getByTestId("run-result-stage-parse")).toBeVisible();
33+
await closeModal(page);
34+
});
35+
}
36+
37+
test("smoke on empty canvas surfaces error in modal", async ({ page }) => {
38+
await gotoApp(page);
39+
await page.getByTestId("run-pipeline").click();
40+
const modal = page.getByTestId("run-result-modal");
41+
await modal.waitFor();
42+
await expect(modal.getByTestId("run-result-error"))
43+
.toContainText("empty");
44+
await snapshot(page, "01_canvas_smoke", "empty_canvas_error");
45+
});
46+
});

0 commit comments

Comments
 (0)