retake-demos 0.3.0 → 0.4.1

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,
@@ -390,6 +504,18 @@ export async function record(m, opts) {
390
504
  entry.camera = await resolveCamera(page, m, q, step, i);
391
505
  }
392
506
  log(`[${String(i).padStart(2, "0")}] ${entry.summary}${entry.camera ? ` · camera ${entry.camera.zoom}× on ${entry.camera.focus}` : ""}`);
507
+ opts.onProgress?.({ phase: "recording", step: i + 1, total: m.steps.length, label: entry.summary });
508
+ // A resume point on disk, so a take that dies is not a take lost. It
509
+ // survives a kill because it is written as it goes, not at the end —
510
+ // and `--from <that scene>` re-records only what came after it.
511
+ if (step.action === "scene" && step.label) {
512
+ try {
513
+ fs.writeFileSync(path.join(opts.outDir, "reached.json"), JSON.stringify({
514
+ demo: m.name, scene: step.label, step: i + 1, of: m.steps.length, at: new Date().toISOString(),
515
+ }));
516
+ }
517
+ catch { /* a resume hint is a courtesy, never a reason to fail */ }
518
+ }
393
519
  try {
394
520
  await runStep(rec, page, step, m, ctx);
395
521
  }
@@ -404,7 +530,27 @@ export async function record(m, opts) {
404
530
  entry.screen = (await page.evaluate(() => document.body?.innerText ?? "")).replace(/\s+/g, " ").trim().slice(0, 240);
405
531
  }
406
532
  catch { /* page gone */ }
533
+ // A picture of the moment it broke. Every diagnosis anyone has made
534
+ // of a failed take came from pulling frames out of the video by hand;
535
+ // this is that, for free, at the exact instant.
536
+ let shot = "";
537
+ try {
538
+ shot = path.join(opts.outDir, "failed-step.png");
539
+ await page.screenshot({ path: shot, fullPage: true });
540
+ entry.screenshot = "failed-step.png";
541
+ }
542
+ catch {
543
+ shot = "";
544
+ }
545
+ try {
546
+ entry.url = page.url();
547
+ }
548
+ catch { /* page gone */ }
407
549
  log(` ✗ ${entry.error}${entry.screen ? ` — on screen: “${entry.screen.slice(0, 120)}”` : ""}`);
550
+ if (entry.url)
551
+ log(` url: ${entry.url}`);
552
+ if (shot)
553
+ log(` picture of the failure: ${path.relative(process.cwd(), shot)}`);
408
554
  entry.end = sec(Date.now() - t0);
409
555
  timeline.push(entry);
