@vosjs/render-core 0.2.9 → 0.3.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.
@@ -0,0 +1,813 @@
1
+ import { RecordingMeta, CursorEvent, Backdrop, RecordingArtifact, ProjectDoc } from '@vosjs/studio-core';
2
+
3
+ /**
4
+ * The recorder's driver: what it needs from a browser, as a STRUCTURAL
5
+ * contract. Playwright's own `Browser`/`BrowserContext`/`Page` satisfy it
6
+ * unchanged, and so does `@cloudflare/playwright`'s (the same client
7
+ * surface over Browser Rendering), so the CLI hands in a launched Node
8
+ * browser and the fleet hands in a connected one, and the recorder never
9
+ * imports either package. Only the members the recorder calls are named
10
+ * here; a driver that offers more is fine, one that offers less fails to
11
+ * type.
12
+ *
13
+ * Frames leave through a `FrameSink`, never through a filesystem: the CLI's
14
+ * sink writes a take directory, the fleet's puts each JPEG to R2.
15
+ */
16
+ interface RecorderRect {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ interface RecorderLocator {
23
+ waitFor(opts: {
24
+ state: 'visible';
25
+ timeout: number;
26
+ }): Promise<unknown>;
27
+ scrollIntoViewIfNeeded(): Promise<unknown>;
28
+ boundingBox(): Promise<RecorderRect | null>;
29
+ click(): Promise<unknown>;
30
+ fill(value: string): Promise<unknown>;
31
+ }
32
+ interface RecorderLocatorRoot {
33
+ first(): RecorderLocator;
34
+ }
35
+ interface RecorderResponse {
36
+ status(): number;
37
+ }
38
+ interface RecorderConsoleMessage {
39
+ type(): string;
40
+ text(): string;
41
+ }
42
+ interface RecorderPage {
43
+ goto(url: string, opts: {
44
+ waitUntil: 'networkidle';
45
+ timeout: number;
46
+ }): Promise<RecorderResponse | null>;
47
+ url(): string;
48
+ title(): Promise<string>;
49
+ evaluate<T>(pageFunction: string | (() => T)): Promise<T>;
50
+ on(event: 'console', cb: (m: RecorderConsoleMessage) => void): unknown;
51
+ locator(selector: string): RecorderLocatorRoot;
52
+ mouse: {
53
+ move(x: number, y: number): Promise<void>;
54
+ down(): Promise<void>;
55
+ up(): Promise<void>;
56
+ wheel(dx: number, dy: number): Promise<void>;
57
+ };
58
+ keyboard: {
59
+ type(text: string): Promise<void>;
60
+ press(key: string): Promise<void>;
61
+ };
62
+ }
63
+ interface RecorderCdp {
64
+ on(event: 'Page.screencastFrame', cb: (ev: {
65
+ data: string;
66
+ sessionId: number;
67
+ metadata: {
68
+ timestamp?: number;
69
+ };
70
+ }) => void): unknown;
71
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
72
+ }
73
+ interface RecorderContext {
74
+ newPage(): Promise<RecorderPage>;
75
+ addInitScript(script: string): Promise<unknown>;
76
+ setExtraHTTPHeaders(headers: Record<string, string>): Promise<unknown>;
77
+ newCDPSession(page: unknown): Promise<RecorderCdp>;
78
+ close(): Promise<void>;
79
+ }
80
+ interface RecorderBrowser {
81
+ newContext(opts: {
82
+ viewport: {
83
+ width: number;
84
+ height: number;
85
+ };
86
+ deviceScaleFactor: number;
87
+ storageState?: string;
88
+ extraHTTPHeaders?: Record<string, string>;
89
+ }): Promise<RecorderContext>;
90
+ }
91
+ /**
92
+ * Where the screencast's frames go. `frame` is called in order with the
93
+ * JPEG bytes; the recorder keeps the index (`file`, `tMs`) itself. The CLI
94
+ * writes `frames/<file>`; the fleet puts to R2. A sink may be async and
95
+ * slow: the recorder never awaits it on the screencast's own thread, so a
96
+ * slow store costs no frames, only memory until it catches up.
97
+ */
98
+ interface FrameSink {
99
+ frame(file: string, bytes: Uint8Array): Promise<void> | void;
100
+ }
101
+ /** The clock and the pauses, injectable so a rehearsal runs fast. */
102
+ interface RecorderClock {
103
+ now(): number;
104
+ sleep(ms: number): Promise<void>;
105
+ }
106
+ declare const realSleep: (ms: number) => Promise<void>;
107
+
108
+ /**
109
+ * The recorder's pace — the pure parts.
110
+ *
111
+ * A take of a scripted flow should run as long as the script asks, plus the
112
+ * gestures a person makes between the asks (the pointer's travel, the press,
113
+ * the settle after it) and no more. Measured before this module (§4.5 of
114
+ * the utility-clip retrospective), hovers ran 2× their ask, drags 4.8× and
115
+ * a 25 s script recorded 42 s, for two reasons the numbers here answer:
116
+ *
117
+ * - The motion loops counted STEPS (one per 16 ms of the asked duration)
118
+ * and awaited a `mouse.move` round trip per step, so a page that
119
+ * re-renders per move (a slider) stretched every gesture by the round
120
+ * trip. A gesture is now driven by the CLOCK: its position is a function
121
+ * of the elapsed time, and it ends when the asked duration has elapsed,
122
+ * however many samples the page allowed.
123
+ * - The settles after a gesture were fixed sleeps (500 ms after a click,
124
+ * 250 after every selector lookup). A settle is DATA: `ms` on the step
125
+ * overrides a small default, and the script's own `wait` steps carry
126
+ * the intended pauses.
127
+ */
128
+ /** Pointer travel: ms per CSS px, floor and ceiling — a person's hand. */
129
+ declare const POINTER_MS_PER_PX = 0.7;
130
+ declare const POINTER_MIN_MS = 250;
131
+ declare const POINTER_MAX_MS = 800;
132
+ /** The sample cadence a motion loop aims for. */
133
+ declare const MOTION_TICK_MS = 16;
134
+ /** The settle after a gesture when the step names none, by verb. */
135
+ declare const SETTLE_MS: {
136
+ readonly click: 150;
137
+ readonly type: 150;
138
+ readonly scroll: 200;
139
+ readonly drag: 80;
140
+ };
141
+ /** The press: the pause before the button goes down, and the hold. */
142
+ declare const PRESS_LEAD_MS = 80;
143
+ declare const PRESS_HOLD_MS = 70;
144
+ /** The pause between a selector lookup that scrolled the page and the move. */
145
+ declare const SCROLL_SETTLE_MS = 120;
146
+ /** The hold after the last step, so the take does not cut on a press. */
147
+ declare const TRAILING_HOLD_MS = 400;
148
+ /** How long the pointer takes to travel `dist` CSS px. */
149
+ declare function pointerTravelMs(dist: number): number;
150
+ /**
151
+ * A click's `ms` is READING time, held from the page's last visual change,
152
+ * and the recorder pays the settle itself: the same `ms` was read on one
153
+ * run and dead on the next because the hold started at the press and the
154
+ * page's settle varied run to run (0.3 s to 0.7 s on the same step).
155
+ *
156
+ * The settle is watched on the screencast, which emits only on change:
157
+ * after the press, a change that arrives within RESPONSE_MS says the page
158
+ * is answering, and it has settled once QUIET_MS pass with no new frame.
159
+ * No change within RESPONSE_MS means the page did not change (a click that
160
+ * only moves state) and the settle is over. SETTLE_CAP_MS bounds a page
161
+ * that never stops changing (a playing preview), which is a page nothing
162
+ * is dead on, so the hold simply starts.
163
+ */
164
+ declare const RESPONSE_MS = 400;
165
+ declare const QUIET_MS = 250;
166
+ declare const SETTLE_CAP_MS = 1200;
167
+ /**
168
+ * Is the page settled? Pure: the recorder feeds it what it knows at each
169
+ * tick since the press.
170
+ */
171
+ declare function settleVerdict(t: {
172
+ /** ms since the press. */
173
+ sincePress: number;
174
+ /** ms since the last screencast frame, or null when none arrived since the press. */
175
+ sinceChange: number | null;
176
+ }, c?: {
177
+ response: number;
178
+ quiet: number;
179
+ cap: number;
180
+ }): 'wait' | 'settled';
181
+ /**
182
+ * What is left of a hold once the settle is detected: the hold is measured
183
+ * from the last change, so the quiet already spent counts toward it.
184
+ */
185
+ declare function holdLeftMs(holdMs: number, sinceChange: number | null): number;
186
+ /** The settle after a step: its own `ms` when it names one, else the verb's. */
187
+ declare function settleMs(step: {
188
+ do: string;
189
+ ms?: number;
190
+ }): number;
191
+ declare const easeInOutCubic: (u: number) => number;
192
+ /**
193
+ * Drive a motion by the clock: `at(u)` is called with the eased progress
194
+ * for each sample, and the loop ends when `dur` ms have elapsed, with the
195
+ * last call at u = 1 exactly. Between samples it sleeps to the next tick
196
+ * only when the sample came back early; a slow sample (a page busy
197
+ * re-rendering) is followed at once, so the gesture keeps its length and
198
+ * loses samples, never the other way round.
199
+ */
200
+ declare function clockMotion(dur: number, at: (u: number) => Promise<void>, clock: {
201
+ now: () => number;
202
+ sleep: (ms: number) => Promise<void>;
203
+ }): Promise<number>;
204
+ /**
205
+ * Typing paced by the clock: the i-th character is due at `start + i·delay`;
206
+ * after each keystroke the loop sleeps to the next due time only if the
207
+ * keystroke came back early. A slow field (an editor re-rendering per key)
208
+ * types as fast as it can and says so in the pace report.
209
+ */
210
+ declare function clockTyping(chars: readonly string[], delay: number, type: (ch: string, index: number) => Promise<void>, clock: {
211
+ now: () => number;
212
+ sleep: (ms: number) => Promise<void>;
213
+ }): Promise<void>;
214
+ /** One step's pace: what the script asked for it and what it took. */
215
+ interface StepPace {
216
+ step: number;
217
+ do: string;
218
+ /** The script's own ask: a wait's ms, a hover's dwell, a drag's ms, typing's chars × delay, a click's read after the settle; 0 for a scroll. */
219
+ askedMs: number;
220
+ /** The gesture the recorder adds by design: pointer travel, the press, the settle. */
221
+ gestureMs: number;
222
+ wallMs: number;
223
+ }
224
+ interface PaceReport {
225
+ askedMs: number;
226
+ gestureMs: number;
227
+ wallMs: number;
228
+ /** Wall time neither asked nor a gesture: what the page and the round trips cost. */
229
+ overheadMs: number;
230
+ overheadPct: number;
231
+ /** The steps whose wall ran past their ask plus gesture by more than a third. */
232
+ slow: {
233
+ step: number;
234
+ do: string;
235
+ askedMs: number;
236
+ wallMs: number;
237
+ }[];
238
+ }
239
+ /** What a script asks of a step, in ms: the part of its wall time that is the author's. */
240
+ declare function askedMs(step: {
241
+ do: string;
242
+ ms?: number;
243
+ text?: string;
244
+ delayMs?: number;
245
+ }): number;
246
+ declare function paceReport(steps: readonly StepPace[]): PaceReport;
247
+ /** The pace in one line for the log. */
248
+ declare function paceLine(r: PaceReport): string;
249
+ /**
250
+ * DEAD TIME, the smoothness fact that replaces the freeze share as the
251
+ * warning. A still frame is not a defect: a page the viewer is reading is
252
+ * content, and a workspace tour is mostly still. What is dead is a still
253
+ * frame under a PARKED cursor past the beat it takes to read what changed:
254
+ * after that the picture, the pointer and the camera all hold and nothing
255
+ * is being said. So the derivation reads, per step, when the page settled
256
+ * after the step's act, how long the settled frame was held, and whether
257
+ * the cursor moved in that hold; a hold past the reading beat is dead.
258
+ *
259
+ * The beat is longer after a page changed (a navigation shows a whole new
260
+ * page to take in) than after a control changed (one thing to see).
261
+ */
262
+ declare const READ_BEAT_MS: {
263
+ readonly page: 1000;
264
+ readonly control: 600;
265
+ };
266
+ /** A dead stretch this long is a warning on its own, whatever the share. */
267
+ declare const DEAD_STRETCH_WARN_MS = 1500;
268
+ /** The share of the take past which dead time is a warning. */
269
+ declare const DEAD_SHARE_WARN_PCT = 20;
270
+ interface DeadStep {
271
+ step: number;
272
+ id?: string;
273
+ do: string;
274
+ /** ms from the step's act (its press, else its start) to the last frame change in the step. */
275
+ settledMs: number;
276
+ /** ms the settled frame was held before the step ended. */
277
+ heldMs: number;
278
+ /** the hold past the reading beat, while the cursor was parked; 0 when the hold was read or the pointer moved. */
279
+ deadMs: number;
280
+ }
281
+ interface DeadReport {
282
+ ms: number;
283
+ pct: number;
284
+ /** the steps that carried any dead time, in order. */
285
+ steps: DeadStep[];
286
+ /** the longest single dead hold. */
287
+ longestMs: number;
288
+ }
289
+ declare function deadTime(steps: readonly {
290
+ step: number;
291
+ id?: string;
292
+ do: string;
293
+ tStart: number;
294
+ tEnd: number;
295
+ navigated?: boolean;
296
+ }[], frames: readonly {
297
+ tMs: number;
298
+ }[], events: readonly {
299
+ t: number;
300
+ type: string;
301
+ }[], durationMs: number): DeadReport;
302
+ /** Whether a dead report earns a warning: the share, or one long hold. */
303
+ declare function deadWarns(r: DeadReport): boolean;
304
+ /** The dead time in words, naming the steps and what to cut. */
305
+ declare function deadLine(r: DeadReport): string;
306
+
307
+ /**
308
+ * What a signed-in take SHOWS. Getting past the login is half the problem;
309
+ * the other half is that the account's own data is now in the frame: an
310
+ * email address in the header, a key on a settings page, a card number.
311
+ * Asking nicely does not hold this line (a person asked to sign in with a
312
+ * demo account signs in as themselves, because it is the account they have),
313
+ * so the recorder looks.
314
+ *
315
+ * Two pieces, both run IN the page:
316
+ *
317
+ * - the EXPOSURE scan reads the text that is visible in the viewport after
318
+ * each step and reports the KIND of thing it saw and where. Never the
319
+ * string itself: a report that quotes the secret is a second leak.
320
+ * - a MASK hides a selector before the first frame is captured and keeps it
321
+ * hidden across navigations and re-renders, so the real value is never in
322
+ * a frame, never in the recording, never pushed. `as: 'text'` swaps the
323
+ * words (a product reads better than a redaction); `as: 'blur'` blurs.
324
+ *
325
+ * The matchers are ONE plain-JavaScript source string, evaluated in the page
326
+ * and, by the tests, in Node: a function serialized out of a bundle picks up
327
+ * the bundler's `__name` helper and dies in the page, and two copies of a
328
+ * regex drift.
329
+ */
330
+
331
+ type ExposureKind = 'email' | 'key' | 'card' | 'card-tail';
332
+ type Exposure = NonNullable<RecordingMeta['exposures']>[number];
333
+ type MaskReport = NonNullable<RecordingMeta['masks']>[number];
334
+ interface MaskRule {
335
+ selector: string;
336
+ /** `blur` (default) blurs the element; `text` swaps its words for `text`. */
337
+ as?: 'blur' | 'text';
338
+ text?: string;
339
+ }
340
+ /**
341
+ * `kindsIn(text)` → the kinds of sensitive-looking strings in `text`.
342
+ *
343
+ * - email: any address, EXCEPT one on a domain that can reach no one
344
+ * (example.com/.org/.net, and the reserved .test/.example/.invalid/
345
+ * .localhost TLDs), which is what demo data should use.
346
+ * - key: the prefixes of keys people actually paste into dashboards, and a
347
+ * JWT's three base64url parts.
348
+ * - card: 13 to 19 digits that pass the Luhn check, so an order number or a
349
+ * phone number is not a card.
350
+ * - card-tail: a masked number's visible tail (•••• 4242).
351
+ */
352
+ declare const EXPOSURE_MATCHERS_SRC = "(() => {\n const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g\n const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i\n const KEY = new RegExp([\n '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',\n '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',\n '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',\n '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',\n '\\\\bAKIA[0-9A-Z]{16}\\\\b',\n '\\\\bAIza[0-9A-Za-z_-]{30,}',\n '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',\n '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',\n ].join('|'))\n const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g\n const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/\n const luhn = (digits) => {\n let sum = 0, alt = false\n for (let i = digits.length - 1; i >= 0; i--) {\n let d = digits.charCodeAt(i) - 48\n if (alt) { d *= 2; if (d > 9) d -= 9 }\n sum += d; alt = !alt\n }\n return sum % 10 === 0\n }\n const kindsIn = (text) => {\n const kinds = []\n if (!text || text.length < 6) return kinds\n EMAIL.lastIndex = 0\n for (let m; (m = EMAIL.exec(text)); ) {\n if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }\n }\n if (KEY.test(text)) kinds.push('key')\n CARD.lastIndex = 0\n for (let m; (m = CARD.exec(text)); ) {\n const digits = m[1].replace(/[ -]/g, '')\n if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }\n }\n if (TAIL.test(text)) kinds.push('card-tail')\n return kinds\n }\n return { kindsIn }\n})()";
353
+ /**
354
+ * The scan: visible text in the viewport, plus the values of text inputs.
355
+ * Skips anything inside a masked element, password fields, and text the eye
356
+ * cannot see. Reports kind + a selector-ish name for the element + its rect.
357
+ */
358
+ declare const EXPOSURE_PROBE = "(() => {\n const { kindsIn } = (() => {\n const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g\n const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i\n const KEY = new RegExp([\n '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',\n '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',\n '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',\n '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',\n '\\\\bAKIA[0-9A-Z]{16}\\\\b',\n '\\\\bAIza[0-9A-Za-z_-]{30,}',\n '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',\n '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',\n ].join('|'))\n const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g\n const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/\n const luhn = (digits) => {\n let sum = 0, alt = false\n for (let i = digits.length - 1; i >= 0; i--) {\n let d = digits.charCodeAt(i) - 48\n if (alt) { d *= 2; if (d > 9) d -= 9 }\n sum += d; alt = !alt\n }\n return sum % 10 === 0\n }\n const kindsIn = (text) => {\n const kinds = []\n if (!text || text.length < 6) return kinds\n EMAIL.lastIndex = 0\n for (let m; (m = EMAIL.exec(text)); ) {\n if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }\n }\n if (KEY.test(text)) kinds.push('key')\n CARD.lastIndex = 0\n for (let m; (m = CARD.exec(text)); ) {\n const digits = m[1].replace(/[ -]/g, '')\n if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }\n }\n if (TAIL.test(text)) kinds.push('card-tail')\n return kinds\n }\n return { kindsIn }\n})()\n const vw = innerWidth, vh = innerHeight\n // A selector that reaches THIS element and no other, so it can be pasted\n // into \"mask\" as it is: climb until the path is unique, and say which\n // sibling wherever a tag-and-class alone would match several.\n const segment = (el) => {\n const tid = el.getAttribute('data-testid')\n if (tid) return el.tagName.toLowerCase() + '[data-testid=\"' + tid + '\"]'\n if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id\n const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)\n let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')\n const p = el.parentElement\n if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)\n seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'\n return seg\n }\n const name = (el) => {\n const parts = []\n for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {\n parts.unshift(segment(cur))\n let n = 2\n try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}\n // a bare tag is unique HERE and nowhere else: give it its parent too\n const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])\n if ((n === 1 && !bare) || parts.length >= 5) break\n }\n return parts.join(' > ')\n }\n const seen = (el) => {\n if (el.closest('[data-vos-masked]')) return null\n const r = el.getBoundingClientRect()\n if (r.width < 2 || r.height < 2) return null\n if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null\n const s = getComputedStyle(el)\n if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null\n return r\n }\n const out = [], keys = new Set()\n const add = (el, text) => {\n const kinds = kindsIn(text)\n if (!kinds.length) return\n const r = seen(el)\n if (!r) return\n const selector = name(el)\n for (const kind of kinds) {\n const k = kind + '|' + selector\n if (keys.has(k) || out.length >= 40) continue\n keys.add(k)\n out.push({ kind, selector, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })\n }\n }\n const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)\n for (let n; (n = walker.nextNode()); ) {\n const el = n.parentElement\n if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue\n add(el, n.nodeValue)\n }\n for (const el of document.querySelectorAll('input, textarea')) {\n if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue\n add(el, el.value)\n }\n const hits = {}\n for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n\n return { exposures: out, maskHits: hits }\n})()";
359
+ /**
360
+ * The mask, as an init script: it runs at document start on EVERY navigation,
361
+ * so a masked value is hidden before the page's first paint, and a
362
+ * MutationObserver re-applies it after a client-side re-render. A form
363
+ * control is blurred even when `as: 'text'` was asked for: writing into an
364
+ * input would change what the app submits.
365
+ */
366
+ declare function maskInitScript(masks: MaskRule[]): string;
367
+ /**
368
+ * Accumulates scans across a take: an exposure is reported ONCE, at the
369
+ * first step it was seen, with how many scans saw it.
370
+ */
371
+ declare class ExposureLog {
372
+ private byKey;
373
+ private maskHits;
374
+ constructor(masks?: MaskRule[]);
375
+ add(step: number, scan: {
376
+ exposures?: Omit<Exposure, 'step' | 'seen'>[];
377
+ maskHits?: Record<string, number>;
378
+ } | null): void;
379
+ exposures(): Exposure[];
380
+ masks(rules: MaskRule[]): MaskReport[];
381
+ }
382
+ /** One exposure, in words. `step` -1 is the page as it opened. */
383
+ declare function exposureLine(e: Exposure): string;
384
+ declare const EXPOSURE_ADVICE = "The recording shows these. Record from a demo account, or hide them before the camera rolls with \"mask\" in actions.json: { \"selector\": \"\u2026\", \"as\": \"text\", \"text\": \"demo@acme.test\" } swaps the words, \"as\": \"blur\" blurs. Then re-record.";
385
+
386
+ /**
387
+ * `setup`: the steps that run BEFORE the camera rolls. A sign-in form, a
388
+ * cookie banner, the "choose your editor" modal, an onboarding tour: things
389
+ * a take must get past and must not show. They run after the first
390
+ * navigation and before `Page.startScreencast`, with no cursor synthesis,
391
+ * no pace accounting and nothing written to `meta.steps`. Then the recorder
392
+ * navigates to `url` again and the take begins where the setup left it.
393
+ *
394
+ * A `type` step's `text` may be `{ "env": "DEMO_PASSWORD" }`: resolved at run
395
+ * time from the `env` the caller hands in (the shell's for the CLI, the
396
+ * job's own map for a hosted take), never logged, never stored. The
397
+ * guarantee is narrow and real: the secret is never in a file that travels
398
+ * (`actions.json` is committed and pushed with the take; `meta.json` is read
399
+ * by the digest) and never in the footage.
400
+ */
401
+
402
+ type SetupText = string | {
403
+ env: string;
404
+ };
405
+ type SetupStep = ({
406
+ do: 'wait';
407
+ ms: number;
408
+ } | {
409
+ do: 'click';
410
+ selector: string;
411
+ ms?: number;
412
+ } | {
413
+ do: 'type';
414
+ selector: string;
415
+ text: SetupText;
416
+ ms?: number;
417
+ } | {
418
+ do: 'press';
419
+ key: string;
420
+ ms?: number;
421
+ } | {
422
+ do: 'goto';
423
+ url: string;
424
+ }) & {
425
+ id?: string;
426
+ };
427
+ /** Structural validation, mirrored on `validateActions` for `steps`. */
428
+ declare function validateSetup(value: unknown): string[];
429
+ /** The names a setup reads from its env, so a host can say what it must supply. */
430
+ declare function setupEnvNames(steps: SetupStep[] | undefined): string[];
431
+ /** A `{ env }` text resolved, or thrown in words. The value is never returned to a log. */
432
+ declare function resolveSetupText(t: SetupText, env: Record<string, string | undefined>): {
433
+ value: string;
434
+ secret: boolean;
435
+ };
436
+ interface SetupResult {
437
+ ran: number;
438
+ /** what the log may say about each step: the verb and the selector, never a typed value */
439
+ lines: string[];
440
+ /** a selector that never appeared; the take does not start */
441
+ failed?: {
442
+ step: number;
443
+ do: string;
444
+ selector?: string;
445
+ };
446
+ }
447
+ /**
448
+ * Run the setup on a page, with plain Playwright actions: no humanized
449
+ * pointer, no cursor events, no frames. A selector that never appears
450
+ * fails the setup, because a take that begins at a half-finished sign-in is
451
+ * the wall by another name.
452
+ */
453
+ declare function runSetup(page: RecorderPage, steps: SetupStep[], opts: {
454
+ sleep: (ms: number) => Promise<void>;
455
+ /** Absent means no env at all: a `{ env }` text then fails in words. */
456
+ env?: Record<string, string | undefined>;
457
+ }): Promise<SetupResult>;
458
+ /** A `{ env }` the shell did not provide: a usage error, not a page's. */
459
+ declare class SetupEnvError extends Error {
460
+ constructor(message: string);
461
+ }
462
+ /** A setup step whose selector never appeared: the take does not start. */
463
+ declare class SetupError extends Error {
464
+ failed: NonNullable<SetupResult['failed']>;
465
+ constructor(failed: NonNullable<SetupResult['failed']>);
466
+ }
467
+ /** `--header name=value`, repeatable: extra request headers on every request. */
468
+ declare function parseHeaders(given: string[] | undefined): Record<string, string>;
469
+
470
+ /**
471
+ * The action script — the declarative recipe an agent (or human) writes to
472
+ * drive a take. Small on purpose: selectors + a handful of verbs. The recorder
473
+ * executes it with humanized cursor motion and synthesizes the CursorTrack
474
+ * from its own dispatches.
475
+ */
476
+ interface ActionsFile {
477
+ /** Page to record. `--url` overrides. */
478
+ url?: string;
479
+ /** Recording viewport in CSS px (default 1280x720). */
480
+ viewport?: {
481
+ width: number;
482
+ height: number;
483
+ };
484
+ /**
485
+ * Hidden BEFORE the first frame is captured and kept hidden across
486
+ * navigations: what a signed-in account shows that must not ship. `blur`
487
+ * (default) blurs the element; `text` swaps its words for `text`, which is
488
+ * for IDENTIFIERS (an email, a name, an account id), never for product
489
+ * copy or numbers: the video must stay true to the product.
490
+ */
491
+ mask?: MaskRule[];
492
+ /**
493
+ * Run BEFORE the camera rolls, after the first navigation: a sign-in form,
494
+ * a cookie banner, an onboarding tour. Plain actions with no cursor, no
495
+ * frames, no pace and nothing in `meta.steps`; then the recorder opens
496
+ * `url` again and the take begins where the setup left it. A `type`
497
+ * step's text may be `{ env: 'NAME' }`, read at run time and never logged
498
+ * or stored.
499
+ */
500
+ setup?: SetupStep[];
501
+ steps: ActionStep[];
502
+ }
503
+ type ActionStep = ({
504
+ do: 'wait';
505
+ ms: number;
506
+ } | {
507
+ do: 'hover';
508
+ selector: string;
509
+ ms?: number;
510
+ }
511
+ /** `ms` is the settle after the press (default 150). */
512
+ | {
513
+ do: 'click';
514
+ selector: string;
515
+ ms?: number;
516
+ }
517
+ /**
518
+ * Type into `selector`. The recorder clicks the field first, which is what
519
+ * opens the typing zoom on it; `focus: false` types into the field as it is
520
+ * already focused, for a keystroke that follows earlier typing (a submitting
521
+ * Enter) rather than starting it — a second click there rings a click effect
522
+ * on empty space beside the text.
523
+ */
524
+ | {
525
+ do: 'type';
526
+ selector: string;
527
+ text: string;
528
+ delayMs?: number;
529
+ focus?: boolean;
530
+ /** the settle after the last character (default 150) */
531
+ ms?: number;
532
+ }
533
+ /** `ms` is the settle after the scroll lands (default 200). */
534
+ | {
535
+ do: 'scroll';
536
+ dy: number;
537
+ ms?: number;
538
+ } | {
539
+ do: 'move';
540
+ x: number;
541
+ y: number;
542
+ }
543
+ /**
544
+ * Press-move-release — real edits (drag an element on the stage canvas,
545
+ * slide a range input, move a timeline clip). Start = the selector's center
546
+ * when given, else (x, y); end = (tx, ty); eased over ms (default 700).
547
+ */
548
+ | {
549
+ do: 'drag';
550
+ selector?: string;
551
+ x?: number;
552
+ y?: number;
553
+ tx: number;
554
+ ty: number;
555
+ ms?: number;
556
+ }) & {
557
+ /**
558
+ * Optional stable identity: anchors in doc.json name a step by this
559
+ * id (else by index), so a step can move or gain neighbours across script
560
+ * edits without breaking the cut anchored to it. Unique when present.
561
+ */
562
+ id?: string;
563
+ /**
564
+ * A beat's caption, two to eight words: `vos deliver` lands it as a
565
+ * lower-third at this step's moment on the cuts that take words, and
566
+ * `vos actions script` leads the beat with it.
567
+ */
568
+ caption?: string;
569
+ };
570
+ /** Structural validation with actionable messages. Returns [] when valid. */
571
+ declare function validateActions(value: unknown): string[];
572
+
573
+ /**
574
+ * The wall check: did the recorder land where it was asked, or in front of
575
+ * a sign-in? A take of a product behind a login, recorded without a session,
576
+ * used to succeed: the footage was the login page (or wherever the site
577
+ * sends a stranger), and the only symptom was a skipped selector. This says
578
+ * it in words, before a frame is captured and before a re-record clears the
579
+ * footage it would have replaced.
580
+ *
581
+ * Pure: the recorder gathers the evidence, this decides. A heuristic is
582
+ * acceptable because both failure directions are cheap: a false refusal
583
+ * costs one flag (`--allow-wall`), a false pass costs what every such take
584
+ * cost before.
585
+ */
586
+ /** What the recorder saw once the first navigation settled. */
587
+ interface Arrival {
588
+ askedUrl: string;
589
+ landedUrl: string;
590
+ /** HTTP status of the main document, when the navigation produced one. */
591
+ status?: number;
592
+ /** `input[type=password]` fields that are visible on the page. */
593
+ passwordFields: number;
594
+ /** A password field marked `autocomplete="new-password"` (a settings or sign-up form). */
595
+ newPasswordField: boolean;
596
+ /** A visible one-time-code field (`autocomplete="one-time-code"`). */
597
+ oneTimeCodeField: boolean;
598
+ }
599
+ type WallKind =
600
+ /** the asked URL answered 401 or 403 */
601
+ 'status'
602
+ /** landed on an identity provider's host */
603
+ | 'idp'
604
+ /** landed on a sign-in form or a sign-in path */
605
+ | 'signin'
606
+ /** sent somewhere else, with no sign-in in sight (a site that shows strangers a public page) */
607
+ | 'redirect';
608
+ interface WallVerdict {
609
+ kind: WallKind;
610
+ /**
611
+ * `hard` is refused always; `soft` is refused under `--strict` and said as
612
+ * a warning otherwise, because a redirect alone is not proof of a wall.
613
+ */
614
+ level: 'hard' | 'soft';
615
+ /** origin + path, never the query or hash (they can carry tokens). */
616
+ asked: string;
617
+ landed: string;
618
+ message: string;
619
+ }
620
+ declare function isIdpHost(host: string): boolean;
621
+ declare function isSignInPath(pathname: string): boolean;
622
+ /**
623
+ * Sent AWAY, as opposed to sent deeper. `/` → `/en`, `/docs` → `/docs/start`
624
+ * and `/pricing` → `/en/pricing` are a site arranging itself; `/dashboard` →
625
+ * `/gallery` is a site declining to show the page. `www.` and the apex are
626
+ * one host.
627
+ */
628
+ declare function redirectedAway(asked: URL, landed: URL): boolean;
629
+ declare function wallVerdict(a: Arrival): WallVerdict | null;
630
+ /** A take refused at the wall. Carries the verdict for `--json`. */
631
+ declare class WallError extends Error {
632
+ verdict: WallVerdict;
633
+ constructor(verdict: WallVerdict);
634
+ }
635
+ /**
636
+ * Evidence gathered in the page. A STRING, never a function: a serialized
637
+ * function picks up the bundler's `__name` helper and dies in the page.
638
+ */
639
+ declare const WALL_PROBE = "(() => {\n const shown = (el) => {\n const r = el.getBoundingClientRect()\n if (r.width < 2 || r.height < 2) return false\n const s = getComputedStyle(el)\n return s.visibility !== 'hidden' && s.display !== 'none'\n }\n const pw = [...document.querySelectorAll('input[type=\"password\"]')].filter(shown)\n const otp = [...document.querySelectorAll('input[autocomplete=\"one-time-code\"]')].filter(shown)\n return {\n passwordFields: pw.length,\n newPasswordField: pw.some((el) => (el.getAttribute('autocomplete') || '').includes('new-password')),\n oneTimeCodeField: otp.length > 0,\n }\n})()";
640
+
641
+ /**
642
+ * The recorder — a browser drives the action script while:
643
+ * - CDP Page.startScreencast collects JPEG frames (epoch-timestamped;
644
+ * hold-last-frame gaps are filled at encode time), and
645
+ * - every dispatched input is logged as a CursorEvent (the synthesized track:
646
+ * exact coords, exact times, fresh element rects — no capture needed).
647
+ *
648
+ * The MECHANISM, host-free (AN4): the browser arrives as a structural driver
649
+ * (a launched Node Playwright browser from the CLI, a connected
650
+ * @cloudflare/playwright browser from the fleet), frames leave through a
651
+ * sink, the platform and the setup env are handed in, and nothing here
652
+ * touches a filesystem or a process. The CLI's `recordTake` wraps this with
653
+ * a take directory; the fleet's consumer wraps it with R2. Both produce the
654
+ * same events, meta and frame index for the same script.
655
+ */
656
+
657
+ interface FrameRec {
658
+ file: string;
659
+ /** ms since t0 */
660
+ tMs: number;
661
+ }
662
+ interface SkippedStep {
663
+ /** index into actions.steps */
664
+ step: number;
665
+ do: string;
666
+ selector: string;
667
+ }
668
+ /** A stretch with no screencast frames — the page pixels did not change. */
669
+ interface FreezeSpan {
670
+ /** seconds into the take */
671
+ from: number;
672
+ to: number;
673
+ ms: number;
674
+ }
675
+ interface RecordResult {
676
+ events: CursorEvent[];
677
+ frames: FrameRec[];
678
+ meta: RecordingMeta;
679
+ /** The take's pace: what the script asked, what the gestures added, what the page cost. */
680
+ pace: PaceReport;
681
+ /** steps whose selector never became visible — the take continued without them. */
682
+ skipped: SkippedStep[];
683
+ /** the initial goto never reached networkidle (recording proceeded anyway). */
684
+ navTimeout: boolean;
685
+ /** A wall the caller let through; null when the recorder landed where it was asked. */
686
+ wall: WallVerdict | null;
687
+ /** Sensitive-looking text seen in the frame (kind + place, never the string). */
688
+ exposures: NonNullable<RecordingMeta['exposures']>;
689
+ /** The script's masks with how many elements each reached; hits 0 hid nothing. */
690
+ masks: NonNullable<RecordingMeta['masks']>;
691
+ /**
692
+ * Smoothness telemetry: stretches ≥400ms with no visual change. Frozen
693
+ * footage is the #1 enemy of a smooth product video — either the flow should
694
+ * keep motion in frame (animate, hover a preview, scroll) or the doc should
695
+ * trim/speed through these. freezePct = share of the take that is frozen.
696
+ */
697
+ freezes: FreezeSpan[];
698
+ freezePct: number;
699
+ /**
700
+ * The smoothness WARNING: still footage under a parked cursor past the
701
+ * beat it takes to read what changed, per step (`deadTime`). A still page
702
+ * being read is content; this is the part of a hold nobody is reading.
703
+ */
704
+ dead: DeadReport;
705
+ /** The take reached --max-duration and stopped there; later steps did not run. */
706
+ capped: boolean;
707
+ /** The actions as recorded, `url` resolved: what a take directory writes back. */
708
+ actions: ActionsFile;
709
+ }
710
+ interface RecordOpts {
711
+ /** Stop the capture at this many seconds (the hosted cap). */
712
+ maxDurationSeconds?: number;
713
+ /**
714
+ * Path to a Playwright storage state (cookies + origin storage), so the
715
+ * recorder drives a SIGNED-IN product. A demo of anything behind a login
716
+ * needs it, and a sign-in form cannot always be scripted (an emailed code,
717
+ * an SSO hop). Export one from a real browser, or with
718
+ * `context.storageState({ path })`.
719
+ */
720
+ storageState?: string;
721
+ /**
722
+ * REHEARSE the flow: every step runs against the real page, in order,
723
+ * because a later selector usually exists only after an earlier click. But
724
+ * nothing is captured (no screencast, no frames), nothing is written, the
725
+ * pointer lands instead of travelling and every pause is cut to a beat, so
726
+ * a script that misses a selector says so in seconds instead of after a
727
+ * full real-time take and its encode. Selector lookups keep their whole
728
+ * timeout, so a miss here is a miss in the take.
729
+ */
730
+ dryRun?: boolean;
731
+ /**
732
+ * Called once the first navigation has settled, BEFORE a frame is captured.
733
+ * The caller judges the wall here and only then prepares the take
734
+ * directory, so a take refused at a sign-in never clears the footage a
735
+ * re-record would have replaced. A throw closes the context and propagates.
736
+ */
737
+ onArrival?: (arrival: Arrival) => Promise<WallVerdict | null | void> | WallVerdict | null | void;
738
+ /** Extra request headers on every request (`--header name=value`). */
739
+ headers?: Record<string, string>;
740
+ /**
741
+ * A ready-made context to record IN, instead of one made from `browser`:
742
+ * `--session <name>`, a persistent profile the person signed in to. The
743
+ * recorder still sizes the viewport and closes it at the end.
744
+ */
745
+ context?: RecorderContext;
746
+ /**
747
+ * The environment a setup's `{ env }` texts resolve from: the shell's
748
+ * for the CLI, the job's own map on the fleet. Absent means no env at
749
+ * all, so a setup that reads one fails in words.
750
+ */
751
+ env?: Record<string, string | undefined>;
752
+ /** The platform the take is recorded on, for `meta.platform`. */
753
+ platform?: RecordingMeta['platform'];
754
+ }
755
+ /** Minimal JPEG SOF parse for real encoded dimensions. */
756
+ declare function jpegDims(buf: Uint8Array): {
757
+ w: number;
758
+ h: number;
759
+ } | null;
760
+ /** Base64 to bytes without Node's Buffer, so the fleet's isolate runs it too. */
761
+ declare function base64Bytes(data: string): Uint8Array;
762
+ declare function recordTake(browser: RecorderBrowser, url: string, actions: ActionsFile, sink: FrameSink, log: (msg: string) => void, opts?: RecordOpts): Promise<RecordResult>;
763
+
764
+ /**
765
+ * The recording cap as the recorder applies it, pure. WHAT the cap is
766
+ * (the hosted plan's number, read from `GET /api/limits`) is a vos.so fact
767
+ * and stays in the CLI's platform client; the recorder only knows a number
768
+ * of seconds and stops there.
769
+ */
770
+ /** True once the take has reached the cap — the recorder stops driving steps. */
771
+ declare function capReached(elapsedMs: number, capSeconds: number): boolean;
772
+ /** A wait step never sleeps past the cap. */
773
+ declare function clampWait(waitMs: number, elapsedMs: number, capSeconds: number): number;
774
+ /** `30 min`, `1 h 30 min`, `45 s` — the duration cap in words. */
775
+ declare function formatDurationCap(seconds: number): string;
776
+ /** The one line printed when the cap stopped the take. */
777
+ declare function cappedLine(capSeconds: number): string;
778
+
779
+ /**
780
+ * Frames → CFR WebM, as a PAGE. The VFR screencast JPEGs are drawn
781
+ * hold-last-frame onto a canvas at a fixed 30fps and encoded with
782
+ * mediabunny (WebCodecs) inside a headless page — no ffmpeg dependency.
783
+ * The CLI serves the take directory to it and reads the bytes back through
784
+ * its own `/save`; the fleet serves the frames through the ingest route and
785
+ * the page PUTs the recording to the same route. One page, two hosts: the
786
+ * URLs and the way out are the host's, the encode is not.
787
+ */
788
+ interface EncodePageOptions {
789
+ /** Where `frames.json` (the index `[{file, tMs}]`) is fetched from. */
790
+ framesIndexUrl: string;
791
+ /** Where `meta.json` is fetched from. */
792
+ metaUrl: string;
793
+ /** `<framesBaseUrl><file>` is each JPEG. */
794
+ framesBaseUrl: string;
795
+ /** The way out: a POST to the CLI's take server, or a PUT to the ingest route. */
796
+ save: {
797
+ method: 'POST' | 'PUT';
798
+ url: string;
799
+ };
800
+ /** Optional `?rt=`-style query the frame fetches must carry (the fleet's token). */
801
+ frameQuery?: string;
802
+ }
803
+ declare function buildEncodePage(o: EncodePageOptions): string;
804
+
805
+ interface FreshPlanOptions {
806
+ /** The backdrop the document opens on; absent keeps the bare frame. */
807
+ backdrop?: Backdrop | null;
808
+ /** A digest's activity bins, when one exists (the speed planner's witness). */
809
+ activity?: readonly number[] | null;
810
+ }
811
+ declare function planFreshTake(artifact: RecordingArtifact, recordingName: string, opts?: FreshPlanOptions): ProjectDoc;
812
+
813
+ export { type ActionStep, type ActionsFile, type Arrival, DEAD_SHARE_WARN_PCT, DEAD_STRETCH_WARN_MS, type DeadReport, type DeadStep, EXPOSURE_ADVICE, EXPOSURE_MATCHERS_SRC, EXPOSURE_PROBE, type EncodePageOptions, type Exposure, type ExposureKind, ExposureLog, type FrameRec, type FrameSink, type FreezeSpan, type FreshPlanOptions, MOTION_TICK_MS, type MaskReport, type MaskRule, POINTER_MAX_MS, POINTER_MIN_MS, POINTER_MS_PER_PX, PRESS_HOLD_MS, PRESS_LEAD_MS, type PaceReport, QUIET_MS, READ_BEAT_MS, RESPONSE_MS, type RecordOpts, type RecordResult, type RecorderBrowser, type RecorderCdp, type RecorderClock, type RecorderConsoleMessage, type RecorderContext, type RecorderLocator, type RecorderLocatorRoot, type RecorderPage, type RecorderRect, type RecorderResponse, SCROLL_SETTLE_MS, SETTLE_CAP_MS, SETTLE_MS, SetupEnvError, SetupError, type SetupResult, type SetupStep, type SetupText, type SkippedStep, type StepPace, TRAILING_HOLD_MS, WALL_PROBE, WallError, type WallKind, type WallVerdict, askedMs, base64Bytes, buildEncodePage, capReached, cappedLine, clampWait, clockMotion, clockTyping, deadLine, deadTime, deadWarns, easeInOutCubic, exposureLine, formatDurationCap, holdLeftMs, isIdpHost, isSignInPath, jpegDims, maskInitScript, paceLine, paceReport, parseHeaders, planFreshTake, pointerTravelMs, realSleep, recordTake, redirectedAway, resolveSetupText, runSetup, settleMs, settleVerdict, setupEnvNames, validateActions, validateSetup, wallVerdict };