test(web): deterministic motion e2e (fetch gate, condition waits) and new frame capture

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:49:49 +01:00
co-authored by Claude Opus 5.5
parent b5e00af4f7
commit 4379d77b9c
2 changed files with 172 additions and 133 deletions
+152 -131
View File
@@ -1,6 +1,10 @@
// 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.
// Navigation motion (v1.1 package M, ruling R19), checked in headless Chrome against the real server (every feature,
// the production bundle) in a subprocess. Skipped without Chrome.
//
// Deterministic by construction: no fixed sub-second sleeps. A probe in the page records animationstart/end and
// transitionstart events and counts any document.startViewTransition call; lesson fetches can be held behind a gate
// (window.__gate / window.__release()); tests wait on conditions, and finish running animations through
// document.getAnimations() where they need a settled page.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -10,9 +14,8 @@ import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrom
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: [] };
window.__m = { vt: 0, anims: [], ends: [], trans: [] };
const vt = Document.prototype.startViewTransition;
if (vt) Document.prototype.startViewTransition = function (...a) { window.__m.vt++; return vt.apply(this, a); };
const who = (e) => {
@@ -21,15 +24,17 @@ const PROBE = `
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.
document.addEventListener("animationend", (e) => window.__m.ends.push(e.animationName + "@" + who(e)), true);
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
// With window.__gate set, lesson requests wait until window.__release().
const waiting = [];
window.__release = () => { window.__gate = false; waiting.splice(0).forEach((r) => r()); return true; };
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));
if (window.__gate && u.includes("/lessons/")) return new Promise((r) => waiting.push(r)).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 = "";
@@ -41,38 +46,51 @@ async function freePort(): Promise<number> {
return p;
}
export async function until<T>(f: () => Promise<T>, what: string, ms = 8000): Promise<NonNullable<T>> {
/** Polls `f` until it returns something truthy. A stalled DevTools call counts as "not yet". */
async function until<T>(f: () => Promise<T>, what: string, ms = 10_000): Promise<NonNullable<T>> {
const end = Date.now() + ms;
for (;;) {
// A stalled DevTools call (a heavily loaded machine) counts as "not yet" instead of hanging the test.
const v = await Promise.race([f(), Bun.sleep(3000).then(() => null)]).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);
await Bun.sleep(20);
}
}
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)");
type Probe = { vt: number; anims: string[]; ends: string[]; trans: string[] };
const m = () => chrome.eval<Probe>("window.__m");
const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.ends = [], 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 path = () => chrome.eval<string>("location.pathname");
const title = () => text(".lr-main .lr-lesson h1.title");
/** Two animation frames: the page has rendered and dispatched its animation events. */
const frames = () => chrome.eval("new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
/** Jumps every finite running animation and transition to its end. */
const finishAll = () => chrome.eval(`(document.getAnimations().forEach((a) => { try { a.finish(); } catch {} }), true)`);
/** What runs now, as "name@class::pseudo" (the infinite loading sweep included). */
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)))";
const focusedH1 = () => chrome.eval<string | null>(`document.activeElement?.tagName === "H1" ? document.activeElement.textContent : null`);
const announced = () => text('[aria-live="polite"].sr');
/** 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);
/** Full page load of `p`, waits for `ready`, settles every animation and clears the probe. */
async function open(p: string, ready: string) {
await chrome.goto(`${base}${p}`, 0);
await until(() => chrome.eval<boolean>(`location.pathname === ${js(p)} && !!document.querySelector(${js(ready)})`), `${p} ${ready}`);
await frames();
await finishAll();
await frames();
await reset();
}
async function key(k: string, code: number) {
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: code });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, windowsVirtualKeyCode: code });
}
async function setReducedMotion(on: boolean) {
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: on ? "reduce" : "no-preference" }] });
}
@@ -96,7 +114,6 @@ run("navigation motion (headless Chrome)", () => {
},
});
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 });
@@ -111,143 +128,157 @@ run("navigation motion (headless Chrome)", () => {
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
test("library to course: one view transition, the course content rises in", async () => {
test("library to course: swaps at once, only the content region enters, focus and announcement follow", 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");
expect(await path()).toBe("/c/sample");
await until(() => exists(".lr-main > article h1"), "course page");
await Bun.sleep(300);
await until(async () => (await m()).anims.includes("stc-page@col"), "content entrance");
await until(async () => (await focusedH1()) === "Sample course", "focus on the h1");
await until(async () => (await announced()) === "Sample course", "announcement");
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);
expect(r.vt).toBe(0);
expect(r.anims.filter((a) => /lr-side|stc-top|lr-shell/.test(a))).toEqual([]);
}, 30_000);
test("course to lesson: the sidebar stays through the transition, which is over within 220 ms", async () => {
// A fresh page, so the lesson route's code is not loaded yet (the case where the sidebar used to drop out).
test("course to lesson: the sidebar is never dropped, not even for a frame", async () => {
await open("/c/sample", ".lr-main > article h1");
const side = await rect(".lr-side");
await click('.lr-les[href="/c/sample/m1-intro"]');
await until(() => exists(".lr-main .lr-lesson h1.title"), "lesson");
await Bun.sleep(300);
const r = await m();
expect(r.vt).toBe(1);
expect(r.anims.filter((a) => a.includes("(lr-side)") || a.includes("(stc-top)"))).toEqual([]);
expect(await rect(".lr-side")).toBe(side);
// Every view-transition animation ends by 220 ms (no 250 ms UA default left over).
await open("/c/sample", ".lr-main > article h1");
const ends = await chrome.eval<number[]>(`(() => {
document.querySelector('.lr-les[href="/c/sample/m1-code"]').click();
const ends = [], t0 = performance.now();
return new Promise((r) => { const f = () => {
for (const a of document.getAnimations()) if (a.effect.pseudoElement?.startsWith("::view-transition")) ends.push(a.effect.getComputedTiming().endTime);
if (performance.now() - t0 < 500) requestAnimationFrame(f); else r(ends);
}; f(); });
await chrome.eval(`(() => {
window.__sideGone = false;
const mo = new MutationObserver(() => { if (!document.querySelector(".lr-side")) window.__sideGone = true; });
mo.observe(document.body, { childList: true, subtree: true });
return true;
})()`);
expect(ends.length).toBeGreaterThan(0);
expect(Math.max(...ends)).toBeLessThanOrEqual(220);
await click('.lr-les[href="/c/sample/m1-intro"]');
await until(async () => (await title()) === "Introduction", "lesson");
await frames();
expect(await chrome.eval<boolean>("window.__sideGone")).toBe(false);
expect(await rect(".lr-side")).toBe(side);
expect((await m()).anims).toContain("stc-page@lr lr-lesson");
}, 30_000);
test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => {
test("lesson to lesson: instant swap with a loading line; a second Next advances again; focus, scroll and back", 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 chrome.eval("window.scrollTo(0, 900), true");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "scrolled");
await frames(); // the scroll event (which records the position) is dispatched on the next frame
await chrome.eval("window.__gate = true");
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.
// Swapped at once: the URL, the sidebar highlight, an empty busy column (never the old lesson), the top of the page.
expect(await path()).toBe("/c/sample/m1-video");
await frames();
expect(await title()).toBe(null);
expect(await exists('.lr-main > .col[aria-busy="true"]')).toBe(true);
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");
await until(() => exists(".lr-main .stc-loading"), "loading line");
// The dock is still there and acts on the lesson the URL names: a second Next goes on to the third lesson.
await click(".stc-dock .stc-pill--primary");
expect(await path()).toBe("/c/sample/m1-code");
await chrome.eval("window.__release()");
await until(async () => (await title()) === "Code along", "third lesson");
await until(async () => (await m()).anims.includes("stc-page@lr lr-lesson"), "lesson entrance");
expect(await exists(".lr-main .stc-loading")).toBe(false);
await until(async () => (await focusedH1()) === "Code along", "focus on the h1");
await until(async () => (await announced()) === "Code along", "announcement");
expect(await chrome.eval<boolean>(`getComputedStyle(document.activeElement).outlineStyle === "none"`)).toBe(true);
expect(await rect(".lr-side")).toBe(side);
expect(await rect(".stc-top")).toBe(top);
expect((await m()).vt).toBe(0);
// 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);
// Back twice: the intro returns where it was left.
await chrome.eval("history.go(-2), true");
await until(async () => (await title()) === "Introduction", "intro again");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 3000);
}, 30_000);
test("a click right after a navigation is handled", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await chrome.eval(`(() => {
document.querySelector(".stc-dock .stc-pill--primary").click();
setTimeout(() => document.querySelector('.lr-les[href="/c/sample/m1-code"]').click(), 0);
return true;
})()`);
await until(async () => (await path()) === "/c/sample/m1-code", "second navigation");
await until(async () => (await title()) === "Code along", "code lesson");
// A real pointer click within the entrance animation lands too.
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").click(), true`);
const r = JSON.parse(await rect('.lr-les[href="/c/sample/m1-intro"]')) as { x: number; y: number; width: number; height: number };
for (const type of ["mousePressed", "mouseReleased"]) await chrome.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
await until(async () => (await path()) === "/c/sample/m1-intro", "pointer click during the entrance");
}, 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);
await until(async () => (await title()) === "Code along", "code lesson");
await frames();
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");
await until(async () => (await m()).anims.includes("stc-enter@lr-lessons"), "list entrance");
}, 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.
// Chrome animates ::details-content without listing it in getAnimations(), so sample the height each frame
// until it settles at its end value.
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 h = () => parseFloat(getComputedStyle(d, "::details-content").height);
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(); });
return new Promise((r) => { const f = () => {
out.push(h());
const settled = out.length > 2 && out.at(-1) === out.at(-2) && (${open} ? out.at(-1) > 0 : out.at(-1) === 0);
if (settled || performance.now() - t0 > 3000) r(out); else requestAnimationFrame(f);
}; 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
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true);
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 }[]>(`(() => {
await open("/c/sample/m1-video", '.stage canvas[data-front][data-page="1"]');
// Sample each frame from the click until the old slide's hold-out has ended (or 5 s).
const sampled = await chrome.eval<{ minMax: number; frames: number }>(`(() => {
document.querySelector('.deckbar [aria-label="Next slide"]').click();
const out = [], t0 = performance.now();
let minMax = 1, frames = 0;
const 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);
minMax = Math.min(minMax, Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))));
frames++;
if (window.__m.ends.some((e) => e.startsWith("stc-hold-out@")) || performance.now() - t0 > 5000) r({ minMax, frames }); else requestAnimationFrame(f);
}; 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(sampled.minMax).toBe(1);
const r = await m();
expect(r.anims.some((a) => a.startsWith("stc-slide-next@"))).toBe(true);
expect(r.ends.some((a) => a.startsWith("stc-hold-out@"))).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);
await key("ArrowLeft", 37);
await until(() => exists('.stage canvas[data-front][data-page="1"]'), "previous slide");
await until(async () => (await m()).anims.some((a) => a.startsWith("stc-slide-prev@")), "mirrored motion");
// 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");
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 0); return true; })()`);
await until(() => exists('.stage canvas[data-front][data-page="3"]'), "slide 3 drawn");
expect(await text(".deckbar .count")).toBe("3 / 3");
}, 30_000);
@@ -260,86 +291,76 @@ run("navigation motion (headless Chrome)", () => {
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);
await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 6000);
await until(async () => !(await exists(".lr-toast")), "toast removed", 3000);
// 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 });
await key("Escape", 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 () => {
test("prefers-reduced-motion: nothing animates, and everything still works", async () => {
await setReducedMotion(true);
const still = async (what: string) => {
await chrome.eval(afterFrame);
await Bun.sleep(250);
await frames();
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 until(async () => (await title()) === "Introduction", "intro");
await still("course to lesson");
await chrome.eval("window.scrollTo(0, 900)");
await chrome.eval("window.__delay = 400");
// Next with the lesson held back: the loading line is there, and static.
await chrome.eval("window.__gate = true");
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 until(() => exists(".lr-main .stc-loading"), "loading line");
await still("loading line");
await chrome.eval("window.__release()");
await until(async () => (await title()) === "Watch the clip", "next lesson");
await until(async () => (await focusedH1()) === "Watch the clip", "focus");
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(); });
const out = [];
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (out.length >= 6) r(out); else requestAnimationFrame(f); }; 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 until(() => exists('.stage canvas[data-front][data-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 until(() => exists('.stage canvas[data-front][data-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 until(() => chrome.eval<boolean>(`document.querySelector(".stc-dock button[aria-pressed]").getAttribute("aria-pressed") === "true"`), "marked done");
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 key("Escape", 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);
await until(async () => !(await exists(".lr-toast")), "toast removed", 6000);
expect((await m()).anims).toEqual([]);
} finally {
await setReducedMotion(false);
+20 -2
View File
@@ -1,6 +1,6 @@
// Captures before / mid / after frames of each navigation motion from a running server, for visual review.
// Animations are slowed 10x through the DevTools Animation domain (view transitions included), so a screenshot
// taken a few hundred ms in lands mid-transition. A second pass with prefers-reduced-motion shows the same
// Animations are slowed 10x through the DevTools Animation domain, so a screenshot taken a few hundred ms in
// lands mid-entrance. Lesson fetches can be held (window.__gate) to show the loading line. A second pass with prefers-reduced-motion shows the same
// moment with no motion. Uses the sample course; sign-up creates a throwaway account on that server.
//
// bun scripts/motion-frames.ts http://localhost:3491 /tmp/frames
@@ -16,6 +16,15 @@ let dir = out;
const chrome = await Chrome.launch();
await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await chrome.send("Animation.enable");
await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: `
const waiting = [];
window.__release = () => { window.__gate = false; waiting.splice(0).forEach((r) => r()); };
const f = window.fetch;
window.fetch = function (input, init) {
const u = String((input && input.url) || input);
if (window.__gate && u.includes("/lessons/")) return new Promise((r) => waiting.push(r)).then(() => f.call(this, input, init));
return f.call(this, input, init);
};` });
const js = (s: string) => JSON.stringify(s);
async function until(cond: string, ms = 8000) {
@@ -58,6 +67,15 @@ const flows: [string, () => Promise<void>][] = [
await open("/c/sample/m1-code", ".lr-main .lr-lesson h1.title");
await frames("03-lesson-next", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [300, 700, 2500]);
}],
["03b-lesson-loading", async () => {
// The next lesson held back: the column is empty at once and the loading line shows after 150 ms.
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await chrome.eval("window.__gate = true");
await frames("03b-lesson-loading", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [80, 600]);
await chrome.eval("window.__release()");
await Bun.sleep(400);
await shot("03b-lesson-loading-3-released-400ms");
}],
["04-module-open", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await frames("04-module-open", `[...document.querySelectorAll(".lr-mod-head")][1].click()`, [500, 2500]);