410
556
  if (m.onFail === "stop") {
@@ -497,6 +643,12 @@ export async function record(m, opts) {
497
643
  // Timeline offsets are already relative to videoStartedAt (= document commit,
498
644
  // calibrated to within ~0.2s of the first video frame on a warm route). The
499
645
  // real file length is authoritative for the end.
646
+ // The take finished, so there is nothing to resume: leaving the hint would
647
+ // send someone back into the middle of a demo they already have.
648
+ try {
649
+ fs.rmSync(path.join(opts.outDir, "reached.json"), { force: true });
650
+ }
651
+ catch { /* fine */ }
500
652
  const trimBefore = sec(setupEnd - t0);
501
653
  // The recording keeps rolling while the browser is torn down, so the raw
502
654
  // video always has a tail of nothing after the last step. The take ends
@@ -515,8 +667,12 @@ export async function record(m, opts) {
515
667
  const take = {
516
668
  video, screenshots, timeline, duration, startedAt, finishedAt, ok, trimBefore, partial,
517
669
  quality: { preset: q.name, width: q.viewport.width, height: q.viewport.height, scale: q.scale, fps: q.fps },
670
+ layoutWidth,
671
+ contentWidth,
672
+ pageErrors,
518
673
  downloads: ctx.downloads,
519
674
  stubbed,
675
+ stubHits: Object.fromEntries(stubHits),
520
676
  captureHash: captureHash(m),
521
677
  captureSec: Math.round((Date.parse(finishedAt) - Date.parse(startedAt)) / 100) / 10,
522
678
  };
@@ -562,6 +718,147 @@ async function resolveCamera(page, m, q, step, index) {
562
718
  return undefined;
563
719
  }
564
720
  }
721
+ /** A previous take is not rubbish until a new one exists to replace it — and
722
+ * even then it might have been the better one.
723
+ *
724
+ * Two separate problems, one mechanism.
725
+ *
726
+ * 1. `run` used to delete the output folder and THEN start the browser, so any
727
+ * failure in between — a crash, a Ctrl-C, a closed pipe, a dev server
728
+ * serving the wrong app — left the person with neither the new take nor the
729
+ * old one. Losing a good recording to a FAILED attempt is the most
730
+ * expensive thing this tool can do to someone.
731
+ * 2. Even a successful re-record threw the previous take away, and you cannot
732
+ * tell whether the new one is better until you have watched it.
733
+ *
734
+ * So old artifacts move aside before recording and, on success, become
735
+ * history rather than rubbish.
736
+ *
737
+ * History is deliberately cheap. Measured across 37 real demos: 506 MB total,
738
+ * of which 345 MB is DERIVED — master.mp4, demo.mp4, stills, thumbnails all
739
+ * re-render from take.json plus the raw recording in seconds. So a kept
740
+ * version stores only those two. The largest demo here costs 41 MB a version
741
+ * instead of 139 MB, and stays fully recoverable.
742
+ */
743
+ const STASH = ".previous";
744
+ const HISTORY = ".history";
745
+ /** How many previous takes of a demo to keep, and the ceiling on their total
746
+ size. Small on purpose: this runs on machines whose disks fill up. */
747
+ export const KEEP_TAKES = 2;
748
+ export const HISTORY_BUDGET_MB = 400;
749
+ /** Put back a stash left by a run that died. Call before anything else. */
750
+ export function restorePrevious(outDir) {
751
+ const stash = path.join(outDir, STASH);
752
+ if (!fs.existsSync(stash))
753
+ return false;
754
+ for (const f of fs.readdirSync(stash)) {
755
+ const to = path.join(outDir, f);
756
+ fs.rmSync(to, { recursive: true, force: true });
757
+ fs.renameSync(path.join(stash, f), to);
758
+ }
759
+ fs.rmSync(stash, { recursive: true, force: true });
760
+ return true;
761
+ }
762
+ /** Move the current artifacts aside, keeping the lock and the chosen poster. */
763
+ export function stashPrevious(outDir) {
764
+ fs.mkdirSync(outDir, { recursive: true });
765
+ restorePrevious(outDir); // a stash from an earlier crash comes back first
766
+ const keep = new Set([".retake-lock", STASH, HISTORY, ".poster"]);
767
+ const files = fs.readdirSync(outDir).filter((f) => !keep.has(f));
768
+ if (!files.length)
769
+ return;
770
+ const stash = path.join(outDir, STASH);
771
+ fs.mkdirSync(stash, { recursive: true });
772
+ for (const f of files)
773
+ fs.renameSync(path.join(outDir, f), path.join(stash, f));
774
+ }
775
+ /** The kept versions of this demo, newest first. */
776
+ export function keptTakes(outDir) {
777
+ const root = path.join(outDir, HISTORY);
778
+ if (!fs.existsSync(root))
779
+ return [];
780
+ const out = [];
781
+ for (const id of fs.readdirSync(root).sort().reverse()) {
782
+ const dir = path.join(root, id);
783
+ try {
784
+ const t = JSON.parse(fs.readFileSync(path.join(dir, "take.json"), "utf8"));
785
+ const vid = fs.readdirSync(dir).find((f) => f.endsWith(".webm"));
786
+ let bytes = 0;
787
+ for (const f of fs.readdirSync(dir))
788
+ bytes += fs.statSync(path.join(dir, f)).size;
789
+ out.push({
790
+ id, at: t.finishedAt ?? id, seconds: Math.round(t.duration - t.trimBefore),
791
+ steps: t.timeline?.length ?? 0, ok: !!t.ok, mb: Math.round(bytes / 1e5) / 10,
792
+ video: vid ? path.join(dir, vid) : null,
793
+ });
794
+ }
795
+ catch { /* an unreadable version is not worth keeping */ }
796
+ }
797
+ return out;
798
+ }
799
+ /** The new take is real. Keep the old one as a version — recording only, since
800
+ everything else re-renders — and prune to the budget. */
801
+ export function keepPrevious(outDir, log) {
802
+ const stash = path.join(outDir, STASH);
803
+ if (!fs.existsSync(stash))
804
+ return;
805
+ try {
806
+ const takeFile = path.join(stash, "take.json");
807
+ const raw = fs.readdirSync(stash).find((f) => f.endsWith(".webm"));
808
+ const prev = fs.existsSync(takeFile) ? JSON.parse(fs.readFileSync(takeFile, "utf8")) : null;
809
+ // A failed take is not worth a version: nobody wants to go back to it.
810
+ if (prev?.ok && raw) {
811
+ const id = (prev.finishedAt ?? new Date().toISOString()).replace(/[:.]/g, "-").slice(0, 19);
812
+ const dir = path.join(outDir, HISTORY, id);
813
+ fs.mkdirSync(dir, { recursive: true });
814
+ fs.renameSync(takeFile, path.join(dir, "take.json"));
815
+ fs.renameSync(path.join(stash, raw), path.join(dir, raw));
816
+ // take.json points at the raw video by absolute path; keep it findable.
817
+ const moved = JSON.parse(fs.readFileSync(path.join(dir, "take.json"), "utf8"));
818
+ moved.video = path.join(dir, raw);
819
+ fs.writeFileSync(path.join(dir, "take.json"), JSON.stringify(moved));
820
+ log?.(`kept the previous take as a version (${id})`);
821
+ }
822
+ pruneHistory(outDir, log);
823
+ }
824
+ catch { /* history is a courtesy; never fail a good take over it */ }
825
+ fs.rmSync(stash, { recursive: true, force: true });
826
+ }
827
+ /** Keep it small: the newest KEEP_TAKES, and under the size budget. */
828
+ export function pruneHistory(outDir, log) {
829
+ let kept = keptTakes(outDir);
830
+ const drop = (k, why) => {
831
+ fs.rmSync(path.join(outDir, HISTORY, k.id), { recursive: true, force: true });
832
+ log?.(`dropped version ${k.id} (${why})`);
833
+ };
834
+ for (const k of kept.slice(KEEP_TAKES))
835
+ drop(k, `only the last ${KEEP_TAKES} are kept`);
836
+ kept = kept.slice(0, KEEP_TAKES);
837
+ let total = kept.reduce((n, k) => n + k.mb, 0);
838
+ while (kept.length && total > HISTORY_BUDGET_MB) {
839
+ const oldest = kept.pop();
840
+ drop(oldest, `history over ${HISTORY_BUDGET_MB} MB`);
841
+ total -= oldest.mb;
842
+ }
843
+ }
844
+ /** Bring a kept version back as the current take. The caller re-renders. */
845
+ export function restoreKept(outDir, id) {
846
+ const dir = path.join(outDir, HISTORY, id);
847
+ if (!fs.existsSync(path.join(dir, "take.json")))
848
+ return false;
849
+ stashPrevious(outDir);
850
+ for (const f of fs.readdirSync(dir))
851
+ fs.copyFileSync(path.join(dir, f), path.join(outDir, f));
852
+ const tp = path.join(outDir, "take.json");
853
+ const t = JSON.parse(fs.readFileSync(tp, "utf8"));
854
+ const raw = fs.readdirSync(outDir).find((f) => f.endsWith(".webm"));
855
+ if (raw) {
856
+ t.video = path.join(outDir, raw);
857
+ fs.writeFileSync(tp, JSON.stringify(t));
858
+ }
859
+ fs.rmSync(path.join(outDir, STASH), { recursive: true, force: true });
860
+ return true;
861
+ }
565
862
  /** One run per output dir at a time — record AND render. Two overlapping runs
566
863
  would wipe each other's files and die with ENOENT halfway. Throws if held. */
567
864
  export function acquireLock(outDir) {
@@ -621,26 +918,29 @@ async function runStep(rec, page, step, m, ctx) {
621
918
  await moveCursorToPoint(page, pt.x, pt.y, { style: q.cursor.style, size: q.cursor.size });
622
919
  if (step.action === "click")
623
920
  await page.mouse.click(pt.x, pt.y);
624
- if (step.pauseAfter)
921
+ if (step.pauseAfter && !ctx.fast)
625
922
  await page.waitForTimeout(step.pauseAfter);
626
923
  return;
627
924
  }
628
925
  if (step.action === "drag") {
629
926
  await performDrag(page, step, q, timeout);
630
- if (step.pauseAfter)
927
+ if (step.pauseAfter && !ctx.fast)
631
928
  await page.waitForTimeout(step.pauseAfter);
632
929
  return;
633
930
  }
634
- const pause = step.pauseAfter;
931
+ const pause = ctx.fast ? undefined : step.pauseAfter;
635
932
  switch (step.action) {
636
933
  case "wait":
637
- await rec.wait(step.ms);
934
+ // An author `wait` is pacing for the viewer; while replaying there is
935
+ // none, so it shrinks. Capped rather than dropped: some apps genuinely
936
+ // need a beat, and the state has to arrive intact.
937
+ await rec.wait(ctx.fast ? Math.min(step.ms, 250) : step.ms);
638
938
  return; // wait's own duration is the pause
639
939
  case "click":
640
940
  await rec.click(step.selector, { timeout, zoom: step.zoom });
641
941
  break;
642
942
  case "type":
643
- await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? (m.typing === "brisk" ? 22 : 70), clear: step.clear, timeout });
943
+ await rec.type(step.selector, expandEnv(step.text), { delay: ctx.fast ? 0 : (step.delay ?? (m.typing === "brisk" ? 22 : 70)), clear: step.clear, timeout });
644
944
  break;
645
945
  case "fill":
646
946
  await rec.fill(step.selector, expandEnv(step.text), { timeout });
@@ -648,6 +948,21 @@ async function runStep(rec, page, step, m, ctx) {
648
948
  case "hover":
649
949
  await rec.hover(step.selector, { timeout });
650
950
  break;
951
+ case "select": {
952
+ /* Hover first so the cursor actually travels to the control — a value
953
+ that changes with nothing moving reads as a glitch rather than a
954
+ choice. Playwright takes the option by value or by visible label, and
955
+ a page may use either, so try one and fall back to the other. */
956
+ await rec.hover(step.selector, { timeout });
957
+ const target = page.locator(step.selector).first();
958
+ try {
959
+ await target.selectOption({ value: step.value }, { timeout });
960
+ }
961
+ catch {
962
+ await target.selectOption({ label: step.value }, { timeout });
963
+ }
964
+ break;
965
+ }
651
966
  case "scroll": {
652
967
  let dy = step.y;
653
968
  if (step.to === "top" || step.to === "bottom") {
@@ -711,9 +1026,54 @@ async function runStep(rec, page, step, m, ctx) {
711
1026
  ctx.downloads.push(dest);
712
1027
  break;
713
1028
  }
714
- case "waitFor":
715
- await page.waitForSelector(step.selector, { timeout: step.timeout ?? 30_000 });
1029
+ case "select": {
1030
+ // The cursor travels to it on camera (testreel has no select of its
1031
+ // own), then the option is chosen by value, falling back to its
1032
+ // visible label — a manifest author should not have to know which
1033
+ // one the page uses.
1034
+ await rec.hover(step.selector, { timeout });
1035
+ const sel = page.locator(step.selector).first();
1036
+ try {
1037
+ await sel.selectOption(step.value, { timeout });
1038
+ }
1039
+ catch {
1040
+ await sel.selectOption({ label: step.value }, { timeout });
1041
+ }
716
1042
  break;
1043
+ }
1044
+ case "waitFor": {
1045
+ const t = step.timeout ?? 30_000;
1046
+ if (step.gone) {
1047
+ await page.waitForSelector(step.selector, { state: "hidden", timeout: t });
1048
+ break;
1049
+ }
1050
+ await page.waitForSelector(step.selector, { timeout: t });
1051
+ if (step.minChars) {
1052
+ await page.waitForFunction(([sel, n]) => (document.querySelector(sel)?.textContent ?? "").trim().length >= n, [step.selector, step.minChars], { timeout: t });
1053
+ }
1054
+ if (step.stableMs) {
1055
+ // Quiet for this long, measured in the page: the only honest signal
1056
+ // that a streamed or animating subtree has finished.
1057
+ await page.waitForFunction(([sel, quiet]) => {
1058
+ const w = window;
1059
+ const el = document.querySelector(sel);
1060
+ if (!el)
1061
+ return false;
1062
+ w.__retakeStable ??= {};
1063
+ const key = sel;
1064
+ const now = Date.now();
1065
+ const snap = el.innerHTML.length + ":" + (el.textContent ?? "").length;
1066
+ const prev = w.__retakeSnap ??= {};
1067
+ if (prev[key] !== snap) {
1068
+ prev[key] = snap;
1069
+ w.__retakeStable[key] = now;
1070
+ return false;
1071
+ }
1072
+ return now - (w.__retakeStable[key] ?? now) >= quiet;
1073
+ }, [step.selector, step.stableMs], { timeout: t, polling: 100 });
1074
+ }
1075
+ break;
1076
+ }
717
1077
  case "evaluate":
718
1078
  await page.evaluate(step.script);
719
1079
  break;
@@ -753,6 +1113,8 @@ export function describe(step) {
753
1113
  return `fill ${step.selector}${step.secret ? " ••••••" : ""}`;
754
1114
  case "hover":
755
1115
  return `hover ${step.selector ?? pointName(step.at)}`;
1116
+ case "select":
1117
+ return `select "${step.value}" in ${step.selector}`;
756
1118
  case "scroll":
757
1119
  return step.to ? `scroll to ${step.to} (${step.align})` : `scroll x=${step.x ?? 0} y=${step.y ?? 0}`;
758
1120
  case "zoom":
@@ -768,7 +1130,7 @@ export function describe(step) {
768
1130
  case "download":
769
1131
  return `download${step.selector ? ` via ${step.selector}` : ""}${step.saveAs ? ` → ${step.saveAs}` : ""}`;
770
1132
  case "waitFor":
771
- return `wait for ${step.selector}`;
1133
+ 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
1134
  case "evaluate":
773
1135
  return `evaluate (${step.script.length} chars)`;
774
1136
  case "stub":
@@ -831,10 +1193,63 @@ export async function runFileOrCommandSeed(s, dir, log) {
831
1193
  }
832
1194
  else if (s.kind === "command") {
833
1195
  log(`seed: $ ${s.run}`);
834
- execSync(expandEnv(s.run), { stdio: "inherit", cwd: dir });
1196
+ await runSeedCommand(expandEnv(s.run), s.run, dir);
1197
+ }
1198
+ }
1199
+ /** Anything that asks to outlive the command that started it. A seed's job is
1200
+ to put data in place and exit; starting a server is `start_app`'s job, and
1201
+ that one is gated on the person's say-so. Backgrounding here would walk
1202
+ straight around that gate, so it is refused by name. */
1203
+ const BACKGROUNDING = [
1204
+ // A lone `&` is backgrounding; `&&` is just "and then". Quoted spans are
1205
+ // stripped before this runs, so a URL's query string is not mistaken for it.
1206
+ [/(?<!&)&(?!&)/, "a bare `&`"],
1207
+ [/\bnohup\b/, "nohup"],
1208
+ [/\bdisown\b/, "disown"],
1209
+ [/\b(?:screen|tmux)\s+(?:-\w+\s+)*(?:new|new-session|start)/, "screen/tmux"],
1210
+ [/\bpm2\s+(?:start|restart)\b/, "pm2"],
1211
+ [/\bforever\s+start\b/, "forever"],
1212
+ ];
1213
+ /** A seed command runs to completion or it does not run. */
1214
+ export const SEED_TIMEOUT_MS = 120_000;
1215
+ export async function runSeedCommand(cmd, shown, dir, timeoutMs = SEED_TIMEOUT_MS) {
1216
+ // Quoted arguments are data, not shell syntax: a URL's `?a=1&b=2` is not an
1217
+ // attempt to background anything.
1218
+ const bare = cmd.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
1219
+ for (const [re, what] of BACKGROUNDING) {
1220
+ if (re.test(bare)) {
1221
+ 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` +
1222
+ `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.`);
1223
+ }
1224
+ }
1225
+ // `detached` puts the shell in its own process group so a timeout can kill
1226
+ // what it spawned as well. spawnSync cannot do this, which is why the seed
1227
+ // path is async: killing only the shell would orphan the very process this
1228
+ // timeout exists to stop.
1229
+ const child = spawn(cmd, [], { shell: true, stdio: "inherit", cwd: dir, detached: true });
1230
+ let timedOut = false;
1231
+ const timer = setTimeout(() => {
1232
+ timedOut = true;
1233
+ try {
1234
+ if (child.pid)
1235
+ process.kill(-child.pid, "SIGKILL");
1236
+ }
1237
+ catch {
1238
+ child.kill("SIGKILL");
1239
+ }
1240
+ }, timeoutMs);
1241
+ const status = await new Promise((done, fail) => {
1242
+ child.once("error", (e) => { clearTimeout(timer); fail(e); });
1243
+ child.once("close", (code) => { clearTimeout(timer); done(code); });
1244
+ });
1245
+ if (timedOut) {
1246
+ throw new Error(`Seed command did not finish within ${Math.round(timeoutMs / 1000)}s and was stopped:\n ${shown}\n\n` +
1247
+ `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
1248
  }
1249
+ if (status !== 0)
1250
+ throw new Error(`Seed command failed (exit ${status}):\n ${shown}`);
836
1251
  }
837
- async function runEvaluateSeed(page, s, dir, log) {
1252
+ export async function runEvaluateSeed(page, s, dir, log) {
838
1253
  const data = s.from ? JSON.parse(fs.readFileSync(path.resolve(dir, s.from), "utf8")) : undefined;
839
1254
  log(`seed: evaluate${s.from ? ` (${s.from})` : ""}`);
840
1255
  await page.evaluate(async ({ script, data }) => {