@vosjs/render-core 0.5.3 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import { RecordingMeta, CursorEvent, Backdrop, RecordingArtifact, ProjectDoc } from '@vosjs/studio-core';
1
+ import { RecordingMeta, CursorEvent, RecordingSkip, RecordingOutput, Backdrop, RecordingArtifact, ProjectDoc } from '@vosjs/studio-core';
2
2
 
3
3
  /**
4
4
  * The recorder's driver: what it needs from a browser, as a STRUCTURAL
@@ -21,13 +21,38 @@ interface RecorderRect {
21
21
  }
22
22
  interface RecorderLocator {
23
23
  waitFor(opts: {
24
- state: 'visible';
24
+ state: 'visible' | 'attached';
25
25
  timeout: number;
26
26
  }): Promise<unknown>;
27
27
  scrollIntoViewIfNeeded(): Promise<unknown>;
28
28
  boundingBox(): Promise<RecorderRect | null>;
29
29
  click(): Promise<unknown>;
30
30
  fill(value: string): Promise<unknown>;
31
+ /** what an `expect` and an `await` read, and the disabled-click report */
32
+ isVisible(): Promise<boolean>;
33
+ isEnabled(opts?: {
34
+ timeout?: number;
35
+ }): Promise<boolean>;
36
+ /** a `download` src reads the media element's source through the locator engine */
37
+ evaluate<R>(fn: (el: RecorderMediaElement) => R): Promise<R>;
38
+ }
39
+ /** What a `download` src reads off the element its selector matched. */
40
+ interface RecorderMediaElement {
41
+ currentSrc?: string;
42
+ src?: string;
43
+ getAttribute(name: string): string | null;
44
+ }
45
+ /** A browser download, as Playwright's `Download` answers it. */
46
+ interface RecorderDownload {
47
+ suggestedFilename(): string;
48
+ createReadStream(): Promise<AsyncIterable<unknown> | null>;
49
+ }
50
+ /** The page's own request context (its cookies), as Playwright's `APIResponse` answers. */
51
+ interface RecorderApiResponse {
52
+ ok(): boolean;
53
+ status(): number;
54
+ headers(): Record<string, string>;
55
+ body(): Promise<Uint8Array>;
31
56
  }
32
57
  interface RecorderLocatorRoot {
33
58
  first(): RecorderLocator;
@@ -46,6 +71,21 @@ interface RecorderPage {
46
71
  }): Promise<RecorderResponse | null>;
47
72
  url(): string;
48
73
  title(): Promise<string>;
74
+ /** An `await` with `reload: true`; absent, the recorder opens the page's URL again. */
75
+ reload?(opts: {
76
+ waitUntil: 'networkidle';
77
+ timeout: number;
78
+ }): Promise<RecorderResponse | null>;
79
+ /** A `download` step's click; absent, the recorder says the browser reports none. */
80
+ waitForEvent?(event: 'download', opts: {
81
+ timeout: number;
82
+ }): Promise<RecorderDownload>;
83
+ /** A `download` step's `src` fetch, with the page's cookies; absent, it fetches in the page. */
84
+ request?: {
85
+ get(url: string, opts?: {
86
+ timeout?: number;
87
+ }): Promise<RecorderApiResponse>;
88
+ };
49
89
  evaluate<T>(pageFunction: string | (() => T)): Promise<T>;
50
90
  on(event: 'console', cb: (m: RecorderConsoleMessage) => void): unknown;
51
91
  locator(selector: string): RecorderLocatorRoot;
