import { test, expect, devices, type Page, type Browser } from '@playwright/test'; /** * Mobile canvas-only mode e2e (features/mobile) — runs under the * `mobile-chromium` project (Pixel 7 emulation, system Chrome). * * 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 { 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 { 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 }]); await pg.waitForTimeout(30); } 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 { 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)); await pg.waitForTimeout(30); } 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 { 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: `test-results/mobile-${name}.png`, scale: 'css' }); 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 }); // let the first paint + fit-to-view and the chrome-hide poll settle await page.waitForTimeout(4000); }); 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]); await page.waitForTimeout(60); await touch(page, 'touchend', [], [p]); await page.waitForTimeout(300); 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 shot('pinch-00-base'); await pinch(page, centre, 40, 140); // pinch OUT = zoom in await page.waitForTimeout(500); const zoomed = await shot('pinch-01-zoomed'); await pinch(page, centre, 140, 40); // pinch IN at the same centre = zoom back out await page.waitForTimeout(500); const restored = await shot('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 shot('pan-00-base'); await fingerDrag(page, from, to); await page.waitForTimeout(500); const panned = await shot('pan-01-panned'); await fingerDrag(page, to, from); // drag back — translation is invertible await page.waitForTimeout(500); const restored = await shot('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); }); 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). const menus = await page.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, ); expect(menus, '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); });