diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 1a95c1a..babe438 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -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 { return p; } -export async function until(f: () => Promise, what: string, ms = 8000): Promise> { +/** Polls `f` until it returns something truthy. A stalled DevTools call counts as "not yet". */ +async function until(f: () => Promise, what: string, ms = 10_000): Promise> { 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; 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("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(`!!document.querySelector(${js(sel)})`); const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`); const text = (sel: string) => chrome.eval(`document.querySelector(${js(sel)})?.textContent ?? null`); const rect = (sel: string) => chrome.eval(`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("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(`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(`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(`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(`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("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(`(() => { - 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("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("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("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("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("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("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(`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("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("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(`${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(`${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(`(() => { 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(`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(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1); expect(await chrome.eval(`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(`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("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(`(() => { 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(`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(`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(`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); diff --git a/scripts/motion-frames.ts b/scripts/motion-frames.ts index 25b8783..bd64629 100644 --- a/scripts/motion-frames.ts +++ b/scripts/motion-frames.ts @@ -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][] = [ 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]);