@@ -114,6 +154,14 @@ interface RecorderBrowser {
114
154
  interface FrameSink {
115
155
  frame(file: string, bytes: Uint8Array): Promise<void> | void;
116
156
  }
157
+ /**
158
+ * Where a `download` step's file goes: the CLI writes `outputs/<name>` in
159
+ * the take directory. A host that hands in none cannot keep a download,
160
+ * and the recorder refuses such a script before capturing anything.
161
+ */
162
+ interface OutputSink {
163
+ save(name: string, bytes: Uint8Array, contentType?: string): Promise<void> | void;
164
+ }
117
165
  /** The clock and the pauses, injectable so a rehearsal runs fast. */
118
166
  interface RecorderClock {
119
167
  now(): number;
@@ -332,6 +380,36 @@ declare function deadWarns(r: DeadReport): boolean;
332
380
  /** The dead time in words, naming the steps and what to cut. */
333
381
  declare function deadLine(r: DeadReport): string;
334
382
 
383
+ /**
384
+ * The page's own console errors, as the recorder reports them. A page
385
+ * under recording is someone else's, and its errors are context, never the
386
+ * story: a third party's Content-Security-Policy violation runs to a few
387
+ * kilobytes and repeats on every load, which buried the step lines. So the
388
+ * log gets one line per distinct error, cut short, and a repeat is counted
389
+ * instead of printed; the full text stays in the result (and a verbose
390
+ * host prints every one whole).
391
+ */
392
+ /** How much of an error a log line keeps. */
393
+ declare const PAGE_ERROR_LINE_CHARS = 160;
394
+ interface PageError {
395
+ /** the full text, whitespace as the page wrote it */
396
+ text: string;
397
+ count: number;
398
+ }
399
+ /** One line of an error: whitespace collapsed, cut to the line's length. */
400
+ declare function pageErrorLine(text: string): string;
401
+ declare class PageErrorLog {
402
+ private readonly verbose;
403
+ private readonly seen;
404
+ constructor(verbose?: boolean);
405
+ /** The line to log for this error now, or null when it repeats one already said. */
406
+ add(text: string): string | null;
407
+ /** Every distinct error with how often it came, in the order first seen. */
408
+ entries(): PageError[];
409
+ /** The closing lines: each error that repeated, with its count. */
410
+ repeats(): string[];
411
+ }
412
+
335
413
  /**
336
414
  * What a signed-in take SHOWS. Getting past the login is half the problem;
337
415
  * the other half is that the account's own data is now in the frame: an
@@ -383,7 +461,7 @@ declare const EXPOSURE_MATCHERS_SRC = "(() => {\n const EMAIL = /[A-Za-z0-9._%+
383
461
  * Skips anything inside a masked element, password fields, and text the eye
384
462
  * cannot see. Reports kind + a selector-ish name for the element + its rect.
385
463
  */
386
- 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})()";
464
+ 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. What the\n // page wrote for people and tests comes first (an id, a test id, an\n // aria-label, a field's name or placeholder); utility classes are a\n // layout's, and they come last. A value that itself holds what the scan\n // looks for is never used: the selector would quote it.\n const attr = (v) => '\"' + v.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"') + '\"'\n const quiet = (v) => !!v && v.length <= 80 && !kindsIn(v).length\n const segment = (el) => {\n const tag = el.tagName.toLowerCase()\n if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id) && !/\\d{2,}/.test(el.id) && quiet(el.id)) return tag + '#' + el.id\n const tid = el.getAttribute('data-testid')\n if (quiet(tid)) return tag + '[data-testid=' + attr(tid) + ']'\n const label = el.getAttribute('aria-label')\n if (quiet(label)) return tag + '[aria-label=' + attr(label) + ']'\n if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {\n const nm = el.getAttribute('name')\n if (quiet(nm)) return tag + '[name=' + attr(nm) + ']'\n const ph = el.getAttribute('placeholder')\n if (quiet(ph)) return tag + '[placeholder=' + attr(ph) + ']'\n }\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})()";
387
465
  /**
388
466
  * The mask, as an init script: it runs at document start on EVERY navigation,
389
467
  * so a masked value is hidden before the page's first paint, and a
@@ -611,7 +689,60 @@ type ActionStep = ({
611
689
  tx: number;
612
690
  ty: number;
613
691
  ms?: number;
692
+ }
693
+ /**
694
+ * Check that the page is as the flow expects, with no cursor move: an
695
+ * element visible (`selector`; with `enabled`, enabled or disabled), some
696
+ * visible text containing `text`, or `gone` no longer visible. Exactly
697
+ * one of the three. Waits up to `timeout` seconds (default 5); a
698
+ * condition that never holds is a skip with its reason.
699
+ */
700
+ | {
701
+ do: 'expect';
702
+ selector?: string;
703
+ text?: string;
704
+ gone?: string;
705
+ enabled?: boolean;
706
+ timeout?: number;
707
+ }
708
+ /**
709
+ * Wait OFF CAMERA for something slow (a render, a generated result, a
710
+ * deploy): the capture stops, the predicate is polled every `poll`
711
+ * seconds (default 2) for up to `timeout` (default 600), reloading the
712
+ * page between polls when `reload` is set, and the capture resumes when
713
+ * it holds. The footage has no gap and no frozen stretch; the cut lands
714
+ * in `meta.skips[]`. On timeout, `onTimeout: "fail"` (the default) stops
715
+ * the take and `"continue"` resumes it.
716
+ */
717
+ | {
718
+ do: 'await';
719
+ until: AwaitUntil;
720
+ timeout?: number;
721
+ poll?: number;
722
+ reload?: boolean;
723
+ onTimeout?: 'fail' | 'continue';
724
+ }
725
+ /**
726
+ * Keep a file the page makes: click `selector` and wait for the
727
+ * browser's download, or fetch the `currentSrc` of the video, audio or
728
+ * img element `src` names, with the page's own cookies. Saved under the
729
+ * take's `outputs/` as `as`, else the name the page suggested, and listed
730
+ * in `meta.outputs[]`.
731
+ */
732
+ | {
733
+ do: 'download';
734
+ selector?: string;
735
+ src?: string;
736
+ as?: string;
614
737
  }) & {
738
+ /**
739
+ * `"spend"`: the step costs something real (credits, a purchase, a
740
+ * message sent). Only a click, a press or a type carries it, and it
741
+ * needs an `id`. A rehearsal hovers it and never presses it; a recording
742
+ * presses it only when the command names it (`--confirm <id>`) and
743
+ * refuses before capturing anything otherwise.
744
+ */
745
+ guard?: StepGuard;
615
746
  /**
616
747
  * Optional stable identity: anchors in doc.json name a step by this
617
748
  * id (else by index), so a step can move or gain neighbours across script
@@ -634,11 +765,62 @@ type ActionStep = ({
634
765
  */
635
766
  read?: string;
636
767
  };
