retake-demos 0.2.2 → 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,10 +282,13 @@ 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`);
288
+ // No session and nothing that could create one: stop here rather than record
289
+ // a logged-out take that passes every check.
290
+ if (statePath && !stateFresh && !(m.auth?.setup?.length))
291
+ throw new Error(`no fresh session at ${path.relative(process.cwd(), statePath)} and no auth.setup to sign in with — run \`retake signin ${path.relative(process.cwd(), path.join(opts.manifestDir, m.name + ".yaml"))}\` and log in once by hand, or add the sign-in steps under auth.setup`);
194
292
  const context = await browser.newContext({
195
293
  viewport: size,
196
294
  colorScheme: m.colorScheme,
@@ -198,52 +296,33 @@ export async function record(m, opts) {
198
296
  recordVideo: { dir: rawDir, size },
199
297
  ...(stateFresh && statePath ? { storageState: statePath } : {}),
200
298
  });
201
- // The page lays out at `scale`× (CSS zoom): the app sees a smaller viewport
202
- // and renders crisp into the full canvas. Coordinates testreel/Playwright
203
- // see are already video pixels, so cursor and clicks line up.
204
- // Scale via a <style> tag, not an inline attribute: React hydration diffs
205
- // element attributes against the server HTML, so style.zoom on <html> made
206
- // every React app report a hydration mismatch — with a dev badge in shot.
207
- if (q.scale !== 1)
208
- await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="html{zoom:${q.scale}}";document.head.appendChild(st)})`);
209
- // One page is what gets recorded, so keep the flow in it: window.open
210
- // navigates in place, and target=_blank is stripped as it appears. A demo
211
- // that spawned a second tab used to simply lose its subject.
212
- if (m.keepInTab) {
213
- await context.addInitScript(`
214
- (() => {
215
- const orig = window.open;
216
- window.open = function (url) { if (url) { location.href = String(url); return window; } return orig.apply(window, arguments); };
217
- const strip = (root) => { for (const a of root.querySelectorAll ? root.querySelectorAll('a[target="_blank"]') : []) a.removeAttribute('target'); };
218
- addEventListener('DOMContentLoaded', () => {
219
- strip(document);
220
- 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 });
221
- });
222
- })();
223
- `);
224
- }
225
- // Dev servers decorate themselves — Next's issues badge, Vite's error
226
- // overlay, webpack's. None of that belongs in a product video, and no
227
- // manifest should have to know about it.
228
- 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);
229
300
  // Canned responses live in a map the route handler reads on every request, so
230
301
  // a `stub` step mid-demo swaps the answer without re-registering anything.
231
302
  // Keyed by method+url: a POST stub must not replace the GET stub on the same
232
303
  // path — they are different answers to different questions.
233
304
  const stubs = new Map();
234
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();
235
311
  const armStub = async (d) => {
236
312
  const key = `${d.method ?? "ANY"} ${d.url}`;
237
313
  const fresh = !stubs.has(key);
238
314
  stubs.set(key, d);
239
315
  if (!stubbed.includes(key))
240
316
  stubbed.push(key);
317
+ if (!stubHits.has(key))
318
+ stubHits.set(key, 0);
241
319
  if (!fresh)
242
320
  return;
243
321
  await context.route(d.url, async (route, request) => {
244
322
  const cur = stubs.get(key);
245
323
  if (cur.method && request.method().toUpperCase() !== cur.method.toUpperCase())
246
324
  return route.fallback();
325
+ stubHits.set(key, (stubHits.get(key) ?? 0) + 1);
247
326
  const body = cur.json !== undefined
248
327
  ? JSON.stringify(cur.json)
249
328
  : cur.from
@@ -256,8 +335,11 @@ export async function record(m, opts) {
256
335
  const ctx = { outDir: opts.outDir, manifestDir: opts.manifestDir, downloads: [], stub: armStub };
257
336
  let ok = true;
258
337
  let partial;
338
+ const pageErrors = [];
259
339
  let video;
260
340
  let screenshots = [];
341
+ let layoutWidth;
342
+ let contentWidth;
261
343
  let t0 = Date.now();
262
344
  let setupEnd = t0;
263
345
  let endMs = t0;
@@ -273,19 +355,37 @@ export async function record(m, opts) {
273
355
  const page = await context.newPage();
274
356
  if (process.env.RETAKE_PAGE_CONSOLE)
275
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) }); });
276
366
  // --- setup phase (still inside the video; trimmed later by `trimBefore`) ---
277
367
  // Document first; idle only if it comes quickly. Busy public sites never
278
368
  // go network-idle, and the manifest's waitForSelector is the real gate.
279
369
  await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
280
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);
281
382
  if (!opts.skipSeed) {
282
383
  for (const s of m.seed) {
283
384
  if (s.kind === "evaluate")
284
385
  await runEvaluateSeed(page, s, opts.manifestDir, log);
285
386
  }
286
387
  }
287
- if (m.waitForSelector)
288
- await page.waitForSelector(m.waitForSelector, { timeout: 20_000 });
388
+ await gateOnApp(page, m, opts.outDir);
289
389
  // Playwright's video begins at the first *painted* frame, not at newPage().
290
390
  // On a dev server that can be seconds later; if testreel's cursor overlay is
291
391
  // told the wrong start it draws every cursor move that many seconds late.
@@ -332,6 +432,10 @@ export async function record(m, opts) {
332
432
  if (statePath && !stateFresh && authOk) {
333
433
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
334
434
  await context.storageState({ path: statePath });
435
+ try {
436
+ fs.chmodSync(statePath, 0o600);
437
+ }
438
+ catch { /* windows */ }
335
439
  log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
336
440
  }
337
441
  else if (statePath && !stateFresh) {
@@ -346,7 +450,24 @@ export async function record(m, opts) {
346
450
  if (q.cursor !== false && moves > CURSOR_MOVE_LIMIT)
347
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.`);
348
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`);
349
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
+ }
350
471
  // --until <scene>: record that scene in full, stop at the next one.
351
472
  if (opts.until && step.action === "scene") {
352
473
  if (pastUntil) {
@@ -357,6 +478,7 @@ export async function record(m, opts) {
357
478
  if (step.label === opts.until)
358
479
  pastUntil = true;
359
480
  }
481
+ ctx.fast = fastForward;
360
482
  const start = Date.now();
361
483
  const entry = {
362
484
  index: i,
@@ -366,6 +488,15 @@ export async function record(m, opts) {
366
488
  end: 0,
367
489
  ok: true,
368
490
  };
491
+ if (step.action === "callout") {
492
+ // Resolve the box now; the ring itself is drawn at render time.
493
+ const target = step.selector ?? step.at;
494
+ const c = await resolvePoint(page, target, step.timeout ?? 10_000);
495
+ const half = typeof target === "string" || !("x" in target)
496
+ ? await page.locator((typeof target === "string" ? target : target.selector)).first().evaluate((el) => { const r = el.getBoundingClientRect(); return { w: r.width, h: r.height }; }).catch(() => ({ w: 48, h: 48 }))
497
+ : { w: 48, h: 48 };
498
+ entry.callout = { box: { x: c.x - half.w / 2, y: c.y - half.h / 2, width: half.w, height: half.h }, label: step.label, ms: step.ms };
499
+ }
369
500
  if (step.action === "scene") {
370
501
  entry.label = step.label;
371
502
  entry.caption = step.caption;
@@ -387,7 +518,27 @@ export async function record(m, opts) {
387
518
  entry.screen = (await page.evaluate(() => document.body?.innerText ?? "")).replace(/\s+/g, " ").trim().slice(0, 240);
388
519
  }
389
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 */ }
390
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)}`);
391
542
  entry.end = sec(Date.now() - t0);
