Squash of experiment/ff-big-modules vs main.
Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.
Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.
One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.
SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.
Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.
CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.
Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
import { test, expect, devices, type Page, type Browser } from '@playwright/test';
|
|
import { openOverlayMenu } from './overlay-menu';
|
|
import { shotPath, settledShot } from '../e2e/utils/element-tracker';
|
|
|
|
// One shared page across the checks (beforeAll boots pcbnew ONCE — each boot
|
|
// costs minutes). Serial group: fullyParallel would re-boot per test.
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
/**
|
|
* Mobile canvas-only mode e2e (features/mobile) — runs under the
|
|
* `web-mobile` project (Pixel 7 emulation on the bundled Chromium).
|
|
*
|
|
* Boots pcbnew ONCE on the demo board with `?mobile=1` (the wasm runtime is
|
|
* process-global and each boot costs minutes, so the four checks share one
|
|
* serial page):
|
|
*
|
|
* 1. chrome-less: no visible wx toolbars/menubar, GL canvas ≈ viewport
|
|
* (needs kicadSetChrome — the wasm side of features/mobile)
|
|
* 2. tap contract: a touch tap synthesizes a LEFT click (selection keeps
|
|
* working), and no phantom middle-button events
|
|
* 3. pinch: two-finger pinch-out zooms in (and pinch-in restores) — rides
|
|
* the wheel→zoom-to-cursor path, asserted per zoom-cursor.spec.ts logic
|
|
* 4. pan: a one-finger drag translates the view (and is NOT the old
|
|
* single-finger rubber-band select, which left the view unchanged)
|
|
*
|
|
* Touches are dispatched as synthetic TouchEvents on #canvas — exactly what
|
|
* the boot shim (touch-gestures.ts) consumes; Playwright has no pinch API.
|
|
*/
|
|
|
|
const FRONTEND_URL = process.env.WEB_APP_URL ?? 'http://localhost:3048';
|
|
const SCOPE = 'default';
|
|
|
|
// NOT describe.serial: the config is fullyParallel:false + workers:1, so the
|
|
// file's tests already run in order in one worker sharing `page` — and a
|
|
// failure (e.g. chrome-hide RED before the wasm lands) must not skip the rest.
|
|
let page: Page;
|
|
|
|
interface Pt {
|
|
id: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/** Dispatch a TouchEvent on #canvas; `active` is the post-event touch list
|
|
* (the shape of `event.touches` — empty for the final touchend). */
|
|
async function touch(pg: Page, type: string, active: Pt[], changed?: Pt[]): Promise<void> {
|
|
await pg.evaluate(
|
|
({ type, active, changed }) => {
|
|
const canvas = document.getElementById('canvas');
|
|
if (!canvas) throw new Error('#canvas not found');
|
|
const mk = (p: { id: number; x: number; y: number }) =>
|
|
new Touch({
|
|
identifier: p.id,
|
|
target: canvas,
|
|
clientX: p.x,
|
|
clientY: p.y,
|
|
screenX: p.x,
|
|
screenY: p.y,
|
|
radiusX: 2.5,
|
|
radiusY: 2.5,
|
|
force: 1,
|
|
});
|
|
const touches = active.map(mk);
|
|
canvas.dispatchEvent(
|
|
new TouchEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
touches,
|
|
targetTouches: touches,
|
|
changedTouches: (changed ?? active).map(mk),
|
|
}),
|
|
);
|
|
},
|
|
{ type, active, changed },
|
|
);
|
|
}
|
|
|
|
/** One-finger drag as a touch sequence. */
|
|
async function fingerDrag(
|
|
pg: Page,
|
|
from: { x: number; y: number },
|
|
to: { x: number; y: number },
|
|
steps = 10,
|
|
): Promise<void> {
|
|
await touch(pg, 'touchstart', [{ id: 1, x: from.x, y: from.y }]);
|
|
for (let i = 1; i <= steps; i++) {
|
|
const x = from.x + ((to.x - from.x) * i) / steps;
|
|
const y = from.y + ((to.y - from.y) * i) / steps;
|
|
await touch(pg, 'touchmove', [{ id: 1, x, y }]);
|
|
// The pacing IS the input: it gives the gesture a finger-plausible velocity.
|
|
await pg.waitForTimeout(30); // eslint-disable-line -- documented interaction dwell: gesture pacing
|
|
}
|
|
await touch(pg, 'touchend', [], [{ id: 1, x: to.x, y: to.y }]);
|
|
}
|
|
|
|
/** Two-finger horizontal pinch around a centre, from ±spreadFrom to ±spreadTo. */
|
|
async function pinch(
|
|
pg: Page,
|
|
centre: { x: number; y: number },
|
|
spreadFrom: number,
|
|
spreadTo: number,
|
|
steps = 8,
|
|
): Promise<void> {
|
|
const at = (s: number): Pt[] => [
|
|
{ id: 1, x: centre.x - s, y: centre.y },
|
|
{ id: 2, x: centre.x + s, y: centre.y },
|
|
];
|
|
await touch(pg, 'touchstart', at(spreadFrom));
|
|
for (let i = 1; i <= steps; i++) {
|
|
const s = spreadFrom + ((spreadTo - spreadFrom) * i) / steps;
|
|
await touch(pg, 'touchmove', at(s));
|
|
// The pacing IS the input: it gives the gesture a finger-plausible velocity.
|
|
await pg.waitForTimeout(30); // eslint-disable-line -- documented interaction dwell: gesture pacing
|
|
}
|
|
await touch(pg, 'touchend', [], at(spreadTo));
|
|
}
|
|
|
|
/** The visible GAL WebGL canvas box (same lookup as zoom-cursor.spec.ts). */
|
|
async function getGlBox(pg: Page): Promise<{ x: number; y: number; width: number; height: number }> {
|
|
const id = await pg.evaluate(() => {
|
|
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
|
.map((c) => c as HTMLCanvasElement)
|
|
.find((c) => {
|
|
const rect = c.getBoundingClientRect();
|
|
return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0;
|
|
});
|
|
return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null;
|
|
});
|
|
if (!id) throw new Error('No visible GL canvas found');
|
|
const box = await pg.locator(`#${id}`).boundingBox();
|
|
if (!box) throw new Error('GL canvas bounding box unavailable');
|
|
return box;
|
|
}
|
|
|
|
/** Fraction of pixels that differ (luma) between two PNGs inside a region
|
|
* (ported from zoom-cursor.spec.ts). */
|
|
async function diffRatio(
|
|
pg: Page,
|
|
a: Buffer,
|
|
b: Buffer,
|
|
box: { x: number; y: number; width: number; height: number },
|
|
): Promise<number> {
|
|
return pg.evaluate(
|
|
async ({ aB64, bB64, box }) => {
|
|
const load = async (s: string) => {
|
|
const i = new Image();
|
|
i.src = `data:image/png;base64,${s}`;
|
|
await i.decode();
|
|
return i;
|
|
};
|
|
const [ia, ib] = await Promise.all([load(aB64), load(bB64)]);
|
|
const w = Math.min(ia.width, ib.width),
|
|
h = Math.min(ia.height, ib.height);
|
|
const px = (img: HTMLImageElement) => {
|
|
const c = document.createElement('canvas');
|
|
c.width = w;
|
|
c.height = h;
|
|
const x = c.getContext('2d')!;
|
|
x.drawImage(img, 0, 0);
|
|
return x.getImageData(0, 0, w, h).data;
|
|
};
|
|
const da = px(ia),
|
|
db = px(ib);
|
|
const x0 = Math.max(0, Math.round(box.x)),
|
|
x1 = Math.min(w, Math.round(box.x + box.width));
|
|
const y0 = Math.max(0, Math.round(box.y)),
|
|
y1 = Math.min(h, Math.round(box.y + box.height));
|
|
let diff = 0,
|
|
total = 0;
|
|
for (let y = y0; y < y1; y++)
|
|
for (let x = x0; x < x1; x++) {
|
|
const i = (y * w + x) * 4;
|
|
const la = 0.299 * da[i] + 0.587 * da[i + 1] + 0.114 * da[i + 2];
|
|
const lb = 0.299 * db[i] + 0.587 * db[i + 1] + 0.114 * db[i + 2];
|
|
if (Math.abs(la - lb) > 24) diff++;
|
|
total++;
|
|
}
|
|
return diff / total;
|
|
},
|
|
{ aB64: a.toString('base64'), bB64: b.toString('base64'), box },
|
|
);
|
|
}
|
|
|
|
const shot = (name: string) =>
|
|
page.screenshot({ path: shotPath(page, `mobile-${name}.png`), scale: 'css' });
|
|
|
|
/** Settle the render (two consecutive identical canvas screenshots), then capture
|
|
* the named gate shot — the deterministic replacement for sleep-then-shoot. */
|
|
const settledCapture = async (name: string): Promise<Buffer> => {
|
|
await settledShot(page.locator('#canvas'), expect);
|
|
return shot(name);
|
|
};
|
|
|
|
test.beforeAll(async ({ browser }: { browser: Browser }) => {
|
|
test.setTimeout(420_000); // cold load: 136 MB wasm download + compile
|
|
page = await browser.newPage({ ...devices['Pixel 7'], baseURL: FRONTEND_URL });
|
|
page.on('console', (m) => {
|
|
if (/Aborted\(|pageerror/i.test(m.text())) console.log(`[mobile-e2e console] ${m.text()}`);
|
|
});
|
|
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?mobile=1`);
|
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
|
|
await expect
|
|
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
|
|
.toMatch(/demo — PCB Editor/i);
|
|
// The full-screen loading overlays (boot + lib fat-load, both `inset-0 z-30`
|
|
// in WasmTool) must be GONE — the gesture tests diff screenshots, and an
|
|
// overlay screenshot diffs to zero (learned the hard way on a cold load).
|
|
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
|
|
// First paint + fit-to-view + the chrome-hide poll: settled when two
|
|
// consecutive canvas screenshots are byte-identical.
|
|
await settledShot(page.locator('#canvas'), expect);
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await page?.close();
|
|
});
|
|
|
|
test('tap contract: a touch tap synthesizes a LEFT click, no phantom middle-drag', async () => {
|
|
const box = await getGlBox(page);
|
|
await page.evaluate(() => {
|
|
const c = document.getElementById('canvas')!;
|
|
const log: [string, number][] = ((window as unknown as { __mouseLog: [string, number][] }).__mouseLog = []);
|
|
for (const type of ['mousedown', 'mouseup'])
|
|
c.addEventListener(type, (e) => log.push([e.type, (e as MouseEvent).button]), true);
|
|
});
|
|
|
|
const p = { id: 1, x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 };
|
|
await touch(page, 'touchstart', [p]);
|
|
// A tap's physical duration — instantaneous taps can be filtered as noise.
|
|
await page.waitForTimeout(60); // eslint-disable-line -- documented interaction dwell: tap duration
|
|
await touch(page, 'touchend', [], [p]);
|
|
// The synthesized click is the observable: wait for the mouseup to land in the log.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(
|
|
() => (window as unknown as { __mouseLog: [string, number][] }).__mouseLog.length,
|
|
),
|
|
{ timeout: 10000, message: 'tap never synthesized any mouse events' },
|
|
)
|
|
.toBeGreaterThanOrEqual(2);
|
|
|
|
const log = await page.evaluate(
|
|
() => (window as unknown as { __mouseLog: [string, number][] }).__mouseLog,
|
|
);
|
|
expect(log, 'tap → left mousedown').toContainEqual(['mousedown', 0]);
|
|
expect(log, 'tap → left mouseup').toContainEqual(['mouseup', 0]);
|
|
expect(
|
|
log.filter(([, button]) => button === 1),
|
|
'a tap must not press the middle (pan) button',
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('pinch: two-finger pinch-out zooms in, pinch-in restores', async () => {
|
|
// shim marker: mobile mode must have armed the canvas for touch
|
|
expect(
|
|
await page.evaluate(() => getComputedStyle(document.getElementById('canvas')!).touchAction),
|
|
'canvas touch-action none (gesture shim installed)',
|
|
).toBe('none');
|
|
|
|
const box = await getGlBox(page);
|
|
const centre = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.45 };
|
|
|
|
const base = await settledCapture('pinch-00-base');
|
|
await pinch(page, centre, 40, 140); // pinch OUT = zoom in
|
|
const zoomed = await settledCapture('pinch-01-zoomed');
|
|
|
|
await pinch(page, centre, 140, 40); // pinch IN at the same centre = zoom back out
|
|
const restored = await settledCapture('pinch-02-restored');
|
|
|
|
const dIn = await diffRatio(page, base, zoomed, box);
|
|
const dBack = await diffRatio(page, base, restored, box);
|
|
console.log(`[mobile pinch] dIn=${dIn.toFixed(3)} dBack=${dBack.toFixed(3)}`);
|
|
|
|
expect(dIn, 'pinch-out visibly zooms').toBeGreaterThan(0.006);
|
|
// zoom-to-cursor at a fixed centroid is invertible (same discriminator as
|
|
// zoom-cursor.spec.ts)
|
|
expect(dBack, 'pinch in+out returns near baseline').toBeLessThan(dIn / 3);
|
|
});
|
|
|
|
test('pan: one-finger drag translates the view (not a rubber-band select)', async () => {
|
|
const box = await getGlBox(page);
|
|
// start/end in the bottom-left margin: the old single-finger LEFT-drag would
|
|
// rubber-band an EMPTY region there (no selection highlight → no pixel change),
|
|
// so this fails RED before the shim and can't false-pass on item selection.
|
|
const from = { x: box.x + box.width * 0.15, y: box.y + box.height * 0.88 };
|
|
const to = { x: box.x + box.width * 0.55, y: box.y + box.height * 0.7 };
|
|
|
|
const base = await settledCapture('pan-00-base');
|
|
await fingerDrag(page, from, to);
|
|
const panned = await settledCapture('pan-01-panned');
|
|
|
|
await fingerDrag(page, to, from); // drag back — translation is invertible
|
|
const restored = await settledCapture('pan-02-restored');
|
|
|
|
const dPan = await diffRatio(page, base, panned, box);
|
|
const dBack = await diffRatio(page, base, restored, box);
|
|
console.log(`[mobile pan] dPan=${dPan.toFixed(3)} dBack=${dBack.toFixed(3)}`);
|
|
|
|
expect(dPan, 'one-finger drag visibly pans the view').toBeGreaterThan(0.006);
|
|
expect(dBack, 'panning back returns near baseline').toBeLessThan(dPan / 3);
|
|
});
|
|
|
|
/** Count of visible menubar titles (0 ⇒ menubar hidden). */
|
|
async function visibleMenuTitles(pg: Page): Promise<number> {
|
|
return pg.evaluate(
|
|
() =>
|
|
Array.from(document.querySelectorAll('.wx-menu-title')).filter((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none';
|
|
}).length,
|
|
);
|
|
}
|
|
|
|
test('editor is canvas-only: no menubar, GL canvas fills the viewport', async () => {
|
|
await shot('chrome');
|
|
|
|
// The menubar is real DOM (.wx-menu-title per menu); AUI toolbars/panels are
|
|
// canvas islands, so the GL-canvas-fills-viewport check below is what proves
|
|
// THEY are gone (hidden AUI panes release their space to the center pane).
|
|
expect(await visibleMenuTitles(page), 'no visible menubar titles').toBe(0);
|
|
|
|
// the drawing canvas reclaims the whole viewport
|
|
const box = await getGlBox(page);
|
|
const vp = page.viewportSize()!;
|
|
expect(box.width, 'GL canvas width ≈ viewport').toBeGreaterThan(vp.width * 0.95);
|
|
expect(box.height, 'GL canvas height ≈ viewport').toBeGreaterThan(vp.height * 0.9);
|
|
|
|
// the shell's own persistent overlays are gone too
|
|
expect(await page.getByText(/console \(/).count(), 'console toggle hidden').toBe(0);
|
|
});
|
|
|
|
test('the floating toggle brings the full UI up and back off (Figma-like hide-UI)', async () => {
|
|
// Canvas-only is the mobile DEFAULT, but the toggle must stay reachable —
|
|
// inside the overlay menu (0010); the FAB is the persistent control.
|
|
await openOverlayMenu(page);
|
|
const toggle = page.locator('[data-testid="chrome-toggle"]');
|
|
await expect(toggle, 'toggle button present in canvas-only mode').toBeVisible();
|
|
|
|
await toggle.tap();
|
|
await expect
|
|
.poll(() => visibleMenuTitles(page), { timeout: 15000 })
|
|
.toBeGreaterThan(0);
|
|
// console footer follows the toggle (full shell UI back)
|
|
await expect(page.getByText(/console \(/)).toHaveCount(1);
|
|
// chrome takes real estate again (side toolbars + layers panel eat width)
|
|
const shown = await getGlBox(page);
|
|
const vp = page.viewportSize()!;
|
|
expect(shown.width, 'chrome reclaims width').toBeLessThan(vp.width * 0.95);
|
|
await shot('toggle-shown');
|
|
|
|
// …and back to canvas-only, leaving the file in its baseline hidden state.
|
|
await toggle.tap();
|
|
await expect.poll(() => visibleMenuTitles(page), { timeout: 15000 }).toBe(0);
|
|
await expect
|
|
.poll(async () => (await getGlBox(page)).width, { timeout: 15000 })
|
|
.toBeGreaterThan(vp.width * 0.95);
|
|
expect(await page.getByText(/console \(/).count(), 'console toggle hidden again').toBe(0);
|
|
await shot('toggle-rehidden');
|
|
});
|