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(); } // 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);