392
543
  timeline.push(entry);
393
544
  if (m.onFail === "stop") {
@@ -481,17 +632,29 @@ export async function record(m, opts) {
481
632
  // calibrated to within ~0.2s of the first video frame on a warm route). The
482
633
  // real file length is authoritative for the end.
483
634
  const trimBefore = sec(setupEnd - t0);
484
- const duration = video ? videoDuration(video) : sec(endMs - t0);
635
+ // The recording keeps rolling while the browser is torn down, so the raw
636
+ // video always has a tail of nothing after the last step. The take ends
637
+ // where the demo ends (plus a breath), never where the recorder stopped —
638
+ // otherwise every video ships with dead seconds at the end.
639
+ const GRACE = 0.4;
640
+ const videoSec = video ? videoDuration(video) : sec(endMs - t0);
485
641
  const last = timeline.at(-1);
486
- if (last && Math.abs(duration - last.end) > 3) {
487
- log(`note: video is ${duration.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
642
+ const duration = last && videoSec > last.end + GRACE + 0.1 ? last.end + GRACE : videoSec;
643
+ if (last && duration < videoSec - 0.05)
644
+ log(`trim: ${(videoSec - duration).toFixed(1)}s of tail after the last step dropped`);
645
+ if (last && videoSec < last.end - 0.5) {
646
+ log(`note: video is ${videoSec.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
488
647
  }
489
648
  const finishedAt = new Date().toISOString();
490
649
  const take = {
491
650
  video, screenshots, timeline, duration, startedAt, finishedAt, ok, trimBefore, partial,
492
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,
493
655
  downloads: ctx.downloads,
494
656
  stubbed,
657
+ stubHits: Object.fromEntries(stubHits),
495
658
  captureHash: captureHash(m),
496
659
  captureSec: Math.round((Date.parse(finishedAt) - Date.parse(startedAt)) / 100) / 10,
497
660
  };
@@ -537,6 +700,147 @@ async function resolveCamera(page, m, q, step, index) {
537
700
  return undefined;
538
701
  }
539
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
+ }
540
844
  /** One run per output dir at a time — record AND render. Two overlapping runs
541
845
  would wipe each other's files and die with ENOENT halfway. Throws if held. */
542
846
  export function acquireLock(outDir) {
@@ -596,26 +900,29 @@ async function runStep(rec, page, step, m, ctx) {
596
900
  await moveCursorToPoint(page, pt.x, pt.y, { style: q.cursor.style, size: q.cursor.size });
597
901
  if (step.action === "click")
598
902
  await page.mouse.click(pt.x, pt.y);
599
- if (step.pauseAfter)
903
+ if (step.pauseAfter && !ctx.fast)
600
904
  await page.waitForTimeout(step.pauseAfter);
601
905
  return;
602
906
  }
603
907
  if (step.action === "drag") {
604
908
  await performDrag(page, step, q, timeout);
605
- if (step.pauseAfter)
909
+ if (step.pauseAfter && !ctx.fast)
606
910
  await page.waitForTimeout(step.pauseAfter);
607
911
  return;
608
912
  }
609
- const pause = step.pauseAfter;
913
+ const pause = ctx.fast ? undefined : step.pauseAfter;
610
914
  switch (step.action) {
611
915
  case "wait":
612
- 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);
613
920
  return; // wait's own duration is the pause
614
921
  case "click":
615
922
  await rec.click(step.selector, { timeout, zoom: step.zoom });
616
923
  break;
617
924
  case "type":
618
- await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? 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 });
619
926
  break;
620
927
  case "fill":
621
928
  await rec.fill(step.selector, expandEnv(step.text), { timeout });
@@ -623,6 +930,21 @@ async function runStep(rec, page, step, m, ctx) {
623
930
  case "hover":
624
931
  await rec.hover(step.selector, { timeout });
625
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
+ }
626
948
  case "scroll": {
627
949
  let dy = step.y;
628
950
  if (step.to === "top" || step.to === "bottom") {
@@ -686,15 +1008,65 @@ async function runStep(rec, page, step, m, ctx) {
686
1008
  ctx.downloads.push(dest);
687
1009
  break;
688
1010
  }
689
- case "waitFor":
690
- 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
+ }
691
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
+ }
692
1059
  case "evaluate":
693
1060
  await page.evaluate(step.script);
694
1061
  break;
695
1062
  case "stub":
696
1063
  await ctx.stub({ url: step.url, method: step.method, status: step.status ?? 200, json: step.json, from: step.from, contentType: "application/json; charset=utf-8" });
697
1064
  break;
1065
+ case "callout":
1066
+ // The box was resolved in the caller; on camera this is a hold while
1067
+ // the (render-time) ring has the viewer's eye.
1068
+ await page.waitForTimeout(step.ms / m.speed);
1069
+ break;
698
1070
  case "scene":
699
1071
  // A marker only. Its timestamp is the point of the step.
700
1072
  break;
@@ -723,6 +1095,8 @@ export function describe(step) {
723
1095
  return `fill ${step.selector}${step.secret ? " ••••••" : ""}`;
724
1096
  case "hover":
725
1097
  return `hover ${step.selector ?? pointName(step.at)}`;
1098
+ case "select":
1099
+ return `select "${step.value}" in ${step.selector}`;
726
1100
  case "scroll":
727
1101
  return step.to ? `scroll to ${step.to} (${step.align})` : `scroll x=${step.x ?? 0} y=${step.y ?? 0}`;
728
1102
  case "zoom":
@@ -738,11 +1112,13 @@ export function describe(step) {
738
1112
  case "download":
739
1113
  return `download${step.selector ? ` via ${step.selector}` : ""}${step.saveAs ? ` → ${step.saveAs}` : ""}`;
740
1114
  case "waitFor":
741
- 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` : ""}`;
742
1116
  case "evaluate":
743
1117
  return `evaluate (${step.script.length} chars)`;
744
1118
  case "stub":
745
1119
  return `stub ${step.method ? step.method + " " : ""}${step.url}`;
1120
+ case "callout":
1121
+ return `callout ${step.selector ?? pointName(step.at)}${step.label ? ` “${step.label}”` : ""}`;
746
1122
  case "scene":
747
1123
  return `scene: ${step.label}`;
748
1124
  }
@@ -799,10 +1175,63 @@ export async function runFileOrCommandSeed(s, dir, log) {
799
1175
  }
800
1176
  else if (s.kind === "command") {
801
1177
  log(`seed: $ ${s.run}`);
802
- 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.`);
803
1230
  }
1231
+ if (status !== 0)
1232
+ throw new Error(`Seed command failed (exit ${status}):\n ${shown}`);
804
1233
  }
805
- async function runEvaluateSeed(page, s, dir, log) {
1234
+ export async function runEvaluateSeed(page, s, dir, log) {
806
1235
  const data = s.from ? JSON.parse(fs.readFileSync(path.resolve(dir, s.from), "utf8")) : undefined;
807
1236
  log(`seed: evaluate${s.from ? ` (${s.from})` : ""}`);
808
1237
  await page.evaluate(async ({ script, data }) => {