768
+ /** What an `await` waits for: exactly one of the three. */
769
+ interface AwaitUntil {
770
+ selector?: string;
771
+ text?: string;
772
+ gone?: string;
773
+ }
774
+ /** The guards a step may carry: one today, a step that spends. */
775
+ declare const STEP_GUARDS: readonly ["spend"];
776
+ type StepGuard = (typeof STEP_GUARDS)[number];
777
+ /** How long an `expect` waits by default, in seconds. */
778
+ declare const EXPECT_TIMEOUT_S = 5;
779
+ /** An `await`'s defaults, in seconds. */
780
+ declare const AWAIT_TIMEOUT_S = 600;
781
+ declare const AWAIT_POLL_S = 2;
782
+ /** How long a `download` click waits for the browser's download, in seconds. */
783
+ declare const DOWNLOAD_TIMEOUT_S = 30;
637
784
  /** The keys a `press` step may name: navigation and control, never a character. */
638
785
  declare const PRESS_KEYS: readonly ["Escape", "Enter", "Tab", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Space"];
639
786
  type PressKey = (typeof PRESS_KEYS)[number];
640
787
  /** Structural validation with actionable messages. Returns [] when valid. */
641
788
  declare function validateActions(value: unknown): string[];
789
+ /**
790
+ * Why a name is not one a download may be saved under, or null. A plain
791
+ * file name only: the file lands in the take's `outputs/` and nowhere
792
+ * else, so no separator, no `..`, nothing hidden, nothing empty.
793
+ */
794
+ declare function fileNameProblem(name: string): string | null;
795
+ /** A guarded step of a script: where it is, what it is, the id that confirms it. */
796
+ interface GuardedStep {
797
+ step: number;
798
+ id: string;
799
+ do: string;
800
+ guard: StepGuard;
801
+ }
802
+ /** The guarded steps of a script, in order. */
803
+ declare function guardedSteps(actions: Pick<ActionsFile, 'steps'>): GuardedStep[];
804
+ /**
805
+ * Whether a recording may run with the confirmations it was given: every
806
+ * guarded step named by `--confirm`, and every name a guarded step. A
807
+ * refusal is one sentence naming the steps and the flag, said before
808
+ * anything is captured.
809
+ */
810
+ declare function guardVerdict(actions: Pick<ActionsFile, 'steps'>, confirm: readonly string[]): {
811
+ ok: true;
812
+ } | {
813
+ ok: false;
814
+ unconfirmed: string[];
815
+ unknown: string[];
816
+ message: string;
817
+ };
818
+ /**
819
+ * What a hosted recorder cannot do with a script, in words, or []: a
820
+ * guarded step has nobody to confirm it on a fleet, and a download has no
821
+ * take directory to land in there.
822
+ */
823
+ declare function hostedRefusals(actions: Pick<ActionsFile, 'steps'>): string[];
642
824
 
643
825
  /**
644
826
  * The wall check: did the recorder land where it was asked, or in front of
@@ -724,6 +906,10 @@ declare const WALL_PROBE = "(() => {\n const shown = (el) => {\n const r = e
724
906
  * same events, meta and frame index for the same script.
725
907
  */
726
908
 
909
+ /** How often a long `await` says it is still waiting, in ms. */
910
+ declare const AWAIT_HEARTBEAT_MS = 30000;
911
+ /** An `await` that held sooner than this cut nothing: no skip, no segment split. */
912
+ declare const MIN_SKIP_MS = 250;
727
913
  /**
728
914
  * The headers a request carries, or null to leave it alone: a job's
729
915
  * headers (a preview's bypass token) go to the ORIGIN the take is of and
@@ -742,8 +928,27 @@ interface FrameRec {
742
928
  interface SkippedStep {
743
929
  /** index into actions.steps */
744
930
  step: number;
931
+ id?: string;
745
932
  do: string;
746
933
  selector: string;
934
+ /** why, when it is not a selector that never appeared: an expect that never held, a download that never came */
935
+ reason?: string;
936
+ }
937
+ /** A click whose target was disabled when it was pressed: the press happened, and likely did nothing. */
938
+ interface DisabledClick {
939
+ step: number;
940
+ id?: string;
941
+ selector: string;
942
+ }
943
+ /** An `await` that timed out with `onTimeout: "fail"`: the take stopped there. */
944
+ interface StoppedTake {
945
+ step: number;
946
+ id?: string;
947
+ reason: string;
948
+ }
949
+ /** Thrown before anything is captured: a guarded step the command did not confirm, a download with nowhere to land. */
950
+ declare class RecordRefusedError extends Error {
951
+ constructor(message: string);
747
952
  }
