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/AGENTS.md +64 -0
- package/README.md +14 -11
- package/dist/captions.js +9 -10
- package/dist/cards.js +57 -14
- package/dist/cli.js +197 -20
- package/dist/describe.js +2 -1
- package/dist/dryrun.js +182 -2
- package/dist/manifest.js +67 -4
- package/dist/operator/tools.js +120 -24
- package/dist/presets.js +13 -13
- package/dist/record.js +458 -43
- package/dist/render.js +255 -23
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +346 -56
- package/dist/ui/server.js +76 -11
- package/package.json +2 -3
- package/site/releases.json +55 -0
- package/skill/SKILL.md +148 -2
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -260
- package/site/logos/avexjets.png +0 -0
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
715
|
-
|
|
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
|
-
|
|
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 }) => {
|