retake-demos 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/record.js CHANGED
@@ -11,7 +11,7 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import { createHash } from "node:crypto";
13
13
  import path from "node:path";
14
- import { execFileSync, execSync } from "node:child_process";
14
+ import { execFileSync, spawn } from "node:child_process";
15
15
  import { chromium } from "playwright";
16
16
  import { recordPage, moveCursorToPoint } from "testreel";
17
17
  import { expandEnv, resolve } from "./manifest.js";
@@ -33,6 +33,83 @@ export function captureHash(m) {
33
33
  h.update(JSON.stringify({ url: m.url, viewport: q.viewport, scale: q.scale, cursor: q.cursor, wait: m.waitForSelector, reduced: m.reducedMotion, auth: m.auth, seed: m.seed, stub: m.stub, setup: m.setup, steps, speed: m.speed, camera: m.camera, colorScheme: m.colorScheme }));
34
34
  return h.digest("hex").slice(0, 12);
35
35
  }
36
+ /** The session a take should start from, and whether it is still good.
37
+ Shared so `dry` starts signed in exactly when `run` would — a dry run
38
+ against a logged-out app "proving" a manifest is a wasted take waiting
39
+ to happen. */
40
+ export function authState(m, manifestDir) {
41
+ const p = m.auth ? path.resolve(manifestDir, expandEnv(m.auth.storageState)) : null;
42
+ const fresh = !!p && fs.existsSync(p) && (Date.now() - fs.statSync(p).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
43
+ return { path: p, fresh };
44
+ }
45
+ /** Everything injected into a page before it loads.
46
+ *
47
+ * ONE function, called by both the recorder and the dry run, because the
48
+ * expensive failure in this tool is a green `dry` followed by a failed `run`
49
+ * — and every knob only one of them applied was a way for dry to prove a
50
+ * different app than run would film. A knob added here reaches both by
51
+ * construction; a knob added to one call site is the bug this replaces. */
52
+ export async function applyPageSetup(context, m, q) {
53
+ // The page lays out at `scale`× (CSS zoom): the app sees a smaller viewport
54
+ // and renders crisp into the full canvas. Coordinates testreel/Playwright
55
+ // see are already video pixels, so cursor and clicks line up.
56
+ // Scale via a <style> tag, not an inline attribute: React hydration diffs
57
+ // element attributes against the server HTML, so style.zoom on <html> made
58
+ // every React app report a hydration mismatch — with a dev badge in shot.
59
+ if (q.scale !== 1)
60
+ await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="html{zoom:${q.scale}}";document.head.appendChild(st)})`);
61
+ // One page is what gets recorded, so keep the flow in it: window.open
62
+ // navigates in place, and target=_blank is stripped as it appears. A demo
63
+ // that spawned a second tab used to simply lose its subject.
64
+ if (m.keepInTab) {
65
+ await context.addInitScript(`
66
+ (() => {
67
+ const orig = window.open;
68
+ window.open = function (url) { if (url) { location.href = String(url); return window; } return orig.apply(window, arguments); };
69
+ const strip = (root) => { for (const a of root.querySelectorAll ? root.querySelectorAll('a[target="_blank"]') : []) a.removeAttribute('target'); };
70
+ addEventListener('DOMContentLoaded', () => {
71
+ strip(document);
72
+ new MutationObserver((ms) => { for (const m of ms) for (const n of m.addedNodes) if (n.nodeType === 1) strip(n); }).observe(document.documentElement, { childList: true, subtree: true });
73
+ });
74
+ })();
75
+ `);
76
+ }
77
+ // Dev servers decorate themselves — Next's issues badge, Vite's error
78
+ // overlay, webpack's. None of that belongs in a product video, and no
79
+ // manifest should have to know about it.
80
+ await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="nextjs-portal,#__next-build-watcher,vite-error-overlay,#webpack-dev-server-client-overlay,#react-refresh-overlay{display:none!important}";document.head.appendChild(st)})`);
81
+ }
82
+ /** The manifest's own readiness gate, with the wrong-app diagnosis attached.
83
+ Both the recorder and `dry` wait on it: a dry run that starts stepping
84
+ before the app has booted reports selector failures that are really a
85
+ race, and it should refuse a wrong build for the same reason run does. */
86
+ export async function gateOnApp(page, m, outDir) {
87
+ if (m.waitForSelector) {
88
+ try {
89
+ await page.waitForSelector(m.waitForSelector, { timeout: 20_000 });
90
+ }
91
+ catch {
92
+ // Almost never a bad selector — almost always the wrong app on the
93
+ // port: a stale dev server, a different build, a login wall, a
94
+ // marketing page. Say THAT, with what is actually there.
95
+ let saw = "", title = "";
96
+ try {
97
+ title = await page.title();
98
+ saw = (await page.evaluate(() => document.body?.innerText ?? "")).replace(/\s+/g, " ").trim().slice(0, 200);
99
+ }
100
+ catch { /* page gone */ }
101
+ try {
102
+ await page.screenshot({ path: path.join(outDir, "not-the-app.png"), fullPage: true });
103
+ }
104
+ catch { /* ignore */ }
105
+ throw new Error(`the app at ${m.url} is not what this manifest expects: waited 20s for \`${m.waitForSelector}\` and it never appeared.\n` +
106
+ ` page title: ${title || "(none)"}\n` +
107
+ ` on screen: ${saw ? `“${saw}”` : "(nothing)"}\n` +
108
+ ` picture: ${path.relative(process.cwd(), path.join(outDir, "not-the-app.png"))}\n` +
109
+ ` Usually the port is serving a different build or mode — restart the app and check the URL in a browser before re-running.`);
110
+ }
111
+ }
112
+ }
36
113
  const noop = () => { };