748
953
  /** A stretch with no screencast frames — the page pixels did not change. */
749
954
  interface FreezeSpan {
@@ -784,6 +989,18 @@ interface RecordResult {
784
989
  dead: DeadReport;
785
990
  /** The take reached --max-duration and stopped there; later steps did not run. */
786
991
  capped: boolean;
992
+ /** Clicks pressed on a disabled target (reported; `--strict` fails on them). */
993
+ disabled: DisabledClick[];
994
+ /** The ids of the guarded steps this take pressed, because the command named them. */
995
+ confirmed: string[];
996
+ /** The cuts `await` steps made (also `meta.skips`). */
997
+ skips: RecordingSkip[];
998
+ /** The files `download` steps kept (also `meta.outputs`). */
999
+ outputs: RecordingOutput[];
1000
+ /** An `await` timed out and the take stopped there; null when it ran to the end. */
1001
+ stopped: StoppedTake | null;
1002
+ /** The page's own console errors, each distinct one with its full text and how often it came. */
1003
+ pageErrors: PageError[];
787
1004
  /** The actions as recorded, `url` resolved: what a take directory writes back. */
788
1005
  actions: ActionsFile;
789
1006
  }
@@ -831,6 +1048,27 @@ interface RecordOpts {
831
1048
  env?: Record<string, string | undefined>;
832
1049
  /** The platform the take is recorded on, for `meta.platform`. */
833
1050
  platform?: RecordingMeta['platform'];
1051
+ /**
1052
+ * The ids of the guarded steps this recording may press (`--confirm`).
1053
+ * A guarded step not named here refuses the take before anything is
1054
+ * captured; a rehearsal hovers every guarded step and presses none.
1055
+ */
1056
+ confirm?: readonly string[];
1057
+ /**
1058
+ * Where a `download` step's file goes. A host that hands in none cannot
1059
+ * keep a download: such a script is refused before anything is captured.
1060
+ * A rehearsal fetches the file and keeps nothing.
1061
+ */
1062
+ outputs?: OutputSink;
1063
+ /** Print every page error whole, each time it comes; by default one cut line per distinct error. */
1064
+ verbose?: boolean;
1065
+ /**
1066
+ * Structured progress for a host that reports it (the CLI's `--json`):
1067
+ * an `await`'s start, a heartbeat while it waits, and its end.
1068
+ */
1069
+ onEvent?: (event: Record<string, unknown>) => void;
1070
+ /** How often a long `await` reports it is still waiting (default 30 s). */
1071
+ awaitHeartbeatMs?: number;
834
1072
  }
835
1073
  /**
836
1074
  * The expression a scroll tick evaluates: move the first scrollable
@@ -847,6 +1085,11 @@ declare function jpegDims(buf: Uint8Array): {
847
1085
  /** Base64 to bytes without Node's Buffer, so the fleet's isolate runs it too. */
848
1086
  declare function base64Bytes(data: string): Uint8Array;
849
1087
  declare function recordTake(browser: RecorderBrowser, url: string, actions: ActionsFile, sink: FrameSink, log: (msg: string) => void, opts?: RecordOpts): Promise<RecordResult>;
1088
+ declare function extForType(type: string | undefined): string;
1089
+ /** A download's type from its name's extension, as `{ contentType }`, or `{}` when the extension says nothing. */
1090
+ declare function typeOfName(name: string): {
1091
+ contentType?: string;
1092
+ };
850
1093
 
851
1094
  /**
852
1095
  * The recording cap as the recorder applies it, pure. WHAT the cap is
@@ -886,6 +1129,8 @@ interface EncodePageOptions {
886
1129
  };
887
1130
  /** Optional `?rt=`-style query the frame fetches must carry (the fleet's token). */
888
1131
  frameQuery?: string;
1132
+ /** Where mediabunny loads from (a host's mirror); esm.sh by default. */
1133
+ mediabunnyUrl?: string;
889
1134
  }
