Files
STCourses/apps/web/src/motion.e2e.test.ts
T
Lucas WintherandClaude Opus 5.5 6bb7681306 test(web): reduced motion disables all motion
Also turns off the fold height transition, which the universal selector does not reach (::details-content).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:55:32 +01:00

323 lines
18 KiB
TypeScript

// Navigation motion (v1.1 package M), checked in headless Chrome against the real server (every feature, the
// production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records
// animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome";
const ROOT = resolve(import.meta.dir, "../../..");
const run = chromePath ? describe : describe.skip;
/** Injected before any page script: counts view transitions and records animation and transition starts. */
const PROBE = `
window.__m = { vt: 0, anims: [], trans: [] };
const vt = Document.prototype.startViewTransition;
if (vt) Document.prototype.startViewTransition = function (...a) { window.__m.vt++; return vt.apply(this, a); };
const who = (e) => {
const t = e.target;
const cls = t && t.getAttribute ? (t.getAttribute("class") || t.tagName.toLowerCase()) : "?";
return cls + (e.pseudoElement || "");
};
document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true);
// window.__delay (ms) holds lesson requests back, to see what the page shows while the next lesson loads.
const f = window.fetch;
window.fetch = function (input, init) {
const u = String((input && input.url) || input);
const d = window.__delay || 0;
if (d && u.includes("/lessons/")) return new Promise((r) => setTimeout(r, d)).then(() => f.call(this, input, init));
return f.call(this, input, init);
};
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
`;
let server: Bun.Subprocess, chrome: Chrome, base = "", dataDir = "";
async function freePort(): Promise<number> {
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
const p = s.port!;
await s.stop(true);
return p;
}
export async function until<T>(f: () => Promise<T>, what: string, ms = 8000): Promise<NonNullable<T>> {
const end = Date.now() + ms;
for (;;) {
const v = await f().catch(() => null);
if (v) return v as NonNullable<T>;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await Bun.sleep(25);
}
}
const js = (s: string) => JSON.stringify(s);
const m = () => chrome.eval<{ vt: number; anims: string[]; trans: string[] }>("window.__m");
const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.trans = [], true)");
const exists = (sel: string) => chrome.eval<boolean>(`!!document.querySelector(${js(sel)})`);
const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`);
const text = (sel: string) => chrome.eval<string | null>(`document.querySelector(${js(sel)})?.textContent ?? null`);
const rect = (sel: string) => chrome.eval<string>(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`);
/** The animations and transitions running now, as "name@class::pseudo" (some pseudo-elements send no events). */
const running = () => chrome.eval<string[]>(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`);
const afterFrame = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))";
/** Full page load of `path`; waits for `ready` to exist. */
async function open(path: string, ready: string) {
await chrome.goto(`${base}${path}`, 50);
await until(() => chrome.eval<boolean>(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`), `${path} ${ready}`);
// Let the first-render animations finish so they do not count for the step under test.
await Bun.sleep(400);
await reset();
}
async function setReducedMotion(on: boolean) {
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: on ? "reduce" : "no-preference" }] });
}
run("navigation motion (headless Chrome)", () => {
beforeAll(async () => {
const port = await freePort();
dataDir = mkdtempSync(join(tmpdir(), "stc-motion-"));
// The sample course, with lessons long enough to scroll.
const courses = join(dataDir, "courses");
cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true });
const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n");
for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`);
base = `http://localhost:${port}`;
server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], {
cwd: ROOT, stdout: "pipe", stderr: "pipe",
env: {
...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789",
STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: courses,
STC_PUBLIC_URL: base,
},
});
await until(async () => (await fetch(`${base}/api/health`)).ok, "server", 30_000);
// The production bundle is built on the first request.
await until(async () => (await fetch(`${base}/`)).ok, "bundle", 30_000);
chrome = await Chrome.launch();
await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: PROBE });
await setReducedMotion(false);
}, 60_000);
afterAll(async () => {
await chrome?.close();
server?.kill();
await server?.exited;
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
test("library to course: one view transition, the course content rises in", async () => {
await open("/", 'a[href="/c/sample"]');
await click('a[href="/c/sample"]');
// The URL changes at once: motion never holds navigation back.
expect(await chrome.eval<string>("location.pathname")).toBe("/c/sample");
await until(() => exists(".lr-main > article h1"), "course page");
await Bun.sleep(300);
const r = await m();
expect(r.vt).toBe(1);
expect(r.anims).toContain("stc-enter@col");
expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true);
}, 30_000);
test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const side = await rect(".lr-side"), top = await rect(".stc-top");
await chrome.eval("window.scrollTo(0, 900)");
await Bun.sleep(150);
expect(await chrome.eval<number>("scrollY")).toBe(900);
await chrome.eval("window.__delay = 500");
await reset();
await click(".stc-dock .stc-pill--primary");
expect(await chrome.eval<string>("location.pathname")).toBe("/c/sample/m1-video");
await Bun.sleep(150);
// The sidebar answers the click at once; the lesson column keeps the old lesson, where it was, until the next is in.
expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip");
expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction");
expect(await chrome.eval<number>("scrollY")).toBe(900);
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson");
await chrome.eval("window.__delay = 0");
await Bun.sleep(300);
expect(await chrome.eval<number>("scrollY")).toBe(0);
const r = await m();
expect(r.vt).toBe(1);
expect(r.anims).toContain("stc-enter@lr lr-lesson");
expect(await rect(".lr-side")).toBe(side);
expect(await rect(".stc-top")).toBe(top);
// Back: the intro returns at the position it was left.
await chrome.eval("history.back()");
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "previous lesson");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 2000);
expect(await rect(".lr-side")).toBe(side);
}, 30_000);
test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await click('.lr-les[href="/c/sample/m1-code"]');
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Code along", "code lesson");
await Bun.sleep(300);
expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]);
await reset();
const head = '[...document.querySelectorAll(".lr-mod-head")][1]';
expect(await chrome.eval<string>(`${head}.getAttribute("aria-expanded")`)).toBe("false");
await chrome.eval(`${head}.click(), true`);
// Open at once: the list is there and focusable in the same task as the click.
expect(await chrome.eval<number>(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2);
await Bun.sleep(300);
expect((await m()).anims).toContain("stc-enter@lr-lessons");
}, 30_000);
test("folds open and close by animating their height", async () => {
// Chrome animates ::details-content without listing it in getAnimations() or sending events, so sample its height.
const sample = (open: boolean) => chrome.eval<number[]>(`(() => {
const d = document.querySelector("details.fold.transcript");
d.querySelector("summary").click();
if (d.open !== ${open}) return [-1];
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 400) requestAnimationFrame(f); else r(out); }; f(); });
})()`);
await open("/c/sample/m1-video", "details.fold.transcript");
const opening = await sample(true);
const full = opening.at(-1)!;
expect(full).toBeGreaterThan(20);
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true); // passed through in-between heights
const closing = await sample(false);
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
expect(closing.at(-1)).toBe(0);
}, 30_000);
test("slides cross-fade in the direction of travel and the stage never blanks", async () => {
await open("/c/sample/m1-video", ".stage canvas[data-front]");
await until(() => chrome.eval<boolean>(`document.querySelector(".deckbar .count").textContent.trim() === "1 / 3"`), "deck loaded");
// Next slide; then sample every frame: the animations running and the most opaque canvas.
const frames = await chrome.eval<{ anims: string[]; maxOpacity: number }[]>(`(() => {
document.querySelector('.deckbar [aria-label="Next slide"]').click();
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => {
const cs = [...document.querySelectorAll(".stage canvas")];
out.push({ anims: document.getAnimations().map((a) => a.animationName).filter(Boolean), maxOpacity: Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))) });
if (performance.now() - t0 < 600) requestAnimationFrame(f); else r(out);
}; f(); });
})()`);
const names = new Set(frames.flatMap((f) => f.anims));
expect(names.has("stc-slide-next")).toBe(true);
expect(names.has("stc-hold-out")).toBe(true);
expect(frames.every((f) => f.maxOpacity === 1)).toBe(true);
expect(await text(".deckbar .count")).toBe("2 / 3");
expect(await chrome.eval<number>(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1);
expect(await chrome.eval<string>(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3");
// The arrow keys still turn slides, backwards with the mirrored motion.
await reset();
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await until(async () => (await text(".deckbar .count")) === "1 / 3", "previous slide");
await Bun.sleep(300);
expect((await m()).anims.some((a) => a.startsWith("stc-slide-prev@"))).toBe(true);
// Two quick clicks: the second supersedes the first draw and the stage ends on the last slide.
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 5); return true; })()`);
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn");
expect(await text(".deckbar .count")).toBe("3 / 3");
}, 30_000);
test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const status = await chrome.eval<number>(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`);
expect(status).toBe(200);
await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])");
await click(".stc-dock button[aria-pressed]");
await until(async () => (await m()).trans.includes("width@i"), "progress fill transition");
// The toast rises in, then fades out before it is removed.
await open("/c/sample/m2-ex-add", ".stc-seg button");
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter");
await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 5000);
await until(async () => !(await exists(".lr-toast")), "toast removed", 2000);
// The course menu drops in; Escape closes it in the same task.
await reset();
await click(".lr-switch-btn");
await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter");
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
expect(await exists(".lr-menu")).toBe(false);
}, 40_000);
test("prefers-reduced-motion: no view transition, animation or transition anywhere, and everything still works", async () => {
await setReducedMotion(true);
const still = async (what: string) => {
await chrome.eval(afterFrame);
await Bun.sleep(250);
const r = await m();
expect([what, r.vt, r.anims, r.trans, await running()]).toEqual([what, 0, [], [], []]);
await reset();
};
try {
// library to course
await open("/", 'a[href="/c/sample"]');
await click('a[href="/c/sample"]');
await until(() => exists(".lr-main > article h1"), "course page");
await still("library to course");
// course to lesson, then Next: the old lesson is still held while the next loads, then swapped plainly
await click('.lr-les[href="/c/sample/m1-intro"]');
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "intro");
await still("course to lesson");
await chrome.eval("window.scrollTo(0, 900)");
await chrome.eval("window.__delay = 400");
await click(".stc-dock .stc-pill--primary");
await Bun.sleep(120);
expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction");
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson");
await chrome.eval("window.__delay = 0");
expect(await chrome.eval<number>("scrollY")).toBe(0);
await still("lesson next");
// module open
await chrome.eval(`[...document.querySelectorAll(".lr-mod-head")][1].click(), true`);
await still("module open");
// fold: straight from closed to open
const heights = await chrome.eval<number[]>(`(() => {
const d = document.querySelector("details.fold.transcript");
d.querySelector("summary").click();
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 300) requestAnimationFrame(f); else r(out); }; f(); });
})()`);
expect(new Set(heights).size).toBe(1);
await still("fold");
// slide change: flips without animating
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]")?.dataset.page === "1"`), "deck");
await click('.deckbar [aria-label="Next slide"]');
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "2"`), "slide 2");
await still("slide");
// mark done (progress fills), menu, toast
await click(".stc-dock button[aria-pressed]");
await Bun.sleep(400);
await still("progress");
await click(".lr-switch-btn");
await until(() => exists(".lr-menu"), "menu");
await still("menu");
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await open("/c/sample/m2-ex-add", ".stc-seg button");
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
await until(() => exists(".lr-toast"), "toast");
await still("toast");
await until(async () => !(await exists(".lr-toast")), "toast removed", 4000);
expect((await m()).anims).toEqual([]);
} finally {
await setReducedMotion(false);
}
}, 60_000);
});