import { chromium } from "@playwright/test"; import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, join } from "node:path"; const root = join(import.meta.dirname, "app/dist"); const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", }; const server = createServer(async (req, res) => { const path = join( root, new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"), ); let body; try { body = await readFile(path); } catch { res.writeHead(404); return res.end(); } res.writeHead(200, { "content-type": types[extname(path)] ?? "application/octet-stream", }); res.end(body); }).listen(0); const base = `http://127.0.0.1:${server.address().port}/`; // CHROMIUM_PATH overrides Playwright's bundled Chromium. const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH, }); const context = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); const results = []; const check = (name, ok, detail) => { results.push({ name, ok, detail }); console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); }; async function open(query = "") { const page = await context.newPage(); page.on("pageerror", (e) => console.log("pageerror", e.message)); await page.goto(base + query); await page.waitForFunction(() => window.scene); return { page, cdp: await context.newCDPSession(page) }; } async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) { await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }], }); for (let i = 1; i <= steps; i++) { const x = from[0] + ((to[0] - from[0]) * i) / steps; const y = from[1] + ((to[1] - from[1]) * i) / steps; await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }], }); await page.waitForTimeout(stepMs); } await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [], }); await page.waitForTimeout(700); } const states = (page) => page.evaluate(() => window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "), ); const log = (page) => page.evaluate(() => window.log.slice()); // 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel). { const t = await open(); await drag(t, [340, 400], [60, 405]); const s = await states(t.page); check( "horizontal swipe over a scroller opens Detail", s === "root:parked detail:active", s, ); const l = await log(t.page); check( "onCommit runs inside the real pointerup event", l.includes("open committed inPointerup=true"), l.join(" | "), ); await t.page.close(); } // 2. Vertical drag on the same host scrolls natively and leaves the gesture alone. { const t = await open(); await drag(t, [200, 700], [205, 300]); const s = await states(t.page); const top = await t.page.evaluate(() => window.scrollTopOf("#home-list")); check( "vertical drag scrolls the list natively", top > 100, `scrollTop=${top}`, ); check( "vertical drag does not start the horizontal gesture", s === "root:active", s, ); await t.page.close(); } // 3. Scroll-edge dismiss. { const t = await open(); await drag(t, [340, 400], [60, 405]); check( "(setup) Detail open", (await states(t.page)) === "root:parked detail:active", ); await drag(t, [200, 700], [200, 300]); // scroll the list down const scrolled = await t.page.evaluate(() => window.scrollTopOf("#detail-list"), ); check( "drag up scrolls the detail list", scrolled > 100, `scrollTop=${scrolled}`, ); await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss const afterDown = await t.page.evaluate(() => window.scrollTopOf("#detail-list"), ); check( "drag down while scrolled scrolls the list, not dismiss", afterDown < scrolled && (await states(t.page)) === "root:parked detail:active", `scrollTop ${scrolled} → ${afterDown}`, ); await t.page.evaluate( () => (document.querySelector("#detail-list").scrollTop = 0), ); await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses const s = await states(t.page); const l = await log(t.page); check( "drag down at the top dismisses", s === "root:active" && l.includes("dismiss committed"), s, ); await t.page.close(); } // 3b. At the top, an upward drag still scrolls the list. { const t = await open(); await drag(t, [340, 400], [60, 405]); await drag(t, [200, 700], [200, 300]); const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list")); check( "at the top, drag up still scrolls (dismiss lets go)", top > 100 && (await states(t.page)) === "root:parked detail:active", `scrollTop=${top}`, ); await t.page.close(); } // 5. Recognition feel: human-like traces on the Home swipe (over a nested // scroller). Real fingers rarely start cleanly; every trace that should open // must open, and the rest must not. { const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]); const traces = [ ["clean swipe", steady(-200, 12), true], ["short steady drag (31%)", steady(-120, 10), true], ["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true], ["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true], ["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true], ["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true], ["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true], ["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false], ["quick flick (15%)", steady(-60, 4, 10), true], ["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false], ]; for (const [name, steps, shouldOpen] of traces) { const t = await open(); let [x, y] = [330, 400]; await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); for (const [dx, dy, ms] of steps) { x += dx; y += dy; if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] }); await t.page.waitForTimeout(ms); } await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await t.page.waitForTimeout(800); const opened = (await states(t.page)) === "root:parked detail:active"; check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed"); await t.page.close(); } } // 6. Frame pacing while a finger drags: no dropped frames from blocking // touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms. async function pacing(query, setup, from, to) { const gaps = []; for (let run = 0; run < 6; run++) { const t = await open(query); await setup(t); await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); }); await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] }); for (let i = 1; i <= 40; i++) { await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] }); await t.page.waitForTimeout(16); } const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; }); await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); gaps.push(...f.slice(1).map((v, i) => v - f[i])); await t.page.close(); } gaps.sort((a, b) => a - b); return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length }; } const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); }; for (const [name, result] of [ ["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])], ["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])], ]) { check( `pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`, result.p95 <= 20 && result.over20 <= 0.05, `p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`, ); } // 4. Continuity warning. for (const [name, expectWarning] of [ ["matched", false], ["mismatched", true], ]) { const t = await open("?view=plain"); await t.page.evaluate((n) => window.begin(n), name); await t.page.waitForTimeout(100); const warned = (await log(t.page)).some((l) => l.includes("moved node")); check( `continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`, warned === expectWarning, (await log(t.page)).join(" | "), ); await t.page.close(); } await browser.close(); server.close(); const failed = results.filter((r) => !r.ok).length; console.log(`\n${results.length - failed}/${results.length} passed`); process.exit(failed ? 1 : 0);