890
1135
  declare function buildEncodePage(o: EncodePageOptions): string;
891
1136
 
@@ -896,5 +1141,176 @@ interface FreshPlanOptions {
896
1141
  activity?: readonly number[] | null;
897
1142
  }
898
1143
  declare function planFreshTake(artifact: RecordingArtifact, recordingName: string, opts?: FreshPlanOptions): ProjectDoc;
1144
+ /**
1145
+ * The footage's segments split at every cut an `await` made
1146
+ * (`meta.skips[].at`, source seconds), so each cut is a real segment
1147
+ * boundary: the timeline shows it, a transition can sit on it, a trim can
1148
+ * take either side. A cut at an edge or outside a segment splits nothing,
1149
+ * and a segment of another media is left alone. A split segment keeps its
1150
+ * arrival on its first piece and its leaving on its last.
1151
+ */
1152
+ declare function splitSegmentsAtSkips(segments: ProjectDoc['segments'], skips: readonly {
1153
+ at: number;
1154
+ }[] | undefined): ProjectDoc['segments'];
1155
+
1156
+ /**
1157
+ * What an `expect` and an `await` wait for: a condition on the page, read
1158
+ * without touching it (no cursor, no input), polled until it holds or the
1159
+ * time runs out. `expect` polls on camera for a few seconds; `await` polls
1160
+ * off camera for as long as a slow result takes. One reader for both, so
1161
+ * "appeared" means the same thing in either.
1162
+ */
1163
+
1164
+ interface WaitCondition {
1165
+ /** an element matching this selector is visible */
1166
+ selector?: string;
1167
+ /** some visible text on the page contains this */
1168
+ text?: string;
1169
+ /** no element matching this selector is visible */
1170
+ gone?: string;
1171
+ /** with `selector`: the element is enabled (true) or disabled (false) */
1172
+ enabled?: boolean;
1173
+ }
1174
+ /** What the condition found the last time it looked: what a miss is said from. */
1175
+ interface ConditionState {
1176
+ holds: boolean;
1177
+ /** with `selector`: whether a match was visible */
1178
+ visible?: boolean;
1179
+ }
1180
+ /**
1181
+ * The visible text of the page contains `text`: `innerText` is what the
1182
+ * page renders, so a hidden element's words never count.
1183
+ */
1184
+ declare function visibleTextScript(text: string): string;
1185
+ /** Read the condition once. A page mid-navigation reads as "not yet". */
1186
+ declare function readCondition(page: RecorderPage, c: WaitCondition): Promise<ConditionState>;
1187
+ /** What a poll found: whether the condition held, after how long, and the last read. */
1188
+ interface PollResult {
1189
+ ok: boolean;
1190
+ ms: number;
1191
+ last: ConditionState;
1192
+ }
1193
+ /**
1194
+ * Poll the condition every `everyMs` until it holds or `timeoutMs` pass.
1195
+ * `between` runs before each look but the first (an `await`'s reload).
1196
+ * With `confirm`, a hold counts only once it has held on that many reads
1197
+ * in a row, `confirm.gapMs` apart: a page that renders after network quiet
1198
+ * reads half-built for a moment, and a `gone` condition holds on a list
1199
+ * that has not been drawn yet. The clock is the wall's: a rehearsal waits
1200
+ * as long as a take would, because how long a result takes is what a
1201
+ * rehearsal is for.
1202
+ */
1203
+ declare function pollCondition(page: RecorderPage, c: WaitCondition, opts: {
1204
+ timeoutMs: number;
1205
+ everyMs: number;
1206
+ sleep: (ms: number) => Promise<void>;
1207
+ now?: () => number;
1208
+ between?: () => Promise<void>;
1209
+ confirm?: {
1210
+ reads: number;
1211
+ gapMs: number;
1212
+ };
1213
+ }): Promise<PollResult>;
1214
+ /** The DOM counts as settled after this long with no change. */
1215
+ declare const DOM_QUIET_MS = 800;
1216
+ /** The longest a settle waits on a page that never stops changing. */
1217
+ declare const DOM_SETTLE_CAP_MS = 5000;
1218
+ /**
1219
+ * The page's own count of DOM changes: an observer installed once per
1220
+ * document, read back as a number. A string, the one evaluate shape every
1221
+ * driver shares.
1222
+ */
1223
+ declare const DOM_CHANGE_PROBE = "(() => {\n if (!window.__vosDomChanges) {\n window.__vosDomChanges = { n: 0 };\n new MutationObserver((list) => { window.__vosDomChanges.n += list.length })\n .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });\n }\n return window.__vosDomChanges.n;\n})()";
1224
+ /**
1225
+ * Wait until the DOM has not changed for `quietMs`, at most `capMs`: what
1226
+ * a page does after its network went quiet (a list rendered from data, a
1227
+ * hydration). The count is polled from here rather than timed in the
1228
+ * page, because a background tab throttles its own timers.
1229
+ */
1230
+ declare function domSettled(page: RecorderPage, opts: {
1231
+ sleep: (ms: number) => Promise<void>;
1232
+ now?: () => number;
1233
+ quietMs?: number;
1234
+ capMs?: number;
1235
+ tickMs?: number;
1236
+ }): Promise<{
1237
+ settled: boolean;
1238
+ ms: number;
1239
+ }>;
1240
+ /** Seconds as the recorder says them: `5 s`, `2.5 s`, `3 min 20 s`. */
1241
+ declare function sayDuration(ms: number): string;
1242
+ /** What the condition waits for, in words: `'#result' to appear`. */
1243
+ declare function sayCondition(c: WaitCondition): string;
1244
+ /** Why the condition did not hold, in words: `'Generate' stayed disabled for 5 s`. */
1245
+ declare function conditionMiss(c: WaitCondition, last: ConditionState, ms: number): string;
1246
+
1247
+ /** The most a download keeps: a file the page made, never a disk's worth. */
1248
+ declare const OUTPUT_MAX_BYTES: number;
1249
+ /**
1250
+ * A name the page suggested, made plain: its last path segment, control
1251
+ * characters and leading dots removed, or `fallback` when nothing usable
1252
+ * is left. A page chooses this name, so it is cleaned, never trusted.
1253
+ */
1254
+ declare function plainName(suggested: string, fallback: string): string;
1255
+ /** The file name a URL's path ends in, decoded, or ''. */
1256
+ declare function nameFromUrl(url: string): string;
1257
+ /**
1258
+ * The name a download is saved under: the step's `as` when it gave one,
1259
+ * else the page's suggestion made plain, made unique among the names this
1260
+ * take already used (`clip.mp4`, `clip-2.mp4`).
1261
+ */
1262
+ declare function outputName(asked: string | undefined, suggested: string, fallback: string, used: ReadonlySet<string>): string;
1263
+ /** Read a stream of byte chunks whole, refusing past `max` bytes. */
1264
+ declare function readAllBytes(stream: AsyncIterable<unknown>, max?: number): Promise<Uint8Array>;
1265
+ /**
1266
+ * Read a media element's source, run in the page on the element the
1267
+ * step's selector matched through the locator engine: its `currentSrc`
1268
+ * (what it is actually playing), else its `src`, else the attribute; ''
1269
+ * while it has none yet. Self-contained, since it is serialized into the
1270
+ * page.
1271
+ */
1272
+ declare const readMediaSource: (el: RecorderMediaElement) => string;
1273
+ /**
1274
+ * The expression that fetches a URL inside the page, for what the page
1275
+ * alone can read (a `blob:` or a `data:` source): base64 and the type, or
1276
+ * an error in words. Same-origin requests carry the page's cookies.
1277
+ */
1278
+ declare function fetchInPageScript(url: string): string;
1279
+
1280
+ /**
1281
+ * The take's clock: ONE path from a wall-clock instant to footage time,
1282
+ * for the cursor events and for every screencast frame alike.
1283
+ *
1284
+ * Footage time is wall time since `t0` minus every off-camera stretch (an
1285
+ * `await`) that began before the instant; an instant inside a stretch reads
1286
+ * as the stretch's start, which is where the cut is. A frame is placed by
1287
+ * the instant it was CAPTURED, never by when its callback ran: a frame in
1288
+ * flight when the capture paused lands after the resume and must still sit
1289
+ * before the cut.
1290
+ *
1291
+ * Frame times are strictly increasing. The screencast does not promise it:
1292
+ * a frame can arrive after a later one, and a frame without a capture
1293
+ * timestamp is stamped by its arrival, a second clock. A frame whose time
1294
+ * is not later than the last one kept holds older pixels than what the
1295
+ * footage already shows, so it is DROPPED (never re-stamped forward, which
1296
+ * would put a stale picture after a newer one).
1297
+ */
1298
+ declare class TakeClock {
1299
+ readonly t0: number;
1300
+ private readonly pauses;
1301
+ private lastFrameMs;
1302
+ constructor(t0: number);
1303
+ /** Footage ms at a wall-clock instant (`Date.now()` space). */
1304
+ footageMs(wallMs: number): number;
1305
+ /** The camera stops at this instant; returns the cut's footage ms. */
1306
+ pause(wallMs: number): number;
1307
+ /** The camera rolls again; returns the wall ms the stretch lasted. */
1308
+ resume(wallMs: number): number;
1309
+ /**
1310
+ * A captured frame's footage ms, or null when it is not later than the
1311
+ * last frame kept (the frame is dropped; see the module comment).
1312
+ */
1313
+ placeFrame(captureWallMs: number): number | null;
1314
+ }
899
1315
 
