The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
257 lines
9.2 KiB
JavaScript
257 lines
9.2 KiB
JavaScript
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);
|