diff --git a/.agents/skills/screenshot/SKILL.md b/.agents/skills/screenshot/SKILL.md index 37c4cfd90..37840630c 100644 --- a/.agents/skills/screenshot/SKILL.md +++ b/.agents/skills/screenshot/SKILL.md @@ -169,9 +169,11 @@ re-seed to reset. `node scripts/record.mjs [--theme dark]` drives the same seeded stack through a step file in `scripts/tutorials/` and writes -`.recordings/.mp4` (gitignored). It fails without writing a video -when an on-screen image does not load, and leaves -`.recordings/.failure.png` with the last frame. +`.recordings/[.dark].mp4` (gitignored). It fails without writing a +video when an on-screen image does not load or an `expect()`ed confirmation +never appears, and leaves `.recordings/[.dark].failure.png` with the +last frame. It refuses a `--db` that is not local, since tutorials delete +their own rows. - `create-monitor` saves only after the checker tests the URL: build and run `apps/checker` (`FLY_REGION=ams CLOUD_PROVIDER=fly CRON_SECRET=x @@ -179,8 +181,8 @@ when an on-screen image does not load, and leaves CRON_SECRET=x`. - Tutorials delete their own previous output first, so they re-run cleanly; run them on a fresh `--reseed` and in series order for consistent videos. -- `--remotion` captures at 2x without the in-page cursor and writes - `.recordings//{video.mp4,timeline.json}` instead. `scripts/videos` +- `--remotion` (light only) captures at 2x without the in-page cursor and + writes `.recordings/remotion//{video.mp4,timeline.json}` instead. `scripts/videos` turns that into the edited cut: shared intro and outro, the recording in a window under a camera that follows the clicks, `step()` captions, and idle stretches sped up. Per-tutorial copy lives in diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index f691c6482..d67c9868c 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -81,7 +81,7 @@ To log in: - **Port 8080 already in use** — a leftover libSQL server from step 2. Kill that terminal and re-run `pnpm -w dev:dashboard`. - **Magic link doesn't arrive** — it isn't emailed in dev. Look in the dashboard terminal stdout for `>>> Magic Link: ...`. - **Reset the dev DB** — stop the libSQL server, delete `openstatus-dev.db` at the repo root, restart libSQL, re-run `pnpm -w dx`. -- **No Turso CLI or Deno on the machine** (a cloud sandbox) — `node scripts/dev-stack.mjs up` does steps 2 to 5 with sqld and deno fetched from npm; `down` stops them. Its database is `.screenshots/stack/openstatus-dev.db`: `up --reseed` resets the fixtures, or delete the directory while the stack is down. +- **No Turso CLI on the machine** (a cloud sandbox) — `node scripts/dev-stack.mjs up` does steps 2 to 5 with sqld fetched from npm; `down` stops them. Its database is `.screenshots/stack/openstatus-dev.db`: `up --reseed` resets the fixtures, or delete the directory while the stack is down. ## Related services (optional for dashboard development) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57da87184..de4f38591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1030,7 +1030,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) apps/screenshot-service: dependencies: @@ -1296,7 +1296,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) apps/status-page: dependencies: @@ -1543,7 +1543,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) apps/web: dependencies: @@ -1832,7 +1832,7 @@ importers: version: 11.0.5 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)) apps/workflows: dependencies: @@ -1995,7 +1995,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(tsx@4.23.15)) packages/ai: dependencies: @@ -2023,7 +2023,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/analytics: dependencies: @@ -3111,7 +3111,7 @@ importers: version: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) devDependencies: '@openstatus/tsconfig': specifier: workspace:* @@ -3418,7 +3418,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/utils: dependencies: @@ -3463,6 +3463,9 @@ importers: specifier: 'catalog:' version: 4.0.536(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: + '@openstatus/test-utils': + specifier: workspace:* + version: link:../../packages/test-utils '@openstatus/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig @@ -3472,6 +3475,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages: @@ -18621,6 +18627,46 @@ snapshots: '@vercel/cli-exec': 1.0.1 jose: 5.10.0 + '@vitest/mocker@5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + msw: 2.15.0(@types/node@26.6.2)(typescript@7.0.2) + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + + '@vitest/mocker@5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + msw: 2.15.0(@types/node@26.6.2)(typescript@7.0.2) + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + + '@vitest/mocker@5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + msw: 2.15.0(@types/node@26.6.2)(typescript@7.0.2) + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + + '@vitest/mocker@5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + msw: 2.15.0(@types/node@26.6.2)(typescript@7.0.2) + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + '@vitest/mocker@5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -23298,6 +23344,22 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.2 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.51.2 + tsx: 4.23.15 + yaml: 2.9.1 + vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -23314,6 +23376,98 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.2 + jsdom: 30.1.1 + transitivePeerDependencies: + - msw + + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.2 + jsdom: 30.1.1 + transitivePeerDependencies: + - msw + + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(esbuild@0.28.2)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.2 + jsdom: 30.1.1 + transitivePeerDependencies: + - msw + + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)(tsx@4.23.15)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.2 + jsdom: 30.1.1 + transitivePeerDependencies: + - msw + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(jsdom@30.1.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.2)): dependencies: '@types/chai': 5.2.3 diff --git a/scripts/record.mjs b/scripts/record.mjs index d33dbc2bf..02bf24e6e 100644 --- a/scripts/record.mjs +++ b/scripts/record.mjs @@ -10,28 +10,36 @@ // Tutorials live in scripts/tutorials/.mjs. Needs the seeded stack from // `node scripts/dev-stack.mjs up`; writes .recordings/[.dark].mp4. // --remotion captures at 2x without the in-page cursor and writes -// .recordings/[.dark]/{video.mp4,timeline.json} for scripts/videos. +// .recordings/remotion//{video.mp4,timeline.json} for scripts/videos. +// Tutorials reset their own rows, so only a local database is accepted. import { spawnSync } from "node:child_process"; import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const tutorialsDir = join(repo, "scripts", "tutorials"); +const BOOLEAN_FLAGS = new Set(["remotion", "no-cursor", "list"]); const args = process.argv.slice(2); const flags = {}; const names = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { + const key = a.slice(2); const next = args[i + 1]; - if (next === undefined || next.startsWith("--")) flags[a.slice(2)] = true; - else flags[a.slice(2)] = args[++i]; + if (BOOLEAN_FLAGS.has(key) || next === undefined || next.startsWith("--")) + flags[key] = true; + else flags[key] = args[++i]; } else names.push(a); } +const fail = (message) => { + console.error(message); + process.exit(2); +}; const available = readdirSync(tutorialsDir) .filter((f) => f.endsWith(".mjs")) @@ -45,12 +53,24 @@ if (flags.list || names.length !== 1 || !available.includes(names[0])) { const name = names[0]; const base = (flags.base ?? "http://localhost:3001").replace(/\/$/, ""); const db = flags.db ?? "http://127.0.0.1:8080"; -const theme = flags.theme === "dark" ? "dark" : "light"; -const width = Number(flags.width ?? 1440); -const height = Number(flags.height ?? 900); -const speed = Number(flags.speed ?? 1); +if (!["localhost", "127.0.0.1"].includes(new URL(db).hostname)) + fail(`refusing non-local database ${db}: tutorials delete their own rows`); +const theme = flags.theme ?? "light"; +if (theme !== "light" && theme !== "dark") + fail(`--theme must be light or dark, got ${theme}`); +const positive = (key, fallback) => { + const value = Number(flags[key] ?? fallback); + if (!Number.isFinite(value) || value <= 0) + fail(`--${key} must be a positive number, got ${flags[key]}`); + return value; +}; +const width = positive("width", 1440); +const height = positive("height", 900); +const speed = positive("speed", 1); const remotion = Boolean(flags.remotion); -const scale = Number(flags.scale ?? (remotion ? 2 : 1)); +// scripts/videos only has light compositions. +if (remotion && theme === "dark") fail("--remotion records light mode only"); +const scale = positive("scale", remotion ? 2 : 1); const showCursor = !remotion && !flags["no-cursor"]; const fps = 30; const out = resolve(flags.out ?? ".recordings"); @@ -219,18 +239,32 @@ const easeInOut = (p) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2); // Real wheel steps on an ease-in-out curve: the page glides instead of // jumping, and the screencast catches every step. +// Paced by the clock, not a step count: each wheel call costs tens of ms. async function wheel(dy) { - const duration = Math.min(1.2, Math.max(0.4, 0.35 + Math.abs(dy) / 2000)); - const steps = Math.max(8, Math.round(duration / 0.05)); + const duration = + (Math.min(1.2, Math.max(0.4, 0.35 + Math.abs(dy) / 2000)) * 1000) / speed; + const started = Date.now(); let done = 0; - for (let i = 1; i <= steps; i++) { - const next = dy * easeInOut(i / steps); + while (done !== dy) { + const p = Math.min(1, (Date.now() - started) / duration); + const next = p === 1 ? dy : dy * easeInOut(p); await page.mouse.wheel(0, next - done); done = next; await page.waitForTimeout(16); } } +// Glide in a straight line and log it, so the Remotion cursor follows. +async function glide(x, y) { + const distance = Math.hypot(x - mouse.x, y - mouse.y); + const t = now(); + await page.mouse.move(x, y, { + steps: Math.max(10, Math.round(distance / 12)), + }); + log({ type: "move", t, t1: now(), from: [mouse.x, mouse.y], to: [x, y] }); + mouse = { x, y }; +} + // Bring an element to the reading line by scrolling, never by a jump cut. async function scrollTo(locator) { const margin = 96; @@ -239,6 +273,11 @@ async function scrollTo(locator) { if (!box) break; if (box.y >= margin && box.y + box.height <= height - margin) return; const dy = box.y + box.height / 2 - height * 0.45; + // The wheel scrolls whatever is under the pointer: bring it over the + // target's column first, so a sidebar or popover does not take the scroll. + const x = Math.min(width - 24, Math.max(24, box.x + box.width / 2)); + if (Math.abs(x - mouse.x) > 48) + await glide(x, Math.min(height - 120, Math.max(120, mouse.y))); const t = now(); await wheel(dy); await page.waitForTimeout(150); @@ -255,20 +294,7 @@ async function moveTo(locator) { await locator.waitFor({ state: "visible" }); await scrollTo(locator); const box = await locator.boundingBox(); - const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; - const distance = Math.hypot(target.x - mouse.x, target.y - mouse.y); - const t = now(); - await page.mouse.move(target.x, target.y, { - steps: Math.max(10, Math.round(distance / 12)), - }); - log({ - type: "move", - t, - t1: now(), - from: [mouse.x, mouse.y], - to: [target.x, target.y], - }); - mouse = target; + await glide(box.x + box.width / 2, box.y + box.height / 2); return box; } @@ -326,6 +352,10 @@ const rec = { log({ type: "type", t, t1: now(), box: rect(box), text }); await wait(300); }, + // Fail the run unless the app confirms the action, e.g. a "Saved" toast. + async expect(text) { + await page.getByText(text, { exact: true }).first().waitFor(); + }, // Caption for what follows; ignored by the plain MP4. step(label) { log({ type: "step", t: now(), label }); @@ -341,15 +371,22 @@ const rec = { }, }; -const tutorial = await import(join(tutorialsDir, `${name}.mjs`)); +const stem = `${name}${theme === "dark" ? ".dark" : ""}`; +const failureShot = join(out, `${stem}.failure.png`); +rmSync(failureShot, { force: true }); let failed = null; +let shot = false; try { + const tutorial = await import( + pathToFileURL(join(tutorialsDir, `${name}.mjs`)).href + ); await tutorial.default(rec); } catch (e) { failed = e; - await page - .screenshot({ path: join(out, `${name}.failure.png`) }) - .catch(() => {}); + shot = await page + .screenshot({ path: failureShot }) + .then(() => true) + .catch(() => false); } await cdp.send("Page.stopScreencast").catch(() => {}); const endAt = now(); @@ -359,7 +396,7 @@ await browser.close(); if (failed) { rmSync(tmp, { recursive: true, force: true }); console.error(`tutorial ${name} failed, no video written: ${failed.message}`); - console.error(`last frame: ${join(out, `${name}.failure.png`)}`); + if (shot) console.error(`last frame: ${failureShot}`); process.exit(1); } @@ -379,8 +416,7 @@ lines.push(`file '${shown.at(-1).file}'`); const list = join(tmp, "frames.txt"); writeFileSync(list, lines.join("\n")); -const stem = `${name}${theme === "dark" ? ".dark" : ""}`; -const dir = remotion ? join(out, stem) : out; +const dir = remotion ? join(out, "remotion", stem) : out; mkdirSync(dir, { recursive: true }); const file = remotion ? join(dir, "video.mp4") : join(dir, `${stem}.mp4`); const ffmpeg = spawnSync("ffmpeg", [ @@ -405,6 +441,7 @@ const ffmpeg = spawnSync("ffmpeg", [ ]); rmSync(tmp, { recursive: true, force: true }); if (ffmpeg.error || ffmpeg.status !== 0) { + rmSync(file, { force: true }); console.error( `ffmpeg failed: ${ffmpeg.error?.message ?? ffmpeg.stderr?.toString()}`, ); diff --git a/scripts/tutorials/connect-monitors.mjs b/scripts/tutorials/connect-monitors.mjs index 483b5eadb..712c73303 100644 --- a/scripts/tutorials/connect-monitors.mjs +++ b/scripts/tutorials/connect-monitors.mjs @@ -14,6 +14,7 @@ export default async function ({ click, wait, settle, + expect, }) { await sql([ { q: "delete from page_component where page_id = ?", params: [PAGE_ID] }, @@ -49,5 +50,6 @@ export default async function ({ step("Save to show them on the page"); await click(page.getByRole("button", { name: "Submit" }).first()); - await wait(2500); + await expect("Saved"); + await wait(2000); } diff --git a/scripts/tutorials/create-monitor.mjs b/scripts/tutorials/create-monitor.mjs index 1f69ccd49..b428defdf 100644 --- a/scripts/tutorials/create-monitor.mjs +++ b/scripts/tutorials/create-monitor.mjs @@ -15,8 +15,14 @@ export default async function ({ type, wait, settle, + expect, }) { - await sql([{ q: "delete from monitor where name = ?", params: [NAME] }]); + await sql([ + { + q: "delete from monitor where name = ? and workspace_id = 1", + params: [NAME], + }, + ]); await warm(["/monitors/create", "/monitors/1/edit"]); @@ -60,5 +66,6 @@ export default async function ({ await click(europe); await wait(500); await click(regions.getByRole("button", { name: "Submit" })); - await wait(2500); + await expect("Saved"); + await wait(2000); } diff --git a/scripts/tutorials/create-status-page.mjs b/scripts/tutorials/create-status-page.mjs index 4e65e1291..1d411695e 100644 --- a/scripts/tutorials/create-status-page.mjs +++ b/scripts/tutorials/create-status-page.mjs @@ -13,9 +13,15 @@ export default async function ({ type, wait, settle, + expect, }) { // Re-runs would otherwise hit the slug-uniqueness check. - await sql([{ q: "delete from page where slug = ?", params: [SLUG] }]); + await sql([ + { + q: "delete from page where slug = ? and workspace_id = 1", + params: [SLUG], + }, + ]); await warm(["/status-pages/create", "/status-pages/1/edit"]); @@ -44,5 +50,6 @@ export default async function ({ await page.waitForURL(/\/status-pages\/\d+\/edit/); await page.waitForLoadState("networkidle"); await settle(); - await wait(2500); + await expect("Saved"); + await wait(2000); } diff --git a/scripts/tutorials/post-status-report.mjs b/scripts/tutorials/post-status-report.mjs index 53494da7d..bc9dbed73 100644 --- a/scripts/tutorials/post-status-report.mjs +++ b/scripts/tutorials/post-status-report.mjs @@ -15,8 +15,9 @@ export default async function ({ type, wait, settle, + expect, }) { - const ids = `select id from status_report where title = '${TITLE}'`; + const ids = `select id from status_report where title = '${TITLE}' and page_id = ${PAGE_ID}`; await sql([ `delete from status_report_update_to_page_component where status_report_update_id in (select id from status_report_update where status_report_id in (${ids}))`, `delete from status_report_update where status_report_id in (${ids})`, @@ -72,5 +73,6 @@ export default async function ({ await click(page.getByRole("option", { name: /resolved/i })); await type(composer, "Certificate renewed, payments work again."); await click(publish); - await wait(2500); + await expect("Update published"); + await wait(2000); } diff --git a/scripts/tutorials/schedule-maintenance.mjs b/scripts/tutorials/schedule-maintenance.mjs index 03cde4962..361ed9bce 100644 --- a/scripts/tutorials/schedule-maintenance.mjs +++ b/scripts/tutorials/schedule-maintenance.mjs @@ -23,8 +23,9 @@ export default async function ({ click, type, wait, + expect, }) { - const ids = `select id from maintenance where title = '${TITLE}'`; + const ids = `select id from maintenance where title = '${TITLE}' and page_id = ${PAGE_ID}`; await sql([ `delete from maintenance_update where maintenance_id in (${ids})`, `delete from maintenance_to_page_component where maintenance_id in (${ids})`, @@ -84,5 +85,6 @@ export default async function ({ step("Schedule it"); await click(sheet.getByRole("button", { name: "Submit" })); await sheet.waitFor({ state: "hidden" }); - await wait(2500); + await expect("Saved"); + await wait(2000); } diff --git a/scripts/tutorials/set-up-alerts.mjs b/scripts/tutorials/set-up-alerts.mjs index 761746d71..8cb686af7 100644 --- a/scripts/tutorials/set-up-alerts.mjs +++ b/scripts/tutorials/set-up-alerts.mjs @@ -12,8 +12,9 @@ export default async function ({ click, type, wait, + expect, }) { - const ids = `select id from notification where name = '${NAME}'`; + const ids = `select id from notification where name = '${NAME}' and workspace_id = 1`; await sql([ `delete from notifications_to_monitors where notification_id in (${ids})`, `delete from notification where id in (${ids})`, @@ -46,5 +47,6 @@ export default async function ({ step("Save"); await click(sheet.getByRole("button", { name: "Submit" })); await sheet.waitFor({ state: "hidden" }); - await wait(2500); + await expect("Saved"); + await wait(2000); } diff --git a/scripts/videos/package.json b/scripts/videos/package.json index 466740a9c..4213c706e 100644 --- a/scripts/videos/package.json +++ b/scripts/videos/package.json @@ -4,9 +4,10 @@ "private": true, "description": "Remotion compositions over the walkthroughs recorded by scripts/record.mjs --remotion", "scripts": { - "studio": "remotion studio", + "check": "tsc --noEmit", "render": "remotion render", - "check": "tsc --noEmit" + "studio": "remotion studio", + "test": "vitest run" }, "dependencies": { "@remotion/cli": "catalog:", @@ -16,8 +17,10 @@ "remotion": "catalog:" }, "devDependencies": { + "@openstatus/test-utils": "workspace:*", "@openstatus/tsconfig": "workspace:*", "@types/react": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/scripts/videos/remotion.config.ts b/scripts/videos/remotion.config.ts index f044bdffe..eb4ed54d2 100644 --- a/scripts/videos/remotion.config.ts +++ b/scripts/videos/remotion.config.ts @@ -2,8 +2,9 @@ import path from "node:path"; import { Config } from "@remotion/cli/config"; -// scripts/record.mjs --remotion writes .recordings//{video.mp4,timeline.json}. -Config.setPublicDir("../../.recordings"); +// scripts/record.mjs --remotion writes .recordings/remotion//; only that +// folder is bundled, not the plain MP4s and renders next to it. +Config.setPublicDir("../../.recordings/remotion"); Config.setVideoImageFormat("jpeg"); Config.setJpegQuality(95); diff --git a/scripts/videos/src/pacing.test.ts b/scripts/videos/src/pacing.test.ts new file mode 100644 index 000000000..2fcdb8398 --- /dev/null +++ b/scripts/videos/src/pacing.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; + +import { buildSegments, pacedDuration, sourceTime } from "./pacing"; +import type { Timeline, TimelineEvent } from "./timeline"; + +const timeline = (duration: number, events: TimelineEvent[]): Timeline => ({ + name: "test", + theme: "light", + fps: 30, + viewport: { width: 1440, height: 900 }, + scale: 2, + duration, + events, +}); + +// Two clicks with a long idle stretch between them. +const idle = timeline(20, [ + { type: "click", t: 1, at: [0, 0], box: [0, 0, 10, 10] }, + { type: "click", t: 12, at: [0, 0], box: [0, 0, 10, 10] }, +]); + +describe("buildSegments", () => { + test("covers the recording without gaps or overlaps", () => { + const segments = buildSegments(idle); + expect(segments[0].from).toBe(0); + expect(segments.at(-1)?.to).toBe(20); + for (let i = 1; i < segments.length; i++) + expect(segments[i].from).toBeCloseTo(segments[i - 1].to); + }); + + test("speeds up long idle stretches, never past 4x", () => { + const rates = buildSegments(idle).map((s) => s.rate); + expect(Math.max(...rates)).toBe(4); + expect(rates.every((r) => r >= 1)).toBe(true); + }); + + test("keeps activity at real speed", () => { + const busy = buildSegments(idle).find((s) => s.from <= 12 && s.to >= 12.5); + expect(busy?.rate).toBe(1); + }); + + test("leaves a recording with no idle time untouched", () => { + const dense = timeline(3, [ + { type: "type", t: 0, t1: 3, box: [0, 0, 10, 10], text: "abc" }, + ]); + expect(pacedDuration(buildSegments(dense))).toBeCloseTo(3); + }); +}); + +describe("sourceTime", () => { + const segments = buildSegments(idle); + + test("starts at zero and ends at the recording's end", () => { + expect(sourceTime(segments, 0)).toBe(0); + expect(sourceTime(segments, pacedDuration(segments) + 5)).toBe(20); + }); + + test("is continuous and increasing across rate boundaries", () => { + const total = pacedDuration(segments); + let previous = sourceTime(segments, 0); + for (let t = 1 / 30; t <= total; t += 1 / 30) { + const s = sourceTime(segments, t); + expect(s).toBeGreaterThan(previous); + expect(s - previous).toBeLessThanOrEqual(4 / 30 + 1e-9); + previous = s; + } + }); + + test("maps each segment's output start to its source start", () => { + let out = 0; + for (const s of segments) { + expect(sourceTime(segments, out)).toBeCloseTo(s.from); + out += (s.to - s.from) / s.rate; + } + }); +}); diff --git a/scripts/videos/src/root.tsx b/scripts/videos/src/root.tsx index 84b9b8d82..0f126aa54 100644 --- a/scripts/videos/src/root.tsx +++ b/scripts/videos/src/root.tsx @@ -2,7 +2,7 @@ import { Composition, staticFile } from "remotion"; import type { Timeline } from "./timeline"; import { tutorials } from "./tutorials"; -import { Video, totalSeconds, type VideoProps } from "./video"; +import { Video, totalFrames, type VideoProps } from "./video"; // Record first: node scripts/record.mjs --remotion export function RemotionRoot() { @@ -31,9 +31,7 @@ export function RemotionRoot() { const timeline = (await res.json()) as Timeline; return { fps: timeline.fps, - durationInFrames: Math.ceil( - totalSeconds(timeline) * timeline.fps, - ), + durationInFrames: totalFrames(timeline, timeline.fps), props: { ...props, timeline }, }; }} diff --git a/scripts/videos/src/video.tsx b/scripts/videos/src/video.tsx index 02deadb22..6fd706764 100644 --- a/scripts/videos/src/video.tsx +++ b/scripts/videos/src/video.tsx @@ -9,7 +9,7 @@ import { import { palette } from "./brand"; import "./fonts"; -import { buildSegments, pacedDuration } from "./pacing"; +import { buildSegments, pacedDuration, type Segment } from "./pacing"; import { Scene } from "./scene"; import type { Timeline } from "./timeline"; import { Intro, Outro } from "./titles"; @@ -27,10 +27,29 @@ export type VideoProps = { timeline: Timeline | null; }; -export function totalSeconds(timeline: Timeline) { - return INTRO + pacedDuration(buildSegments(timeline)) + OUTRO - 2 * FADE; +// Section lengths in whole frames: the composition length and the sequences +// inside it come from this one place, so they cannot drift apart. +export function sections(segments: Segment[], fps: number) { + const intro = Math.round(INTRO * fps); + const fade = Math.round(FADE * fps); + const body = Math.ceil(pacedDuration(segments) * fps); + const outro = Math.round(OUTRO * fps); + const bodyStart = intro - fade; + const outroStart = bodyStart + body - fade; + return { + intro, + fade, + body, + outro, + bodyStart, + outroStart, + total: outroStart + outro, + }; } +export const totalFrames = (timeline: Timeline, fps: number) => + sections(buildSegments(timeline), fps).total; + function Fade({ frames, inAt, @@ -67,12 +86,7 @@ export function Video({ recording, tutorial, timeline }: VideoProps) { if (!timeline) return null; const theme = timeline.theme; - const intro = Math.round(INTRO * fps); - const fade = Math.round(FADE * fps); - const body = Math.ceil(pacedDuration(segments) * fps); - const outro = Math.round(OUTRO * fps); - const bodyStart = intro - fade; - const outroStart = bodyStart + body - fade; + const { intro, body, outro, bodyStart, outroStart } = sections(segments, fps); return ( diff --git a/scripts/videos/vitest.config.ts b/scripts/videos/vitest.config.ts new file mode 100644 index 000000000..26bdb37da --- /dev/null +++ b/scripts/videos/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineTestConfig } from "@openstatus/test-utils/vitest"; + +export default defineTestConfig(import.meta.dirname);