900
- 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_KEYS, PRESS_LEAD_MS, type PaceReport, type PressKey, 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, type RecorderRoute, SCROLL_MAX_MS, SCROLL_MIN_MS, SCROLL_MS_PER_PX, 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, headersFor, holdLeftMs, isIdpHost, isSignInPath, jpegDims, maskInitScript, paceLine, paceReport, parseHeaders, planFreshTake, pointerTravelMs, publicUrl, realSleep, recordTake, redirectedAway, resolveSetupText, runSetup, scrollByScript, scrollTravelMs, settleMs, settleVerdict, setupEnvNames, urlSchemeProblem, validateActions, validateSetup, wallVerdict };
1316
+ export { AWAIT_HEARTBEAT_MS, AWAIT_POLL_S, AWAIT_TIMEOUT_S, type ActionStep, type ActionsFile, type Arrival, type AwaitUntil, type ConditionState, DEAD_SHARE_WARN_PCT, DEAD_STRETCH_WARN_MS, DOM_CHANGE_PROBE, DOM_QUIET_MS, DOM_SETTLE_CAP_MS, DOWNLOAD_TIMEOUT_S, type DeadReport, type DeadStep, type DisabledClick, EXPECT_TIMEOUT_S, EXPOSURE_ADVICE, EXPOSURE_MATCHERS_SRC, EXPOSURE_PROBE, type EncodePageOptions, type Exposure, type ExposureKind, ExposureLog, type FrameRec, type FrameSink, type FreezeSpan, type FreshPlanOptions, type GuardedStep, MIN_SKIP_MS, MOTION_TICK_MS, type MaskReport, type MaskRule, OUTPUT_MAX_BYTES, type OutputSink, PAGE_ERROR_LINE_CHARS, POINTER_MAX_MS, POINTER_MIN_MS, POINTER_MS_PER_PX, PRESS_HOLD_MS, PRESS_KEYS, PRESS_LEAD_MS, type PaceReport, type PageError, PageErrorLog, type PollResult, type PressKey, QUIET_MS, READ_BEAT_MS, RESPONSE_MS, type RecordOpts, RecordRefusedError, type RecordResult, type RecorderApiResponse, type RecorderBrowser, type RecorderCdp, type RecorderClock, type RecorderConsoleMessage, type RecorderContext, type RecorderDownload, type RecorderLocator, type RecorderLocatorRoot, type RecorderMediaElement, type RecorderPage, type RecorderRect, type RecorderResponse, type RecorderRoute, SCROLL_MAX_MS, SCROLL_MIN_MS, SCROLL_MS_PER_PX, SCROLL_SETTLE_MS, SETTLE_CAP_MS, SETTLE_MS, STEP_GUARDS, SetupEnvError, SetupError, type SetupResult, type SetupStep, type SetupText, type SkippedStep, type StepGuard, type StepPace, type StoppedTake, TRAILING_HOLD_MS, TakeClock, WALL_PROBE, type WaitCondition, WallError, type WallKind, type WallVerdict, askedMs, base64Bytes, buildEncodePage, capReached, cappedLine, clampWait, clockMotion, clockTyping, conditionMiss, deadLine, deadTime, deadWarns, domSettled, easeInOutCubic, exposureLine, extForType, fetchInPageScript, fileNameProblem, formatDurationCap, guardVerdict, guardedSteps, headersFor, holdLeftMs, hostedRefusals, isIdpHost, isSignInPath, jpegDims, maskInitScript, nameFromUrl, outputName, paceLine, paceReport, pageErrorLine, parseHeaders, plainName, planFreshTake, pointerTravelMs, pollCondition, publicUrl, readAllBytes, readCondition, readMediaSource, realSleep, recordTake, redirectedAway, resolveSetupText, runSetup, sayCondition, sayDuration, scrollByScript, scrollTravelMs, settleMs, settleVerdict, setupEnvNames, splitSegmentsAtSkips, typeOfName, urlSchemeProblem, validateActions, validateSetup, visibleTextScript, wallVerdict };