feat(web): view transitions on route changes; content enter animation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
635fd66d04
commit
d607938630
@@ -0,0 +1,108 @@
|
||||
// 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 { 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);
|
||||
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())`);
|
||||
|
||||
/** 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-"));
|
||||
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: join(ROOT, "fixtures/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);
|
||||
});
|
||||
Reference in New Issue
Block a user