// 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 { 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(f: () => Promise, what: string, ms = 8000): Promise> { const end = Date.now() + ms; for (;;) { const v = await f().catch(() => null); if (v) return v as NonNullable; 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(`!!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 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)))"; /** 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); 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("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("scrollY")).toBe(900); await chrome.eval("window.__delay = 500"); await reset(); 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. 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"); 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("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(`${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"); }, 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(`(() => { 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(`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(`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); // 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"); 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(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "motion@example.org", 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("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(`(() => { 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(`document.querySelector(".stage canvas[data-front]")?.dataset.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 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); });