fix: make mobile sidebar swipes track touch gestures

This commit is contained in:
2026-10-27 06:49:28 +09:00
parent 623c31615d
commit 5b12c3e085
5 changed files with 585 additions and 195 deletions
+226 -81
View File
@@ -1,30 +1,49 @@
/// <reference lib="dom" />
// Native Chromium touch input against the production shell; HTTP uses the auth fixture.
import { assertEquals } from "@std/assert";
import type { Browser } from "playwright";
import { assert, assertEquals } from "@std/assert";
import type { Browser, Page } from "playwright";
async function painted(page: Page) {
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
}
export async function sidebarSwipe(
t: Deno.TestContext,
browser: Browser,
origin: string,
) {
const kinds = [
"drag",
"flick",
"slow",
"short",
"arc",
"ambiguous",
"return",
"reverse",
"vertical",
"diagonal",
"horizontal-scroll",
"horizontal-edge",
"input",
"button",
"button-short",
"link",
"edge",
"cancel",
"multitouch",
"resize",
];
for (
const scenario of [
"right",
"vertical",
"diagonal",
"short",
"horizontal-scroll",
"input",
...kinds,
...kinds.map((kind) => `close-${kind}`),
"desktop",
"anonymous",
"close",
"close-vertical",
"close-diagonal",
"close-short",
"close-horizontal-scroll",
"close-input",
"close-opposite",
]
) {
await t.step(`native touch swipe: ${scenario}`, async () => {
@@ -32,7 +51,7 @@ export async function sidebarSwipe(
viewport: { width: scenario === "desktop" ? 1200 : 390, height: 844 },
hasTouch: true,
isMobile: scenario !== "desktop",
reducedMotion: "reduce",
reducedMotion: scenario.endsWith("drag") ? "no-preference" : "reduce",
});
try {
const page = await context.newPage();
@@ -54,107 +73,233 @@ export async function sidebarSwipe(
if (scenario === "desktop") {
await page.getByRole("button", { name: "Unpin sidebar" }).click();
}
const closing = scenario.startsWith("close");
const closing = scenario.startsWith("close-");
const kind = closing ? scenario.slice(6) : scenario;
if (closing) {
await page.getByRole("button", { name: "Show sidebar", exact: true })
.click();
await page.waitForFunction(() =>
Math.abs(
document.querySelector(".sidebar-frame")!.getBoundingClientRect()
.left,
) < 1
);
}
const surface = closing ? ".sidebar-frame-content" : "main";
// Supply deterministic scroll/control surfaces inside the actual shell.
await page.locator(surface).evaluate((main) => {
const area = document.createElement("div");
area.innerHTML =
'<div id="swipe-plain" style="height:180px">Swipe surface</div><div id="swipe-horizontal" style="overflow-x:auto;width:280px"><div style="width:900px;height:100px">Horizontal content</div></div><input id="swipe-input" type="range" style="width:280px"><div style="height:1800px"></div>';
'<div id="swipe-plain" style="height:180px">Swipe surface</div><div id="swipe-horizontal" style="overflow-x:auto;width:280px"><div style="width:900px;height:100px">Horizontal content</div></div><input id="swipe-input" type="range" style="width:280px"><button id="swipe-button" style="display:block;width:280px;height:70px">Ordinary button</button><a id="swipe-link" href="#unexpected" style="display:block;width:280px;height:70px">Ordinary link</a><div style="height:1800px"></div>';
for (const control of area.querySelectorAll("button, a")) {
control.addEventListener("click", () => {
area.dataset.clicked = "true";
});
}
area.id = "swipe-fixture";
main.prepend(area);
});
const target = kind === "horizontal-scroll"
const target = kind.startsWith("horizontal-")
? "#swipe-horizontal"
: kind === "input"
? "#swipe-input"
: kind.startsWith("button")
? "#swipe-button"
: kind === "link"
? "#swipe-link"
: "#swipe-plain";
const box = (await page.locator(target).boundingBox())!;
if (kind === "horizontal-scroll") {
await page.locator(target).evaluate((node) => {
node.scrollLeft = 200;
});
}
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
const client = await context.newCDPSession(page);
const startX = box.x + (closing ? 210 : 60);
await painted(page);
const box = (await page.locator(target).boundingBox())!;
const startX = kind === "edge"
? (closing ? 380 : 10)
: box.x + (closing ? 245 : 35);
const startY = box.y + (kind === "vertical" ? 150 : box.height / 2);
const dx = (closing && kind !== "opposite" ? -1 : 1) *
(kind === "vertical" ? 8 : kind === "short" ? 40 : 150);
const dy = kind === "vertical" ? -130 : kind === "diagonal" ? 100 : 3;
const direction = closing ? -1 : 1;
// dx is measured toward the requested state. Explicit event timestamps
// make flick/hold tests independent of CDP roundtrip speed.
let points = [[20, 1, 40], [80, 2, 160], [200, 3, 400]];
if (kind === "flick") points = [[12, 1, 15], [40, 2, 50]];
if (kind === "slow") {
points = [[20, 1, 400], [80, 2, 900], [200, 3, 1600]];
}
if (kind === "short" || kind === "button-short") {
points = [[20, 1, 200], [45, 2, 400]];
}
if (kind === "arc") {
points = [[20, 2, 40], [80, 35, 160], [200, 65, 400]];
}
if (kind === "ambiguous") {
points = [[8, 7, 20], [25, 10, 60], [200, 15, 400]];
}
if (kind === "return") {
points = [[20, 1, 40], [200, 2, 200], [5, 3, 600]];
}
if (kind === "reverse") {
// Stationary CDP points do not emit touchmove. Sample both moving
// points of the reversal so its release velocity is observable.
points = [[20, 1, 40], [250, 2, 200], [230, 2, 530], [180, 3, 560]];
}
if (kind === "vertical") {
points = [[2, -20, 40], [4, -70, 160], [8, -130, 400]];
}
if (kind === "diagonal") {
points = [[15, -15, 40], [50, -50, 160], [100, -100, 400]];
}
const client = await context.newCDPSession(page);
const base = Date.now() / 1000;
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
timestamp: base,
touchPoints: [{ x: startX, y: startY, id: 1 }],
});
for (let i = 1; i <= 8; i++) {
for (const [dx, dy, time] of points) {
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
timestamp: base + time / 1000,
touchPoints: [{
x: startX + dx * i / 8,
y: startY + dy * i / 8,
x: startX + dx * direction,
y: startY + dy,
id: 1,
}],
});
}
await client.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
if (scenario === "right") {
await page.getByRole("button", { name: "Hide sidebar", exact: true })
.waitFor();
assertEquals(await page.locator("main").getAttribute("inert"), "");
await page.getByRole("button", { name: "Hide sidebar", exact: true })
.click();
await page.getByRole("button", { name: "Show sidebar", exact: true })
.waitFor();
} else if (scenario === "close") {
await page.getByRole("button", { name: "Show sidebar", exact: true })
.waitFor();
assertEquals(await page.locator("main").getAttribute("inert"), null);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
const [dx, dy, time] = points.at(-1)!;
const claimed = ![
"vertical",
"diagonal",
"horizontal-scroll",
"horizontal-edge",
"input",
"edge",
"desktop",
"anonymous",
].includes(kind);
await painted(page);
if (claimed) {
assertEquals(
await page.locator(".sidebar-swiping").count(),
1,
`${scenario}: preview before release`,
);
const actual = await page.locator(".sidebar-frame").evaluate((
panel,
) => ({
x: panel.getBoundingClientRect().left,
width: panel.getBoundingClientRect().width,
visibility: getComputedStyle(panel).visibility,
transition: getComputedStyle(panel).transitionDuration,
}));
assert(
Math.abs(actual.x - (closing ? -dx : -actual.width + dx)) < 2,
`${scenario}: panel must track finger, got ${actual.x}`,
);
assertEquals(actual.visibility, "visible");
assertEquals(actual.transition, "0s");
assertEquals(
await page.locator("main").getAttribute("inert"),
closing ? "" : null,
);
assertEquals(
await page.locator(".sidebar-frame-content").getAttribute("inert"),
closing ? null : "",
);
} else {
assertEquals(await page.locator(".sidebar-swiping").count(), 0);
}
if (kind === "cancel") {
await client.send("Input.dispatchTouchEvent", {
type: "touchCancel",
touchPoints: [],
});
} else {
if (kind === "multitouch") {
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
timestamp: base + (time + 5) / 1000,
touchPoints: [
{ x: startX + dx * direction, y: startY + dy, id: 1 },
{ x: startX + dx * direction + 30, y: startY + dy, id: 2 },
],
});
}
if (kind === "resize") {
await page.setViewportSize({ width: 400, height: 844 });
}
await client.send("Input.dispatchTouchEvent", {
type: "touchEnd",
timestamp: base + (time + 10) / 1000,
touchPoints: [],
});
}
const commits = [
"drag",
"flick",
"slow",
"arc",
"ambiguous",
"button",
"link",
].includes(kind);
const expectedOpen = commits ? !closing : closing;
await painted(page);
if (scenario !== "desktop" && scenario !== "anonymous") {
assertEquals(
await page.locator(".app-shell.sidebar-open").count(),
closing ? 1 : 0,
expectedOpen ? 1 : 0,
`${scenario}: final state`,
);
if (kind === "vertical") {
await page.waitForFunction(
(selector) =>
(document.querySelector(selector)?.scrollTop ?? 0) > 0,
surface,
);
}
if (kind === "horizontal-scroll") {
await page.waitForFunction(
(closing) =>
closing
? document.querySelector("#swipe-horizontal")!.scrollLeft >
200
: document.querySelector("#swipe-horizontal")!.scrollLeft <
200,
closing,
);
}
if (scenario === "anonymous") {
assertEquals(await page.locator(".sidebar-frame").count(), 0);
}
assertEquals(
await page.locator("main").getAttribute("inert"),
expectedOpen ? "" : null,
);
await page.waitForFunction((open) => {
const bounds = document.querySelector(".sidebar-frame")!
.getBoundingClientRect();
return Math.abs(bounds.left - (open ? 0 : -bounds.width)) < 1;
}, expectedOpen);
}
assertEquals(await page.locator(".sidebar-swiping").count(), 0);
assertEquals(
await page.locator("#swipe-fixture").getAttribute("data-clicked"),
null,
);
assertEquals(new URL(page.url()).hash, "");
if (kind === "vertical") {
await page.waitForFunction(
(selector) =>
(document.querySelector(selector)?.scrollTop ?? 0) > 0,
surface,
);
}
if (kind === "horizontal-scroll") {
await page.waitForFunction(
(closing) =>
closing
? document.querySelector("#swipe-horizontal")!.scrollLeft > 200
: document.querySelector("#swipe-horizontal")!.scrollLeft < 200,
closing,
);
}
if (kind === "input") {
assert(
(await page.locator(target).inputValue()) !== "50",
"native range control remains operable",
);
}
if (kind === "button-short") {
// A new tap after snap-back must still activate the same button.
await page.locator(target).tap();
assertEquals(
await page.locator("#swipe-fixture").getAttribute("data-clicked"),
"true",
);
}
if (scenario === "anonymous") {
assertEquals(await page.locator(".sidebar-frame").count(), 0);
}
assertEquals(errors, []);
} finally {