Files
STCourses/scripts/motion-frames.ts
T

118 lines
5.8 KiB
TypeScript

// 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, 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
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Chrome } from "../packages/feature-learner/test/chrome";
const [base = "http://localhost:3491", out = "motion-frames"] = process.argv.slice(2);
const SLOW = 0.1;
mkdirSync(out, { recursive: true });
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) {
const end = Date.now() + ms;
while (!(await chrome.eval<boolean>(cond).catch(() => false))) {
if (Date.now() > end) throw new Error(`timed out: ${cond}`);
await Bun.sleep(30);
}
}
async function shot(name: string) {
const r = await chrome.send("Page.captureScreenshot", { format: "png" });
writeFileSync(join(dir, `${name}.png`), Buffer.from(r.result.data, "base64"));
}
async function open(path: string, ready: string) {
await chrome.goto(`${base}${path}`, 100);
await until(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`);
await Bun.sleep(500);
}
/** Runs `action` (page JS), then captures frames at the given real-time offsets (ms). */
async function frames(name: string, action: string, at: number[]) {
await shot(`${name}-0-before`);
const t0 = Date.now();
await chrome.eval(`(${action}, true)`);
for (const [i, t] of at.entries()) {
await Bun.sleep(Math.max(0, t - (Date.now() - t0)));
await shot(`${name}-${i + 1}-${t}ms`);
}
}
const flows: [string, () => Promise<void>][] = [
["01-library-to-course", async () => {
await open("/", 'a[href="/c/sample"]');
await frames("01-library-to-course", `document.querySelector('a[href="/c/sample"]').click()`, [300, 700, 2500]);
}],
["02-course-to-lesson", async () => {
await open("/c/sample", ".lr-main > article h1");
await frames("02-course-to-lesson", `document.querySelector('.lr-les[href="/c/sample/m1-code"]').click()`, [300, 700, 2500]);
}],
["03-lesson-next", async () => {
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]);
}],
["05-fold-open", async () => {
await open("/c/sample/m1-video", "details.fold.transcript");
await chrome.eval(`document.querySelector("details.fold.transcript").scrollIntoView({ block: "center" })`);
// ::details-content transitions are not slowed by the Animation domain; sample real time instead.
await frames("05-fold-open", `document.querySelector("details.fold.transcript > summary").click()`, [80, 140, 1000]);
}],
["06-slide-next", async () => {
await open("/c/sample/m1-video", '.stage canvas[data-page="1"]');
await chrome.eval(`document.querySelector(".deck").scrollIntoView({ block: "center" })`);
await Bun.sleep(300);
await frames("06-slide-next", `document.querySelector('.deckbar [aria-label="Next slide"]').click()`, [600, 2500]);
}],
["07-menu", async () => {
await open("/c/sample/m1-intro", ".lr-switch-btn");
await frames("07-menu", `document.querySelector(".lr-switch-btn").click()`, [500, 2000]);
}],
["08-toast", async () => {
await open("/c/sample/m2-ex-add", ".stc-seg button");
await frames("08-toast", `[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click()`, [500, 2000]);
}],
];
try {
await chrome.send("Animation.setPlaybackRate", { playbackRate: SLOW });
for (const [, run] of flows) await run();
// The same moments with reduced motion: the end state at once.
await chrome.send("Animation.setPlaybackRate", { playbackRate: 1 });
await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] });
dir = join(out, "reduced");
mkdirSync(dir, { recursive: true });
for (const [name, run] of flows) await run().catch((e) => console.error(name, e));
} finally {
await chrome.close();
}
console.log(`frames written to ${out}`);