37
114
  /** The take's time ceiling: the manifest's own maxSeconds, else ≈10s per
38
115
  step plus explicit waits, never under 240s or over an hour. Used by the
@@ -43,6 +120,24 @@ export function capSecondsFor(m) {
43
120
  const waits = m.steps.reduce((n, st) => n + (st.action === "wait" ? st.ms / 1000 : 0) + (st.pauseAfter ?? 0) / 1000, 0);
44
121
  return Math.min(3600, Math.max(240, Math.round(m.steps.length * 10 + waits)));
45
122
  }
123
+ /** What the LAST take of this demo actually cost, in seconds of wall clock.
124
+ *
125
+ * Measured, because estimating it does not work. Fitted against 31 real takes,
126
+ * step count predicts capture time with ~33% mean error and is wrong in both
127
+ * directions by 3x: an 11-step demo of a slow remote app took 221s while a
128
+ * 105-step local one took 116s. What the app does between the steps is the
129
+ * whole cost, and a manifest cannot know that. The previous take can. */
130
+ export function lastCaptureSeconds(outDir) {
131
+ try {
132
+ const t = JSON.parse(fs.readFileSync(path.join(outDir, "take.json"), "utf8"));
133
+ return typeof t.captureSec === "number" && t.captureSec > 0 ? t.captureSec : null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /** Long enough that a person should see a draft before the full take is spent. */
140
+ export const EXPENSIVE_TAKE_SECONDS = 150;
46
141
  /** Roughly how many times the cursor will move on camera. */
47
142
  /** Cursor moves per take. testreel's nested-if overlay died at ~45 (ffmpeg's
48
143
  parser stops at 98 levels); scripts/patch-testreel.mjs flattens those
@@ -187,8 +282,7 @@ export async function record(m, opts) {
187
282
  const size = { ...q.viewport };
188
283
  // A saved session (auth.storageState) means the login already happened on an
189
284
  // earlier run: load it and the setup login steps can be skipped.
190
- const statePath = m.auth ? path.resolve(opts.manifestDir, expandEnv(m.auth.storageState)) : null;
191
- const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
285
+ const { path: statePath, fresh: stateFresh } = authState(m, opts.manifestDir);
192
286
  if (statePath)
193
287
  log(stateFresh ? `auth: reusing session ${path.relative(process.cwd(), statePath)}` : `auth: no fresh session — running setup to sign in`);
194
288
  // No session and nothing that could create one: stop here rather than record
@@ -202,52 +296,33 @@ export async function record(m, opts) {
202
296
  recordVideo: { dir: rawDir, size },
203
297
  ...(stateFresh && statePath ? { storageState: statePath } : {}),
204
298
  });
205
- // The page lays out at `scale`× (CSS zoom): the app sees a smaller viewport
206
- // and renders crisp into the full canvas. Coordinates testreel/Playwright
207
- // see are already video pixels, so cursor and clicks line up.
208
- // Scale via a <style> tag, not an inline attribute: React hydration diffs
209
- // element attributes against the server HTML, so style.zoom on <html> made
210
- // every React app report a hydration mismatch — with a dev badge in shot.
211
- if (q.scale !== 1)
212
- await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="html{zoom:${q.scale}}";document.head.appendChild(st)})`);
213
- // One page is what gets recorded, so keep the flow in it: window.open
214
- // navigates in place, and target=_blank is stripped as it appears. A demo
215
- // that spawned a second tab used to simply lose its subject.
216
- if (m.keepInTab) {
217
- await context.addInitScript(`
218
- (() => {
219
- const orig = window.open;
220
- window.open = function (url) { if (url) { location.href = String(url); return window; } return orig.apply(window, arguments); };
221
- const strip = (root) => { for (const a of root.querySelectorAll ? root.querySelectorAll('a[target="_blank"]') : []) a.removeAttribute('target'); };
222
- addEventListener('DOMContentLoaded', () => {
223
- strip(document);
224
- new MutationObserver((ms) => { for (const m of ms) for (const n of m.addedNodes) if (n.nodeType === 1) strip(n); }).observe(document.documentElement, { childList: true, subtree: true });
225
- });
226
- })();
227
- `);
228
- }
229
- // Dev servers decorate themselves — Next's issues badge, Vite's error
230
- // overlay, webpack's. None of that belongs in a product video, and no
231
- // manifest should have to know about it.
232
- await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="nextjs-portal,#__next-build-watcher,vite-error-overlay,#webpack-dev-server-client-overlay,#react-refresh-overlay{display:none!important}";document.head.appendChild(st)})`);
299
+ await applyPageSetup(context, m, q);
233
300
  // Canned responses live in a map the route handler reads on every request, so
234
301
  // a `stub` step mid-demo swaps the answer without re-registering anything.
235
302
  // Keyed by method+url: a POST stub must not replace the GET stub on the same
236
303
  // path — they are different answers to different questions.
237
304
  const stubs = new Map();
238
305
  const stubbed = [];
306
+ // How many requests each stub actually answered. A stub that answered NONE
307
+ // is the quiet failure: the take passes, the screen looks plausible, and it
308
+ // is showing live data the demo was written to replace. Counting is free;
309
+ // not counting cost someone a morning of reading screenshots.
310
+ const stubHits = new Map();
239
311
  const armStub = async (d) => {
240
312
  const key = `${d.method ?? "ANY"} ${d.url}`;
241
313
  const fresh = !stubs.has(key);
242
314
  stubs.set(key, d);
243
315
  if (!stubbed.includes(key))
244
316
  stubbed.push(key);
317
+ if (!stubHits.has(key))
318
+ stubHits.set(key, 0);
245
319
  if (!fresh)
246
320
  return;
247
321
  await context.route(d.url, async (route, request) => {
248
322
  const cur = stubs.get(key);
249
323
  if (cur.method && request.method().toUpperCase() !== cur.method.toUpperCase())
250
324
  return route.fallback();
325
+ stubHits.set(key, (stubHits.get(key) ?? 0) + 1);
251
326
  const body = cur.json !== undefined
252
327
  ? JSON.stringify(cur.json)
253
328
  : cur.from
@@ -260,8 +335,11 @@ export async function record(m, opts) {
260
335
  const ctx = { outDir: opts.outDir, manifestDir: opts.manifestDir, downloads: [], stub: armStub };
261
336
  let ok = true;
262
337
  let partial;
338
+ const pageErrors = [];
263
339
  let video;
264
340
  let screenshots = [];
341
+ let layoutWidth;
342
+ let contentWidth;
265
343
  let t0 = Date.now();
266
344
  let setupEnd = t0;
267
345
  let endMs = t0;
@@ -277,19 +355,37 @@ export async function record(m, opts) {
277
355
  const page = await context.newPage();
278
356
  if (process.env.RETAKE_PAGE_CONSOLE)
279
357
  page.on("console", (msg) => log(` [page] ${msg.text().slice(0, 200)}`));
358
+ // What the app itself said went wrong, with the moment it said it. A take
359
+ // can pass every step and still END on "This page couldn\u2019t load" —
360
+ // one did, and only frame-by-frame review caught it. Timestamped so
361
+ // `check` can ask the only question that matters: was it still broken
362
+ // when the video stopped?
363
+ page.on("pageerror", (e) => pageErrors.push({ at: sec(Date.now() - t0), text: String(e.message ?? e).slice(0, 300) }));
364
+ page.on("console", (msg) => { if (msg.type() === "error")
365
+ pageErrors.push({ at: sec(Date.now() - t0), text: msg.text().slice(0, 300) }); });
280
366
  // --- setup phase (still inside the video; trimmed later by `trimBefore`) ---
281
367
  // Document first; idle only if it comes quickly. Busy public sites never
282
368
  // go network-idle, and the manifest's waitForSelector is the real gate.
283
369
  await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
284
370
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
371
+ // Two different widths, and the difference is the whole trap.
372
+ //
373
+ // Measured, on a real navigation: under `html{zoom:2}` a width:100%
374
+ // element gets 960 CSS px of LAYOUT SPACE while media queries still
375
+ // report 1920. So a responsive app does not switch to its mobile layout —
376
+ // it renders its DESKTOP layout squeezed into half the room, which is
377
+ // why the result looks wrong in a way no breakpoint explains.
378
+ //
379
+ // Both numbers go in the proof log, because either one alone misleads.
380
+ layoutWidth = await page.evaluate(() => document.documentElement.clientWidth).catch(() => undefined);
381
+ contentWidth = Math.round((q.viewport.width) / q.scale);
285
382
  if (!opts.skipSeed) {
286
383
  for (const s of m.seed) {
287
384
  if (s.kind === "evaluate")
288
385
  await runEvaluateSeed(page, s, opts.manifestDir, log);
289
386
  }
290
387
  }
291
- if (m.waitForSelector)
292
- await page.waitForSelector(m.waitForSelector, { timeout: 20_000 });
388
+ await gateOnApp(page, m, opts.outDir);
293
389
  // Playwright's video begins at the first *painted* frame, not at newPage().
294
390
  // On a dev server that can be seconds later; if testreel's cursor overlay is
295
391
  // told the wrong start it draws every cursor move that many seconds late.
@@ -354,7 +450,24 @@ export async function record(m, opts) {
354
450
  if (q.cursor !== false && moves > CURSOR_MOVE_LIMIT)
355
451
  log(`cursor: ~${moves} cursor moves — past ~${CURSOR_MOVE_LIMIT} the cursor filter no longer fits in one ffmpeg argument and the overlay may be MISSING. Split the demo (a viewer wants chapters anyway), or set cursor: false to be honest about it.`);
356
452
  let pastUntil = false;
453
+ // --from <scene>: everything before it still runs (the app must reach that
454
+ // state) but at full speed, and `trimBefore` moves to the moment it lands,
455
+ // so none of it is in the finished video. Re-recording only the tail.
456
+ let fastForward = !!opts.from && m.steps.some((st) => st.action === "scene" && st.label === opts.from);
457
+ if (opts.from && !fastForward)
458
+ throw new Error(`--from "${opts.from}": no scene with that label. Scenes here: ${m.steps.filter((st) => st.action === "scene").map((st) => st.label).join(", ") || "(none)"}`);
459
+ if (fastForward)
460
+ log(`fast-forwarding to scene "${opts.from}" — the steps before it run but are not in the video`);
357
461
  for (const [i, step] of m.steps.entries()) {
462
+ if (fastForward && step.action === "scene" && step.label === opts.from) {
463
+ fastForward = false;
464
+ setupEnd = Date.now(); // the take starts HERE
465
+ // A fragment, like --until: say so, or every check that judges a
466
+ // whole video (length, scene count) fails a take that is working
467
+ // exactly as asked.
468
+ partial = `recorded from scene "${opts.from}" (from) — a fragment for iteration, not a finished cut`;
469
+ log(`▶ recording from "${opts.from}"`);
470
+ }
358
471
  // --until <scene>: record that scene in full, stop at the next one.
359
472
  if (opts.until && step.action === "scene") {
360
473
  if (pastUntil) {
@@ -365,6 +478,7 @@ export async function record(m, opts) {
365
478
  if (step.label === opts.until)
366
479
  pastUntil = true;
367
480
  }
481
+ ctx.fast = fastForward;
368
482
  const start = Date.now();
369
483
  const entry = {
370
484
  index: i,
@@ -404,7 +518,27 @@ export async function record(m, opts) {
404
518
  entry.screen = (await page.evaluate(() => document.body?.innerText ?? "")).replace(/\s+/g, " ").trim().slice(0, 240);
405
519
  }
406
520
  catch { /* page gone */ }
521
+ // A picture of the moment it broke. Every diagnosis anyone has made
522
+ // of a failed take came from pulling frames out of the video by hand;
523
+ // this is that, for free, at the exact instant.
524
+ let shot = "";
525
+ try {
526
+ shot = path.join(opts.outDir, "failed-step.png");
527
+ await page.screenshot({ path: shot, fullPage: true });
528
+ entry.screenshot = "failed-step.png";
529
+ }
530
+ catch {
531
+ shot = "";
532
+ }
533
+ try {
534
+ entry.url = page.url();
535
+ }
536
+ catch { /* page gone */ }
407
537
  log(` ✗ ${entry.error}${entry.screen ? ` — on screen: “${entry.screen.slice(0, 120)}”` : ""}`);
538
+ if (entry.url)
539
+ log(` url: ${entry.url}`);
540
+ if (shot)
541
+ log(` picture of the failure: ${path.relative(process.cwd(), shot)}`);
408
542
  entry.end = sec(Date.now() - t0);
409
543
  timeline.push(entry);
410
544
  if (m.onFail === "stop") {
@@ -515,8 +649,12 @@ export async function record(m, opts) {
515
649
  const take = {
516
650
  video, screenshots, timeline, duration, startedAt, finishedAt, ok, trimBefore, partial,
517
651
  quality: { preset: q.name, width: q.viewport.width, height: q.viewport.height, scale: q.scale, fps: q.fps },
652
+ layoutWidth,
653
+ contentWidth,
654
+ pageErrors,
518
655
  downloads: ctx.downloads,
519
656
  stubbed,
657
+ stubHits: Object.fromEntries(stubHits),
520
658
  captureHash: captureHash(m),
521
659
  captureSec: Math.round((Date.parse(finishedAt) - Date.parse(startedAt)) / 100) / 10,
522
660
  };
@@ -562,6 +700,147 @@ async function resolveCamera(page, m, q, step, index) {
562
700
  return undefined;
563
701
  }
564
702
  }
703
+ /** A previous take is not rubbish until a new one exists to replace it — and
704
+ * even then it might have been the better one.
705
+ *
706
+ * Two separate problems, one mechanism.
707
+ *
708
+ * 1. `run` used to delete the output folder and THEN start the browser, so any
709
+ * failure in between — a crash, a Ctrl-C, a closed pipe, a dev server
710
+ * serving the wrong app — left the person with neither the new take nor the
711
+ * old one. Losing a good recording to a FAILED attempt is the most
712
+ * expensive thing this tool can do to someone.
713
+ * 2. Even a successful re-record threw the previous take away, and you cannot
714
+ * tell whether the new one is better until you have watched it.
715
+ *
716
+ * So old artifacts move aside before recording and, on success, become
717
+ * history rather than rubbish.
718
+ *
719
+ * History is deliberately cheap. Measured across 37 real demos: 506 MB total,
720
+ * of which 345 MB is DERIVED — master.mp4, demo.mp4, stills, thumbnails all
721
+ * re-render from take.json plus the raw recording in seconds. So a kept
722
+ * version stores only those two. The largest demo here costs 41 MB a version
723
+ * instead of 139 MB, and stays fully recoverable.
724
+ */
725
+ const STASH = ".previous";
726
+ const HISTORY = ".history";
727
+ /** How many previous takes of a demo to keep, and the ceiling on their total
728
+ size. Small on purpose: this runs on machines whose disks fill up. */
729
+ export const KEEP_TAKES = 2;
730
+ export const HISTORY_BUDGET_MB = 400;
731
+ /** Put back a stash left by a run that died. Call before anything else. */
732
+ export function restorePrevious(outDir) {
733
+ const stash = path.join(outDir, STASH);
734
+ if (!fs.existsSync(stash))
735
+ return false;
736
+ for (const f of fs.readdirSync(stash)) {
737
+ const to = path.join(outDir, f);
738
+ fs.rmSync(to, { recursive: true, force: true });
739
+ fs.renameSync(path.join(stash, f), to);
740
+ }
741
+ fs.rmSync(stash, { recursive: true, force: true });
742
+ return true;
743
+ }
744
+ /** Move the current artifacts aside, keeping the lock and the chosen poster. */
745
+ export function stashPrevious(outDir) {
746
+ fs.mkdirSync(outDir, { recursive: true });
747
+ restorePrevious(outDir); // a stash from an earlier crash comes back first
748
+ const keep = new Set([".retake-lock", STASH, HISTORY, ".poster"]);
749
+ const files = fs.readdirSync(outDir).filter((f) => !keep.has(f));
750
+ if (!files.length)
751
+ return;
752
+ const stash = path.join(outDir, STASH);
753
+ fs.mkdirSync(stash, { recursive: true });
754
+ for (const f of files)
755
+ fs.renameSync(path.join(outDir, f), path.join(stash, f));
756
+ }
757
+ /** The kept versions of this demo, newest first. */
758
+ export function keptTakes(outDir) {
759
+ const root = path.join(outDir, HISTORY);
760
+ if (!fs.existsSync(root))
761
+ return [];
762
+ const out = [];
763
+ for (const id of fs.readdirSync(root).sort().reverse()) {
764
+ const dir = path.join(root, id);
765
+ try {
766
+ const t = JSON.parse(fs.readFileSync(path.join(dir, "take.json"), "utf8"));
767
+ const vid = fs.readdirSync(dir).find((f) => f.endsWith(".webm"));
768
+ let bytes = 0;
769
+ for (const f of fs.readdirSync(dir))
770
+ bytes += fs.statSync(path.join(dir, f)).size;
771
+ out.push({
772
+ id, at: t.finishedAt ?? id, seconds: Math.round(t.duration - t.trimBefore),
773
+ steps: t.timeline?.length ?? 0, ok: !!t.ok, mb: Math.round(bytes / 1e5) / 10,
774
+ video: vid ? path.join(dir, vid) : null,
775
+ });
776
+ }
777
+ catch { /* an unreadable version is not worth keeping */ }
778
+ }
779
+ return out;
780
+ }
781
+ /** The new take is real. Keep the old one as a version — recording only, since
782
+ everything else re-renders — and prune to the budget. */
783
+ export function keepPrevious(outDir, log) {
784
+ const stash = path.join(outDir, STASH);
785
+ if (!fs.existsSync(stash))
786
+ return;
787
+ try {
788
+ const takeFile = path.join(stash, "take.json");
789
+ const raw = fs.readdirSync(stash).find((f) => f.endsWith(".webm"));
790
+ const prev = fs.existsSync(takeFile) ? JSON.parse(fs.readFileSync(takeFile, "utf8")) : null;
791
+ // A failed take is not worth a version: nobody wants to go back to it.
792
+ if (prev?.ok && raw) {
793
+ const id = (prev.finishedAt ?? new Date().toISOString()).replace(/[:.]/g, "-").slice(0, 19);
794
+ const dir = path.join(outDir, HISTORY, id);
795
+ fs.mkdirSync(dir, { recursive: true });
796
+ fs.renameSync(takeFile, path.join(dir, "take.json"));
797
+ fs.renameSync(path.join(stash, raw), path.join(dir, raw));
798
+ // take.json points at the raw video by absolute path; keep it findable.
799
+ const moved = JSON.parse(fs.readFileSync(path.join(dir, "take.json"), "utf8"));
800
+ moved.video = path.join(dir, raw);
801
+ fs.writeFileSync(path.join(dir, "take.json"), JSON.stringify(moved));
802
+ log?.(`kept the previous take as a version (${id})`);
803
+ }
804
+ pruneHistory(outDir, log);
805
+ }
806
+ catch { /* history is a courtesy; never fail a good take over it */ }
807
+ fs.rmSync(stash, { recursive: true, force: true });
808
+ }
809
+ /** Keep it small: the newest KEEP_TAKES, and under the size budget. */
810
+ export function pruneHistory(outDir, log) {
811
+ let kept = keptTakes(outDir);
812
+ const drop = (k, why) => {
813
+ fs.rmSync(path.join(outDir, HISTORY, k.id), { recursive: true, force: true });
814
+ log?.(`dropped version ${k.id} (${why})`);
815
+ };
816
+ for (const k of kept.slice(KEEP_TAKES))
817
+ drop(k, `only the last ${KEEP_TAKES} are kept`);
818
+ kept = kept.slice(0, KEEP_TAKES);
819
+ let total = kept.reduce((n, k) => n + k.mb, 0);
820
+ while (kept.length && total > HISTORY_BUDGET_MB) {
821
+ const oldest = kept.pop();
822
+ drop(oldest, `history over ${HISTORY_BUDGET_MB} MB`);
823
+ total -= oldest.mb;
824
+ }
825
+ }
826
+ /** Bring a kept version back as the current take. The caller re-renders. */
827
+ export function restoreKept(outDir, id) {
828
+ const dir = path.join(outDir, HISTORY, id);
829
+ if (!fs.existsSync(path.join(dir, "take.json")))
830
+ return false;
831
+ stashPrevious(outDir);
832
+ for (const f of fs.readdirSync(dir))
833
+ fs.copyFileSync(path.join(dir, f), path.join(outDir, f));
834
+ const tp = path.join(outDir, "take.json");
835
+ const t = JSON.parse(fs.readFileSync(tp, "utf8"));
836
+ const raw = fs.readdirSync(outDir).find((f) => f.endsWith(".webm"));
837
+ if (raw) {
838
+ t.video = path.join(outDir, raw);
839
+ fs.writeFileSync(tp, JSON.stringify(t));
840
+ }
841
+ fs.rmSync(path.join(outDir, STASH), { recursive: true, force: true });
842
+ return true;
843
+ }
565
844
  /** One run per output dir at a time — record AND render. Two overlapping runs
566
845
  would wipe each other's files and die with ENOENT halfway. Throws if held. */
567
846
  export function acquireLock(outDir) {
@@ -621,26 +900,29 @@ async function runStep(rec, page, step, m, ctx) {
621
900
  await moveCursorToPoint(page, pt.x, pt.y, { style: q.cursor.style, size: q.cursor.size });
622
901
  if (step.action === "click")
623
902
  await page.mouse.click(pt.x, pt.y);
624
- if (step.pauseAfter)
903
+ if (step.pauseAfter && !ctx.fast)
625
904
  await page.waitForTimeout(step.pauseAfter);
626
905
  return;
627
906
  }
628
907
  if (step.action === "drag") {
629
908
  await performDrag(page, step, q, timeout);
630
- if (step.pauseAfter)
909
+ if (step.pauseAfter && !ctx.fast)
631
910
  await page.waitForTimeout(step.pauseAfter);
632
911
  return;
633
912
  }
634
- const pause = step.pauseAfter;
913
+ const pause = ctx.fast ? undefined : step.pauseAfter;
635
914
  switch (step.action) {
636
915
  case "wait":
637
- await rec.wait(step.ms);
916
+ // An author `wait` is pacing for the viewer; while replaying there is
917
+ // none, so it shrinks. Capped rather than dropped: some apps genuinely
918
+ // need a beat, and the state has to arrive intact.
919
+ await rec.wait(ctx.fast ? Math.min(step.ms, 250) : step.ms);
638
920
  return; // wait's own duration is the pause
639
921
  case "click":
640
922
  await rec.click(step.selector, { timeout, zoom: step.zoom });
641
923
  break;
642
924
  case "type":
643
- await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? (m.typing === "brisk" ? 22 : 70), clear: step.clear, timeout });
925
+ await rec.type(step.selector, expandEnv(step.text), { delay: ctx.fast ? 0 : (step.delay ?? (m.typing === "brisk" ? 22 : 70)), clear: step.clear, timeout });
644
926
  break;
645
927
  case "fill":
646
928
  await rec.fill(step.selector, expandEnv(step.text), { timeout });
@@ -648,6 +930,21 @@ async function runStep(rec, page, step, m, ctx) {
648
930
  case "hover":
649
931
  await rec.hover(step.selector, { timeout });
650
932
  break;
933
+ case "select": {
934
+ /* Hover first so the cursor actually travels to the control — a value
935
+ that changes with nothing moving reads as a glitch rather than a
936
+ choice. Playwright takes the option by value or by visible label, and
937
+ a page may use either, so try one and fall back to the other. */
938
+ await rec.hover(step.selector, { timeout });
939
+ const target = page.locator(step.selector).first();
940
+ try {
941
+ await target.selectOption({ value: step.value }, { timeout });
942
+ }
943
+ catch {
944
+ await target.selectOption({ label: step.value }, { timeout });
945
+ }
946
+ break;
947
+ }
651
948
  case "scroll": {
652
949
  let dy = step.y;
653
950
  if (step.to === "top" || step.to === "bottom") {
@@ -711,9 +1008,54 @@ async function runStep(rec, page, step, m, ctx) {
711
1008
  ctx.downloads.push(dest);
712
1009
  break;
713
1010
  }
714
- case "waitFor":
715
- await page.waitForSelector(step.selector, { timeout: step.timeout ?? 30_000 });
1011
+ case "select": {
1012
+ // The cursor travels to it on camera (testreel has no select of its
1013
+ // own), then the option is chosen by value, falling back to its
1014
+ // visible label — a manifest author should not have to know which
1015
+ // one the page uses.
1016
+ await rec.hover(step.selector, { timeout });
1017
+ const sel = page.locator(step.selector).first();
1018
+ try {
1019
+ await sel.selectOption(step.value, { timeout });
1020
+ }
1021
+ catch {
1022
+ await sel.selectOption({ label: step.value }, { timeout });
1023
+ }
716
1024
  break;
1025
+ }
1026
+ case "waitFor": {
1027
+ const t = step.timeout ?? 30_000;
1028
+ if (step.gone) {
1029
+ await page.waitForSelector(step.selector, { state: "hidden", timeout: t });
1030
+ break;
1031
+ }
1032
+ await page.waitForSelector(step.selector, { timeout: t });
1033
+ if (step.minChars) {
1034
+ await page.waitForFunction(([sel, n]) => (document.querySelector(sel)?.textContent ?? "").trim().length >= n, [step.selector, step.minChars], { timeout: t });
1035
+ }
1036
+ if (step.stableMs) {
1037
+ // Quiet for this long, measured in the page: the only honest signal
1038
+ // that a streamed or animating subtree has finished.
1039
+ await page.waitForFunction(([sel, quiet]) => {
1040
+ const w = window;
1041
+ const el = document.querySelector(sel);
1042
+ if (!el)
1043
+ return false;
1044
+ w.__retakeStable ??= {};
1045
+ const key = sel;
1046
+ const now = Date.now();
1047
+ const snap = el.innerHTML.length + ":" + (el.textContent ?? "").length;
1048
+ const prev = w.__retakeSnap ??= {};
1049
+ if (prev[key] !== snap) {
1050
+ prev[key] = snap;
1051
+ w.__retakeStable[key] = now;
1052
+ return false;
1053
+ }
1054
+ return now - (w.__retakeStable[key] ?? now) >= quiet;
1055
+ }, [step.selector, step.stableMs], { timeout: t, polling: 100 });
1056
+ }
1057
+ break;
1058
+ }
717
1059
  case "evaluate":
718
1060
  await page.evaluate(step.script);
719
1061
  break;
@@ -753,6 +1095,8 @@ export function describe(step) {
753
1095
  return `fill ${step.selector}${step.secret ? " ••••••" : ""}`;
754
1096
  case "hover":
755
1097
  return `hover ${step.selector ?? pointName(step.at)}`;
1098
+ case "select":
1099
+ return `select "${step.value}" in ${step.selector}`;
756
1100
  case "scroll":
757
1101
  return step.to ? `scroll to ${step.to} (${step.align})` : `scroll x=${step.x ?? 0} y=${step.y ?? 0}`;
758
1102
  case "zoom":
@@ -768,7 +1112,7 @@ export function describe(step) {
768
1112
  case "download":
769
1113
  return `download${step.selector ? ` via ${step.selector}` : ""}${step.saveAs ? ` → ${step.saveAs}` : ""}`;
770
1114
  case "waitFor":
771
- return `wait for ${step.selector}`;
1115
+ return `wait for ${step.selector}${step.gone ? " to go" : ""}${step.stableMs ? ` to settle (${step.stableMs}ms quiet)` : ""}${step.minChars ? ` to hold ${step.minChars}+ chars` : ""}`;
772
1116
  case "evaluate":
773
1117
  return `evaluate (${step.script.length} chars)`;
774
1118
  case "stub":
@@ -831,10 +1175,63 @@ export async function runFileOrCommandSeed(s, dir, log) {
831
1175
  }
832
1176
  else if (s.kind === "command") {
833
1177
  log(`seed: $ ${s.run}`);
834
- execSync(expandEnv(s.run), { stdio: "inherit", cwd: dir });
1178
+ await runSeedCommand(expandEnv(s.run), s.run, dir);
1179
+ }
1180
+ }
1181
+ /** Anything that asks to outlive the command that started it. A seed's job is
1182
+ to put data in place and exit; starting a server is `start_app`'s job, and
1183
+ that one is gated on the person's say-so. Backgrounding here would walk
1184
+ straight around that gate, so it is refused by name. */
1185
+ const BACKGROUNDING = [
1186
+ // A lone `&` is backgrounding; `&&` is just "and then". Quoted spans are
1187
+ // stripped before this runs, so a URL's query string is not mistaken for it.
1188
+ [/(?<!&)&(?!&)/, "a bare `&`"],
1189
+ [/\bnohup\b/, "nohup"],
1190
+ [/\bdisown\b/, "disown"],
1191
+ [/\b(?:screen|tmux)\s+(?:-\w+\s+)*(?:new|new-session|start)/, "screen/tmux"],
1192
+ [/\bpm2\s+(?:start|restart)\b/, "pm2"],
1193
+ [/\bforever\s+start\b/, "forever"],
1194
+ ];
1195
+ /** A seed command runs to completion or it does not run. */
1196
+ export const SEED_TIMEOUT_MS = 120_000;
1197
+ export async function runSeedCommand(cmd, shown, dir, timeoutMs = SEED_TIMEOUT_MS) {
1198
+ // Quoted arguments are data, not shell syntax: a URL's `?a=1&b=2` is not an
1199
+ // attempt to background anything.
1200
+ const bare = cmd.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
1201
+ for (const [re, what] of BACKGROUNDING) {
1202
+ if (re.test(bare)) {
1203
+ throw new Error(`Refusing to run this seed command because it uses ${what}, which leaves a process running after the seed finishes:\n ${shown}\n\n` +
1204
+ `A seed puts data in place and exits. To start the app itself, use Retake's start_app — starting processes is something the person allows explicitly, and a seed must not become the way around that.`);
1205
+ }
1206
+ }
1207
+ // `detached` puts the shell in its own process group so a timeout can kill
1208
+ // what it spawned as well. spawnSync cannot do this, which is why the seed
1209
+ // path is async: killing only the shell would orphan the very process this
1210
+ // timeout exists to stop.
1211
+ const child = spawn(cmd, [], { shell: true, stdio: "inherit", cwd: dir, detached: true });
1212
+ let timedOut = false;
1213
+ const timer = setTimeout(() => {
1214
+ timedOut = true;
1215
+ try {
1216
+ if (child.pid)
1217
+ process.kill(-child.pid, "SIGKILL");
1218
+ }
1219
+ catch {
1220
+ child.kill("SIGKILL");
1221
+ }
1222
+ }, timeoutMs);
1223
+ const status = await new Promise((done, fail) => {
1224
+ child.once("error", (e) => { clearTimeout(timer); fail(e); });
1225
+ child.once("close", (code) => { clearTimeout(timer); done(code); });
1226
+ });
1227
+ if (timedOut) {
1228
+ throw new Error(`Seed command did not finish within ${Math.round(timeoutMs / 1000)}s and was stopped:\n ${shown}\n\n` +
1229
+ `If it is meant to start the app rather than seed it, use start_app instead — a seed has to exit before the take can begin.`);
835
1230
  }
1231
+ if (status !== 0)
1232
+ throw new Error(`Seed command failed (exit ${status}):\n ${shown}`);
836
1233
  }
837
- async function runEvaluateSeed(page, s, dir, log) {
1234
+ export async function runEvaluateSeed(page, s, dir, log) {
838
1235
  const data = s.from ? JSON.parse(fs.readFileSync(path.resolve(dir, s.from), "utf8")) : undefined;
839
1236
  log(`seed: evaluate${s.from ? ` (${s.from})` : ""}`);
840
1237
  await page.evaluate(async ({ script, data }) => {