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/.env.example +10 -0
- package/AGENTS.md +277 -0
- package/README.md +63 -22
- package/dist/captions.js +55 -0
- package/dist/cards.js +177 -0
- package/dist/cli.js +300 -15
- package/dist/describe.js +30 -10
- package/dist/dryrun.js +185 -2
- package/dist/env.js +76 -0
- package/dist/manifest.js +178 -10
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +111 -13
- package/dist/pace.js +99 -0
- package/dist/presets.js +7 -7
- package/dist/record.js +475 -46
- package/dist/render.js +427 -64
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +301 -43
- package/dist/ui/guide.html +2 -1
- package/dist/ui/server.js +172 -36
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +5 -3
- package/site/releases.json +45 -0
- package/skill/SKILL.md +272 -8
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -158
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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 "
|
|
690
|
-
|
|
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
|
-
|
|
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 }) => {
|