retake-demos 0.2.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/logo.svg +13 -0
- package/demos/example.yaml +65 -0
- package/dist/appserver.js +316 -0
- package/dist/cli.js +467 -0
- package/dist/describe.js +377 -0
- package/dist/digest.js +197 -0
- package/dist/dryrun.js +196 -0
- package/dist/edits.js +229 -0
- package/dist/manifest.js +358 -0
- package/dist/operator/run.js +231 -0
- package/dist/operator/tools.js +412 -0
- package/dist/paths.js +39 -0
- package/dist/presets.js +75 -0
- package/dist/record.js +814 -0
- package/dist/render.js +661 -0
- package/dist/ui/chat.html +385 -0
- package/dist/ui/guide.html +85 -0
- package/dist/ui/index.html +722 -0
- package/dist/ui/server.js +1112 -0
- package/package.json +67 -0
- package/site/index.html +158 -0
- package/skill/SKILL.md +154 -0
package/dist/record.js
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a manifest against a real browser and produces the raw take:
|
|
3
|
+
* a video (via testreel's recordPage — cursor, ripples, zoom, chrome, mp4),
|
|
4
|
+
* screenshots, and a timeline of every step with wall-clock offsets.
|
|
5
|
+
*
|
|
6
|
+
* The timeline is what makes captions and the proof log exact rather than
|
|
7
|
+
* guessed: for AI-driven apps the next screen arrives whenever the model
|
|
8
|
+
* answers, so no one can script timings up front.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
15
|
+
import { chromium } from "playwright";
|
|
16
|
+
import { recordPage, moveCursorToPoint } from "testreel";
|
|
17
|
+
import { expandEnv, resolve } from "./manifest.js";
|
|
18
|
+
import { ffmpegBin, videoDuration } from "./render.js";
|
|
19
|
+
/** What the *recording* depends on — nothing that render can change afterwards.
|
|
20
|
+
A scene's caption, hold and camera zoom are render-time, so editing them must
|
|
21
|
+
not cost a new take; its label and camera focus are not, because the focus
|
|
22
|
+
box is measured live while recording. */
|
|
23
|
+
export function captureHash(m) {
|
|
24
|
+
const q = resolve(m);
|
|
25
|
+
const steps = m.steps.map((s) => {
|
|
26
|
+
if (s.action !== "scene")
|
|
27
|
+
return s;
|
|
28
|
+
const cam = s.camera;
|
|
29
|
+
const focus = cam && typeof cam === "object" ? cam.focus ?? null : cam === "static" ? "static" : null;
|
|
30
|
+
return { action: "scene", label: s.label, focus };
|
|
31
|
+
});
|
|
32
|
+
const h = createHash("sha1");
|
|
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
|
+
return h.digest("hex").slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
const noop = () => { };
|
|
37
|
+
/** The take's time ceiling: the manifest's own maxSeconds, else ≈10s per
|
|
38
|
+
step plus explicit waits, never under 240s or over an hour. Used by the
|
|
39
|
+
recorder and by check(), so the two can never disagree. */
|
|
40
|
+
export function capSecondsFor(m) {
|
|
41
|
+
if (m.maxSeconds)
|
|
42
|
+
return m.maxSeconds;
|
|
43
|
+
const waits = m.steps.reduce((n, st) => n + (st.action === "wait" ? st.ms / 1000 : 0) + (st.pauseAfter ?? 0) / 1000, 0);
|
|
44
|
+
return Math.min(3600, Math.max(240, Math.round(m.steps.length * 10 + waits)));
|
|
45
|
+
}
|
|
46
|
+
/** Roughly how many times the cursor will move on camera. */
|
|
47
|
+
export const CURSOR_MOVE_LIMIT = 45;
|
|
48
|
+
export function cursorMoves(m) {
|
|
49
|
+
return m.steps.filter((s) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(s.action)).length;
|
|
50
|
+
}
|
|
51
|
+
/** A point, read from the page's own geometry. Playwright's locator engine
|
|
52
|
+
refuses elements whose handlers intercept pointer events (Blockly's toolbox
|
|
53
|
+
categories, for one); getBoundingClientRect does not care. */
|
|
54
|
+
export async function resolvePoint(page, p, timeout) {
|
|
55
|
+
if (typeof p === "object" && "x" in p)
|
|
56
|
+
return { x: p.x, y: p.y };
|
|
57
|
+
const selector = typeof p === "string" ? p : p.selector;
|
|
58
|
+
const dx = typeof p === "string" ? 0 : p.dx;
|
|
59
|
+
const dy = typeof p === "string" ? 0 : p.dy;
|
|
60
|
+
// Playwright finds it (so :has-text and >> nth= work), the page measures it
|
|
61
|
+
// (so an element whose handlers intercept pointer events still yields a
|
|
62
|
+
// rect — locator.boundingBox() waits for visibility and can time out on
|
|
63
|
+
// Blockly's toolbox).
|
|
64
|
+
// Poll: a zero-size box usually means the panel is still animating in, not
|
|
65
|
+
// that the selector is wrong.
|
|
66
|
+
const until = Date.now() + timeout;
|
|
67
|
+
let rect = null;
|
|
68
|
+
while (Date.now() < until) {
|
|
69
|
+
const handle = await page.locator(selector).first().elementHandle({ timeout: Math.max(500, until - Date.now()) });
|
|
70
|
+
if (handle) {
|
|
71
|
+
rect = await handle.evaluate((el) => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width, h: r.height }; });
|
|
72
|
+
await handle.dispose();
|
|
73
|
+
if (rect && (rect.w || rect.h))
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
await page.waitForTimeout(250);
|
|
77
|
+
}
|
|
78
|
+
if (!rect)
|
|
79
|
+
throw new Error(`no element for point target "${selector}"`);
|
|
80
|
+
if (!rect.w && !rect.h)
|
|
81
|
+
throw new Error(`point target "${selector}" never got a size (still hidden or collapsed?)`);
|
|
82
|
+
return { x: rect.x + dx, y: rect.y + dy };
|
|
83
|
+
}
|
|
84
|
+
/** Press, move, release, with the cursor overlay following. testreel records
|
|
85
|
+
cursor keyframes only for its own actions, so we log two of our own and let
|
|
86
|
+
it animate between them while the page gets the many small moves it needs
|
|
87
|
+
to believe a drag is happening. Two keyframes, so a drag costs the overlay
|
|
88
|
+
what a click costs. */
|
|
89
|
+
export async function dragPoints(page, from, to, o) {
|
|
90
|
+
await page.mouse.move(from.x, from.y);
|
|
91
|
+
await page.mouse.down();
|
|
92
|
+
if (o.holdMs)
|
|
93
|
+
await page.waitForTimeout(o.holdMs);
|
|
94
|
+
await page.mouse.move(from.x + 8, from.y + 8);
|
|
95
|
+
const per = Math.max(4, Math.round(o.durationMs / o.steps));
|
|
96
|
+
for (let i = 1; i <= o.steps; i++) {
|
|
97
|
+
await page.mouse.move(from.x + ((to.x - from.x) * i) / o.steps, from.y + ((to.y - from.y) * i) / o.steps);
|
|
98
|
+
await page.waitForTimeout(per);
|
|
99
|
+
}
|
|
100
|
+
await page.mouse.up();
|
|
101
|
+
}
|
|
102
|
+
async function performDrag(page, step, q, timeout) {
|
|
103
|
+
const from = await resolvePoint(page, step.from, timeout);
|
|
104
|
+
const to = await resolvePoint(page, step.to, timeout);
|
|
105
|
+
const opts = q.cursor === false ? undefined : { style: q.cursor.style, size: q.cursor.size };
|
|
106
|
+
if (opts)
|
|
107
|
+
await moveCursorToPoint(page, from.x, from.y, opts);
|
|
108
|
+
await page.mouse.move(from.x, from.y);
|
|
109
|
+
await page.mouse.down();
|
|
110
|
+
if (step.holdMs)
|
|
111
|
+
await page.waitForTimeout(step.holdMs);
|
|
112
|
+
await page.mouse.move(from.x + 8, from.y + 8); // cross the editor's drag threshold
|
|
113
|
+
if (opts)
|
|
114
|
+
void moveCursorToPoint(page, to.x, to.y, { ...opts, transitionMs: step.durationMs });
|
|
115
|
+
const per = Math.max(8, Math.round(step.durationMs / step.steps));
|
|
116
|
+
for (let i = 1; i <= step.steps; i++) {
|
|
117
|
+
await page.mouse.move(from.x + ((to.x - from.x) * i) / step.steps, from.y + ((to.y - from.y) * i) / step.steps);
|
|
118
|
+
await page.waitForTimeout(per);
|
|
119
|
+
}
|
|
120
|
+
await page.mouse.up();
|
|
121
|
+
}
|
|
122
|
+
/** Wait for a named resource lock (outputs/.locks/<name>), up to 10 min.
|
|
123
|
+
A stale lock from a dead process is reclaimed after its owner is gone. */
|
|
124
|
+
async function acquireNamedLock(outRoot, name, log) {
|
|
125
|
+
const dir = path.join(outRoot, ".locks");
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
const file = path.join(dir, `${name}.lock`);
|
|
128
|
+
const alive = (pid) => { try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
} };
|
|
135
|
+
const until = Date.now() + 10 * 60_000;
|
|
136
|
+
let waited = false;
|
|
137
|
+
while (Date.now() < until) {
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(file, String(process.pid), { flag: "wx" });
|
|
140
|
+
if (waited)
|
|
141
|
+
log(`lock "${name}": acquired`);
|
|
142
|
+
return () => { try {
|
|
143
|
+
if (fs.readFileSync(file, "utf8") === String(process.pid))
|
|
144
|
+
fs.unlinkSync(file);
|
|
145
|
+
}
|
|
146
|
+
catch { /* gone */ } };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
const owner = Number(fs.readFileSync(file, "utf8").trim() || 0);
|
|
150
|
+
if (!owner || !alive(owner)) {
|
|
151
|
+
fs.rmSync(file, { force: true });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!waited) {
|
|
155
|
+
log(`lock "${name}": another take (pid ${owner}) holds it — waiting so we do not wipe each other's state`);
|
|
156
|
+
waited = true;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`lock "${name}": still held after 10 minutes`);
|
|
162
|
+
}
|
|
163
|
+
export async function record(m, opts) {
|
|
164
|
+
let releaseNamed = null;
|
|
165
|
+
const log = opts.log ?? noop;
|
|
166
|
+
const startedAt = new Date().toISOString();
|
|
167
|
+
fs.mkdirSync(opts.outDir, { recursive: true });
|
|
168
|
+
// The caller (cli run) holds the folder lock across record + render; if it
|
|
169
|
+
// did not, take it here for the recording at least.
|
|
170
|
+
const ownLock = !opts.locked && acquireLock(opts.outDir);
|
|
171
|
+
// Playwright writes its in-progress .webm to a private per-run dir, never to
|
|
172
|
+
// the shared output dir, so nothing else can delete it mid-take.
|
|
173
|
+
const rawDir = fs.mkdtempSync(path.join(os.tmpdir(), `retake-${m.name}-`));
|
|
174
|
+
const q = resolve(m);
|
|
175
|
+
log(`preset ${q.name} · canvas ${q.width}×${q.height} · viewport ${q.viewport.width}×${q.viewport.height} @ ${q.fps}fps · page scale ${q.scale}×`);
|
|
176
|
+
if (!opts.skipSeed) {
|
|
177
|
+
if (m.lock)
|
|
178
|
+
releaseNamed = await acquireNamedLock(path.dirname(opts.outDir), m.lock, log);
|
|
179
|
+
for (const s of m.seed)
|
|
180
|
+
await runFileOrCommandSeed(s, opts.manifestDir, log);
|
|
181
|
+
}
|
|
182
|
+
const browser = await chromium.launch({ headless: !opts.headed });
|
|
183
|
+
const size = { ...q.viewport };
|
|
184
|
+
// A saved session (auth.storageState) means the login already happened on an
|
|
185
|
+
// earlier run: load it and the setup login steps can be skipped.
|
|
186
|
+
const statePath = m.auth ? path.resolve(opts.manifestDir, expandEnv(m.auth.storageState)) : null;
|
|
187
|
+
const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
|
|
188
|
+
if (statePath)
|
|
189
|
+
log(stateFresh ? `auth: reusing session ${path.relative(process.cwd(), statePath)}` : `auth: no fresh session — running setup to sign in`);
|
|
190
|
+
const context = await browser.newContext({
|
|
191
|
+
viewport: size,
|
|
192
|
+
colorScheme: m.colorScheme,
|
|
193
|
+
...(m.reducedMotion ? { reducedMotion: "reduce" } : {}),
|
|
194
|
+
recordVideo: { dir: rawDir, size },
|
|
195
|
+
...(stateFresh && statePath ? { storageState: statePath } : {}),
|
|
196
|
+
});
|
|
197
|
+
// The page lays out at `scale`× (CSS zoom): the app sees a smaller viewport
|
|
198
|
+
// and renders crisp into the full canvas. Coordinates testreel/Playwright
|
|
199
|
+
// see are already video pixels, so cursor and clicks line up.
|
|
200
|
+
// Scale via a <style> tag, not an inline attribute: React hydration diffs
|
|
201
|
+
// element attributes against the server HTML, so style.zoom on <html> made
|
|
202
|
+
// every React app report a hydration mismatch — with a dev badge in shot.
|
|
203
|
+
if (q.scale !== 1)
|
|
204
|
+
await context.addInitScript(`document.addEventListener("DOMContentLoaded",()=>{const st=document.createElement("style");st.textContent="html{zoom:${q.scale}}";document.head.appendChild(st)})`);
|
|
205
|
+
// One page is what gets recorded, so keep the flow in it: window.open
|
|
206
|
+
// navigates in place, and target=_blank is stripped as it appears. A demo
|
|
207
|
+
// that spawned a second tab used to simply lose its subject.
|
|
208
|
+
if (m.keepInTab) {
|
|
209
|
+
await context.addInitScript(`
|
|
210
|
+
(() => {
|
|
211
|
+
const orig = window.open;
|
|
212
|
+
window.open = function (url) { if (url) { location.href = String(url); return window; } return orig.apply(window, arguments); };
|
|
213
|
+
const strip = (root) => { for (const a of root.querySelectorAll ? root.querySelectorAll('a[target="_blank"]') : []) a.removeAttribute('target'); };
|
|
214
|
+
addEventListener('DOMContentLoaded', () => {
|
|
215
|
+
strip(document);
|
|
216
|
+
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 });
|
|
217
|
+
});
|
|
218
|
+
})();
|
|
219
|
+
`);
|
|
220
|
+
}
|
|
221
|
+
// Dev servers decorate themselves — Next's issues badge, Vite's error
|
|
222
|
+
// overlay, webpack's. None of that belongs in a product video, and no
|
|
223
|
+
// manifest should have to know about it.
|
|
224
|
+
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)})`);
|
|
225
|
+
// Canned responses live in a map the route handler reads on every request, so
|
|
226
|
+
// a `stub` step mid-demo swaps the answer without re-registering anything.
|
|
227
|
+
// Keyed by method+url: a POST stub must not replace the GET stub on the same
|
|
228
|
+
// path — they are different answers to different questions.
|
|
229
|
+
const stubs = new Map();
|
|
230
|
+
const stubbed = [];
|
|
231
|
+
const armStub = async (d) => {
|
|
232
|
+
const key = `${d.method ?? "ANY"} ${d.url}`;
|
|
233
|
+
const fresh = !stubs.has(key);
|
|
234
|
+
stubs.set(key, d);
|
|
235
|
+
if (!stubbed.includes(key))
|
|
236
|
+
stubbed.push(key);
|
|
237
|
+
if (!fresh)
|
|
238
|
+
return;
|
|
239
|
+
await context.route(d.url, async (route, request) => {
|
|
240
|
+
const cur = stubs.get(key);
|
|
241
|
+
if (cur.method && request.method().toUpperCase() !== cur.method.toUpperCase())
|
|
242
|
+
return route.fallback();
|
|
243
|
+
const body = cur.json !== undefined
|
|
244
|
+
? JSON.stringify(cur.json)
|
|
245
|
+
: cur.from
|
|
246
|
+
? fs.readFileSync(path.resolve(opts.manifestDir, expandEnv(cur.from)), "utf8")
|
|
247
|
+
: "{}";
|
|
248
|
+
await route.fulfill({ status: cur.status ?? 200, contentType: cur.contentType, body });
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
const timeline = [];
|
|
252
|
+
const ctx = { outDir: opts.outDir, manifestDir: opts.manifestDir, downloads: [], stub: armStub };
|
|
253
|
+
let ok = true;
|
|
254
|
+
let partial;
|
|
255
|
+
let video;
|
|
256
|
+
let screenshots = [];
|
|
257
|
+
let t0 = Date.now();
|
|
258
|
+
let setupEnd = t0;
|
|
259
|
+
let endMs = t0;
|
|
260
|
+
const sec = (ms) => Math.round((ms / 1000) * 1000) / 1000;
|
|
261
|
+
try {
|
|
262
|
+
for (const d of m.stub)
|
|
263
|
+
await armStub(d);
|
|
264
|
+
if (m.stub.length)
|
|
265
|
+
log(`stub: ${m.stub.map((d) => d.url).join(", ")}`);
|
|
266
|
+
// Pre-warm the route (a dev server may compile for seconds on first hit) so
|
|
267
|
+
// the recorded navigation is short and the video's start is predictable.
|
|
268
|
+
await context.request.get(expandEnv(m.url)).catch(noop);
|
|
269
|
+
const page = await context.newPage();
|
|
270
|
+
if (process.env.RETAKE_PAGE_CONSOLE)
|
|
271
|
+
page.on("console", (msg) => log(` [page] ${msg.text().slice(0, 200)}`));
|
|
272
|
+
// --- setup phase (still inside the video; trimmed later by `trimBefore`) ---
|
|
273
|
+
// Document first; idle only if it comes quickly. Busy public sites never
|
|
274
|
+
// go network-idle, and the manifest's waitForSelector is the real gate.
|
|
275
|
+
await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
276
|
+
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
|
|
277
|
+
if (!opts.skipSeed) {
|
|
278
|
+
for (const s of m.seed) {
|
|
279
|
+
if (s.kind === "evaluate")
|
|
280
|
+
await runEvaluateSeed(page, s, opts.manifestDir, log);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (m.waitForSelector)
|
|
284
|
+
await page.waitForSelector(m.waitForSelector, { timeout: 20_000 });
|
|
285
|
+
// Playwright's video begins at the first *painted* frame, not at newPage().
|
|
286
|
+
// On a dev server that can be seconds later; if testreel's cursor overlay is
|
|
287
|
+
// told the wrong start it draws every cursor move that many seconds late.
|
|
288
|
+
const videoStartedAt = await firstPaintWallClock(page);
|
|
289
|
+
t0 = videoStartedAt;
|
|
290
|
+
if (q.scale !== 1)
|
|
291
|
+
await page.evaluate((z) => { document.documentElement.style.zoom = String(z); }, q.scale);
|
|
292
|
+
// Idle-hide fades are nested if()s in testreel's ffmpeg alpha expression;
|
|
293
|
+
// past roughly sixty steps the nesting exceeds ffmpeg's parser and the
|
|
294
|
+
// composite fails. Long demos get an always-visible cursor unless told
|
|
295
|
+
// otherwise — the fade was never the point of the video.
|
|
296
|
+
const LONG = 60;
|
|
297
|
+
const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide ?? (m.steps.length <= LONG);
|
|
298
|
+
if (q.cursor !== false && q.cursor.idleHide === undefined && m.steps.length > LONG)
|
|
299
|
+
log(`cursor: ${m.steps.length} steps — idle-hide fades off (ffmpeg nesting limit); set cursor.idleHide to override`);
|
|
300
|
+
const rec = await recordPage(page, {
|
|
301
|
+
outputDir: opts.outDir,
|
|
302
|
+
name: m.name,
|
|
303
|
+
clean: false,
|
|
304
|
+
cursor: q.cursor === false ? false : { style: q.cursor.style, size: q.cursor.size, idleHideMs: q.cursor.idleHideMs, idleHide: cursorIdleHide },
|
|
305
|
+
chrome: false,
|
|
306
|
+
background: false,
|
|
307
|
+
speed: m.speed,
|
|
308
|
+
// webm: skip testreel's own H.264 pass — render.ts does the one clean encode.
|
|
309
|
+
outputFormat: "webm",
|
|
310
|
+
videoStartedAt,
|
|
311
|
+
});
|
|
312
|
+
// Sign-in steps run only when there is no fresh session to reuse; the rest
|
|
313
|
+
// of `setup` always runs. (They were once the same list, which meant a
|
|
314
|
+
// saved session silently skipped the steps that put the app on its opening
|
|
315
|
+
// screen — a whole take of the wrong thing, with nothing in the log.)
|
|
316
|
+
let authOk = true;
|
|
317
|
+
for (const step of [...(stateFresh ? [] : (m.auth?.setup ?? [])).map((st) => ({ st, auth: true })), ...m.setup.map((st) => ({ st, auth: false }))]) {
|
|
318
|
+
try {
|
|
319
|
+
await runStep(rec, page, step.st, m, ctx);
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
if (step.auth)
|
|
323
|
+
authOk = false;
|
|
324
|
+
log(`setup step failed: ${describe(step.st)} — ${e.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Persist the signed-in session so later takes skip the login entirely —
|
|
328
|
+
// but never a broken one: saving after a failed login poisons every later
|
|
329
|
+
// run, which then "reuses" a logged-out session and records the login
|
|
330
|
+
// page while believing it is signed in.
|
|
331
|
+
if (statePath && !stateFresh && authOk) {
|
|
332
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
333
|
+
await context.storageState({ path: statePath });
|
|
334
|
+
log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
|
|
335
|
+
}
|
|
336
|
+
else if (statePath && !stateFresh) {
|
|
337
|
+
log("auth: a sign-in step failed — NOT saving this session");
|
|
338
|
+
}
|
|
339
|
+
setupEnd = Date.now();
|
|
340
|
+
const capSeconds = capSecondsFor(m);
|
|
341
|
+
// testreel draws the cursor with one nested if() per move, and ffmpeg's
|
|
342
|
+
// expression parser stops at 98 levels — about 49 moves. Past that the
|
|
343
|
+
// overlay silently fails and the video ships with no cursor at all.
|
|
344
|
+
const moves = cursorMoves(m);
|
|
345
|
+
if (q.cursor !== false && moves > CURSOR_MOVE_LIMIT)
|
|
346
|
+
log(`cursor: ~${moves} cursor moves — testreel's overlay cannot exceed ~${CURSOR_MOVE_LIMIT}; the cursor may be MISSING from this video. Split the demo, or set cursor: false to be honest about it.`);
|
|
347
|
+
let pastUntil = false;
|
|
348
|
+
for (const [i, step] of m.steps.entries()) {
|
|
349
|
+
// --until <scene>: record that scene in full, stop at the next one.
|
|
350
|
+
if (opts.until && step.action === "scene") {
|
|
351
|
+
if (pastUntil) {
|
|
352
|
+
partial = `stopped after scene "${opts.until}" (until)`;
|
|
353
|
+
log(`■ ${partial}`);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (step.label === opts.until)
|
|
357
|
+
pastUntil = true;
|
|
358
|
+
}
|
|
359
|
+
const start = Date.now();
|
|
360
|
+
const entry = {
|
|
361
|
+
index: i,
|
|
362
|
+
action: step.action,
|
|
363
|
+
summary: describe(step),
|
|
364
|
+
start: sec(start - t0),
|
|
365
|
+
end: 0,
|
|
366
|
+
ok: true,
|
|
367
|
+
};
|
|
368
|
+
if (step.action === "scene") {
|
|
369
|
+
entry.label = step.label;
|
|
370
|
+
entry.caption = step.caption;
|
|
371
|
+
entry.holdMs = step.holdMs;
|
|
372
|
+
entry.camera = await resolveCamera(page, m, q, step, i);
|
|
373
|
+
}
|
|
374
|
+
log(`[${String(i).padStart(2, "0")}] ${entry.summary}${entry.camera ? ` · camera ${entry.camera.zoom}× on ${entry.camera.focus}` : ""}`);
|
|
375
|
+
try {
|
|
376
|
+
await runStep(rec, page, step, m, ctx);
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
ok = false;
|
|
380
|
+
entry.ok = false;
|
|
381
|
+
entry.error = e.message.split("\n")[0];
|
|
382
|
+
// What the page actually showed at the moment of failure is worth
|
|
383
|
+
// more than the error: it is how a reader (or agent) sees that the
|
|
384
|
+
// login never happened, or the modal never closed.
|
|
385
|
+
try {
|
|
386
|
+
entry.screen = (await page.evaluate(() => document.body?.innerText ?? "")).replace(/\s+/g, " ").trim().slice(0, 240);
|
|
387
|
+
}
|
|
388
|
+
catch { /* page gone */ }
|
|
389
|
+
log(` ✗ ${entry.error}${entry.screen ? ` — on screen: “${entry.screen.slice(0, 120)}”` : ""}`);
|
|
390
|
+
entry.end = sec(Date.now() - t0);
|
|
391
|
+
timeline.push(entry);
|
|
392
|
+
if (m.onFail === "stop") {
|
|
393
|
+
// The camera stops here. One interaction and ten minutes of nothing
|
|
394
|
+
// is the worst thing a demo tool can hand someone.
|
|
395
|
+
partial = `stopped at step ${i} (${entry.summary}) — ${entry.error}`;
|
|
396
|
+
log(`■ ${partial}`);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
entry.end = sec(Date.now() - t0);
|
|
402
|
+
timeline.push(entry);
|
|
403
|
+
if ((Date.now() - t0) / 1000 > capSeconds) {
|
|
404
|
+
ok = false;
|
|
405
|
+
partial = `stopped: the take passed ${capSeconds}s (the maxSeconds cap) at step ${i} of ${m.steps.length} — if the demo is meant to be this long, set maxSeconds in the manifest; if not, something is stuck`;
|
|
406
|
+
log(`■ ${partial}`);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
endMs = Date.now();
|
|
411
|
+
// testreel: close context, save video, composite cursor/zoom/chrome, mp4.
|
|
412
|
+
// Its ffmpeg pass prints multi-kilobyte expression dumps and, when the
|
|
413
|
+
// cursor expression is too deep, "Error initializing filters" — then
|
|
414
|
+
// carries on and reports success. We listen, trim, and refuse to call
|
|
415
|
+
// that a clean take.
|
|
416
|
+
const compositeLog = [];
|
|
417
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
418
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
419
|
+
const tap = (orig) => ((chunk, ...rest) => {
|
|
420
|
+
const text = String(chunk);
|
|
421
|
+
compositeLog.push(text);
|
|
422
|
+
const trimmed = text.length > 400 ? text.slice(0, 220) + ` … [${text.length - 220} more chars of ffmpeg expression trimmed]\n` : text;
|
|
423
|
+
return orig(trimmed, ...rest);
|
|
424
|
+
});
|
|
425
|
+
process.stdout.write = tap(origOut);
|
|
426
|
+
process.stderr.write = tap(origErr);
|
|
427
|
+
try {
|
|
428
|
+
const result = await rec.stop();
|
|
429
|
+
video = result.video;
|
|
430
|
+
screenshots = result.screenshots;
|
|
431
|
+
const joined = compositeLog.join("");
|
|
432
|
+
if (/Error initializing filters|Missing '\)' or too many args/.test(joined)) {
|
|
433
|
+
const why = `cursor overlay failed in testreel's ffmpeg pass (expression too deep — ~${moves} moves, limit ~${CURSOR_MOVE_LIMIT}); the video has NO CURSOR. Split the demo or set cursor: false.`;
|
|
434
|
+
partial = partial ? `${partial}; ${why}` : why;
|
|
435
|
+
ok = false;
|
|
436
|
+
log(`✗ ${why}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
partial = `testreel stop() failed: ${e.message.split("\n")[0]}`;
|
|
441
|
+
log(`✗ ${partial}`);
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
process.stdout.write = origOut;
|
|
445
|
+
process.stderr.write = origErr;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
ok = false;
|
|
450
|
+
partial = `run aborted: ${e.message.split("\n")[0]}`;
|
|
451
|
+
log(`✗ ${partial}`);
|
|
452
|
+
if (endMs === t0)
|
|
453
|
+
endMs = Date.now();
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
await context.close().catch(noop);
|
|
457
|
+
await browser.close().catch(noop);
|
|
458
|
+
}
|
|
459
|
+
// Fallback: whatever happened above, if Playwright wrote a .webm we can still
|
|
460
|
+
// ship a plain mp4 (no cursor overlay / chrome) rather than an empty folder.
|
|
461
|
+
if (!video) {
|
|
462
|
+
const webm = fs.readdirSync(rawDir).find((f) => f.endsWith(".webm"));
|
|
463
|
+
if (webm) {
|
|
464
|
+
const dest = path.join(opts.outDir, `${m.name}.mp4`);
|
|
465
|
+
try {
|
|
466
|
+
execFileSync(ffmpegBin(), ["-hide_banner", "-loglevel", "error", "-y", "-i", path.join(rawDir, webm), "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-an", dest], { stdio: "inherit" });
|
|
467
|
+
video = dest;
|
|
468
|
+
partial = (partial ?? "polished render unavailable") + " — raw video used (no cursor overlay)";
|
|
469
|
+
log(`fallback: ${path.relative(process.cwd(), dest)}`);
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
log(`fallback conversion failed: ${e.message.split("\n")[0]}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
fs.rmSync(rawDir, { recursive: true, force: true });
|
|
477
|
+
if (ownLock)
|
|
478
|
+
releaseLock(opts.outDir);
|
|
479
|
+
// Timeline offsets are already relative to videoStartedAt (= document commit,
|
|
480
|
+
// calibrated to within ~0.2s of the first video frame on a warm route). The
|
|
481
|
+
// real file length is authoritative for the end.
|
|
482
|
+
const trimBefore = sec(setupEnd - t0);
|
|
483
|
+
const duration = video ? videoDuration(video) : sec(endMs - t0);
|
|
484
|
+
const last = timeline.at(-1);
|
|
485
|
+
if (last && Math.abs(duration - last.end) > 3) {
|
|
486
|
+
log(`note: video is ${duration.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
|
|
487
|
+
}
|
|
488
|
+
const finishedAt = new Date().toISOString();
|
|
489
|
+
const take = {
|
|
490
|
+
video, screenshots, timeline, duration, startedAt, finishedAt, ok, trimBefore, partial,
|
|
491
|
+
quality: { preset: q.name, width: q.viewport.width, height: q.viewport.height, scale: q.scale, fps: q.fps },
|
|
492
|
+
downloads: ctx.downloads,
|
|
493
|
+
stubbed,
|
|
494
|
+
captureHash: captureHash(m),
|
|
495
|
+
captureSec: Math.round((Date.parse(finishedAt) - Date.parse(startedAt)) / 100) / 10,
|
|
496
|
+
};
|
|
497
|
+
releaseNamed?.();
|
|
498
|
+
fs.writeFileSync(path.join(opts.outDir, "take.json"), JSON.stringify(take, null, 2));
|
|
499
|
+
return take;
|
|
500
|
+
}
|
|
501
|
+
/** Decide where the camera looks for a scene: explicit focus, else (auto) the
|
|
502
|
+
last selector the demo touched, if it is on screen now. Boxes are video px. */
|
|
503
|
+
async function resolveCamera(page, m, q, step, index) {
|
|
504
|
+
const policy = step.camera ?? m.camera;
|
|
505
|
+
if (policy === "static")
|
|
506
|
+
return undefined;
|
|
507
|
+
let focus;
|
|
508
|
+
let zoom = q.cameraZoom;
|
|
509
|
+
if (typeof policy === "object" && !("focus" in policy && policy.focus === undefined && policy.zoom === undefined)) {
|
|
510
|
+
if ("focus" in policy && policy.focus)
|
|
511
|
+
focus = policy.focus;
|
|
512
|
+
if (policy.zoom)
|
|
513
|
+
zoom = policy.zoom;
|
|
514
|
+
}
|
|
515
|
+
if (!focus) {
|
|
516
|
+
// auto: walk back to the last step with a selector (a waitFor result beats a click target).
|
|
517
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
518
|
+
const s = m.steps[j];
|
|
519
|
+
if (s.action === "scene")
|
|
520
|
+
break;
|
|
521
|
+
if ("selector" in s && s.selector && (s.action === "waitFor" || s.action === "click" || s.action === "type" || s.action === "hover")) {
|
|
522
|
+
focus = s.selector;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!focus)
|
|
528
|
+
return undefined;
|
|
529
|
+
try {
|
|
530
|
+
const box = await page.locator(focus).first().boundingBox({ timeout: 1500 });
|
|
531
|
+
if (!box || box.width < 4 || box.height < 4)
|
|
532
|
+
return undefined;
|
|
533
|
+
return { zoom, box, focus };
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** One run per output dir at a time — record AND render. Two overlapping runs
|
|
540
|
+
would wipe each other's files and die with ENOENT halfway. Throws if held. */
|
|
541
|
+
export function acquireLock(outDir) {
|
|
542
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
543
|
+
const lock = path.join(outDir, ".retake-lock");
|
|
544
|
+
if (fs.existsSync(lock)) {
|
|
545
|
+
const pid = Number(fs.readFileSync(lock, "utf8"));
|
|
546
|
+
if (pid && pid !== process.pid && isAlive(pid))
|
|
547
|
+
throw new Error(`another retake run (pid ${pid}) is using ${path.relative(process.cwd(), outDir)} — wait for it to finish`);
|
|
548
|
+
}
|
|
549
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
export function releaseLock(outDir) {
|
|
553
|
+
fs.rmSync(path.join(outDir, ".retake-lock"), { force: true });
|
|
554
|
+
}
|
|
555
|
+
function isAlive(pid) {
|
|
556
|
+
try {
|
|
557
|
+
process.kill(pid, 0);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** testreel clicks and types at *screen* coordinates and never scrolls first,
|
|
565
|
+
so anything below the fold gets a click on empty air. Bring it into view
|
|
566
|
+
ourselves — centred, so the viewer sees the target and the cursor lands on it. */
|
|
567
|
+
async function bringIntoView(page, selector, timeout) {
|
|
568
|
+
const loc = page.locator(selector).first();
|
|
569
|
+
await loc.waitFor({ state: "visible", timeout });
|
|
570
|
+
const box = await loc.boundingBox();
|
|
571
|
+
const vh = page.viewportSize()?.height ?? 800;
|
|
572
|
+
// Off-screen is obvious; the subtler case is a target hugging the bottom
|
|
573
|
+
// edge — visible, but cramped against the caption band and half-cut in
|
|
574
|
+
// the video. Keep a safe zone top and bottom and centre anything inside it.
|
|
575
|
+
const safe = vh * 0.12;
|
|
576
|
+
if (box && (box.y < safe || box.y + box.height > vh - safe)) {
|
|
577
|
+
await loc.evaluate((el) => el.scrollIntoView({ block: "center", behavior: "smooth" }));
|
|
578
|
+
await page.waitForTimeout(450);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function runStep(rec, page, step, m, ctx) {
|
|
582
|
+
const timeout = step.timeout ?? 8000;
|
|
583
|
+
if (step.waitFor)
|
|
584
|
+
await page.waitForSelector(step.waitFor, { timeout });
|
|
585
|
+
if ((step.action === "click" || step.action === "type" || step.action === "fill" || step.action === "hover") && "selector" in step && step.selector) {
|
|
586
|
+
await bringIntoView(page, step.selector, timeout);
|
|
587
|
+
}
|
|
588
|
+
const q = resolve(m);
|
|
589
|
+
if ((step.action === "click" || step.action === "hover") && step.at) {
|
|
590
|
+
// A point click: the cursor travels there on camera, then the real
|
|
591
|
+
// pointer does the same. For canvases, and for elements whose own
|
|
592
|
+
// handlers make Playwright's actionability checks time out.
|
|
593
|
+
const pt = await resolvePoint(page, step.at, timeout);
|
|
594
|
+
if (q.cursor !== false)
|
|
595
|
+
await moveCursorToPoint(page, pt.x, pt.y, { style: q.cursor.style, size: q.cursor.size });
|
|
596
|
+
if (step.action === "click")
|
|
597
|
+
await page.mouse.click(pt.x, pt.y);
|
|
598
|
+
if (step.pauseAfter)
|
|
599
|
+
await page.waitForTimeout(step.pauseAfter);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (step.action === "drag") {
|
|
603
|
+
await performDrag(page, step, q, timeout);
|
|
604
|
+
if (step.pauseAfter)
|
|
605
|
+
await page.waitForTimeout(step.pauseAfter);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const pause = step.pauseAfter;
|
|
609
|
+
switch (step.action) {
|
|
610
|
+
case "wait":
|
|
611
|
+
await rec.wait(step.ms);
|
|
612
|
+
return; // wait's own duration is the pause
|
|
613
|
+
case "click":
|
|
614
|
+
await rec.click(step.selector, { timeout, zoom: step.zoom });
|
|
615
|
+
break;
|
|
616
|
+
case "type":
|
|
617
|
+
await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? 70, clear: step.clear, timeout });
|
|
618
|
+
break;
|
|
619
|
+
case "fill":
|
|
620
|
+
await rec.fill(step.selector, expandEnv(step.text), { timeout });
|
|
621
|
+
break;
|
|
622
|
+
case "hover":
|
|
623
|
+
await rec.hover(step.selector, { timeout });
|
|
624
|
+
break;
|
|
625
|
+
case "scroll": {
|
|
626
|
+
let dy = step.y;
|
|
627
|
+
if (step.to === "top" || step.to === "bottom") {
|
|
628
|
+
// Absolute: the page's ends. A relative y: 0 is, correctly, no move.
|
|
629
|
+
const want = step.to;
|
|
630
|
+
const cur = await page.evaluate(() => ({ y: window.scrollY, max: document.documentElement.scrollHeight - window.innerHeight }));
|
|
631
|
+
dy = Math.round((want === "top" ? 0 : cur.max) - cur.y);
|
|
632
|
+
}
|
|
633
|
+
else if (step.to) {
|
|
634
|
+
// Work out how far to move so the element sits where we asked, then let
|
|
635
|
+
// testreel animate that distance (an eased scroll, not a jump).
|
|
636
|
+
const box = await page.locator(step.to).first().boundingBox({ timeout });
|
|
637
|
+
if (box) {
|
|
638
|
+
const vh = page.viewportSize()?.height ?? 800;
|
|
639
|
+
const want = step.align === "top" ? 80 : step.align === "bottom" ? vh - box.height - 80 : (vh - box.height) / 2;
|
|
640
|
+
dy = Math.round(box.y - want);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// A viewer's eye wants roughly constant pixels per second, not a fixed
|
|
644
|
+
// 600ms per scroll: unset, speed derives from distance (~1100 px/s,
|
|
645
|
+
// never faster than 0.4s, never slower than 2.5s).
|
|
646
|
+
const dist = Math.abs(dy ?? 0) + Math.abs(step.x ?? 0);
|
|
647
|
+
const autoMs = Math.min(2500, Math.max(400, (dist / 1100) * 1000));
|
|
648
|
+
const scrollSpeed = step.speed ?? (dist ? 600 / autoMs : 1);
|
|
649
|
+
if (dy || step.x)
|
|
650
|
+
await rec.scroll({ x: step.x, y: dy, scrollSpeed });
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case "zoom":
|
|
654
|
+
await rec.zoom({ selector: step.selector, scale: step.scale, x: step.x, y: step.y, duration: step.duration });
|
|
655
|
+
break;
|
|
656
|
+
case "keyboard":
|
|
657
|
+
await rec.keyboard(step.key);
|
|
658
|
+
break;
|
|
659
|
+
case "navigate":
|
|
660
|
+
await rec.navigate(expandEnv(step.url));
|
|
661
|
+
break;
|
|
662
|
+
case "screenshot":
|
|
663
|
+
await rec.screenshot(step.name);
|
|
664
|
+
break;
|
|
665
|
+
case "upload": {
|
|
666
|
+
const files = (Array.isArray(step.files) ? step.files : [step.files]).map((f) => path.resolve(ctx.manifestDir, expandEnv(f)));
|
|
667
|
+
for (const f of files)
|
|
668
|
+
if (!fs.existsSync(f))
|
|
669
|
+
throw new Error(`upload: no such file ${f}`);
|
|
670
|
+
await page.setInputFiles(step.selector, files, { timeout });
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
case "download": {
|
|
674
|
+
const dir = path.join(ctx.outDir, "downloads");
|
|
675
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
676
|
+
const [dl] = await Promise.all([
|
|
677
|
+
page.waitForEvent("download", { timeout: step.timeout ?? 30_000 }),
|
|
678
|
+
step.selector ? rec.click(step.selector, { timeout }) : Promise.resolve(),
|
|
679
|
+
]);
|
|
680
|
+
const name = step.saveAs ?? dl.suggestedFilename();
|
|
681
|
+
if (step.expect && !dl.suggestedFilename().includes(step.expect))
|
|
682
|
+
throw new Error(`download: expected a name containing "${step.expect}", got "${dl.suggestedFilename()}"`);
|
|
683
|
+
const dest = path.join(dir, name);
|
|
684
|
+
await dl.saveAs(dest);
|
|
685
|
+
ctx.downloads.push(dest);
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case "waitFor":
|
|
689
|
+
await page.waitForSelector(step.selector, { timeout: step.timeout ?? 30_000 });
|
|
690
|
+
break;
|
|
691
|
+
case "evaluate":
|
|
692
|
+
await page.evaluate(step.script);
|
|
693
|
+
break;
|
|
694
|
+
case "stub":
|
|
695
|
+
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" });
|
|
696
|
+
break;
|
|
697
|
+
case "scene":
|
|
698
|
+
// A marker only. Its timestamp is the point of the step.
|
|
699
|
+
break;
|
|
700
|
+
default: {
|
|
701
|
+
const never = step;
|
|
702
|
+
throw new Error(`unknown step ${JSON.stringify(never)}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (pause)
|
|
706
|
+
await page.waitForTimeout(pause / m.speed);
|
|
707
|
+
}
|
|
708
|
+
const pointName = (p) => p === undefined ? "?" : typeof p === "string" ? p : "x" in p ? `(${Math.round(p.x)},${Math.round(p.y)})` : `${p.selector}+(${p.dx},${p.dy})`;
|
|
709
|
+
export function describe(step) {
|
|
710
|
+
switch (step.action) {
|
|
711
|
+
case "wait":
|
|
712
|
+
return `wait ${step.ms}ms`;
|
|
713
|
+
case "click":
|
|
714
|
+
return `click ${step.selector ?? pointName(step.at)}${step.zoom ? ` (zoom ${step.zoom}x)` : ""}`;
|
|
715
|
+
case "drag":
|
|
716
|
+
return `drag ${pointName(step.from)} → ${pointName(step.to)}`;
|
|
717
|
+
case "type":
|
|
718
|
+
if (step.secret)
|
|
719
|
+
return `type •••••• → ${step.selector}`;
|
|
720
|
+
return `type "${step.text.length > 48 ? step.text.slice(0, 45) + "…" : step.text}" → ${step.selector}`;
|
|
721
|
+
case "fill":
|
|
722
|
+
return `fill ${step.selector}${step.secret ? " ••••••" : ""}`;
|
|
723
|
+
case "hover":
|
|
724
|
+
return `hover ${step.selector ?? pointName(step.at)}`;
|
|
725
|
+
case "scroll":
|
|
726
|
+
return step.to ? `scroll to ${step.to} (${step.align})` : `scroll x=${step.x ?? 0} y=${step.y ?? 0}`;
|
|
727
|
+
case "zoom":
|
|
728
|
+
return `zoom ${step.scale}x ${step.selector ?? ""}`.trim();
|
|
729
|
+
case "keyboard":
|
|
730
|
+
return `key ${step.key}`;
|
|
731
|
+
case "navigate":
|
|
732
|
+
return `go ${step.url}`;
|
|
733
|
+
case "screenshot":
|
|
734
|
+
return `screenshot ${step.name ?? ""}`.trim();
|
|
735
|
+
case "upload":
|
|
736
|
+
return `upload ${(Array.isArray(step.files) ? step.files : [step.files]).map((f) => path.basename(f)).join(", ")} → ${step.selector}`;
|
|
737
|
+
case "download":
|
|
738
|
+
return `download${step.selector ? ` via ${step.selector}` : ""}${step.saveAs ? ` → ${step.saveAs}` : ""}`;
|
|
739
|
+
case "waitFor":
|
|
740
|
+
return `wait for ${step.selector}`;
|
|
741
|
+
case "evaluate":
|
|
742
|
+
return `evaluate (${step.script.length} chars)`;
|
|
743
|
+
case "stub":
|
|
744
|
+
return `stub ${step.method ? step.method + " " : ""}${step.url}`;
|
|
745
|
+
case "scene":
|
|
746
|
+
return `scene: ${step.label}`;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** Wall-clock ms when the recorded page's document committed — empirically
|
|
750
|
+
where Playwright's first video frame lands (falls back to now). */
|
|
751
|
+
async function firstPaintWallClock(page) {
|
|
752
|
+
try {
|
|
753
|
+
const t = await page.evaluate(() => {
|
|
754
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
755
|
+
return nav ? performance.timeOrigin + nav.responseStart : null;
|
|
756
|
+
});
|
|
757
|
+
if (t)
|
|
758
|
+
return Math.round(t);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
/* fall through */
|
|
762
|
+
}
|
|
763
|
+
return Date.now();
|
|
764
|
+
}
|
|
765
|
+
// --- seeding -------------------------------------------------------------
|
|
766
|
+
/** "-12d" → twelve days ago, "+6h" → six hours from now, "now" → now; all
|
|
767
|
+
as epoch ms. Anything else passes through untouched. */
|
|
768
|
+
export function resolveRelativeDates(value, now = Date.now()) {
|
|
769
|
+
const UNIT = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 };
|
|
770
|
+
const walk = (v) => {
|
|
771
|
+
if (typeof v === "string") {
|
|
772
|
+
if (v === "now")
|
|
773
|
+
return now;
|
|
774
|
+
const m = /^([+-])(\d+(?:\.\d+)?)([mhdw])$/.exec(v);
|
|
775
|
+
if (m)
|
|
776
|
+
return now + (m[1] === "-" ? -1 : 1) * Number(m[2]) * UNIT[m[3]];
|
|
777
|
+
return v;
|
|
778
|
+
}
|
|
779
|
+
if (Array.isArray(v))
|
|
780
|
+
return v.map(walk);
|
|
781
|
+
if (v && typeof v === "object")
|
|
782
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
783
|
+
return v;
|
|
784
|
+
};
|
|
785
|
+
return walk(value);
|
|
786
|
+
}
|
|
787
|
+
export async function runFileOrCommandSeed(s, dir, log) {
|
|
788
|
+
if (s.kind === "file") {
|
|
789
|
+
const src = path.resolve(dir, s.from);
|
|
790
|
+
const dest = path.resolve(dir, expandEnv(s.path));
|
|
791
|
+
let body = fs.readFileSync(src, "utf8");
|
|
792
|
+
const parsed = JSON.parse(body); // fail loudly on a broken seed
|
|
793
|
+
if (s.relativeDates)
|
|
794
|
+
body = JSON.stringify(resolveRelativeDates(parsed));
|
|
795
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
796
|
+
fs.writeFileSync(dest, body);
|
|
797
|
+
log(`seed: wrote ${path.relative(process.cwd(), dest)}${s.relativeDates ? " (relative dates resolved)" : ""}`);
|
|
798
|
+
}
|
|
799
|
+
else if (s.kind === "command") {
|
|
800
|
+
log(`seed: $ ${s.run}`);
|
|
801
|
+
execSync(expandEnv(s.run), { stdio: "inherit", cwd: dir });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async function runEvaluateSeed(page, s, dir, log) {
|
|
805
|
+
const data = s.from ? JSON.parse(fs.readFileSync(path.resolve(dir, s.from), "utf8")) : undefined;
|
|
806
|
+
log(`seed: evaluate${s.from ? ` (${s.from})` : ""}`);
|
|
807
|
+
await page.evaluate(async ({ script, data }) => {
|
|
808
|
+
window.__seed = data;
|
|
809
|
+
// eslint-disable-next-line no-new-func
|
|
810
|
+
await new Function(`return (async () => { ${script} })()`)();
|
|
811
|
+
}, { script: s.script, data });
|
|
812
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 60000 });
|
|
813
|
+
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
|
|
814
|
+
}
|