@vosjs/render-core 0.6.0 → 0.8.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.
@@ -29,6 +29,7 @@ var SETTLE_MS = {
29
29
  click: 150,
30
30
  type: 150,
31
31
  press: 150,
32
+ paste: 150,
32
33
  scroll: 200,
33
34
  drag: 80
34
35
  };
@@ -112,6 +113,8 @@ function askedMs(step) {
112
113
  return (step.text?.length ?? 0) * (step.delayMs ?? 40);
113
114
  case "press":
114
115
  return step.ms ?? SETTLE_MS.press;
116
+ case "paste":
117
+ return step.ms ?? SETTLE_MS.paste;
115
118
  default:
116
119
  return 0;
117
120
  }
@@ -149,6 +152,7 @@ function deadTime(steps, frames, events, durationMs) {
149
152
  let ms = 0;
150
153
  let longestMs = 0;
151
154
  for (const s of steps) {
155
+ if (s.do === "expect" || s.do === "await" || s.do === "download") continue;
152
156
  const t0 = s.tStart * 1e3;
153
157
  const t1 = s.tEnd * 1e3;
154
158
  if (!(t1 > t0)) continue;
@@ -371,16 +375,141 @@ var EXPOSURE_MATCHERS_SRC = `(() => {
371
375
  }
372
376
  return { kindsIn }
373
377
  })()`;
378
+ var NOTICE_SRC = String.raw`const notices = () => {
379
+ // A label made only of these words is the control's, never a person's:
380
+ // "Account menu", "Open user menu", "Profile settings".
381
+ const UI_WORDS = new Set(['account', 'accounts', 'profile', 'profiles', 'menu', 'user', 'users', 'settings', 'setting', 'avatar', 'open', 'close', 'toggle', 'show', 'hide', 'the', 'my', 'your', 'me', 'you', 'home', 'guest', 'admin', 'team', 'workspace', 'dashboard', 'sign', 'signed', 'in', 'out', 'log', 'logged', 'logout', 'login', 'help', 'search', 'notification', 'notifications', 'more', 'options', 'option', 'button', 'dropdown', 'picture', 'image', 'photo', 'main', 'navigation', 'nav', 'preferences', 'switch', 'manage'])
382
+ const nameLike = (v) => {
383
+ if (!v) return null
384
+ const t = v.replace(/\s+/g, ' ').trim().replace(/[!.,:]+$/, '')
385
+ if (t.length < 2 || t.length > 40 || /[@\d<>/|]/.test(t)) return null
386
+ if (!/^\p{Lu}[\p{L}'’.-]*(?: \p{L}[\p{L}'’.-]*){0,3}$/u.test(t)) return null
387
+ if (t.toLowerCase().split(' ').every((w) => UI_WORDS.has(w))) return null
388
+ return t
389
+ }
390
+ const GREETING = /^(?:signed in as|logged in as|welcome back,?|welcome,|hi,|hello,|hey,)\s+(.+)$/i
391
+ const LABELS = [
392
+ /^(?:account|user|profile)(?: menu)?(?: for|:|,| -)\s*(.+)$/i,
393
+ /^open (?:the )?(?:user|account|profile) menu(?: for|,|:)?\s*(.+)$/i,
394
+ /^(.+?)[’']s? (?:account|profile|avatar)$/i,
395
+ ]
396
+ const avatarish = (el) => el.tagName === 'IMG' || !!el.querySelector('img, svg') || /avatar/i.test(String(el.className || '') + ' ' + (el.getAttribute('data-testid') || ''))
397
+ let sure = false
398
+ const texts = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
399
+ for (let n; !acct && (n = texts.nextNode()); ) {
400
+ const el = n.parentElement
401
+ if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
402
+ const line = (el.textContent || '').replace(/\s+/g, ' ').trim()
403
+ if (line.length > 80) continue
404
+ const m = GREETING.exec(line)
405
+ const who = m && nameLike(m[1])
406
+ if (who && seen(el)) { acct = who; sure = true }
407
+ }
408
+ let maybe = null
409
+ if (!acct) {
410
+ for (const el of document.querySelectorAll('[aria-label], img[alt], [title]')) {
411
+ const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || ''
412
+ const box = el.closest('button, a, [role="button"], [aria-haspopup]') || el
413
+ if (!seen(el) && !seen(box)) continue
414
+ let hit = null
415
+ for (const rx of LABELS) { const m = rx.exec(label.trim()); if (m && (hit = nameLike(m[1]))) break }
416
+ if (hit) { acct = hit; sure = true; break }
417
+ if (!maybe && (avatarish(el) || avatarish(box)) && box !== el.ownerDocument.body) maybe = nameLike(label)
418
+ }
419
+ }
420
+ // The account menu's TRIGGER, whose own words are the name: a control
421
+ // that opens a menu and holds the avatar, or one labelled as the
422
+ // account's. The name-like line among its words (an address there is
423
+ // an exposure already, and never a name).
424
+ if (!acct) {
425
+ const TRIGGER_LABEL = /(account|user|profile|avatar)/i
426
+ for (const el of document.querySelectorAll('button, a, [role="button"], [aria-haspopup]')) {
427
+ const label = (el.getAttribute('aria-label') || '') + ' ' + (el.getAttribute('title') || '')
428
+ const trigger = (el.hasAttribute('aria-haspopup') && avatarish(el)) || TRIGGER_LABEL.test(label)
429
+ if (!trigger || !seen(el)) continue
430
+ const words = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
431
+ for (let n; !acct && (n = words.nextNode()); ) {
432
+ const who = nameLike(n.nodeValue)
433
+ if (who && n.parentElement && seen(n.parentElement)) { acct = who; sure = true }
434
+ }
435
+ if (acct) break
436
+ }
437
+ }
438
+ if (!acct && maybe) acct = maybe
439
+ const found = []
440
+ if (acct) {
441
+ const places = [], at = new Set()
442
+ const walk = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
443
+ for (let n; (n = walk.nextNode()) && places.length < 20; ) {
444
+ const el = n.parentElement
445
+ if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
446
+ if (!(n.nodeValue || '').includes(acct)) continue
447
+ const r = seen(el)
448
+ if (!r) continue
449
+ const selector = name(el)
450
+ if (at.has(selector)) continue
451
+ at.add(selector)
452
+ places.push({ selector, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
453
+ }
454
+ if (places.length) found.push({ kind: 'account-name', sure, places })
455
+ }
456
+ const RECENT = /^(recents?|recently (?:viewed|opened|visited|edited)|history|recent (?:files|projects|documents|items|pages|boards|chats|conversations|searches))$/i
457
+ const ITEMS = 'a[href], [role="link"], [role="menuitem"], [role="treeitem"], [role="option"]'
458
+ const listed = new Set()
459
+ for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"], summary, div, span, p, li, label, strong, button, a')) {
460
+ if (el.children.length > 2) continue
461
+ const word = (el.textContent || '').replace(/\s+/g, ' ').trim()
462
+ if (word.length > 40 || !RECENT.test(word)) continue
463
+ // A nav link that happens to say History is a page, not a list.
464
+ if (/^(A|BUTTON)$/.test(el.tagName) && !el.hasAttribute('aria-expanded')) continue
465
+ if (!seen(el)) continue
466
+ for (const c of [el.nextElementSibling, el.parentElement && el.parentElement.nextElementSibling, el.parentElement]) {
467
+ if (!c || c === document.body) continue
468
+ const items = [...c.querySelectorAll(ITEMS)].filter((x) => x !== el && !x.contains(el) && seen(x))
469
+ if (items.length < 3) continue
470
+ const r = seen(c)
471
+ if (!r) continue
472
+ const selector = name(c)
473
+ if (!listed.has(selector)) {
474
+ listed.add(selector)
475
+ found.push({ kind: 'personal-list', heading: word, selector, items: items.length, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
476
+ }
477
+ break
478
+ }
479
+ if (listed.size >= 5) break
480
+ }
481
+ return found
482
+ }
483
+ // Read before any selector is made, so none of them quotes the name.
484
+ const noticeList = notices()`;
374
485
  var EXPOSURE_PROBE = `(() => {
375
486
  const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
376
487
  const vw = innerWidth, vh = innerHeight
377
488
  // A selector that reaches THIS element and no other, so it can be pasted
378
489
  // into "mask" as it is: climb until the path is unique, and say which
379
- // sibling wherever a tag-and-class alone would match several.
490
+ // sibling wherever a tag-and-class alone would match several. What the
491
+ // page wrote for people and tests comes first (an id, a test id, an
492
+ // aria-label, a field's name or placeholder); utility classes are a
493
+ // layout's, and they come last. A value that itself holds what the scan
494
+ // looks for is never used: the selector would quote it.
495
+ const attr = (v) => '"' + v.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"'
496
+ // The signed-in account's own name, once the notices have read it: a
497
+ // selector never quotes it either.
498
+ let acct = null
499
+ const quiet = (v) => !!v && v.length <= 80 && !kindsIn(v).length && !(acct && v.toLowerCase().includes(acct.toLowerCase()))
380
500
  const segment = (el) => {
501
+ const tag = el.tagName.toLowerCase()
502
+ if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id) && !/\\d{2,}/.test(el.id) && quiet(el.id)) return tag + '#' + el.id
381
503
  const tid = el.getAttribute('data-testid')
382
- if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
383
- if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
504
+ if (quiet(tid)) return tag + '[data-testid=' + attr(tid) + ']'
505
+ const label = el.getAttribute('aria-label')
506
+ if (quiet(label)) return tag + '[aria-label=' + attr(label) + ']'
507
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {
508
+ const nm = el.getAttribute('name')
509
+ if (quiet(nm)) return tag + '[name=' + attr(nm) + ']'
510
+ const ph = el.getAttribute('placeholder')
511
+ if (quiet(ph)) return tag + '[placeholder=' + attr(ph) + ']'
512
+ }
384
513
  const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
385
514
  let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
386
515
  const p = el.parentElement
@@ -409,6 +538,7 @@ var EXPOSURE_PROBE = `(() => {
409
538
  if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
410
539
  return r
411
540
  }
541
+ ${NOTICE_SRC}
412
542
  const out = [], keys = new Set()
413
543
  const add = (el, text) => {
414
544
  const kinds = kindsIn(text)
@@ -435,7 +565,7 @@ var EXPOSURE_PROBE = `(() => {
435
565
  }
436
566
  const hits = {}
437
567
  for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
438
- return { exposures: out, maskHits: hits }
568
+ return { exposures: out, maskHits: hits, notices: noticeList }
439
569
  })()`;
440
570
  function maskInitScript(masks) {
441
571
  const rules = masks.map((m) => ({
@@ -520,6 +650,43 @@ function exposureLine(e) {
520
650
  return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
521
651
  }
522
652
  var 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.';
653
+ var NoticeLog = class {
654
+ byKey = /* @__PURE__ */ new Map();
655
+ add(step, notices) {
656
+ for (const n of notices ?? []) {
657
+ const key = n.kind === "account-name" ? n.kind : `${n.kind}|${n.selector ?? ""}`;
658
+ const had = this.byKey.get(key);
659
+ if (!had) {
660
+ this.byKey.set(key, { step, ...n, seen: 1 });
661
+ continue;
662
+ }
663
+ had.seen += 1;
664
+ if (n.kind !== "account-name") continue;
665
+ if (n.sure) had.sure = true;
666
+ const places = had.places ?? [];
667
+ for (const p of n.places ?? [])
668
+ if (places.length < 20 && !places.some((q) => q.selector === p.selector))
669
+ places.push(p);
670
+ had.places = places;
671
+ }
672
+ }
673
+ notices() {
674
+ return [...this.byKey.values()];
675
+ }
676
+ };
677
+ function noticeLine(n) {
678
+ const when = n.step < 0 ? "as the page opened" : `from step #${n.step}`;
679
+ if (n.kind === "account-name") {
680
+ const places = n.places ?? [];
681
+ return `${n.sure ? "" : "what may be "}the account's name (${places.length} ${places.length === 1 ? "place" : "places"}, ${when}): ${places.map((p) => p.selector).join("; ")}`;
682
+ }
683
+ const mask = JSON.stringify({ selector: n.selector, as: "blur" });
684
+ return `a "${n.heading}" list of the account's own items (${n.items} items, ${when}) in ${n.selector}; to hide it, add to "mask": ${mask}`;
685
+ }
686
+ function noticeAdvice(notices) {
687
+ const name = notices.some((n) => n.kind === "account-name");
688
+ return `These are the signed-in account's own: fine for a demo account, worth a mask on a real one.${name ? ' The name reads better swapped than blurred: { "selector": "\u2026", "as": "text", "text": "Jane Doe" }.' : ""}`;
689
+ }
523
690
 
524
691
  // src/record/setup.ts
525
692
  var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
@@ -604,7 +771,10 @@ async function runSetup(page, steps, opts) {
604
771
  lines.push(`setup #${i} wait ${step.ms}ms`);
605
772
  break;
606
773
  case "goto":
607
- await page.goto(step.url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
774
+ await (opts.goto ? opts.goto(step.url) : page.goto(step.url, {
775
+ waitUntil: "networkidle",
776
+ timeout: 45e3
777
+ })).catch(() => null);
608
778
  lines.push(`setup #${i} goto ${step.url}`);
609
779
  break;
610
780
  case "press":
@@ -673,173 +843,1800 @@ function parseHeaders(given) {
673
843
  return out;
674
844
  }
675
845
 
676
- // src/record/recorder.ts
677
- var READ_WAIT_MS = 1500;
678
- var READ_MAX_CHARS = 2048;
679
- function headersFor(requestUrl, entryUrl, headers) {
846
+ // src/record/stepBound.ts
847
+ var STEP_MARGIN_MS = 3e4;
848
+ var WAIT_MARGIN_MS = 5e3;
849
+ var IDLE_CALL_MS = 6e4;
850
+ function stepBoundMs(step, plannedMs, defaults) {
851
+ if (step.do === "expect")
852
+ return (step.timeout ?? defaults.expectS) * 1e3 + WAIT_MARGIN_MS;
853
+ if (step.do === "await")
854
+ return (step.timeout ?? defaults.awaitS) * 1e3 + STEP_MARGIN_MS;
855
+ if (step.do === "download") return defaults.downloadS * 1e3 + STEP_MARGIN_MS;
856
+ return plannedMs + STEP_MARGIN_MS;
857
+ }
858
+ var StepBound = class {
859
+ constructor(now = Date.now, idleMs = IDLE_CALL_MS) {
860
+ this.now = now;
861
+ this.idleMs = idleMs;
862
+ }
863
+ now;
864
+ idleMs;
865
+ deadline = null;
866
+ started = 0;
867
+ hung = false;
868
+ /** A step begins: calls into the page answer within `boundMs` from now. */
869
+ start(boundMs) {
870
+ this.started = this.now();
871
+ this.deadline = this.started + boundMs;
872
+ this.hung = false;
873
+ }
874
+ /** The step ends: whether it ran into its bound, and how long it really took. */
875
+ end() {
876
+ const out = {
877
+ hung: this.hung,
878
+ ms: this.now() - this.started,
879
+ boundMs: (this.deadline ?? this.started) - this.started
880
+ };
881
+ this.deadline = null;
882
+ this.hung = false;
883
+ return out;
884
+ }
885
+ /** The pending call, or `undefined` once the bound has passed. */
886
+ race(p) {
887
+ const left = this.deadline === null ? this.idleMs : this.deadline - this.now();
888
+ if (left <= 0) {
889
+ this.hung = true;
890
+ p.catch(() => {
891
+ });
892
+ return Promise.resolve(void 0);
893
+ }
894
+ return new Promise((resolve, reject) => {
895
+ const timer = setTimeout(() => {
896
+ this.hung = true;
897
+ resolve(void 0);
898
+ }, left);
899
+ p.then(
900
+ (v) => {
901
+ clearTimeout(timer);
902
+ resolve(v);
903
+ },
904
+ (e) => {
905
+ clearTimeout(timer);
906
+ reject(e);
907
+ }
908
+ );
909
+ });
910
+ }
911
+ };
912
+ function bounded(target, bound) {
913
+ const cache = /* @__PURE__ */ new WeakMap();
914
+ const wrap = (obj) => {
915
+ const had = cache.get(obj);
916
+ if (had) return had;
917
+ const proxy = new Proxy(obj, {
918
+ get(t, prop) {
919
+ const value = Reflect.get(t, prop, t);
920
+ if (typeof value === "function")
921
+ return (...args) => {
922
+ const out = value.apply(
923
+ t,
924
+ args
925
+ );
926
+ if (out && typeof out.then === "function")
927
+ return bound.race(out);
928
+ if (out && typeof out === "object" && out !== t) return wrap(out);
929
+ return out;
930
+ };
931
+ if (value && typeof value === "object") return wrap(value);
932
+ return value;
933
+ }
934
+ });
935
+ cache.set(obj, proxy);
936
+ return proxy;
937
+ };
938
+ return wrap(target);
939
+ }
940
+
941
+ // src/record/pageSound.ts
942
+ var MAX_ELEMENTS = 50;
943
+ var MAX_NOTES = 2e3;
944
+ var MIN_HEARD_S = 0.05;
945
+ var MEDIA_WATCH_SCRIPT = `(() => {
946
+ let host
947
+ try { host = window.top; void host.document.title } catch { return }
948
+ if (window.__vosMediaWatch) return
949
+ window.__vosMediaWatch = true
950
+ if (!host.__vosMedia) {
951
+ const M = { log: [], seq: 0, els: new Set(), doc: Math.random().toString(36).slice(2) }
952
+ M.fields = (el, kind) => ({
953
+ id: el.__vosMediaId,
954
+ tag: el.tagName === 'VIDEO' ? 'video' : 'audio',
955
+ src: el.currentSrc || el.src || '',
956
+ kind,
957
+ wall: Date.now(),
958
+ t: el.currentTime,
959
+ rate: el.playbackRate || 1,
960
+ paused: el.paused || el.ended,
961
+ audible: !el.muted && el.volume > 0,
962
+ volume: el.volume,
963
+ duration: isFinite(el.duration) ? el.duration : null,
964
+ ab: typeof el.webkitAudioDecodedByteCount === 'number' ? el.webkitAudioDecodedByteCount : null,
965
+ })
966
+ host.__vosMedia = M
967
+ }
968
+ const M = host.__vosMedia
969
+ const EVENTS = ['playing', 'pause', 'ended', 'seeking', 'seeked', 'volumechange', 'ratechange', 'emptied']
970
+ const note = (e) => {
971
+ const el = e.target
972
+ if (!el || typeof el.currentTime !== 'number' || !/^(VIDEO|AUDIO)$/.test(el.tagName || '')) return
973
+ if (e.__vosNoted) return
974
+ e.__vosNoted = true
975
+ if (!el.__vosMediaId) {
976
+ if (M.els.size >= ${MAX_ELEMENTS}) return
977
+ el.__vosMediaId = ++M.seq
978
+ M.els.add(el)
979
+ }
980
+ if (M.log.length < ${MAX_NOTES}) M.log.push(M.fields(el, e.type))
981
+ }
982
+ for (const type of EVENTS) document.addEventListener(type, note, true)
983
+ const proto = window.HTMLMediaElement && window.HTMLMediaElement.prototype
984
+ if (proto && proto.play) {
985
+ const play = proto.play
986
+ proto.play = function () {
987
+ if (!this.__vosListening && !this.isConnected) {
988
+ this.__vosListening = true
989
+ for (const type of EVENTS) this.addEventListener(type, note)
990
+ }
991
+ return play.apply(this, arguments)
992
+ }
993
+ }
994
+ })()`;
995
+ var MEDIA_DRAIN_PROBE = `(() => {
996
+ const M = window.__vosMedia
997
+ if (!M) return { now: Date.now(), doc: null, notes: [] }
998
+ const notes = M.log.splice(0)
999
+ M.els.forEach((el) => notes.push(M.fields(el, 'tick')))
1000
+ return { now: Date.now(), doc: M.doc, notes }
1001
+ })()`;
1002
+ function soundSrcForMeta(src) {
1003
+ if (/^blob:/i.test(src)) return "blob:";
1004
+ if (/^data:/i.test(src)) return src.slice(0, src.search(/[;,]/) + 1 || 5);
680
1005
  try {
681
- return new URL(requestUrl).origin === new URL(entryUrl).origin ? headers : null;
1006
+ const u = new URL(src);
1007
+ return `${u.origin}${u.pathname}`;
682
1008
  } catch {
683
- return null;
1009
+ return "";
684
1010
  }
685
1011
  }
686
- async function applyHeaders(context, headers, entryUrl, log) {
687
- if (!context.route) {
688
- await context.setExtraHTTPHeaders(headers);
689
- log(" (this browser scopes no headers: they ride every request)");
690
- return;
1012
+ var PageSoundLog = class {
1013
+ byKey = /* @__PURE__ */ new Map();
1014
+ openKeyOf = /* @__PURE__ */ new Map();
1015
+ doc = null;
1016
+ lastWall = 0;
1017
+ add(drain, nodeNow, step) {
1018
+ if (!drain) return;
1019
+ const skew = nodeNow - drain.now;
1020
+ if (drain.doc !== this.doc) {
1021
+ if (this.doc !== null) this.closeAll(this.lastWall);
1022
+ this.openKeyOf.clear();
1023
+ this.doc = drain.doc;
1024
+ }
1025
+ const notes = [...drain.notes].sort((a, b) => a.wall - b.wall);
1026
+ for (const n of notes) this.note(n, n.wall + skew, step);
1027
+ this.lastWall = nodeNow;
691
1028
  }
692
- await context.route("**/*", async (route) => {
693
- const req = route.request();
694
- const scoped = headersFor(req.url(), entryUrl, headers);
695
- await route.continue(
696
- scoped ? { headers: { ...req.headers(), ...scoped } } : void 0
697
- );
1029
+ note(n, wall, step) {
1030
+ const key = `${this.doc}|${n.id}|${n.src}`;
1031
+ const prevKey = this.openKeyOf.get(n.id);
1032
+ if (prevKey && prevKey !== key) {
1033
+ const prev = this.byKey.get(prevKey);
1034
+ if (prev) this.close(prev, wall);
1035
+ }
1036
+ let f = this.byKey.get(key);
1037
+ if (!f) {
1038
+ if (!n.src) return;
1039
+ f = {
1040
+ tag: n.tag,
1041
+ src: n.src,
1042
+ played: false,
1043
+ audioBytes: null,
1044
+ open: null,
1045
+ stretches: []
1046
+ };
1047
+ this.byKey.set(key, f);
1048
+ }
1049
+ this.openKeyOf.set(n.id, key);
1050
+ if (n.duration !== null) f.duration = n.duration;
1051
+ if (n.ab !== null) f.audioBytes = Math.max(f.audioBytes ?? 0, n.ab);
1052
+ if (!n.paused) f.played = true;
1053
+ const stops = ["pause", "ended", "seeking", "emptied"].includes(n.kind);
1054
+ const heard = !n.paused && n.audible && !stops;
1055
+ const resync = ["playing", "seeked", "ratechange"].includes(n.kind);
1056
+ if (f.open && (!heard || resync))
1057
+ this.close(
1058
+ f,
1059
+ wall,
1060
+ n.kind === "ended" ? n.duration ?? void 0 : void 0
1061
+ );
1062
+ if (!f.open && heard) {
1063
+ f.open = { wall, t: n.t, rate: n.rate > 0 ? n.rate : 1 };
1064
+ f.volume = n.volume;
1065
+ if (f.step === void 0 && step !== void 0 && step >= 0) f.step = step;
1066
+ }
1067
+ }
1068
+ close(f, wall, endT) {
1069
+ if (!f.open) return;
1070
+ const { wall: from, t, rate } = f.open;
1071
+ f.open = null;
1072
+ if (wall <= from) return;
1073
+ const to = endT ?? t + (wall - from) / 1e3 * rate;
1074
+ f.stretches.push({
1075
+ fromWall: from,
1076
+ toWall: from + (to - t) / rate * 1e3,
1077
+ t0: t,
1078
+ rate
1079
+ });
1080
+ }
1081
+ closeAll(wall) {
1082
+ for (const f of this.byKey.values()) this.close(f, wall);
1083
+ }
1084
+ /**
1085
+ * Every element the page played, each heard stretch as footage seconds
1086
+ * `at` with media seconds `from`..`to`, none past `endMs` of footage.
1087
+ * A video whose decoder never decoded a byte of sound played none.
1088
+ */
1089
+ heard(clock, endWall, endMs) {
1090
+ this.closeAll(endWall);
1091
+ const out = [];
1092
+ for (const f of this.byKey.values()) {
1093
+ if (!f.played) continue;
1094
+ const silentVideo = f.tag === "video" && f.audioBytes === 0;
1095
+ const intervals = [];
1096
+ if (!silentVideo)
1097
+ for (const s of f.stretches)
1098
+ for (const c of clock.onCamera(s.fromWall, s.toWall)) {
1099
+ const at = c.atMs / 1e3;
1100
+ let from = s.t0 + (c.wallMs - s.fromWall) / 1e3 * s.rate;
1101
+ let to = from + c.ms / 1e3 * s.rate;
1102
+ if (f.duration !== void 0) to = Math.min(to, f.duration);
1103
+ const room = (endMs - c.atMs) / 1e3;
1104
+ if (room <= 0) continue;
1105
+ to = Math.min(to, from + room * s.rate);
1106
+ from = Math.max(0, from);
1107
+ if (to - from < MIN_HEARD_S) continue;
1108
+ intervals.push({
1109
+ at: +at.toFixed(3),
1110
+ from: +from.toFixed(3),
1111
+ to: +to.toFixed(3)
1112
+ });
1113
+ }
1114
+ out.push({
1115
+ src: f.src,
1116
+ meta: {
1117
+ ...f.step !== void 0 ? { step: f.step } : {},
1118
+ src: soundSrcForMeta(f.src),
1119
+ tag: f.tag,
1120
+ ...f.duration !== void 0 ? { duration: +f.duration.toFixed(3) } : {},
1121
+ ...intervals.length && f.volume !== void 0 ? { volume: +f.volume.toFixed(3) } : {},
1122
+ intervals,
1123
+ ...intervals.length ? {} : { muted: true }
1124
+ }
1125
+ });
1126
+ }
1127
+ return out;
1128
+ }
1129
+ };
1130
+
1131
+ // src/record/takeClock.ts
1132
+ var TakeClock = class {
1133
+ constructor(t0) {
1134
+ this.t0 = t0;
1135
+ }
1136
+ t0;
1137
+ pauses = [];
1138
+ lastFrameMs = -1;
1139
+ /** Footage ms at a wall-clock instant (`Date.now()` space). */
1140
+ footageMs(wallMs) {
1141
+ let off = 0;
1142
+ for (const p of this.pauses) {
1143
+ if (wallMs <= p.from) break;
1144
+ off += Math.min(wallMs, p.to ?? Infinity) - p.from;
1145
+ }
1146
+ return wallMs - this.t0 - off;
1147
+ }
1148
+ /** The camera stops at this instant; returns the cut's footage ms. */
1149
+ pause(wallMs) {
1150
+ const at = this.footageMs(wallMs);
1151
+ this.pauses.push({ from: wallMs, to: null });
1152
+ return at;
1153
+ }
1154
+ /** The camera rolls again; returns the wall ms the stretch lasted. */
1155
+ resume(wallMs) {
1156
+ const open = this.pauses[this.pauses.length - 1];
1157
+ if (!open || open.to !== null) return 0;
1158
+ open.to = Math.max(open.from, wallMs);
1159
+ return open.to - open.from;
1160
+ }
1161
+ /**
1162
+ * The parts of a wall-clock stretch the camera saw: each sub-stretch
1163
+ * outside every off-camera wait, as its wall start (`wallMs`), its
1164
+ * footage start (`atMs`) and its length. A stretch an `await` cut in two
1165
+ * comes back as two; one wholly off camera as none.
1166
+ */
1167
+ onCamera(fromWall, toWall) {
1168
+ const out = [];
1169
+ let cur = Math.max(fromWall, this.t0);
1170
+ for (const p of this.pauses) {
1171
+ const pTo = p.to ?? Infinity;
1172
+ if (pTo <= cur) continue;
1173
+ if (p.from >= toWall) break;
1174
+ if (p.from > cur)
1175
+ out.push({
1176
+ wallMs: cur,
1177
+ atMs: this.footageMs(cur),
1178
+ ms: p.from - cur
1179
+ });
1180
+ cur = Math.max(cur, pTo);
1181
+ if (cur >= toWall) return out;
1182
+ }
1183
+ if (toWall > cur)
1184
+ out.push({ wallMs: cur, atMs: this.footageMs(cur), ms: toWall - cur });
1185
+ return out;
1186
+ }
1187
+ /**
1188
+ * A captured frame's footage ms, or null when it is not later than the
1189
+ * last frame kept (the frame is dropped; see the module comment).
1190
+ */
1191
+ placeFrame(captureWallMs) {
1192
+ const t = Math.max(0, Math.round(this.footageMs(captureWallMs)));
1193
+ if (t <= this.lastFrameMs) return null;
1194
+ this.lastFrameMs = t;
1195
+ return t;
1196
+ }
1197
+ };
1198
+
1199
+ // src/record/pageErrors.ts
1200
+ var PAGE_ERROR_LINE_CHARS = 160;
1201
+ function pageErrorLine(text) {
1202
+ const one = text.replace(/\s+/g, " ").trim();
1203
+ return one.length > PAGE_ERROR_LINE_CHARS ? `${one.slice(0, PAGE_ERROR_LINE_CHARS - 1)}\u2026` : one;
1204
+ }
1205
+ var PageErrorLog = class {
1206
+ constructor(verbose = false) {
1207
+ this.verbose = verbose;
1208
+ }
1209
+ verbose;
1210
+ seen = /* @__PURE__ */ new Map();
1211
+ /** The line to log for this error now, or null when it repeats one already said. */
1212
+ add(text) {
1213
+ const known = this.seen.get(text);
1214
+ if (known) {
1215
+ known.count++;
1216
+ return this.verbose ? ` [page error] ${text}` : null;
1217
+ }
1218
+ this.seen.set(text, { text, count: 1 });
1219
+ return ` [page error] ${this.verbose ? text : pageErrorLine(text)}`;
1220
+ }
1221
+ /** Every distinct error with how often it came, in the order first seen. */
1222
+ entries() {
1223
+ return [...this.seen.values()].map((e) => ({ ...e }));
1224
+ }
1225
+ /** The closing lines: each error that repeated, with its count. */
1226
+ repeats() {
1227
+ if (this.verbose) return [];
1228
+ return [...this.seen.values()].filter((e) => e.count > 1).map((e) => ` [page error \xD7${e.count}] ${pageErrorLine(e.text)}`);
1229
+ }
1230
+ };
1231
+
1232
+ // src/record/surfaceFit.ts
1233
+ async function surfaceSize(cdp, dpr) {
1234
+ const shot = await cdp.send("Page.captureScreenshot", {
1235
+ format: "jpeg",
1236
+ quality: 1
698
1237
  });
1238
+ const dims = shot.data ? jpegDims(base64Bytes(shot.data)) : null;
1239
+ if (!dims) return null;
1240
+ return { width: Math.round(dims.w / dpr), height: Math.round(dims.h / dpr) };
699
1241
  }
700
- function publicUrl(u) {
1242
+ async function fitSurfaceToViewport(cdp, width, height) {
701
1243
  try {
702
- const x = new URL(u);
703
- return `${x.origin}${x.pathname}`;
1244
+ const ratio = await cdp.send("Runtime.evaluate", {
1245
+ expression: "devicePixelRatio",
1246
+ returnByValue: true
1247
+ });
1248
+ const value = ratio.result?.value;
1249
+ const dpr = typeof value === "number" && value > 0 ? value : 1;
1250
+ const before = await surfaceSize(cdp, dpr);
1251
+ if (!before) return null;
1252
+ let after = before;
1253
+ for (let tries = 0; tries < 2; tries++) {
1254
+ if (after.width === width && after.height === height) break;
1255
+ const win = await cdp.send("Browser.getWindowForTarget");
1256
+ const { width: ww, height: wh } = win.bounds;
1257
+ if (ww === void 0 || wh === void 0) break;
1258
+ await cdp.send("Browser.setWindowBounds", {
1259
+ windowId: win.windowId,
1260
+ bounds: {
1261
+ width: ww + (width - after.width),
1262
+ height: wh + (height - after.height)
1263
+ }
1264
+ });
1265
+ after = await surfaceSize(cdp, dpr) ?? after;
1266
+ }
1267
+ return { before, after };
704
1268
  } catch {
705
- return "";
1269
+ return null;
706
1270
  }
707
1271
  }
708
- var DRY_PAUSE_MS = 120;
709
- function scrollByScript(x, y, dy) {
710
- return `(() => {
711
- let el = document.elementFromPoint(${Math.round(x)}, ${Math.round(y)});
712
- while (el && el !== document.body && el !== document.documentElement) {
713
- const cs = getComputedStyle(el);
714
- if (/(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1) {
715
- el.scrollBy(0, ${dy});
716
- return 'el';
1272
+
1273
+ // src/record/waits.ts
1274
+ function visibleTextScript(text) {
1275
+ return `(() => { const b = document.body; return !!b && (b.innerText || '').includes(${JSON.stringify(text)}) })()`;
1276
+ }
1277
+ async function readCondition(page, c) {
1278
+ try {
1279
+ if (c.text !== void 0)
1280
+ return { holds: await page.evaluate(visibleTextScript(c.text)) };
1281
+ if (c.gone !== void 0)
1282
+ return { holds: !await page.locator(c.gone).first().isVisible() };
1283
+ if (c.selector !== void 0) {
1284
+ const loc = page.locator(c.selector).first();
1285
+ const visible = await loc.isVisible();
1286
+ if (!visible) return { holds: false, visible };
1287
+ if (c.enabled === void 0) return { holds: true, visible };
1288
+ const enabled = await loc.isEnabled({ timeout: 1e3 });
1289
+ return { holds: enabled === c.enabled, visible };
717
1290
  }
718
- el = el.parentElement;
1291
+ } catch {
719
1292
  }
720
- window.scrollBy(0, ${dy});
721
- return 'window';
722
- })()`;
1293
+ return { holds: false };
723
1294
  }
724
- function jpegDims(buf) {
725
- const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
726
- let i = 2;
727
- while (i < buf.length - 9) {
728
- if (buf[i] !== 255) return null;
729
- const marker = buf[i + 1];
730
- const len = view.getUint16(i + 2);
731
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
732
- return { h: view.getUint16(i + 5), w: view.getUint16(i + 7) };
1295
+ async function pollCondition(page, c, opts) {
1296
+ const now = opts.now ?? Date.now;
1297
+ const start = now();
1298
+ const reads = Math.max(1, opts.confirm?.reads ?? 1);
1299
+ for (let first = true; ; first = false) {
1300
+ if (!first && opts.between) await opts.between();
1301
+ let last = await readCondition(page, c);
1302
+ for (let n = 1; last.holds && n < reads; n++) {
1303
+ await opts.sleep(opts.confirm?.gapMs ?? 0);
1304
+ last = await readCondition(page, c);
733
1305
  }
734
- i += 2 + len;
1306
+ const ms = now() - start;
1307
+ if (last.holds) return { ok: true, ms, last };
1308
+ const left = opts.timeoutMs - ms;
1309
+ if (left <= 0) return { ok: false, ms, last };
1310
+ await opts.sleep(Math.min(opts.everyMs, left));
735
1311
  }
736
- return null;
737
1312
  }
738
- function base64Bytes(data) {
739
- const bin = atob(data);
740
- const out = new Uint8Array(bin.length);
741
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
742
- return out;
1313
+ var DOM_QUIET_MS = 800;
1314
+ var DOM_SETTLE_CAP_MS = 5e3;
1315
+ var DOM_CHANGE_PROBE = `(() => {
1316
+ if (!window.__vosDomChanges) {
1317
+ window.__vosDomChanges = { n: 0 };
1318
+ new MutationObserver((list) => { window.__vosDomChanges.n += list.length })
1319
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
1320
+ }
1321
+ return window.__vosDomChanges.n;
1322
+ })()`;
1323
+ async function domSettled(page, opts) {
1324
+ const now = opts.now ?? Date.now;
1325
+ const quiet = opts.quietMs ?? DOM_QUIET_MS;
1326
+ const cap = opts.capMs ?? DOM_SETTLE_CAP_MS;
1327
+ const start = now();
1328
+ let seen = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1329
+ let since = start;
1330
+ for (; ; ) {
1331
+ const t = now();
1332
+ if (t - since >= quiet) return { settled: true, ms: t - start };
1333
+ if (t - start >= cap) return { settled: false, ms: t - start };
1334
+ await opts.sleep(opts.tickMs ?? 100);
1335
+ const n = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1336
+ if (n !== seen) {
1337
+ seen = n;
1338
+ since = now();
1339
+ }
1340
+ }
743
1341
  }
744
- async function recordTake(browser, url, actions, sink, log, opts = {}) {
745
- const dry = opts.dryRun === true;
746
- const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
747
- const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
748
- const vw = actions.viewport?.width ?? 1280;
749
- const vh = actions.viewport?.height ?? 720;
750
- const context = opts.context ?? await browser.newContext({
751
- viewport: { width: vw, height: vh },
752
- deviceScaleFactor: 1,
753
- ...opts.storageState ? { storageState: opts.storageState } : {}
1342
+ function sayDuration(ms) {
1343
+ const s = Math.round(ms / 100) / 10;
1344
+ if (s < 60) return `${s} s`;
1345
+ const m = Math.floor(s / 60);
1346
+ const rest = Math.round(s - m * 60);
1347
+ return rest ? `${m} min ${rest} s` : `${m} min`;
1348
+ }
1349
+ function sayCondition(c) {
1350
+ if (c.text !== void 0) return `text "${c.text}" to show`;
1351
+ if (c.gone !== void 0) return `'${c.gone}' to go`;
1352
+ if (c.enabled === true) return `'${c.selector}' to be enabled`;
1353
+ if (c.enabled === false) return `'${c.selector}' to be disabled`;
1354
+ return `'${c.selector}' to appear`;
1355
+ }
1356
+ function conditionMiss(c, last, ms) {
1357
+ const t = sayDuration(ms);
1358
+ if (c.text !== void 0)
1359
+ return `no visible text contained "${c.text}" for ${t}`;
1360
+ if (c.gone !== void 0) return `'${c.gone}' was still there after ${t}`;
1361
+ if (last.visible && c.enabled === true)
1362
+ return `'${c.selector}' stayed disabled for ${t}`;
1363
+ if (last.visible && c.enabled === false)
1364
+ return `'${c.selector}' stayed enabled for ${t}`;
1365
+ return `'${c.selector}' never appeared in ${t}`;
1366
+ }
1367
+
1368
+ // src/record/ready.ts
1369
+ var READY_MODES = ["dom", "networkidle"];
1370
+ var DEFAULT_READY = "dom";
1371
+ var NAV_TIMEOUT_MS = 45e3;
1372
+ var READY_SETTLE_CAP_MS = 8e3;
1373
+ var NETWORKIDLE_HYDRATION_MS = 800;
1374
+ function readyProblem(v) {
1375
+ if (v === void 0) return null;
1376
+ return typeof v === "string" && READY_MODES.includes(v) ? null : `ready must be ${READY_MODES.map((m) => `"${m}"`).join(" or ")}`;
1377
+ }
1378
+ async function untilReady(page, mode, navigate, opts) {
1379
+ const now = opts.now ?? Date.now;
1380
+ const start = now();
1381
+ const timeout = opts.timeoutMs ?? NAV_TIMEOUT_MS;
1382
+ if (mode === "networkidle") {
1383
+ let idle = true;
1384
+ const response2 = await navigate({
1385
+ waitUntil: "networkidle",
1386
+ timeout
1387
+ }).catch(() => {
1388
+ idle = false;
1389
+ return null;
1390
+ });
1391
+ await opts.sleep(NETWORKIDLE_HYDRATION_MS);
1392
+ return {
1393
+ response: response2,
1394
+ ready: { mode, ms: Math.round(now() - start), capped: !idle }
1395
+ };
1396
+ }
1397
+ const response = await navigate({ waitUntil: "load", timeout }).catch(
1398
+ () => null
1399
+ );
1400
+ const settled = await domSettled(page, {
1401
+ sleep: opts.sleep,
1402
+ now,
1403
+ quietMs: DOM_QUIET_MS,
1404
+ capMs: opts.capMs ?? READY_SETTLE_CAP_MS
754
1405
  });
755
- if (opts.headers && Object.keys(opts.headers).length)
756
- await applyHeaders(context, opts.headers, url, log);
757
- const masks = actions.mask ?? [];
758
- if (masks.length) await context.addInitScript(maskInitScript(masks));
759
- const exposureLog = new ExposureLog(masks);
760
- const page = await context.newPage();
761
- const scanExposures = async (step) => {
762
- const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
763
- exposureLog.add(step, scan);
1406
+ return {
1407
+ response,
1408
+ ready: { mode, ms: Math.round(now() - start), capped: !settled.settled }
764
1409
  };
765
- page.on("console", (m) => {
766
- if (m.type() === "error") log(` [page error] ${m.text()}`);
767
- });
768
- log(`goto ${url}`);
769
- let navTimeout = false;
770
- let response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
771
- navTimeout = true;
772
- log(" (networkidle timeout \u2014 continuing)");
1410
+ }
1411
+ async function reloadUntilReady(page, mode, timeoutMs, opts = {}) {
1412
+ const nav = {
1413
+ waitUntil: mode === "networkidle" ? "networkidle" : "load",
1414
+ timeout: Math.min(NAV_TIMEOUT_MS, timeoutMs)
1415
+ };
1416
+ await (page.reload ? page.reload(nav) : page.goto(page.url(), nav)).catch(
1417
+ () => null
1418
+ );
1419
+ await domSettled(page, { sleep: opts.sleep ?? realSleep });
1420
+ }
1421
+ function readyLine(r) {
1422
+ const s = (r.ms / 1e3).toFixed(1);
1423
+ if (r.mode === "networkidle")
1424
+ return r.capped ? `(networkidle timeout after ${s} s, continuing)` : `(network idle after ${s} s)`;
1425
+ return r.capped ? `(the page was still changing after ${s} s, starting anyway)` : `(the page settled after ${s} s)`;
1426
+ }
1427
+
1428
+ // src/record/gestures.ts
1429
+ var MODIFIERS = [
1430
+ "Shift",
1431
+ "Control",
1432
+ "Alt",
1433
+ "Meta",
1434
+ "Mod",
1435
+ "ControlOrMeta"
1436
+ ];
1437
+ var DRAG_MODIFIERS = [
1438
+ "Alt",
1439
+ "Shift",
1440
+ "Meta",
1441
+ "Control",
1442
+ "Mod"
1443
+ ];
1444
+ var NAMED_KEYS = /* @__PURE__ */ new Set([
1445
+ "Escape",
1446
+ "Enter",
1447
+ "Tab",
1448
+ "Backspace",
1449
+ "Delete",
1450
+ "Insert",
1451
+ "ArrowUp",
1452
+ "ArrowDown",
1453
+ "ArrowLeft",
1454
+ "ArrowRight",
1455
+ "Home",
1456
+ "End",
1457
+ "PageUp",
1458
+ "PageDown",
1459
+ "Space",
1460
+ "CapsLock",
1461
+ "ContextMenu",
1462
+ "Backquote",
1463
+ "Minus",
1464
+ "Equal",
1465
+ "BracketLeft",
1466
+ "BracketRight",
1467
+ "Backslash",
1468
+ "Semicolon",
1469
+ "Quote",
1470
+ "Comma",
1471
+ "Period",
1472
+ "Slash",
1473
+ "NumpadAdd",
1474
+ "NumpadSubtract",
1475
+ "NumpadMultiply",
1476
+ "NumpadDivide",
1477
+ "NumpadDecimal",
1478
+ "NumpadEnter"
1479
+ ]);
1480
+ var isModifier = (s) => MODIFIERS.includes(s);
1481
+ function isKeyName(k) {
1482
+ if ([...k].length === 1) return k.trim().length === 1 || k === " ";
1483
+ if (NAMED_KEYS.has(k)) return true;
1484
+ return /^Key[A-Z]$/.test(k) || /^Digit[0-9]$/.test(k) || /^Numpad[0-9]$/.test(k) || /^F([1-9]|1[0-9]|2[0-4])$/.test(k);
1485
+ }
1486
+ function splitChord(chord) {
1487
+ if (chord === "+") return { mods: [], key: "+" };
1488
+ const plusKey = chord.endsWith("++");
1489
+ const parts = (plusKey ? chord.slice(0, -2) : chord).split("+");
1490
+ if (plusKey) return { mods: parts, key: "+" };
1491
+ const key = parts.pop() ?? "";
1492
+ return { mods: parts, key };
1493
+ }
1494
+ function chordProblem(chord) {
1495
+ if (typeof chord !== "string" || !chord)
1496
+ return 'must be a key name or a chord like "Shift+D"';
1497
+ const { mods, key } = splitChord(chord);
1498
+ for (const m of mods) {
1499
+ if (!isModifier(m))
1500
+ return `"${chord}": "${m}" is not a modifier (one of ${MODIFIERS.join(", ")})`;
1501
+ }
1502
+ if (new Set(mods).size !== mods.length)
1503
+ return `"${chord}" names a modifier twice`;
1504
+ if (!key) return `"${chord}" ends without a key`;
1505
+ if (isModifier(key)) {
1506
+ if (mods.length) return `"${chord}" ends on a modifier, not a key`;
773
1507
  return null;
774
- });
775
- await sleep(800);
776
- const setup = actions.setup ?? [];
777
- if (setup.length) {
778
- log(`setup: ${setup.length} step(s), off camera`);
779
- const result = await runSetup(page, setup, {
780
- sleep: realSleep,
781
- env: opts.env ?? {}
782
- });
783
- for (const line of result.lines) log(` ${line}`);
784
- if (result.failed) {
785
- await context.close().catch(() => {
786
- });
787
- throw new SetupError(result.failed);
788
- }
789
- log(`goto ${url} (after setup)`);
790
- response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
791
- await sleep(800);
792
1508
  }
793
- let wall = null;
794
- if (opts.onArrival) {
795
- const seen = await page.evaluate(WALL_PROBE).catch(() => null);
796
- try {
797
- wall = await opts.onArrival({
798
- askedUrl: url,
799
- landedUrl: page.url(),
800
- ...response ? { status: response.status() } : {},
801
- passwordFields: seen?.passwordFields ?? 0,
802
- newPasswordField: seen?.newPasswordField ?? false,
803
- oneTimeCodeField: seen?.oneTimeCodeField ?? false
804
- }) ?? null;
805
- } catch (e) {
806
- await context.close().catch(() => {
807
- });
808
- throw e;
809
- }
1509
+ if (!isKeyName(key))
1510
+ return `"${chord}": "${key}" is not a key (a single character, a named key like Escape or ArrowLeft, F1 to F24, or a code like KeyA or Digit1)`;
1511
+ return null;
1512
+ }
1513
+ function resolveChord(chord) {
1514
+ const { mods, key } = splitChord(chord);
1515
+ const k = key === "Mod" ? "ControlOrMeta" : key;
1516
+ return [...mods.map((m) => m === "Mod" ? "ControlOrMeta" : m), k].join("+");
1517
+ }
1518
+ function pressSequence(step) {
1519
+ if (step.keys !== void 0)
1520
+ return typeof step.keys === "string" ? [step.keys] : step.keys;
1521
+ return step.key ? [step.key] : [];
1522
+ }
1523
+ var KEY_SEQUENCE_GAP_MS = 150;
1524
+ var KEY_ROUNDTRIP_MS = 50;
1525
+ var keySequencePlanMs = (keys) => Math.max(0, keys - 1) * KEY_SEQUENCE_GAP_MS + keys * KEY_ROUNDTRIP_MS;
1526
+ var DRAG_HOLD_MS = 400;
1527
+ var MAX_KEYS = 50;
1528
+ function resolveModifier(m) {
1529
+ return m === "Mod" ? "ControlOrMeta" : m;
1530
+ }
1531
+ function modifierBits(mods, platform) {
1532
+ let bits = 0;
1533
+ for (const m of mods) {
1534
+ const k = m === "Mod" ? platform === "mac" ? "Meta" : "Control" : m;
1535
+ bits |= k === "Alt" ? 1 : k === "Control" ? 2 : k === "Meta" ? 4 : 8;
810
1536
  }
811
- await scanExposures(-1);
812
- const cdp = await context.newCDPSession(page);
813
- const frames = [];
814
- let frameIdx = 0;
815
- let t0 = 0;
816
- const first = { dims: null };
817
- let sinkChain = Promise.resolve();
818
- if (!dry)
819
- cdp.on("Page.screencastFrame", (ev) => {
1537
+ return bits;
1538
+ }
1539
+ var CLICK_BUTTONS = ["left", "right", "middle"];
1540
+ var buttonNumber = (b) => b === "right" ? 2 : b === "middle" ? 1 : 0;
1541
+ var MULTI_CLICK_GAP_MS = 90;
1542
+ var MAX_PATH_POINTS = 2e4;
1543
+ var PATH_DEFAULT_MS = 700;
1544
+ var finite = (v) => typeof v === "number" && Number.isFinite(v);
1545
+ function readPoint(raw) {
1546
+ if (Array.isArray(raw)) {
1547
+ if (raw.length < 2 || raw.length > 4)
1548
+ return "must be [x, y], [x, y, tMs] or [x, y, tMs, pressure]";
1549
+ const [x, y, t, pressure] = raw;
1550
+ if (!finite(x) || !finite(y)) return "x and y must be numbers (CSS px)";
1551
+ if (t !== void 0 && !finite(t)) return "its time must be a number of ms";
1552
+ if (pressure !== void 0 && !finite(pressure))
1553
+ return "its pressure must be a number from 0 to 1";
1554
+ return {
1555
+ x,
1556
+ y,
1557
+ ...t !== void 0 ? { t } : {},
1558
+ ...pressure !== void 0 ? { pressure } : {}
1559
+ };
1560
+ }
1561
+ if (typeof raw === "object" && raw !== null) {
1562
+ const o = raw;
1563
+ const extra = Object.keys(o).filter(
1564
+ (k) => !["x", "y", "t", "pressure"].includes(k)
1565
+ );
1566
+ if (extra.length)
1567
+ return `takes x, y, t and pressure, not ${extra.join(", ")}`;
1568
+ if (!finite(o.x) || !finite(o.y)) return "x and y must be numbers (CSS px)";
1569
+ if (o.t !== void 0 && !finite(o.t)) return "its t must be a number of ms";
1570
+ if (o.pressure !== void 0 && !finite(o.pressure))
1571
+ return "its pressure must be a number from 0 to 1";
1572
+ return {
1573
+ x: o.x,
1574
+ y: o.y,
1575
+ ...o.t !== void 0 ? { t: o.t } : {},
1576
+ ...o.pressure !== void 0 ? { pressure: o.pressure } : {}
1577
+ };
1578
+ }
1579
+ return "must be [x, y] or { x, y }";
1580
+ }
1581
+ function readPath(raw) {
1582
+ const list = typeof raw === "object" && raw !== null && !Array.isArray(raw) && Array.isArray(raw.points) ? raw.points : raw;
1583
+ if (!Array.isArray(list))
1584
+ return { problems: ['must be an array of points, or { "points": [...] }'] };
1585
+ if (list.length < 2) return { problems: ["needs at least two points"] };
1586
+ if (list.length > MAX_PATH_POINTS)
1587
+ return { problems: [`holds more than ${MAX_PATH_POINTS} points`] };
1588
+ const problems = [];
1589
+ const read = [];
1590
+ list.forEach((p, i) => {
1591
+ const r = readPoint(p);
1592
+ if (typeof r === "string") problems.push(`point ${i} ${r}`);
1593
+ else {
1594
+ if (r.x < 0 || r.y < 0)
1595
+ problems.push(`point ${i} is off the viewport (x and y are \u2265 0)`);
1596
+ if (r.pressure !== void 0 && (r.pressure < 0 || r.pressure > 1))
1597
+ problems.push(`point ${i} pressure must be from 0 to 1`);
1598
+ read.push(r);
1599
+ }
1600
+ });
1601
+ if (problems.length) return { problems: problems.slice(0, 5) };
1602
+ const timedCount = read.filter((p) => p.t !== void 0).length;
1603
+ if (timedCount !== 0 && timedCount !== read.length)
1604
+ return {
1605
+ problems: [
1606
+ "every point carries a time or none does: a path is replayed by its own timing, or evenly over ms"
1607
+ ]
1608
+ };
1609
+ const timed = timedCount > 0;
1610
+ if (timed) {
1611
+ for (let i = 1; i < read.length; i++)
1612
+ if (read[i].t < read[i - 1].t)
1613
+ return {
1614
+ problems: [`point ${i}'s time runs backwards (times never decrease)`]
1615
+ };
1616
+ }
1617
+ const t0 = timed ? read[0].t : 0;
1618
+ return {
1619
+ timed,
1620
+ points: read.map((p) => ({
1621
+ x: p.x,
1622
+ y: p.y,
1623
+ at: timed ? p.t - t0 : 0,
1624
+ ...p.pressure !== void 0 ? { pressure: p.pressure } : {}
1625
+ }))
1626
+ };
1627
+ }
1628
+ function schedulePath(path, ms) {
1629
+ if (path.timed) return path.points;
1630
+ const dur = ms ?? PATH_DEFAULT_MS;
1631
+ const n = path.points.length;
1632
+ return path.points.map((p, i) => ({
1633
+ ...p,
1634
+ at: n > 1 ? Math.round(dur * i / (n - 1)) : 0
1635
+ }));
1636
+ }
1637
+ var pathDurationMs = (s) => s.length ? s[s.length - 1].at : 0;
1638
+ var OVERRUN_SHARE = 0.2;
1639
+ var OVERRUN_MIN_PLANNED_MS = 100;
1640
+ var OVERRUN_MIN_MS = 100;
1641
+ function overrunOf(plannedMs, tookMs) {
1642
+ if (!(plannedMs >= OVERRUN_MIN_PLANNED_MS)) return null;
1643
+ if (tookMs <= plannedMs * (1 + OVERRUN_SHARE)) return null;
1644
+ if (tookMs - plannedMs < OVERRUN_MIN_MS) return null;
1645
+ return Math.round(tookMs - plannedMs);
1646
+ }
1647
+ function overrunLine(overruns) {
1648
+ if (!overruns.length) return null;
1649
+ const planned = overruns.reduce((a, o) => a + o.plannedMs, 0);
1650
+ const took = overruns.reduce((a, o) => a + o.tookMs, 0);
1651
+ const ratio = planned > 0 ? took / planned : 0;
1652
+ const n = overruns.length;
1653
+ const which = overruns.map((o) => `#${o.step}${o.id ? ` (${o.id})` : ""} ${o.do}`).join(", ");
1654
+ return `${n} step${n === 1 ? "" : "s"} ran ${ratio.toFixed(1)}\xD7 slower than scripted (${which}): the page could not keep up; add settle or raise ms`;
1655
+ }
1656
+
1657
+ // src/record/paste.ts
1658
+ var PASTE_READ_WINDOW_MS = 300;
1659
+ var PASTE_UNREAD_MS = 3e3;
1660
+ var PASTE_MAX_BYTES = 10 * 1024 * 1024;
1661
+ var PASTE_TYPES = {
1662
+ ".png": "image/png",
1663
+ ".jpg": "image/jpeg",
1664
+ ".jpeg": "image/jpeg",
1665
+ ".webp": "image/webp",
1666
+ ".gif": "image/gif",
1667
+ ".svg": "image/svg+xml",
1668
+ ".txt": "text/plain",
1669
+ ".md": "text/plain",
1670
+ ".html": "text/html",
1671
+ ".htm": "text/html",
1672
+ ".json": "text/plain",
1673
+ ".csv": "text/plain"
1674
+ };
1675
+ function pasteTypeOf(name) {
1676
+ const dot = name.lastIndexOf(".");
1677
+ if (dot < 0) return null;
1678
+ return PASTE_TYPES[name.slice(dot).toLowerCase()] ?? null;
1679
+ }
1680
+ var BUILD = (item) => `
1681
+ const item = ${JSON.stringify(item)};
1682
+ const bytes = item.b64 ? Uint8Array.from(atob(item.b64), (c) => c.charCodeAt(0)) : null;
1683
+ const asText = bytes ? new TextDecoder().decode(bytes) : null;
1684
+ async function blobs() {
1685
+ if (item.text !== undefined) return { 'text/plain': new Blob([item.text], { type: 'text/plain' }) };
1686
+ const type = item.type;
1687
+ if (type === 'text/plain') return { 'text/plain': new Blob([asText], { type }) };
1688
+ if (type === 'text/html') return { 'text/html': new Blob([asText], { type }), 'text/plain': new Blob([asText], { type: 'text/plain' }) };
1689
+ if (type === 'image/svg+xml') return { 'image/svg+xml': new Blob([bytes], { type }), 'text/plain': new Blob([asText], { type: 'text/plain' }) };
1690
+ if (type === 'image/png') return { 'image/png': new Blob([bytes], { type }) };
1691
+ const bmp = await createImageBitmap(new Blob([bytes], { type }));
1692
+ const c = document.createElement('canvas');
1693
+ c.width = bmp.width; c.height = bmp.height;
1694
+ c.getContext('2d').drawImage(bmp, 0, 0);
1695
+ const png = await new Promise((r) => c.toBlob(r, 'image/png'));
1696
+ return { 'image/png': png };
1697
+ }
1698
+ `;
1699
+ function stageClipboardScript(item) {
1700
+ return `(async () => {
1701
+ ${BUILD(item)}
1702
+ try {
1703
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return 'refused: this page has no clipboard';
1704
+ const b = await blobs();
1705
+ try {
1706
+ await navigator.clipboard.write([new ClipboardItem(b)]);
1707
+ } catch (e) {
1708
+ if (!b['text/plain'] || Object.keys(b).length === 1) throw e;
1709
+ await navigator.clipboard.write([new ClipboardItem({ 'text/plain': b['text/plain'] })]);
1710
+ }
1711
+ return 'ok';
1712
+ } catch (e) {
1713
+ return 'refused: ' + (e && e.message ? e.message : String(e));
1714
+ }
1715
+ })()`;
1716
+ }
1717
+ function syntheticPasteScript(item, opts = {}) {
1718
+ return `(async () => {
1719
+ ${BUILD(item)}
1720
+ try {
1721
+ // With original, an image file is handed over as its own bytes and type
1722
+ // (an SVG also as its markup), never redrawn.
1723
+ const b = ${opts.original ? `item.type === 'image/svg+xml' ? { 'image/svg+xml': new Blob([bytes], { type: item.type }), 'text/plain': new Blob([asText], { type: 'text/plain' }) } : { [item.type]: new Blob([bytes], { type: item.type }) }` : "await blobs()"};
1724
+ const dt = new DataTransfer();
1725
+ let file = null;
1726
+ for (const [type, blob] of Object.entries(b)) {
1727
+ if (type.startsWith('image/')) {
1728
+ file = new File([blob], item.name || 'image.png', { type: blob.type || type });
1729
+ dt.items.add(file);
1730
+ } else dt.setData(type, await blob.text());
1731
+ }
1732
+ try {
1733
+ // The async reads answer the item for THIS paste only: shortly after
1734
+ // the first read (an app may read text and items in turn), or after a
1735
+ // few seconds when nothing reads, the browser's own reads come back,
1736
+ // so a later paste through the clipboard is the clipboard's.
1737
+ const staged = [new ClipboardItem(b)];
1738
+ const clip = navigator.clipboard;
1739
+ const own = { read: Object.prototype.hasOwnProperty.call(clip, 'read'), readText: Object.prototype.hasOwnProperty.call(clip, 'readText') };
1740
+ const was = { read: clip.read, readText: clip.readText };
1741
+ let restored = false;
1742
+ const restore = () => {
1743
+ if (restored) return;
1744
+ restored = true;
1745
+ for (const k of ['read', 'readText']) {
1746
+ if (own[k]) clip[k] = was[k];
1747
+ else delete clip[k];
1748
+ }
1749
+ };
1750
+ let soon = null;
1751
+ const readOnce = () => { if (!soon) soon = setTimeout(restore, ${PASTE_READ_WINDOW_MS}) };
1752
+ clip.read = async () => { readOnce(); return staged };
1753
+ clip.readText = async () => { readOnce(); return b['text/plain'] ? b['text/plain'].text() : '' };
1754
+ setTimeout(restore, ${PASTE_UNREAD_MS});
1755
+ } catch {}
1756
+ const target = document.activeElement || document.body;
1757
+ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true, composed: true });
1758
+ const taken = !target.dispatchEvent(ev);
1759
+ const words = dt.getData('text/plain');
1760
+ if (!taken && words && (target.isContentEditable || /^(INPUT|TEXTAREA)$/.test(target.tagName)))
1761
+ document.execCommand('insertText', false, words);
1762
+ return 'ok';
1763
+ } catch (e) {
1764
+ return 'failed: ' + (e && e.message ? e.message : String(e));
1765
+ }
1766
+ })()`;
1767
+ }
1768
+
1769
+ // src/record/settle.ts
1770
+ var APP_QUIET_MS = 300;
1771
+ var APP_SETTLE_CAP_MS = 4e3;
1772
+ var SETTLE_VERBS = /* @__PURE__ */ new Set([
1773
+ "hover",
1774
+ "click",
1775
+ "type",
1776
+ "press",
1777
+ "scroll",
1778
+ "move",
1779
+ "drag",
1780
+ "paste"
1781
+ ]);
1782
+ var CHANGE_AGE_PROBE = `(() => {
1783
+ const w = window;
1784
+ if (!w.__vosChangeAt) {
1785
+ w.__vosChangeAt = { at: Date.now() };
1786
+ new MutationObserver(() => { w.__vosChangeAt.at = Date.now() })
1787
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
1788
+ return -1;
1789
+ }
1790
+ return Date.now() - w.__vosChangeAt.at;
1791
+ })()`;
1792
+ function settleOn(step, scriptDefault) {
1793
+ if (!SETTLE_VERBS.has(step.do)) return false;
1794
+ return step.settle ?? scriptDefault ?? false;
1795
+ }
1796
+ async function appSettled(page, frames, opts) {
1797
+ const quiet = opts.quietMs ?? APP_QUIET_MS;
1798
+ const age = await page.evaluate(CHANGE_AGE_PROBE).catch(() => -1);
1799
+ const frameAge = opts.sinceFrame?.() ?? null;
1800
+ if (typeof age === "number" && age >= quiet && (frameAge === null || frameAge >= quiet))
1801
+ return { settled: true, ms: 0 };
1802
+ const counted = {
1803
+ evaluate: async (script) => {
1804
+ const n = await page.evaluate(script).catch(() => 0);
1805
+ return (typeof n === "number" && n > 0 ? n : 0) + frames();
1806
+ }
1807
+ };
1808
+ return domSettled(counted, {
1809
+ sleep: opts.sleep,
1810
+ ...opts.now ? { now: opts.now } : {},
1811
+ quietMs: quiet,
1812
+ capMs: opts.capMs ?? APP_SETTLE_CAP_MS,
1813
+ tickMs: 50
1814
+ });
1815
+ }
1816
+
1817
+ // src/record/actions.ts
1818
+ var STEP_GUARDS = ["spend"];
1819
+ var GUARDABLE = /* @__PURE__ */ new Set(["click", "press", "type"]);
1820
+ var EXPECT_TIMEOUT_S = 5;
1821
+ var AWAIT_TIMEOUT_S = 600;
1822
+ var AWAIT_POLL_S = 2;
1823
+ var DOWNLOAD_TIMEOUT_S = 30;
1824
+ var VERBS2 = /* @__PURE__ */ new Set([
1825
+ "wait",
1826
+ "hover",
1827
+ "click",
1828
+ "type",
1829
+ "press",
1830
+ "scroll",
1831
+ "move",
1832
+ "drag",
1833
+ "expect",
1834
+ "await",
1835
+ "download",
1836
+ "paste"
1837
+ ]);
1838
+ var PRESS_KEYS = [
1839
+ "Escape",
1840
+ "Enter",
1841
+ "Tab",
1842
+ "Backspace",
1843
+ "Delete",
1844
+ "ArrowUp",
1845
+ "ArrowDown",
1846
+ "ArrowLeft",
1847
+ "ArrowRight",
1848
+ "Home",
1849
+ "End",
1850
+ "PageUp",
1851
+ "PageDown",
1852
+ "Space"
1853
+ ];
1854
+ function validateActions(value) {
1855
+ const errors = [];
1856
+ if (typeof value !== "object" || value === null)
1857
+ return ["actions file must be a JSON object"];
1858
+ const obj = value;
1859
+ if (obj.url !== void 0) {
1860
+ if (typeof obj.url !== "string") errors.push("url must be a string");
1861
+ else {
1862
+ const problem = urlSchemeProblem(obj.url);
1863
+ if (problem) errors.push(`url ${problem}`);
1864
+ }
1865
+ }
1866
+ if (obj.viewport !== void 0) {
1867
+ const v = obj.viewport;
1868
+ if (typeof v !== "object" || v === null) {
1869
+ errors.push("viewport must be { width, height }");
1870
+ } else {
1871
+ const vp = v;
1872
+ if (typeof vp.width !== "number" || typeof vp.height !== "number") {
1873
+ errors.push("viewport must be { width, height }");
1874
+ }
1875
+ }
1876
+ }
1877
+ if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
1878
+ const ready = readyProblem(obj.ready);
1879
+ if (ready) errors.push(ready);
1880
+ if (obj.settle !== void 0 && typeof obj.settle !== "boolean")
1881
+ errors.push("settle must be true or false");
1882
+ if (obj.mask !== void 0) {
1883
+ if (!Array.isArray(obj.mask)) {
1884
+ errors.push("mask must be an array of { selector, as?, text? }");
1885
+ } else {
1886
+ obj.mask.forEach((raw, i) => {
1887
+ const at = `mask[${i}]`;
1888
+ const m = raw ?? {};
1889
+ if (typeof m.selector !== "string" || !m.selector.trim())
1890
+ errors.push(`${at}: needs a selector`);
1891
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
1892
+ errors.push(`${at}: as must be "blur" or "text"`);
1893
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
1894
+ errors.push(`${at}: as "text" needs the text to show instead`);
1895
+ if (m.as !== "text" && m.text !== void 0)
1896
+ errors.push(`${at}: text is only read with as "text"`);
1897
+ });
1898
+ }
1899
+ }
1900
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
1901
+ errors.push("steps must be a non-empty array");
1902
+ return errors;
1903
+ }
1904
+ const seenIds = /* @__PURE__ */ new Set();
1905
+ obj.steps.forEach((raw, i) => {
1906
+ const at = `steps[${i}]`;
1907
+ if (typeof raw !== "object" || raw === null) {
1908
+ errors.push(`${at}: must be an object`);
1909
+ return;
1910
+ }
1911
+ const s = raw;
1912
+ if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
1913
+ errors.push(`${at}: read must be a selector`);
1914
+ if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
1915
+ errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
1916
+ return;
1917
+ }
1918
+ if (s.id !== void 0) {
1919
+ if (typeof s.id !== "string" || !s.id.trim()) {
1920
+ errors.push(`${at}: id must be a non-empty string`);
1921
+ } else if (seenIds.has(s.id)) {
1922
+ errors.push(
1923
+ `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
1924
+ );
1925
+ } else {
1926
+ seenIds.add(s.id);
1927
+ }
1928
+ }
1929
+ if (s.do === "type" && typeof s.selector !== "string")
1930
+ errors.push(`${at}: type needs a selector`);
1931
+ if (s.do === "hover" || s.do === "click") {
1932
+ const point = s.x !== void 0 || s.y !== void 0;
1933
+ if (point && s.selector !== void 0)
1934
+ errors.push(
1935
+ `${at}: ${s.do} takes a selector or x and y (a viewport point), not both`
1936
+ );
1937
+ else if (point) {
1938
+ if (!isCoord(s.x) || !isCoord(s.y))
1939
+ errors.push(
1940
+ `${at}: ${s.do} x and y must both be numbers of CSS px, 0 or more`
1941
+ );
1942
+ } else if (typeof s.selector !== "string")
1943
+ errors.push(
1944
+ `${at}: ${s.do} needs a selector, or x and y (a viewport point)`
1945
+ );
1946
+ }
1947
+ if (s.do === "wait" && typeof s.ms !== "number")
1948
+ errors.push(`${at}: wait needs ms`);
1949
+ if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
1950
+ errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
1951
+ if (s.do === "type" && typeof s.text !== "string")
1952
+ errors.push(`${at}: type needs text`);
1953
+ if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
1954
+ errors.push(`${at}: type focus must be true or false`);
1955
+ if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
1956
+ errors.push(`${at}: type replace must be true or false`);
1957
+ if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
1958
+ errors.push(`${at}: type at must be "end" or "start"`);
1959
+ if (s.do === "press") {
1960
+ if (s.key !== void 0 && s.keys !== void 0)
1961
+ errors.push(
1962
+ `${at}: press takes key or keys, not both (keys says everything key does)`
1963
+ );
1964
+ else if (s.keys !== void 0) {
1965
+ const list = Array.isArray(s.keys) ? s.keys : [s.keys];
1966
+ if (!list.length)
1967
+ errors.push(`${at}: press keys must name at least one key`);
1968
+ else if (list.length > MAX_KEYS)
1969
+ errors.push(`${at}: press keys names more than ${MAX_KEYS} keys`);
1970
+ list.forEach((k, j) => {
1971
+ const problem = chordProblem(k);
1972
+ if (problem)
1973
+ errors.push(
1974
+ `${at}: press keys${Array.isArray(s.keys) ? `[${j}]` : ""} ${problem}`
1975
+ );
1976
+ });
1977
+ } else if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
1978
+ errors.push(
1979
+ `${at}: press needs a key, one of ${PRESS_KEYS.join(", ")}, or keys (a chord like "Shift+D", a plain key like "r", or a sequence); words are typed with type`
1980
+ );
1981
+ if (s.selector !== void 0 && typeof s.selector !== "string")
1982
+ errors.push(`${at}: press selector must be a string`);
1983
+ }
1984
+ if (s.do === "click") {
1985
+ errors.push(...modifiersProblem(at, "click", s.modifiers));
1986
+ if (s.count !== void 0 && s.count !== 1 && s.count !== 2 && s.count !== 3)
1987
+ errors.push(
1988
+ `${at}: click count must be 1, 2 (a double click) or 3 (a triple click)`
1989
+ );
1990
+ if (s.button !== void 0 && !CLICK_BUTTONS.includes(s.button))
1991
+ errors.push(`${at}: click button must be "left", "right" or "middle"`);
1992
+ }
1993
+ if (s.settle !== void 0) {
1994
+ if (typeof s.settle !== "boolean")
1995
+ errors.push(`${at}: settle must be true or false`);
1996
+ else if (!SETTLE_VERBS.has(s.do))
1997
+ errors.push(
1998
+ `${at}: settle follows a gesture (hover, click, type, press, scroll, move, drag, paste); a ${s.do} sends the page nothing to catch up on`
1999
+ );
2000
+ }
2001
+ if (s.do === "paste") {
2002
+ const given = ["text", "file"].filter(
2003
+ (k) => s[k] !== void 0
2004
+ );
2005
+ if (given.length !== 1)
2006
+ errors.push(
2007
+ `${at}: paste needs exactly one of text (words) or file (an image, an SVG or a text file beside the actions file)`
2008
+ );
2009
+ if (s.text !== void 0 && typeof s.text !== "string")
2010
+ errors.push(`${at}: paste text must be a string`);
2011
+ if (s.file !== void 0) {
2012
+ const problem = typeof s.file === "string" ? fileNameProblem(s.file) ?? (pasteTypeOf(s.file) ? null : `"${s.file}" is not a type a paste takes (.png, .jpg, .webp, .gif, .svg, .txt, .md, .html, .json, .csv)`) : "is not a string";
2013
+ if (problem) errors.push(`${at}: paste file ${problem}`);
2014
+ }
2015
+ if (s.selector !== void 0 && !isFilled(s.selector))
2016
+ errors.push(`${at}: paste selector must be a non-empty string`);
2017
+ }
2018
+ if (s.do === "scroll" && typeof s.dy !== "number")
2019
+ errors.push(`${at}: scroll needs dy`);
2020
+ if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
2021
+ errors.push(`${at}: move needs x and y (CSS px)`);
2022
+ }
2023
+ if (s.guard !== void 0) {
2024
+ if (!STEP_GUARDS.includes(s.guard))
2025
+ errors.push(
2026
+ `${at}: guard must be ${STEP_GUARDS.map((g) => `"${g}"`).join(" or ")}`
2027
+ );
2028
+ else if (!GUARDABLE.has(s.do))
2029
+ errors.push(
2030
+ `${at}: guard sits on a click, a press or a type, the steps that commit something; a ${s.do} spends nothing`
2031
+ );
2032
+ else if (typeof s.id !== "string" || !s.id.trim())
2033
+ errors.push(
2034
+ `${at}: a guarded step needs an id, which is what --confirm <id> names`
2035
+ );
2036
+ }
2037
+ if (s.do === "expect") {
2038
+ const given = ["selector", "text", "gone"].filter(
2039
+ (k) => s[k] !== void 0
2040
+ );
2041
+ if (given.length !== 1)
2042
+ errors.push(
2043
+ `${at}: expect needs exactly one of selector, text or gone${given.length ? ` (it has ${given.join(" and ")})` : ""}`
2044
+ );
2045
+ for (const k of given)
2046
+ if (!isFilled(s[k]))
2047
+ errors.push(`${at}: expect ${k} must be a non-empty string`);
2048
+ if (s.enabled !== void 0) {
2049
+ if (typeof s.enabled !== "boolean")
2050
+ errors.push(`${at}: expect enabled must be true or false`);
2051
+ else if (s.selector === void 0)
2052
+ errors.push(
2053
+ `${at}: expect enabled is about the selector's element, so it needs a selector`
2054
+ );
2055
+ }
2056
+ errors.push(...secondsProblem(at, "expect timeout", s.timeout));
2057
+ }
2058
+ if (s.do === "await") {
2059
+ const u = s.until;
2060
+ if (typeof u !== "object" || u === null || Array.isArray(u)) {
2061
+ errors.push(
2062
+ `${at}: await needs until, one of { "selector" }, { "text" } or { "gone" }`
2063
+ );
2064
+ } else {
2065
+ const until = u;
2066
+ const keys = Object.keys(until);
2067
+ const known = keys.filter(
2068
+ (k) => k === "selector" || k === "text" || k === "gone"
2069
+ );
2070
+ const unknown = keys.filter((k) => !known.includes(k));
2071
+ if (unknown.length)
2072
+ errors.push(
2073
+ `${at}: await until takes selector, text or gone, not ${unknown.join(", ")}`
2074
+ );
2075
+ if (known.length !== 1)
2076
+ errors.push(
2077
+ `${at}: await until needs exactly one of selector, text or gone${known.length ? ` (it has ${known.join(" and ")})` : ""}`
2078
+ );
2079
+ for (const k of known) {
2080
+ const v = until[k];
2081
+ if (typeof v !== "string" || !v.trim())
2082
+ errors.push(`${at}: await until.${k} must be a non-empty string`);
2083
+ }
2084
+ }
2085
+ errors.push(...secondsProblem(at, "await timeout", s.timeout));
2086
+ errors.push(...secondsProblem(at, "await poll", s.poll));
2087
+ if (s.reload !== void 0 && typeof s.reload !== "boolean")
2088
+ errors.push(`${at}: await reload must be true or false`);
2089
+ if (s.onTimeout !== void 0 && s.onTimeout !== "fail" && s.onTimeout !== "continue")
2090
+ errors.push(`${at}: await onTimeout must be "fail" or "continue"`);
2091
+ }
2092
+ if (s.do === "download") {
2093
+ const given = ["selector", "src"].filter(
2094
+ (k) => s[k] !== void 0
2095
+ );
2096
+ if (given.length !== 1)
2097
+ errors.push(
2098
+ `${at}: download needs exactly one of selector (a control that downloads) or src (a video, audio or img element whose source is fetched)`
2099
+ );
2100
+ for (const k of given)
2101
+ if (!isFilled(s[k]))
2102
+ errors.push(`${at}: download ${k} must be a non-empty string`);
2103
+ if (s.as !== void 0) {
2104
+ const problem = typeof s.as === "string" ? fileNameProblem(s.as) : "is not a string";
2105
+ if (problem) errors.push(`${at}: download as ${problem}`);
2106
+ }
2107
+ }
2108
+ if (s.do === "drag") {
2109
+ if (s.path !== void 0) {
2110
+ const clash = ["selector", "x", "y", "tx", "ty"].filter(
2111
+ (k) => s[k] !== void 0
2112
+ );
2113
+ if (clash.length)
2114
+ errors.push(
2115
+ `${at}: a drag with a path starts at its first point and ends at its last, so it takes no ${clash.join(", ")}`
2116
+ );
2117
+ if (typeof s.path === "string") {
2118
+ const problem = fileNameProblem(s.path);
2119
+ if (problem)
2120
+ errors.push(
2121
+ `${at}: drag path ${problem} (a file beside the actions file)`
2122
+ );
2123
+ } else {
2124
+ const read = readPath(s.path);
2125
+ if ("problems" in read)
2126
+ for (const p of read.problems) errors.push(`${at}: drag path ${p}`);
2127
+ }
2128
+ } else {
2129
+ if (typeof s.tx !== "number" || typeof s.ty !== "number") {
2130
+ errors.push(`${at}: drag needs tx and ty (CSS px), or a path`);
2131
+ }
2132
+ const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
2133
+ if (!hasStart)
2134
+ errors.push(
2135
+ `${at}: drag needs a selector OR x and y as the start point`
2136
+ );
2137
+ }
2138
+ errors.push(...modifiersProblem(at, "drag", s.modifiers));
2139
+ }
2140
+ });
2141
+ return errors;
2142
+ }
2143
+ var isCoord = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
2144
+ function modifiersProblem(at, verb, v) {
2145
+ if (v === void 0) return [];
2146
+ if (!Array.isArray(v) || v.some((m) => !DRAG_MODIFIERS.includes(m)))
2147
+ return [
2148
+ `${at}: ${verb} modifiers must be a list of ${DRAG_MODIFIERS.map((m) => `"${m}"`).join(", ")} (Mod is Meta on macOS, Control elsewhere)`
2149
+ ];
2150
+ if (new Set(v).size !== v.length)
2151
+ return [`${at}: ${verb} modifiers names a modifier twice`];
2152
+ return [];
2153
+ }
2154
+ var isFilled = (v) => typeof v === "string" && v.trim().length > 0;
2155
+ function secondsProblem(at, what, v) {
2156
+ if (v === void 0) return [];
2157
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0)
2158
+ return [`${at}: ${what} must be a number of seconds above 0`];
2159
+ return [];
2160
+ }
2161
+ function fileNameProblem(name) {
2162
+ if (!name.trim()) return "must be a file name";
2163
+ if (/[/\\]/.test(name))
2164
+ return `"${name}" must be a plain file name, with no path separator`;
2165
+ if (name.includes(".."))
2166
+ return `"${name}" must be a plain file name, with no ".."`;
2167
+ if (name.startsWith(".")) return `"${name}" must not be a hidden file`;
2168
+ if (/[\u0000-\u001f\u007f]/.test(name))
2169
+ return `"${name}" must not hold control characters`;
2170
+ if (name.length > 200) return "must be at most 200 characters";
2171
+ return null;
2172
+ }
2173
+ function guardedSteps(actions) {
2174
+ const out = [];
2175
+ actions.steps.forEach((s, i) => {
2176
+ if (s.guard)
2177
+ out.push({ step: i, id: s.id ?? String(i), do: s.do, guard: s.guard });
2178
+ });
2179
+ return out;
2180
+ }
2181
+ function guardVerdict(actions, confirm) {
2182
+ const guarded = guardedSteps(actions);
2183
+ const unconfirmed = guarded.filter((g) => !confirm.includes(g.id));
2184
+ const unknown = confirm.filter((c) => !guarded.some((g) => g.id === c));
2185
+ if (!unconfirmed.length && !unknown.length) return { ok: true };
2186
+ const parts = [];
2187
+ if (unconfirmed.length) {
2188
+ const one = unconfirmed.length === 1;
2189
+ parts.push(
2190
+ `${unconfirmed.map((g) => `#${g.step} ${g.id} (${g.do})`).join(", ")} ${one ? "is" : "are"} guarded (${unconfirmed[0].guard}): ${one ? "it spends" : "they spend"} something real, so the recorder presses ${one ? "it" : "them"} only when the command says so. Add ${unconfirmed.map((g) => `--confirm ${g.id}`).join(" ")} to record ${one ? "it" : "them"}, or rehearse with --dry-run, which hovers and never presses`
2191
+ );
2192
+ }
2193
+ if (unknown.length)
2194
+ parts.push(
2195
+ `${unknown.map((c) => `--confirm ${c}`).join(", ")} names no guarded step${guarded.length ? ` (the guarded ones: ${guarded.map((g) => g.id).join(", ")})` : ""}`
2196
+ );
2197
+ return {
2198
+ ok: false,
2199
+ unconfirmed: unconfirmed.map((g) => g.id),
2200
+ unknown,
2201
+ message: `${parts.join(". ")}. Nothing was recorded.`
2202
+ };
2203
+ }
2204
+ function hostedRefusals(actions) {
2205
+ const out = [];
2206
+ for (const g of guardedSteps(actions))
2207
+ out.push(
2208
+ `steps[${g.step}] (${g.id}) is guarded (${g.guard}), and a hosted take has no way to confirm it: record it on this machine with --confirm ${g.id}`
2209
+ );
2210
+ actions.steps.forEach((s, i) => {
2211
+ if (s.do === "download")
2212
+ out.push(
2213
+ `steps[${i}] is a download, which a hosted take does not keep: record it on this machine, where the file lands in the take's outputs/`
2214
+ );
2215
+ if (s.do === "paste")
2216
+ out.push(
2217
+ `steps[${i}] is a paste, and a hosted browser has no clipboard to paste from: record it on this machine`
2218
+ );
2219
+ if (s.do === "drag" && typeof s.path === "string")
2220
+ out.push(
2221
+ `steps[${i}] draws a path from the file "${s.path}", and a hosted take carries the script alone: write the points inline as path`
2222
+ );
2223
+ });
2224
+ return out;
2225
+ }
2226
+ function scriptFiles(actions) {
2227
+ const out = [];
2228
+ actions.steps.forEach((s, i) => {
2229
+ if (s.do === "drag" && typeof s.path === "string")
2230
+ out.push({ step: i, name: s.path, as: "path" });
2231
+ if (s.do === "paste" && typeof s.file === "string")
2232
+ out.push({ step: i, name: s.file, as: "paste" });
2233
+ });
2234
+ return out;
2235
+ }
2236
+
2237
+ // src/record/scriptFiles.ts
2238
+ async function loadScriptFiles(actions, source) {
2239
+ const loaded = { paths: /* @__PURE__ */ new Map(), pastes: /* @__PURE__ */ new Map() };
2240
+ const named = scriptFiles(actions);
2241
+ if (!named.length) return loaded;
2242
+ if (!source) {
2243
+ const f = named[0];
2244
+ return {
2245
+ problem: `steps[${f.step}] names the file "${f.name}", and this recorder has no actions directory to read it from${f.as === "path" ? ": write the points inline as path" : ""}. Nothing was recorded.`
2246
+ };
2247
+ }
2248
+ for (const f of named) {
2249
+ const at = `steps[${f.step}]`;
2250
+ const bad = fileNameProblem(f.name);
2251
+ if (bad) return { problem: `${at}: ${bad}. Nothing was recorded.` };
2252
+ let bytes;
2253
+ try {
2254
+ bytes = await source.read(f.name);
2255
+ } catch (e) {
2256
+ const why = e instanceof Error ? e.message : String(e);
2257
+ return {
2258
+ problem: f.as === "paste" ? `${at} pastes the file "${f.name}", which could not be read beside the actions file (${why}): a file paste needs its file beside the script, so put ${f.name} next to it (a take fetched from vos.so carries its paths and its words, never a pasted file). Nothing was recorded.` : `${at} names "${f.name}", which could not be read beside the actions file (${why}). Nothing was recorded.`
2259
+ };
2260
+ }
2261
+ if (f.as === "path") {
2262
+ let raw;
2263
+ try {
2264
+ raw = JSON.parse(new TextDecoder().decode(bytes));
2265
+ } catch {
2266
+ return {
2267
+ problem: `${at}: the path file "${f.name}" is not JSON (an array of [x, y] or [x, y, tMs] points). Nothing was recorded.`
2268
+ };
2269
+ }
2270
+ const path = readPath(raw);
2271
+ if ("problems" in path)
2272
+ return {
2273
+ problem: `${at}: the path file "${f.name}" ${path.problems.join("; ")}. Nothing was recorded.`
2274
+ };
2275
+ loaded.paths.set(f.step, path);
2276
+ } else {
2277
+ if (bytes.length > PASTE_MAX_BYTES)
2278
+ return {
2279
+ problem: `${at}: "${f.name}" is larger than ${PASTE_MAX_BYTES / 1024 / 1024} MB, more than a paste carries. Nothing was recorded.`
2280
+ };
2281
+ loaded.pastes.set(f.step, {
2282
+ bytes,
2283
+ type: pasteTypeOf(f.name) ?? "application/octet-stream",
2284
+ name: f.name
2285
+ });
2286
+ }
2287
+ }
2288
+ return loaded;
2289
+ }
2290
+ function inlinePoints(path) {
2291
+ return path.points.map((p) => {
2292
+ if (path.timed)
2293
+ return p.pressure !== void 0 ? [p.x, p.y, p.at, p.pressure] : [p.x, p.y, p.at];
2294
+ return p.pressure !== void 0 ? { x: p.x, y: p.y, pressure: p.pressure } : [p.x, p.y];
2295
+ });
2296
+ }
2297
+ function inlineScript(actions, files) {
2298
+ if (!files.paths.size) return actions;
2299
+ return {
2300
+ ...actions,
2301
+ steps: actions.steps.map((s, i) => {
2302
+ const read = files.paths.get(i);
2303
+ if (s.do !== "drag" || typeof s.path !== "string" || !read) return s;
2304
+ return { ...s, path: inlinePoints(read) };
2305
+ })
2306
+ };
2307
+ }
2308
+
2309
+ // src/record/pasteStep.ts
2310
+ function toBase64(bytes) {
2311
+ let bin = "";
2312
+ for (let i = 0; i < bytes.length; i += 32768)
2313
+ bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
2314
+ return btoa(bin);
2315
+ }
2316
+ function pasteItemOf(step, stepIdx, files) {
2317
+ if (step.text !== void 0) return { text: step.text };
2318
+ const f = files.pastes.get(stepIdx);
2319
+ if (!f) return null;
2320
+ return { b64: toBase64(f.bytes), type: f.type, name: f.name };
2321
+ }
2322
+ async function runPaste(page, context, item, press) {
2323
+ if (item.b64 !== void 0 && item.type?.startsWith("image/")) {
2324
+ const done2 = await page.evaluate(syntheticPasteScript(item, { original: true })).catch((e) => `failed: ${e instanceof Error ? e.message : e}`);
2325
+ return done2 === "ok" ? { via: "file" } : { failed: `the page's paste failed (${done2.replace(/^failed: /, "")})` };
2326
+ }
2327
+ let origin;
2328
+ try {
2329
+ origin = new URL(page.url()).origin;
2330
+ } catch {
2331
+ origin = void 0;
2332
+ }
2333
+ if (context.grantPermissions && origin && origin !== "null")
2334
+ await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin }).catch(() => void 0);
2335
+ const staged = await page.evaluate(stageClipboardScript(item)).catch((e) => `refused: ${e instanceof Error ? e.message : e}`);
2336
+ if (staged === "ok") {
2337
+ await press("ControlOrMeta+v");
2338
+ return { via: "clipboard" };
2339
+ }
2340
+ const why = staged.replace(/^refused: /, "");
2341
+ const done = await page.evaluate(syntheticPasteScript(item)).catch((e) => `failed: ${e instanceof Error ? e.message : e}`);
2342
+ if (done === "ok") return { via: "page", why };
2343
+ return {
2344
+ failed: `this browser has no clipboard to paste from (${why}), and the page's own paste failed too (${done.replace(/^failed: /, "")})`
2345
+ };
2346
+ }
2347
+
2348
+ // src/record/outputs.ts
2349
+ var OUTPUT_MAX_BYTES = 512 * 1024 * 1024;
2350
+ function plainName(suggested, fallback) {
2351
+ const last = suggested.split(/[/\\]/).pop() ?? "";
2352
+ const clean = last.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\.\.+/g, ".").replace(/^\.+/, "").trim().slice(0, 200);
2353
+ return clean && !fileNameProblem(clean) ? clean : fallback;
2354
+ }
2355
+ function nameFromUrl(url) {
2356
+ try {
2357
+ const u = new URL(url);
2358
+ if (u.protocol === "blob:" || u.protocol === "data:") return "";
2359
+ const seg = u.pathname.split("/").filter(Boolean).pop() ?? "";
2360
+ return decodeURIComponent(seg);
2361
+ } catch {
2362
+ return "";
2363
+ }
2364
+ }
2365
+ function outputName(asked, suggested, fallback, used) {
2366
+ const base = asked ?? plainName(suggested, fallback);
2367
+ if (!used.has(base)) return base;
2368
+ const dot = base.lastIndexOf(".");
2369
+ const stem = dot > 0 ? base.slice(0, dot) : base;
2370
+ const ext = dot > 0 ? base.slice(dot) : "";
2371
+ for (let n = 2; ; n++) {
2372
+ const next = `${stem}-${n}${ext}`;
2373
+ if (!used.has(next)) return next;
2374
+ }
2375
+ }
2376
+ function chunkBytes(c) {
2377
+ if (c instanceof Uint8Array) return c;
2378
+ if (typeof c === "string") return new TextEncoder().encode(c);
2379
+ if (c instanceof ArrayBuffer) return new Uint8Array(c);
2380
+ throw new Error("the download stream gave something that is not bytes");
2381
+ }
2382
+ async function readAllBytes(stream, max = OUTPUT_MAX_BYTES) {
2383
+ const parts = [];
2384
+ let n = 0;
2385
+ for await (const c of stream) {
2386
+ const u = chunkBytes(c);
2387
+ n += u.length;
2388
+ if (n > max)
2389
+ throw new Error(
2390
+ `the file is larger than ${Math.round(max / 1024 / 1024)} MB`
2391
+ );
2392
+ parts.push(u);
2393
+ }
2394
+ const out = new Uint8Array(n);
2395
+ let at = 0;
2396
+ for (const p of parts) {
2397
+ out.set(p, at);
2398
+ at += p.length;
2399
+ }
2400
+ return out;
2401
+ }
2402
+ var readMediaSource = (el) => el.currentSrc || el.src || el.getAttribute("src") || "";
2403
+ function fetchInPageScript(url) {
2404
+ return `(async () => {
2405
+ try {
2406
+ const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
2407
+ if (!res.ok) return { error: 'answered ' + res.status };
2408
+ const buf = new Uint8Array(await res.arrayBuffer());
2409
+ let bin = '';
2410
+ for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
2411
+ return { b64: btoa(bin), type: res.headers.get('content-type') || '' };
2412
+ } catch (e) {
2413
+ return { error: String((e && e.message) || e) };
2414
+ }
2415
+ })()`;
2416
+ }
2417
+
2418
+ // src/record/recorder.ts
2419
+ var EXPECT_EVERY_MS = 100;
2420
+ var SRC_WAIT_MS = 8e3;
2421
+ var AWAIT_HEARTBEAT_MS = 3e4;
2422
+ var MIN_SKIP_MS = 250;
2423
+ var READ_WAIT_MS = 1500;
2424
+ var READ_MAX_CHARS = 2048;
2425
+ function headersFor(requestUrl, entryUrl, headers) {
2426
+ try {
2427
+ return new URL(requestUrl).origin === new URL(entryUrl).origin ? headers : null;
2428
+ } catch {
2429
+ return null;
2430
+ }
2431
+ }
2432
+ async function applyHeaders(context, headers, entryUrl, log) {
2433
+ if (!context.route) {
2434
+ await context.setExtraHTTPHeaders(headers);
2435
+ log(" (this browser scopes no headers: they ride every request)");
2436
+ return;
2437
+ }
2438
+ await context.route("**/*", async (route) => {
2439
+ const req = route.request();
2440
+ const scoped = headersFor(req.url(), entryUrl, headers);
2441
+ await route.continue(
2442
+ scoped ? { headers: { ...req.headers(), ...scoped } } : void 0
2443
+ );
2444
+ });
2445
+ }
2446
+ function publicUrl(u) {
2447
+ try {
2448
+ const x = new URL(u);
2449
+ return `${x.origin}${x.pathname}`;
2450
+ } catch {
2451
+ return "";
2452
+ }
2453
+ }
2454
+ var RecordRefusedError = class extends Error {
2455
+ constructor(message) {
2456
+ super(message);
2457
+ this.name = "RecordRefusedError";
2458
+ }
2459
+ };
2460
+ var DRY_PAUSE_MS = 120;
2461
+ function scrollByScript(x, y, dy) {
2462
+ return `(() => {
2463
+ let el = document.elementFromPoint(${Math.round(x)}, ${Math.round(y)});
2464
+ while (el && el !== document.body && el !== document.documentElement) {
2465
+ const cs = getComputedStyle(el);
2466
+ if (/(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1) {
2467
+ el.scrollBy(0, ${dy});
2468
+ return 'el';
2469
+ }
2470
+ el = el.parentElement;
2471
+ }
2472
+ window.scrollBy(0, ${dy});
2473
+ return 'window';
2474
+ })()`;
2475
+ }
2476
+ function jpegDims(buf) {
2477
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
2478
+ let i = 2;
2479
+ while (i < buf.length - 9) {
2480
+ if (buf[i] !== 255) return null;
2481
+ const marker = buf[i + 1];
2482
+ const len = view.getUint16(i + 2);
2483
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
2484
+ return { h: view.getUint16(i + 5), w: view.getUint16(i + 7) };
2485
+ }
2486
+ i += 2 + len;
2487
+ }
2488
+ return null;
2489
+ }
2490
+ function base64Bytes(data) {
2491
+ const bin = atob(data);
2492
+ const out = new Uint8Array(bin.length);
2493
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2494
+ return out;
2495
+ }
2496
+ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2497
+ const dry = opts.dryRun === true;
2498
+ const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
2499
+ const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
2500
+ if (!dry) {
2501
+ const guard = guardVerdict(actions, opts.confirm ?? []);
2502
+ if (!guard.ok) throw new RecordRefusedError(guard.message);
2503
+ const download = actions.steps.findIndex((s) => s.do === "download");
2504
+ if (download >= 0 && !opts.outputs)
2505
+ throw new RecordRefusedError(
2506
+ `steps[${download}] is a download, and this recorder has nowhere to keep a file. Nothing was recorded.`
2507
+ );
2508
+ }
2509
+ const read = await loadScriptFiles(actions, opts.files);
2510
+ if ("problem" in read) throw new RecordRefusedError(read.problem);
2511
+ const files = read;
2512
+ const vw = actions.viewport?.width ?? 1280;
2513
+ const vh = actions.viewport?.height ?? 720;
2514
+ const context = opts.context ?? await browser.newContext({
2515
+ viewport: { width: vw, height: vh },
2516
+ deviceScaleFactor: 1,
2517
+ ...opts.storageState ? { storageState: opts.storageState } : {}
2518
+ });
2519
+ if (opts.headers && Object.keys(opts.headers).length)
2520
+ await applyHeaders(context, opts.headers, url, log);
2521
+ const masks = actions.mask ?? [];
2522
+ if (masks.length) await context.addInitScript(maskInitScript(masks));
2523
+ await context.addInitScript(MEDIA_WATCH_SCRIPT);
2524
+ const soundLog = new PageSoundLog();
2525
+ const exposureLog = new ExposureLog(masks);
2526
+ const noticeLog = new NoticeLog();
2527
+ const rawPage = await context.newPage();
2528
+ const pageBound = new StepBound();
2529
+ const page = bounded(rawPage, pageBound);
2530
+ const scanExposures = async (step) => {
2531
+ const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
2532
+ exposureLog.add(step, scan);
2533
+ noticeLog.add(step, scan?.notices);
2534
+ };
2535
+ const pageErrors = new PageErrorLog(opts.verbose === true);
2536
+ page.on("console", (m) => {
2537
+ if (m.type() !== "error") return;
2538
+ const line = pageErrors.add(m.text());
2539
+ if (line) log(line);
2540
+ });
2541
+ const readyMode = actions.ready ?? DEFAULT_READY;
2542
+ const open = (target) => untilReady(page, readyMode, (o) => page.goto(target, o), {
2543
+ sleep: realSleep
2544
+ });
2545
+ log(`goto ${url}`);
2546
+ const opened = await open(url);
2547
+ let response = opened.response;
2548
+ const ready = opened.ready;
2549
+ log(` ${readyLine(ready)}`);
2550
+ const navTimeout = ready.mode === "networkidle" && ready.capped;
2551
+ const setup = actions.setup ?? [];
2552
+ if (setup.length) {
2553
+ log(`setup: ${setup.length} step(s), off camera`);
2554
+ const result = await runSetup(page, setup, {
2555
+ sleep: realSleep,
2556
+ env: opts.env ?? {},
2557
+ goto: (target) => open(target)
2558
+ });
2559
+ for (const line of result.lines) log(` ${line}`);
2560
+ if (result.failed) {
2561
+ await context.close().catch(() => {
2562
+ });
2563
+ throw new SetupError(result.failed);
2564
+ }
2565
+ log(`goto ${url} (after setup)`);
2566
+ response = (await open(url)).response;
2567
+ }
2568
+ let wall = null;
2569
+ if (opts.onArrival) {
2570
+ const seen = await page.evaluate(WALL_PROBE).catch(() => null);
2571
+ try {
2572
+ wall = await opts.onArrival({
2573
+ askedUrl: url,
2574
+ landedUrl: page.url(),
2575
+ ...response ? { status: response.status() } : {},
2576
+ passwordFields: seen?.passwordFields ?? 0,
2577
+ newPasswordField: seen?.newPasswordField ?? false,
2578
+ oneTimeCodeField: seen?.oneTimeCodeField ?? false
2579
+ }) ?? null;
2580
+ } catch (e) {
2581
+ await context.close().catch(() => {
2582
+ });
2583
+ throw e;
2584
+ }
2585
+ }
2586
+ await scanExposures(-1);
2587
+ await page.evaluate(MEDIA_DRAIN_PROBE).catch(() => null);
2588
+ if (actions.steps.some((s) => settleOn(s, actions.settle)))
2589
+ await page.evaluate(CHANGE_AGE_PROBE).catch(() => void 0);
2590
+ const cdp = await context.newCDPSession(rawPage);
2591
+ if (!dry) await fitSurfaceToViewport(cdp, vw, vh);
2592
+ const frames = [];
2593
+ let frameIdx = 0;
2594
+ let t0 = 0;
2595
+ let takeClock = new TakeClock(0);
2596
+ const first = { dims: null };
2597
+ let sinkChain = Promise.resolve();
2598
+ if (!dry)
2599
+ cdp.on("Page.screencastFrame", (ev) => {
2600
+ cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
2601
+ });
820
2602
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
2603
+ const tMs = takeClock.placeFrame(tsMs);
2604
+ if (tMs === null) return;
821
2605
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
822
2606
  const bytes = base64Bytes(ev.data);
823
2607
  if (!first.dims) first.dims = jpegDims(bytes);
824
- frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
2608
+ frames.push({ file, tMs });
825
2609
  sinkChain = sinkChain.then(() => sink.frame(file, bytes)).catch(() => {
826
2610
  });
827
- cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
828
- });
829
2611
  });
2612
+ const screencast = {
2613
+ format: "jpeg",
2614
+ quality: 90,
2615
+ maxWidth: vw,
2616
+ maxHeight: vh,
2617
+ everyNthFrame: 1
2618
+ };
830
2619
  t0 = Date.now();
831
- if (!dry)
832
- await cdp.send("Page.startScreencast", {
833
- format: "jpeg",
834
- quality: 90,
835
- maxWidth: vw,
836
- maxHeight: vh,
837
- everyNthFrame: 1
838
- });
2620
+ takeClock = new TakeClock(t0);
2621
+ if (!dry) await cdp.send("Page.startScreencast", screencast);
839
2622
  const events = [];
840
2623
  const cur = { x: 48, y: 48 };
841
- const now = () => Date.now() - t0;
2624
+ const now = () => takeClock.footageMs(Date.now());
2625
+ const offCamera = async (fn) => {
2626
+ const at = takeClock.pause(Date.now());
2627
+ if (!dry) await cdp.send("Page.stopScreencast").catch(() => {
2628
+ });
2629
+ const out = await fn();
2630
+ const ms = takeClock.resume(Date.now());
2631
+ if (!dry) await cdp.send("Page.startScreencast", screencast).catch(() => {
2632
+ });
2633
+ return { out, at, ms };
2634
+ };
842
2635
  const emit = (e) => events.push({ t: now(), ...e });
2636
+ const drainSound = async (step) => {
2637
+ const drain = await page.evaluate(MEDIA_DRAIN_PROBE).catch(() => null);
2638
+ soundLog.add(drain, Date.now(), step);
2639
+ };
843
2640
  await page.mouse.move(cur.x, cur.y);
844
2641
  emit({ x: cur.x, y: cur.y, type: "move" });
845
2642
  const clock = { now: () => Date.now(), sleep };
@@ -886,27 +2683,103 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
886
2683
  return null;
887
2684
  }
888
2685
  };
889
- const clickAt = async (rect) => {
890
- await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
891
- await sleep(PRESS_LEAD_MS);
892
- emit({
893
- x: Math.round(cur.x),
894
- y: Math.round(cur.y),
895
- type: "down",
896
- button: 0,
897
- rect
898
- });
899
- await page.mouse.down();
900
- await sleep(PRESS_HOLD_MS);
901
- await page.mouse.up();
902
- emit({
903
- x: Math.round(cur.x),
904
- y: Math.round(cur.y),
905
- type: "up",
906
- button: 0,
907
- rect
908
- });
2686
+ const srcOf = async (selector) => {
2687
+ const deadline = Date.now() + SRC_WAIT_MS;
2688
+ const loc = page.locator(selector).first();
2689
+ try {
2690
+ await loc.waitFor({ state: "attached", timeout: SRC_WAIT_MS });
2691
+ } catch {
2692
+ return null;
2693
+ }
2694
+ for (; ; ) {
2695
+ const v = await loc.evaluate(readMediaSource).catch(() => "");
2696
+ if (v || Date.now() >= deadline) return v;
2697
+ await realSleep(200);
2698
+ }
2699
+ };
2700
+ const fetchSource = async (src) => {
2701
+ const abs = new URL(src, page.url()).href;
2702
+ const suggested = nameFromUrl(abs);
2703
+ if (page.request && /^https?:/i.test(abs)) {
2704
+ const res = await page.request.get(abs, {
2705
+ timeout: DOWNLOAD_TIMEOUT_S * 1e3
2706
+ });
2707
+ if (!res.ok())
2708
+ throw new Error(
2709
+ `fetching ${publicUrl(abs) || "the source"} answered ${res.status()}`
2710
+ );
2711
+ const bytes = await res.body();
2712
+ if (bytes.length > OUTPUT_MAX_BYTES)
2713
+ throw new Error(
2714
+ `the file is larger than ${OUTPUT_MAX_BYTES / 1024 / 1024} MB`
2715
+ );
2716
+ const type2 = res.headers()["content-type"]?.split(";")[0].trim();
2717
+ return { bytes, suggested, ...type2 ? { contentType: type2 } : {} };
2718
+ }
2719
+ const r = await page.evaluate(fetchInPageScript(abs));
2720
+ if (!r || r.error || r.b64 === void 0)
2721
+ throw new Error(
2722
+ `fetching the source in the page failed: ${r?.error ?? "no answer"}`
2723
+ );
2724
+ const type = r.type?.split(";")[0].trim();
2725
+ return {
2726
+ bytes: base64Bytes(r.b64),
2727
+ suggested,
2728
+ ...type ? { contentType: type } : {}
2729
+ };
909
2730
  };
2731
+ const clickAt = async (at, how = {}) => {
2732
+ const rect = "w" in at ? at : void 0;
2733
+ await moveTo(
2734
+ rect ? rect.x + rect.w / 2 : at.x,
2735
+ rect ? rect.y + rect.h / 2 : at.y
2736
+ );
2737
+ const held = [];
2738
+ try {
2739
+ for (const m of how.modifiers ?? []) {
2740
+ const key = resolveModifier(m);
2741
+ await page.keyboard.down(key);
2742
+ held.push(key);
2743
+ }
2744
+ await sleep(PRESS_LEAD_MS);
2745
+ const count = how.count ?? 1;
2746
+ const button = buttonNumber(how.button);
2747
+ for (let n = 1; n <= count; n++) {
2748
+ if (n > 1) await sleep(MULTI_CLICK_GAP_MS);
2749
+ const press = {
2750
+ ...how.button && how.button !== "left" ? { button: how.button } : {},
2751
+ ...n > 1 ? { clickCount: n } : {}
2752
+ };
2753
+ const hasOpts = Object.keys(press).length > 0;
2754
+ emit({
2755
+ x: Math.round(cur.x),
2756
+ y: Math.round(cur.y),
2757
+ type: "down",
2758
+ button,
2759
+ ...rect ? { rect } : {}
2760
+ });
2761
+ await (hasOpts ? page.mouse.down(press) : page.mouse.down());
2762
+ await sleep(n < count ? PRESS_HOLD_MS / 2 : PRESS_HOLD_MS);
2763
+ await (hasOpts ? page.mouse.up(press) : page.mouse.up());
2764
+ emit({
2765
+ x: Math.round(cur.x),
2766
+ y: Math.round(cur.y),
2767
+ type: "up",
2768
+ button,
2769
+ ...rect ? { rect } : {}
2770
+ });
2771
+ }
2772
+ } finally {
2773
+ for (const key of held.reverse())
2774
+ await page.keyboard.up(key).catch(() => void 0);
2775
+ }
2776
+ };
2777
+ const keyPing = (rect) => emit({
2778
+ x: Math.round(rect ? rect.x + rect.w / 2 : cur.x),
2779
+ y: Math.round(rect ? rect.y + rect.h / 2 : cur.y),
2780
+ type: "key",
2781
+ ...rect ? { rect } : {}
2782
+ });
910
2783
  const waitSettled = async () => {
911
2784
  if (dry) return null;
912
2785
  const pressAt = now();
@@ -923,6 +2796,14 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
923
2796
  const skipped = [];
924
2797
  const stepSpans = [];
925
2798
  const paces = [];
2799
+ const disabled = [];
2800
+ const confirmed = [];
2801
+ const skips = [];
2802
+ const outputs = [];
2803
+ const outputNames = /* @__PURE__ */ new Set();
2804
+ const overruns = [];
2805
+ let stopped = null;
2806
+ let unpressedGuard = null;
926
2807
  let capped = false;
927
2808
  for (const [stepIdx, step] of actions.steps.entries()) {
928
2809
  if (capReached(now(), maxSeconds)) {
@@ -937,17 +2818,90 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
937
2818
  gestureMs += now() - t;
938
2819
  return out;
939
2820
  };
940
- const skippedBefore = skipped.length;
941
- const urlBefore = page.url();
942
- let stepRect = null;
2821
+ const skippedBefore = skipped.length;
2822
+ const paced = {
2823
+ v: null
2824
+ };
2825
+ const timed = async (plannedMs, fn) => {
2826
+ const t = Date.now();
2827
+ const out = await fn();
2828
+ paced.v = { plannedMs, tookMs: Date.now() - t };
2829
+ return out;
2830
+ };
2831
+ let askedOverride = null;
2832
+ const urlBefore = page.url();
2833
+ let stepRect = null;
2834
+ const extra = {};
2835
+ const tag = `#${stepIdx}${step.id ? ` ${step.id}` : ""}`;
2836
+ const skip = (selector, reason) => {
2837
+ skipped.push({
2838
+ step: stepIdx,
2839
+ ...step.id ? { id: step.id } : {},
2840
+ do: step.do,
2841
+ selector,
2842
+ ...reason ? { reason } : {}
2843
+ });
2844
+ if (reason) extra.reason = reason;
2845
+ };
2846
+ if (step.guard) extra.guard = step.guard;
2847
+ const rehearseGuard = async (sel, point) => {
2848
+ if (!step.guard || !dry) return false;
2849
+ const rect = sel ? await boxOf(sel) : null;
2850
+ stepRect = rect;
2851
+ if (sel && !rect) skip(sel);
2852
+ else {
2853
+ if (rect)
2854
+ await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
2855
+ else if (point) await gesture(() => moveTo(point.x, point.y));
2856
+ log(
2857
+ `${tag}: guarded (${step.guard}), ${rect || point ? "hovered" : "left alone"}, not pressed`
2858
+ );
2859
+ }
2860
+ unpressedGuard = stepIdx;
2861
+ return true;
2862
+ };
2863
+ const notWaited = () => {
2864
+ if (!dry || unpressedGuard === null) return false;
2865
+ extra.followsGuard = unpressedGuard;
2866
+ log(
2867
+ `${tag} ${step.do}: not waited: it follows #${unpressedGuard} (guarded, not pressed)`
2868
+ );
2869
+ return true;
2870
+ };
2871
+ const boundMs = stepBoundMs(
2872
+ step,
2873
+ askedMs(step),
2874
+ {
2875
+ expectS: EXPECT_TIMEOUT_S,
2876
+ awaitS: AWAIT_TIMEOUT_S,
2877
+ downloadS: DOWNLOAD_TIMEOUT_S
2878
+ }
2879
+ );
2880
+ opts.onEvent?.({
2881
+ event: "step",
2882
+ phase: "start",
2883
+ i: stepIdx,
2884
+ ...step.id ? { id: step.id } : {},
2885
+ do: step.do
2886
+ });
2887
+ const stepWall = Date.now();
2888
+ pageBound.start(boundMs);
943
2889
  switch (step.do) {
944
2890
  case "wait":
945
2891
  await sleep(clampWait(step.ms, now(), maxSeconds));
946
2892
  break;
947
2893
  case "move":
948
- await moveTo(step.x, step.y);
2894
+ await gesture(() => moveTo(step.x, step.y));
949
2895
  break;
950
2896
  case "hover": {
2897
+ if (step.selector === void 0) {
2898
+ const px = step.x ?? cur.x;
2899
+ const py = step.y ?? cur.y;
2900
+ await gesture(() => moveTo(px, py));
2901
+ log(`hover ${Math.round(px)},${Math.round(py)}`);
2902
+ await sleep(step.ms ?? 700);
2903
+ break;
2904
+ }
951
2905
  const rect = await boxOf(step.selector);
952
2906
  stepRect = rect;
953
2907
  if (rect) {
@@ -959,11 +2913,38 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
959
2913
  break;
960
2914
  }
961
2915
  case "click": {
2916
+ const how = {
2917
+ count: step.count,
2918
+ button: step.button,
2919
+ modifiers: step.modifiers
2920
+ };
2921
+ const said = `${step.count === 3 ? "triple-click" : step.count === 2 ? "double-click" : "click"}${step.button && step.button !== "left" ? ` (${step.button})` : ""}${step.modifiers?.length ? ` holding ${step.modifiers.join("+")}` : ""}`;
2922
+ if (step.selector === void 0) {
2923
+ const point = { x: step.x ?? cur.x, y: step.y ?? cur.y };
2924
+ if (await rehearseGuard(void 0, point)) break;
2925
+ await gesture(() => clickAt(point, how));
2926
+ log(`${said} at ${Math.round(point.x)},${Math.round(point.y)}`);
2927
+ const sinceChange = await gesture(() => waitSettled());
2928
+ await sleep(holdLeftMs(settleMs(step), sinceChange));
2929
+ break;
2930
+ }
2931
+ if (await rehearseGuard(step.selector)) break;
962
2932
  const rect = await boxOf(step.selector);
963
2933
  stepRect = rect;
964
2934
  if (rect) {
965
- await gesture(() => clickAt(rect));
966
- log(`click ${step.selector}`);
2935
+ const enabled = await page.locator(step.selector).first().isEnabled({ timeout: 1e3 }).catch(() => true);
2936
+ if (!enabled) {
2937
+ disabled.push({
2938
+ step: stepIdx,
2939
+ ...step.id ? { id: step.id } : {},
2940
+ selector: step.selector
2941
+ });
2942
+ log(
2943
+ ` (${tag} click: ${step.selector} is disabled; pressed anyway)`
2944
+ );
2945
+ }
2946
+ await gesture(() => clickAt(rect, how));
2947
+ log(`${said} ${step.selector}`);
967
2948
  const sinceChange = await gesture(() => waitSettled());
968
2949
  await sleep(holdLeftMs(settleMs(step), sinceChange));
969
2950
  } else
@@ -971,6 +2952,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
971
2952
  break;
972
2953
  }
973
2954
  case "type": {
2955
+ if (await rehearseGuard(step.selector)) break;
974
2956
  const rect = await boxOf(step.selector);
975
2957
  stepRect = rect;
976
2958
  if (rect) {
@@ -986,17 +2968,21 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
986
2968
  rect
987
2969
  });
988
2970
  let lastPing = -Infinity;
989
- await clockTyping(
990
- [...step.text],
991
- delay,
992
- async (ch) => {
993
- if (now() - lastPing >= 350) {
994
- lastPing = now();
995
- ping();
996
- }
997
- await page.keyboard.type(ch);
998
- },
999
- clock
2971
+ const chars = [...step.text];
2972
+ await timed(
2973
+ Math.max(0, chars.length - 1) * delay,
2974
+ () => clockTyping(
2975
+ chars,
2976
+ delay,
2977
+ async (ch) => {
2978
+ if (now() - lastPing >= 350) {
2979
+ lastPing = now();
2980
+ ping();
2981
+ }
2982
+ await page.keyboard.type(ch);
2983
+ },
2984
+ clock
2985
+ )
1000
2986
  );
1001
2987
  ping();
1002
2988
  log(`type ${step.text.length} chars into ${step.selector}`);
@@ -1006,6 +2992,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1006
2992
  break;
1007
2993
  }
1008
2994
  case "press": {
2995
+ if (await rehearseGuard(step.selector)) break;
1009
2996
  let rect = null;
1010
2997
  if (step.selector) {
1011
2998
  rect = await boxOf(step.selector);
@@ -1019,14 +3006,18 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1019
3006
  break;
1020
3007
  }
1021
3008
  }
1022
- emit({
1023
- x: Math.round(rect ? rect.x + rect.w / 2 : cur.x),
1024
- y: Math.round(rect ? rect.y + rect.h / 2 : cur.y),
1025
- type: "key",
1026
- ...rect ? { rect } : {}
3009
+ const seq = pressSequence(step);
3010
+ const chords = step.keys !== void 0 ? seq.map(resolveChord) : seq;
3011
+ const plan = keySequencePlanMs(chords.length);
3012
+ if (chords.length > 1) askedOverride = plan + settleMs(step);
3013
+ await timed(plan, async () => {
3014
+ for (const [i, chord] of chords.entries()) {
3015
+ if (i > 0) await sleep(KEY_SEQUENCE_GAP_MS);
3016
+ keyPing(rect);
3017
+ await page.keyboard.press(chord);
3018
+ }
1027
3019
  });
1028
- await page.keyboard.press(step.key);
1029
- log(`press ${step.key}`);
3020
+ log(`press ${seq.join(", ")}`);
1030
3021
  const sinceChange = await gesture(() => waitSettled());
1031
3022
  await sleep(holdLeftMs(settleMs(step), sinceChange));
1032
3023
  break;
@@ -1057,9 +3048,19 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1057
3048
  break;
1058
3049
  }
1059
3050
  case "drag": {
3051
+ let path = null;
3052
+ if (typeof step.path === "string") {
3053
+ const read2 = files.paths.get(stepIdx);
3054
+ if (read2) path = schedulePath(read2, step.ms);
3055
+ } else if (step.path !== void 0) {
3056
+ const read2 = readPath(step.path);
3057
+ if (!("problems" in read2)) path = schedulePath(read2, step.ms);
3058
+ }
1060
3059
  let start = null;
1061
3060
  let rect;
1062
- if (step.selector) {
3061
+ if (path) {
3062
+ start = { x: path[0].x, y: path[0].y };
3063
+ } else if (step.selector) {
1063
3064
  const box = await boxOf(step.selector);
1064
3065
  stepRect = box;
1065
3066
  if (box) {
@@ -1077,46 +3078,351 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1077
3078
  start = { x: step.x, y: step.y };
1078
3079
  }
1079
3080
  if (!start) break;
1080
- await gesture(() => moveTo(start.x, start.y));
1081
- await gesture(() => sleep(PRESS_LEAD_MS));
1082
- emit({
1083
- x: Math.round(cur.x),
1084
- y: Math.round(cur.y),
1085
- type: "down",
1086
- button: 0,
1087
- rect
1088
- });
1089
- await page.mouse.down();
1090
- await gesture(() => sleep(PRESS_HOLD_MS));
1091
- const dur = step.ms ?? 700;
1092
- const from = { ...cur };
1093
- await clockMotion(
1094
- dur,
1095
- async (u) => {
1096
- cur.x = from.x + (step.tx - from.x) * u;
1097
- cur.y = from.y + (step.ty - from.y) * u;
1098
- await page.mouse.move(cur.x, cur.y);
1099
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
1100
- },
1101
- clock
3081
+ const from0 = start;
3082
+ await gesture(() => moveTo(from0.x, from0.y));
3083
+ const mods = step.modifiers ?? [];
3084
+ const held = [];
3085
+ try {
3086
+ for (const m of mods) {
3087
+ const key = resolveModifier(m);
3088
+ await page.keyboard.down(key);
3089
+ held.push(key);
3090
+ }
3091
+ await gesture(() => sleep(PRESS_LEAD_MS));
3092
+ const bits = modifierBits(mods, opts.platform);
3093
+ const penAt = (type, p) => cdp.send("Input.dispatchMouseEvent", {
3094
+ type,
3095
+ x: p.x,
3096
+ y: p.y,
3097
+ button: "left",
3098
+ buttons: type === "mouseReleased" ? 0 : 1,
3099
+ ...type === "mouseMoved" ? {} : { clickCount: 1 },
3100
+ pointerType: "pen",
3101
+ force: type === "mouseReleased" ? 0 : p.pressure ?? 0.5,
3102
+ modifiers: bits
3103
+ });
3104
+ let pen = path?.some((p) => p.pressure !== void 0) ?? false;
3105
+ if (pen && path) {
3106
+ try {
3107
+ await penAt("mousePressed", path[0]);
3108
+ } catch {
3109
+ pen = false;
3110
+ log(
3111
+ ` (${tag} drag: this browser takes no pen input; the path is drawn with the mouse and its pressure dropped)`
3112
+ );
3113
+ }
3114
+ }
3115
+ emit({
3116
+ x: Math.round(cur.x),
3117
+ y: Math.round(cur.y),
3118
+ type: "down",
3119
+ button: 0,
3120
+ rect
3121
+ });
3122
+ if (!pen) await page.mouse.down();
3123
+ await gesture(() => sleep(PRESS_HOLD_MS));
3124
+ if (path) {
3125
+ const pts = path;
3126
+ const total = pathDurationMs(pts);
3127
+ askedOverride = total;
3128
+ await timed(total, async () => {
3129
+ const from = Date.now();
3130
+ for (let i = 1; i < pts.length; i++) {
3131
+ const p = pts[i];
3132
+ if (!dry) {
3133
+ const wait = from + p.at - Date.now();
3134
+ if (wait > 0) await realSleep(wait);
3135
+ }
3136
+ if (pen) await penAt("mouseMoved", p);
3137
+ else await page.mouse.move(p.x, p.y);
3138
+ cur.x = p.x;
3139
+ cur.y = p.y;
3140
+ emit({ x: Math.round(p.x), y: Math.round(p.y), type: "move" });
3141
+ }
3142
+ });
3143
+ } else {
3144
+ const dur = step.ms ?? 700;
3145
+ const from = { ...cur };
3146
+ const tx = step.tx ?? from.x;
3147
+ const ty = step.ty ?? from.y;
3148
+ await timed(
3149
+ dur,
3150
+ () => clockMotion(
3151
+ dur,
3152
+ async (u) => {
3153
+ cur.x = from.x + (tx - from.x) * u;
3154
+ cur.y = from.y + (ty - from.y) * u;
3155
+ await page.mouse.move(cur.x, cur.y);
3156
+ emit({
3157
+ x: Math.round(cur.x),
3158
+ y: Math.round(cur.y),
3159
+ type: "move"
3160
+ });
3161
+ },
3162
+ clock
3163
+ )
3164
+ );
3165
+ }
3166
+ await gesture(() => sleep(settleMs({ do: "drag" })));
3167
+ if (pen) await penAt("mouseReleased", { x: cur.x, y: cur.y });
3168
+ else await page.mouse.up();
3169
+ emit({
3170
+ x: Math.round(cur.x),
3171
+ y: Math.round(cur.y),
3172
+ type: "up",
3173
+ button: 0,
3174
+ rect
3175
+ });
3176
+ } finally {
3177
+ for (const key of held.reverse())
3178
+ await page.keyboard.up(key).catch(() => void 0);
3179
+ }
3180
+ const holding = mods.length ? ` holding ${mods.join("+")}` : "";
3181
+ log(
3182
+ path ? `drag a path of ${path.length} points from ${Math.round(start.x)},${Math.round(start.y)}${holding}` : `drag ${step.selector ?? `${start.x},${start.y}`} \u2192 ${step.tx},${step.ty}${holding}`
1102
3183
  );
1103
- await gesture(() => sleep(settleMs({ do: "drag" })));
1104
- await page.mouse.up();
1105
- emit({
1106
- x: Math.round(cur.x),
1107
- y: Math.round(cur.y),
1108
- type: "up",
1109
- button: 0,
1110
- rect
3184
+ await gesture(() => sleep(DRAG_HOLD_MS));
3185
+ break;
3186
+ }
3187
+ case "paste": {
3188
+ const item = pasteItemOf(step, stepIdx, files);
3189
+ if (!item) {
3190
+ skip(step.file ?? "", `"${step.file}" was not read`);
3191
+ break;
3192
+ }
3193
+ let rect = null;
3194
+ if (step.selector) {
3195
+ rect = await boxOf(step.selector);
3196
+ stepRect = rect;
3197
+ if (!rect) {
3198
+ skip(step.selector);
3199
+ break;
3200
+ }
3201
+ const at = rect;
3202
+ await gesture(() => clickAt(at));
3203
+ }
3204
+ const ping = { done: false };
3205
+ const outcome = await runPaste(page, context, item, async (chord) => {
3206
+ keyPing(rect);
3207
+ ping.done = true;
3208
+ await page.keyboard.press(chord);
1111
3209
  });
3210
+ if ("failed" in outcome) {
3211
+ skip(step.selector ?? step.file ?? "paste", outcome.failed);
3212
+ log(` (${tag} paste: ${outcome.failed})`);
3213
+ break;
3214
+ }
3215
+ if (!ping.done) keyPing(rect);
3216
+ const what = step.text !== void 0 ? `${[...step.text].length} chars` : `the file ${step.file}`;
3217
+ const into = step.selector ? ` into ${step.selector}` : "";
1112
3218
  log(
1113
- `drag ${step.selector ?? `${start.x},${start.y}`} \u2192 ${step.tx},${step.ty}`
3219
+ outcome.via === "file" ? `paste ${what}${into}, dispatched in the page with the file's own bytes (the browser's clipboard re-encodes an image and drops what it carries, its density among it); its events are untrusted` : outcome.via === "page" ? `paste ${what}${into}, dispatched in the page (the browser's clipboard refused it: ${outcome.why}); its events are untrusted` : `paste ${what}${into}`
1114
3220
  );
1115
- await sleep(400);
3221
+ const sinceChange = await gesture(() => waitSettled());
3222
+ await sleep(holdLeftMs(settleMs(step), sinceChange));
3223
+ break;
3224
+ }
3225
+ case "expect": {
3226
+ if (notWaited()) break;
3227
+ const cond = step;
3228
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
3229
+ const res = await pollCondition(page, cond, {
3230
+ timeoutMs,
3231
+ everyMs: EXPECT_EVERY_MS,
3232
+ sleep: realSleep
3233
+ });
3234
+ if (res.ok) log(`expect ${sayCondition(cond)}: holds`);
3235
+ else {
3236
+ const reason = conditionMiss(cond, res.last, timeoutMs);
3237
+ skip(step.selector ?? step.gone ?? step.text ?? "", reason);
3238
+ log(` (${tag} expect: ${reason})`);
3239
+ }
3240
+ break;
3241
+ }
3242
+ case "await": {
3243
+ if (notWaited()) break;
3244
+ const cond = step.until;
3245
+ const timeoutMs = (step.timeout ?? AWAIT_TIMEOUT_S) * 1e3;
3246
+ const reload = step.reload ? () => reloadUntilReady(page, readyMode, timeoutMs) : void 0;
3247
+ log(`await ${sayCondition(cond)} (off camera)\u2026`);
3248
+ const awaitEvent = {
3249
+ event: "await",
3250
+ step: stepIdx,
3251
+ ...step.id ? { id: step.id } : {}
3252
+ };
3253
+ opts.onEvent?.({ ...awaitEvent, phase: "start" });
3254
+ const beatMs = opts.awaitHeartbeatMs ?? AWAIT_HEARTBEAT_MS;
3255
+ const waitFrom = Date.now();
3256
+ const beat = setInterval(() => {
3257
+ const elapsedMs = Date.now() - waitFrom;
3258
+ opts.onEvent?.({ ...awaitEvent, phase: "wait", elapsedMs });
3259
+ log(` still waiting, ${sayDuration(elapsedMs)} so far`);
3260
+ }, beatMs);
3261
+ let waited;
3262
+ try {
3263
+ waited = await offCamera(
3264
+ () => pollCondition(page, cond, {
3265
+ timeoutMs,
3266
+ everyMs: (step.poll ?? AWAIT_POLL_S) * 1e3,
3267
+ sleep: realSleep,
3268
+ between: reload,
3269
+ // After a reload the condition must hold on two reads in a
3270
+ // row, or a half-built page ends the wait early.
3271
+ ...reload ? { confirm: { reads: 2, gapMs: 500 } } : {}
3272
+ })
3273
+ );
3274
+ } finally {
3275
+ clearInterval(beat);
3276
+ }
3277
+ const { out, at, ms } = waited;
3278
+ opts.onEvent?.({ ...awaitEvent, phase: "end", ms, held: out.ok });
3279
+ extra.offCameraMs = ms;
3280
+ if (!out.ok) extra.timedOut = true;
3281
+ if (!dry && (ms >= MIN_SKIP_MS || !out.ok))
3282
+ skips.push({
3283
+ step: stepIdx,
3284
+ ...step.id ? { id: step.id } : {},
3285
+ at: +(at / 1e3).toFixed(3),
3286
+ ms,
3287
+ ...out.ok ? {} : { timedOut: true }
3288
+ });
3289
+ if (out.ok) log(` held after ${sayDuration(ms)} off camera`);
3290
+ else if ((step.onTimeout ?? "fail") === "continue")
3291
+ log(
3292
+ ` (${tag} await: ${conditionMiss(cond, out.last, ms)}; going on, as onTimeout says)`
3293
+ );
3294
+ else {
3295
+ stopped = {
3296
+ step: stepIdx,
3297
+ ...step.id ? { id: step.id } : {},
3298
+ reason: `${tag} await: ${conditionMiss(cond, out.last, ms)}, so the take stopped there. Raise its timeout, or set onTimeout: "continue" to go on without it`
3299
+ };
3300
+ extra.reason = stopped.reason;
3301
+ log(` ${stopped.reason}`);
3302
+ }
3303
+ break;
3304
+ }
3305
+ case "download": {
3306
+ let got = null;
3307
+ let reason = null;
3308
+ const target = step.selector ?? step.src ?? "";
3309
+ try {
3310
+ if (step.selector !== void 0) {
3311
+ const rect = await boxOf(step.selector);
3312
+ stepRect = rect;
3313
+ if (!rect) {
3314
+ skip(step.selector);
3315
+ break;
3316
+ }
3317
+ if (!page.waitForEvent) {
3318
+ reason = "this browser reports no downloads";
3319
+ } else {
3320
+ const waitMs = DOWNLOAD_TIMEOUT_S * 1e3;
3321
+ const arriving = page.waitForEvent("download", { timeout: waitMs }).catch(() => null);
3322
+ await gesture(() => clickAt(rect));
3323
+ log(`click ${step.selector} (download)`);
3324
+ const dl = await arriving;
3325
+ if (!dl)
3326
+ reason = `no download arrived within ${sayDuration(waitMs)} of the click on '${step.selector}'`;
3327
+ else {
3328
+ const stream = await dl.createReadStream();
3329
+ if (!stream) reason = "the download failed in the browser";
3330
+ else
3331
+ got = {
3332
+ bytes: await readAllBytes(stream),
3333
+ suggested: dl.suggestedFilename(),
3334
+ // A browser download carries no headers here: the type is
3335
+ // read from the name the page gave it.
3336
+ ...typeOfName(dl.suggestedFilename())
3337
+ };
3338
+ }
3339
+ }
3340
+ } else if (step.src !== void 0) {
3341
+ const src = await srcOf(step.src);
3342
+ if (src === null) reason = `no element matches '${step.src}'`;
3343
+ else if (!src) reason = `'${step.src}' has no source to fetch`;
3344
+ else got = await fetchSource(src);
3345
+ }
3346
+ } catch (e) {
3347
+ reason = e instanceof Error ? e.message : String(e);
3348
+ }
3349
+ if (got) {
3350
+ const name = outputName(
3351
+ step.as,
3352
+ got.suggested,
3353
+ `download-${stepIdx}${extForType(got.contentType)}`,
3354
+ outputNames
3355
+ );
3356
+ outputNames.add(name);
3357
+ if (!dry) await opts.outputs?.save(name, got.bytes, got.contentType);
3358
+ outputs.push({
3359
+ step: stepIdx,
3360
+ ...step.id ? { id: step.id } : {},
3361
+ name,
3362
+ bytes: got.bytes.length,
3363
+ ...got.contentType ? { contentType: got.contentType } : {}
3364
+ });
3365
+ log(
3366
+ `download \u2192 outputs/${name} (${got.bytes.length} bytes)${dry ? ", not kept in a rehearsal" : ""}`
3367
+ );
3368
+ } else {
3369
+ const why = reason ?? "nothing came";
3370
+ skip(target, why);
3371
+ log(` (${tag} download: ${why})`);
3372
+ }
1116
3373
  break;
1117
3374
  }
1118
3375
  }
3376
+ if (settleOn(step, actions.settle) && skipped.length === skippedBefore && !stopped) {
3377
+ const res = await gesture(
3378
+ () => appSettled(page, () => frames.length, {
3379
+ sleep: realSleep,
3380
+ sinceFrame: () => frames.length ? now() - frames[frames.length - 1].tMs : null
3381
+ })
3382
+ );
3383
+ extra.settleMs = res.ms;
3384
+ if (!res.settled)
3385
+ log(
3386
+ ` (${tag} settle: the page was still changing after ${sayDuration(res.ms)}; going on)`
3387
+ );
3388
+ }
3389
+ if (paced.v && !dry) {
3390
+ const over = overrunOf(paced.v.plannedMs, paced.v.tookMs);
3391
+ if (over !== null) {
3392
+ overruns.push({
3393
+ step: stepIdx,
3394
+ ...step.id ? { id: step.id } : {},
3395
+ do: step.do,
3396
+ plannedMs: Math.round(paced.v.plannedMs),
3397
+ tookMs: Math.round(paced.v.tookMs),
3398
+ overrunMs: over
3399
+ });
3400
+ extra.overrunMs = over;
3401
+ }
3402
+ }
3403
+ const hang = pageBound.end();
3404
+ if (hang.hung) {
3405
+ const reason = hangReason(step.do, hang.ms, hang.boundMs);
3406
+ const said = skipped.length > skippedBefore ? skipped.at(-1) : void 0;
3407
+ if (said) {
3408
+ said.reason = reason;
3409
+ extra.reason = reason;
3410
+ } else skip(step.selector ?? "", reason);
3411
+ log(` (${tag} ${step.do}: ${reason})`);
3412
+ if (opts.stopOnHang && !stopped)
3413
+ stopped = {
3414
+ step: stepIdx,
3415
+ ...step.id ? { id: step.id } : {},
3416
+ reason: `${tag} ${step.do}: ${reason}, so the take stopped there`
3417
+ };
3418
+ }
3419
+ pageBound.start(POST_STEP_READ_MS);
3420
+ if (step.guard && !dry && skipped.length === skippedBefore) {
3421
+ confirmed.push(step.id ?? String(stepIdx));
3422
+ extra.confirmed = true;
3423
+ }
1119
3424
  const stepInfo = step;
3425
+ const about = stepInfo.selector ?? stepInfo.until?.selector ?? stepInfo.until?.gone ?? stepInfo.gone ?? stepInfo.src;
1120
3426
  const urlAfter = publicUrl(page.url());
1121
3427
  const text = stepInfo.read ? await readText(page, stepInfo.read) : null;
1122
3428
  if (stepInfo.read && text === null)
@@ -1125,7 +3431,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1125
3431
  step: stepIdx,
1126
3432
  ...stepInfo.id ? { id: stepInfo.id } : {},
1127
3433
  do: step.do,
1128
- ...stepInfo.selector ? { selector: stepInfo.selector } : {},
3434
+ ...about ? { selector: about } : {},
1129
3435
  tStart: +(stepStart / 1e3).toFixed(3),
1130
3436
  tEnd: +(now() / 1e3).toFixed(3),
1131
3437
  ...skipped.length > skippedBefore ? { skipped: true } : {},
@@ -1139,28 +3445,58 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1139
3445
  w: +stepRect.w.toFixed(1),
1140
3446
  h: +stepRect.h.toFixed(1)
1141
3447
  }
1142
- } : {}
3448
+ } : {},
3449
+ ...extra
1143
3450
  });
1144
3451
  paces.push({
1145
3452
  step: stepIdx,
1146
3453
  do: step.do,
1147
- askedMs: askedMs(step),
3454
+ askedMs: askedOverride ?? askedMs(step),
1148
3455
  gestureMs,
1149
3456
  wallMs: now() - stepStart
1150
3457
  });
1151
3458
  await scanExposures(stepIdx);
3459
+ await drainSound(stepIdx);
3460
+ pageBound.end();
3461
+ const stepSpan = stepSpans[stepSpans.length - 1];
3462
+ opts.onEvent?.({
3463
+ event: "step",
3464
+ phase: "end",
3465
+ i: stepIdx,
3466
+ ...step.id ? { id: step.id } : {},
3467
+ do: step.do,
3468
+ ms: Date.now() - stepWall,
3469
+ ...stepSpan?.skipped ? { skipped: true } : {},
3470
+ ...hang.hung ? { hung: true } : {}
3471
+ });
3472
+ if (stopped) break;
1152
3473
  }
1153
- if (!capped) await sleep(TRAILING_HOLD_MS);
3474
+ if (!capped && !stopped) await sleep(TRAILING_HOLD_MS);
1154
3475
  if (capped || capReached(now(), maxSeconds)) {
1155
3476
  capped = true;
1156
3477
  log(` ${cappedLine(maxSeconds)}`);
1157
3478
  }
3479
+ pageBound.start(POST_STEP_READ_MS * 2);
3480
+ await drainSound();
3481
+ const endWall = Date.now();
1158
3482
  const durationMs = capped ? Math.min(now(), maxSeconds * 1e3) : now();
3483
+ const heard = soundLog.heard(takeClock, endWall, durationMs);
1159
3484
  await cdp.send("Page.stopScreencast").catch(() => {
1160
3485
  });
3486
+ const media = await keepPageSound(
3487
+ heard,
3488
+ dry ? void 0 : opts.sounds,
3489
+ fetchSource,
3490
+ log
3491
+ );
1161
3492
  await sleep(200);
1162
- const pageTitle = await page.title().catch(() => "");
1163
- await context.close();
3493
+ const pageTitle = await page.title().catch(() => "") ?? "";
3494
+ for (const line of pageErrors.repeats()) log(line);
3495
+ pageBound.end();
3496
+ pageBound.start(CLOSE_BOUND_MS);
3497
+ await pageBound.race(context.close().catch(() => {
3498
+ }));
3499
+ pageBound.end();
1164
3500
  await sinkChain;
1165
3501
  const meta = {
1166
3502
  dpr: 1,
@@ -1183,7 +3519,11 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1183
3519
  steps: stepSpans,
1184
3520
  ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
1185
3521
  ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
1186
- ...masks.length ? { masks: exposureLog.masks(masks) } : {}
3522
+ ...noticeLog.notices().length ? { notices: noticeLog.notices() } : {},
3523
+ ...masks.length ? { masks: exposureLog.masks(masks) } : {},
3524
+ ...skips.length ? { skips } : {},
3525
+ ...outputs.length ? { outputs } : {},
3526
+ ...media.length ? { media } : {}
1187
3527
  };
1188
3528
  const freezes = [];
1189
3529
  for (let i = 1; i < frames.length; i++) {
@@ -1199,9 +3539,11 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1199
3539
  const frozenMs = freezes.reduce((sum, f) => sum + f.ms, 0);
1200
3540
  const freezePct = durationMs > 0 ? Math.round(frozenMs / durationMs * 100) : 0;
1201
3541
  const dead = deadTime(stepSpans, frames, events, durationMs);
1202
- if (deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
3542
+ if (!dry && deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
1203
3543
  const pace = paceReport(paces);
1204
3544
  if (!dry) log(` ${paceLine(pace)}`);
3545
+ const overrun = overrunLine(overruns);
3546
+ if (overrun) log(` WARNING: ${overrun}`);
1205
3547
  return {
1206
3548
  events,
1207
3549
  frames,
@@ -1209,155 +3551,90 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1209
3551
  pace,
1210
3552
  skipped,
1211
3553
  navTimeout,
3554
+ ready,
1212
3555
  wall,
1213
3556
  exposures: exposureLog.exposures(),
3557
+ notices: noticeLog.notices(),
1214
3558
  masks: exposureLog.masks(masks),
1215
3559
  freezes,
1216
3560
  freezePct,
1217
3561
  dead,
1218
3562
  capped,
1219
- actions: { ...actions, url }
3563
+ disabled,
3564
+ confirmed,
3565
+ skips,
3566
+ outputs,
3567
+ media,
3568
+ stopped,
3569
+ pageErrors: pageErrors.entries(),
3570
+ overruns,
3571
+ // Self-contained: a path a file held is inline, so the take re-records
3572
+ // from its own script with the file gone.
3573
+ actions: { ...inlineScript(actions, files), url }
1220
3574
  };
1221
3575
  }
1222
-
1223
- // src/record/actions.ts
1224
- var VERBS2 = /* @__PURE__ */ new Set([
1225
- "wait",
1226
- "hover",
1227
- "click",
1228
- "type",
1229
- "press",
1230
- "scroll",
1231
- "move",
1232
- "drag"
1233
- ]);
1234
- var PRESS_KEYS = [
1235
- "Escape",
1236
- "Enter",
1237
- "Tab",
1238
- "Backspace",
1239
- "Delete",
1240
- "ArrowUp",
1241
- "ArrowDown",
1242
- "ArrowLeft",
1243
- "ArrowRight",
1244
- "Home",
1245
- "End",
1246
- "PageUp",
1247
- "PageDown",
1248
- "Space"
1249
- ];
1250
- function validateActions(value) {
1251
- const errors = [];
1252
- if (typeof value !== "object" || value === null)
1253
- return ["actions file must be a JSON object"];
1254
- const obj = value;
1255
- if (obj.url !== void 0) {
1256
- if (typeof obj.url !== "string") errors.push("url must be a string");
1257
- else {
1258
- const problem = urlSchemeProblem(obj.url);
1259
- if (problem) errors.push(`url ${problem}`);
1260
- }
1261
- }
1262
- if (obj.viewport !== void 0) {
1263
- const v = obj.viewport;
1264
- if (typeof v !== "object" || v === null) {
1265
- errors.push("viewport must be { width, height }");
1266
- } else {
1267
- const vp = v;
1268
- if (typeof vp.width !== "number" || typeof vp.height !== "number") {
1269
- errors.push("viewport must be { width, height }");
1270
- }
1271
- }
1272
- }
1273
- if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
1274
- if (obj.mask !== void 0) {
1275
- if (!Array.isArray(obj.mask)) {
1276
- errors.push("mask must be an array of { selector, as?, text? }");
1277
- } else {
1278
- obj.mask.forEach((raw, i) => {
1279
- const at = `mask[${i}]`;
1280
- const m = raw ?? {};
1281
- if (typeof m.selector !== "string" || !m.selector.trim())
1282
- errors.push(`${at}: needs a selector`);
1283
- if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
1284
- errors.push(`${at}: as must be "blur" or "text"`);
1285
- if (m.as === "text" && (typeof m.text !== "string" || !m.text))
1286
- errors.push(`${at}: as "text" needs the text to show instead`);
1287
- if (m.as !== "text" && m.text !== void 0)
1288
- errors.push(`${at}: text is only read with as "text"`);
3576
+ async function keepPageSound(heard, sink, fetchSource, log) {
3577
+ const kept = /* @__PURE__ */ new Map();
3578
+ const used = /* @__PURE__ */ new Set();
3579
+ for (const h of heard) {
3580
+ if (!h.meta.intervals.length) continue;
3581
+ const stretches = h.meta.intervals.length;
3582
+ log(
3583
+ ` page sound: ${h.meta.src || "a media element"} heard ${stretches === 1 ? "once" : `${stretches} times`}`
3584
+ );
3585
+ if (!sink || kept.has(h.src)) continue;
3586
+ try {
3587
+ const got = await fetchSource(h.src);
3588
+ const name = outputName(
3589
+ void 0,
3590
+ got.suggested,
3591
+ `sound-${kept.size + 1}${extForType(got.contentType)}`,
3592
+ used
3593
+ );
3594
+ used.add(name);
3595
+ kept.set(h.src, {
3596
+ file: await sink.save(name, got.bytes, got.contentType)
1289
3597
  });
3598
+ } catch (e) {
3599
+ const why = e instanceof Error ? e.message : String(e);
3600
+ kept.set(h.src, { fetchError: why });
3601
+ log(` (page sound not kept: ${why})`);
1290
3602
  }
1291
3603
  }
1292
- if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
1293
- errors.push("steps must be a non-empty array");
1294
- return errors;
1295
- }
1296
- const seenIds = /* @__PURE__ */ new Set();
1297
- obj.steps.forEach((raw, i) => {
1298
- const at = `steps[${i}]`;
1299
- if (typeof raw !== "object" || raw === null) {
1300
- errors.push(`${at}: must be an object`);
1301
- return;
1302
- }
1303
- const s = raw;
1304
- if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
1305
- errors.push(`${at}: read must be a selector`);
1306
- if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
1307
- errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
1308
- return;
1309
- }
1310
- if (s.id !== void 0) {
1311
- if (typeof s.id !== "string" || !s.id.trim()) {
1312
- errors.push(`${at}: id must be a non-empty string`);
1313
- } else if (seenIds.has(s.id)) {
1314
- errors.push(
1315
- `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
1316
- );
1317
- } else {
1318
- seenIds.add(s.id);
1319
- }
1320
- }
1321
- const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
1322
- if (needSelector && typeof s.selector !== "string")
1323
- errors.push(`${at}: ${s.do} needs a selector`);
1324
- if (s.do === "wait" && typeof s.ms !== "number")
1325
- errors.push(`${at}: wait needs ms`);
1326
- if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
1327
- errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
1328
- if (s.do === "type" && typeof s.text !== "string")
1329
- errors.push(`${at}: type needs text`);
1330
- if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
1331
- errors.push(`${at}: type focus must be true or false`);
1332
- if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
1333
- errors.push(`${at}: type replace must be true or false`);
1334
- if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
1335
- errors.push(`${at}: type at must be "end" or "start"`);
1336
- if (s.do === "press") {
1337
- if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
1338
- errors.push(
1339
- `${at}: press needs a key, one of ${PRESS_KEYS.join(", ")}; words are typed with type`
1340
- );
1341
- if (s.selector !== void 0 && typeof s.selector !== "string")
1342
- errors.push(`${at}: press selector must be a string`);
1343
- }
1344
- if (s.do === "scroll" && typeof s.dy !== "number")
1345
- errors.push(`${at}: scroll needs dy`);
1346
- if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
1347
- errors.push(`${at}: move needs x and y (CSS px)`);
1348
- }
1349
- if (s.do === "drag") {
1350
- if (typeof s.tx !== "number" || typeof s.ty !== "number") {
1351
- errors.push(`${at}: drag needs tx and ty (CSS px)`);
1352
- }
1353
- const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
1354
- if (!hasStart)
1355
- errors.push(
1356
- `${at}: drag needs a selector OR x and y as the start point`
1357
- );
1358
- }
1359
- });
1360
- return errors;
3604
+ return heard.map((h) => ({ ...h.meta, ...kept.get(h.src) ?? {} }));
3605
+ }
3606
+ var POST_STEP_READ_MS = 5e3;
3607
+ var CLOSE_BOUND_MS = 1e4;
3608
+ function hangReason(verb, ms, boundMs) {
3609
+ return `the page stopped answering: this ${verb} ran ${sayDuration(ms)}, past its bound of ${sayDuration(boundMs)}`;
3610
+ }
3611
+ var EXT_FOR_TYPE = {
3612
+ "video/mp4": ".mp4",
3613
+ "video/webm": ".webm",
3614
+ "video/quicktime": ".mov",
3615
+ "audio/mpeg": ".mp3",
3616
+ "audio/mp4": ".m4a",
3617
+ "audio/wav": ".wav",
3618
+ "audio/ogg": ".ogg",
3619
+ "image/png": ".png",
3620
+ "image/jpeg": ".jpg",
3621
+ "image/webp": ".webp",
3622
+ "image/gif": ".gif",
3623
+ "image/svg+xml": ".svg",
3624
+ "application/pdf": ".pdf",
3625
+ "application/json": ".json",
3626
+ "text/csv": ".csv",
3627
+ "application/zip": ".zip"
3628
+ };
3629
+ function extForType(type) {
3630
+ return (type && EXT_FOR_TYPE[type.split(";")[0].trim().toLowerCase()]) ?? "";
3631
+ }
3632
+ function typeOfName(name) {
3633
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
3634
+ if (!name.includes(".")) return {};
3635
+ for (const [type, e] of Object.entries(EXT_FOR_TYPE))
3636
+ if (e === ext) return { contentType: type };
3637
+ return ext === ".jpeg" ? { contentType: "image/jpeg" } : {};
1361
3638
  }
1362
3639
 
1363
3640
  // src/record/encodePage.ts
@@ -1392,7 +3669,15 @@ try {
1392
3669
  fi = next
1393
3670
  const blob = await (await fetch(${JSON.stringify(o.framesBaseUrl)} + frames[fi].file + ${JSON.stringify(q)})).blob()
1394
3671
  const bmp = await createImageBitmap(blob)
1395
- c2.drawImage(bmp, 0, 0, W, H)
3672
+ // A frame is drawn at its own size, never stretched to the take's: a
3673
+ // frame of another size means the surface moved mid-take, and a
3674
+ // picture scaled to fit would be a distorted take nobody was told of.
3675
+ if (bmp.width !== W || bmp.height !== H) {
3676
+ const size = bmp.width + 'x' + bmp.height
3677
+ bmp.close()
3678
+ throw new Error('frame ' + frames[fi].file + ' is ' + size + ', the take is ' + W + 'x' + H + ' (meta.captureWidth/captureHeight): frames are never resized, so the recorded surface changed size mid-take. Re-record it')
3679
+ }
3680
+ c2.drawImage(bmp, 0, 0)
1396
3681
  bmp.close()
1397
3682
  }
1398
3683
  await src.add(t, 1 / FPS)
@@ -1416,14 +3701,18 @@ try {
1416
3701
  // src/record/plan.ts
1417
3702
  import {
1418
3703
  DEFAULT_FRAME_STYLE,
3704
+ EXPORT_RESOLUTION_OPTIONS,
1419
3705
  planAutoSpeed,
1420
3706
  planAutoZoom,
1421
3707
  projectFromArtifact,
3708
+ ratedSegments,
3709
+ recommendedExportResolution,
1422
3710
  withBackdrop
1423
3711
  } from "@vosjs/studio-core";
1424
3712
  function planFreshTake(artifact, recordingName, opts = {}) {
1425
3713
  const ingest = opts.backdrop ? { frame: withBackdrop(DEFAULT_FRAME_STYLE, opts.backdrop) } : {};
1426
3714
  const doc = projectFromArtifact(artifact, recordingName, ingest).doc;
3715
+ doc.segments = splitSegmentsAtSkips(doc.segments, artifact.meta.skips);
1427
3716
  doc.zoom = planAutoZoom(doc.source.cursor, {
1428
3717
  width: doc.source.meta.width,
1429
3718
  height: doc.source.meta.height,
@@ -1436,75 +3725,272 @@ function planFreshTake(artifact, recordingName, opts = {}) {
1436
3725
  params: doc.speedParams,
1437
3726
  activity: opts.activity
1438
3727
  });
3728
+ return withFittingExport(withPageSound(doc, artifact.meta.media));
3729
+ }
3730
+ function withFittingExport(doc) {
3731
+ if (!doc.export) return doc;
3732
+ const fits = recommendedExportResolution(doc);
3733
+ const order = EXPORT_RESOLUTION_OPTIONS;
3734
+ if (order.indexOf(fits) < order.indexOf(doc.export.resolution))
3735
+ doc.export = { ...doc.export, resolution: fits };
3736
+ return doc;
3737
+ }
3738
+ function sourceToOutput(pieces, s) {
3739
+ let acc = 0;
3740
+ for (const p of pieces) {
3741
+ const rate = p.rate && p.rate > 0 ? p.rate : 1;
3742
+ if (p.media) {
3743
+ acc += Math.max(0, p.out - p.in) / rate;
3744
+ continue;
3745
+ }
3746
+ if (s < p.in) return acc;
3747
+ if (s <= p.out) return acc + (s - p.in) / rate;
3748
+ acc += Math.max(0, p.out - p.in) / rate;
3749
+ }
3750
+ return acc;
3751
+ }
3752
+ function withPageSound(doc, media) {
3753
+ const kept = (doc.audio ?? []).filter((a) => a.source !== "page");
3754
+ const heard = (media ?? []).filter((m) => m.file && m.intervals.length);
3755
+ if (!heard.length) {
3756
+ doc.audio = kept;
3757
+ return doc;
3758
+ }
3759
+ const spans = heard.flatMap(
3760
+ (m) => m.intervals.map((iv) => ({ in: iv.at, out: iv.at + (iv.to - iv.from) }))
3761
+ );
3762
+ doc.speed = (doc.speed ?? []).filter(
3763
+ (sp) => sp.source !== "auto" || !!sp.media || !spans.some((h) => sp.in < h.out && h.in < sp.out)
3764
+ );
3765
+ const pieces = ratedSegments(doc);
3766
+ const clips = [];
3767
+ heard.forEach((m, i) => {
3768
+ const file = m.file;
3769
+ const name = file.split("/").pop() || file;
3770
+ const length = Math.max(m.duration ?? 0, ...m.intervals.map((iv) => iv.to));
3771
+ m.intervals.forEach((iv, j) => {
3772
+ clips.push({
3773
+ id: `page-${i + 1}-${j + 1}`,
3774
+ key: file,
3775
+ name,
3776
+ start: +sourceToOutput(pieces, iv.at).toFixed(3),
3777
+ in: iv.from,
3778
+ out: iv.to,
3779
+ duration: +length.toFixed(3),
3780
+ gain: m.volume ?? 1,
3781
+ fadeIn: 0,
3782
+ fadeOut: 0,
3783
+ source: "page"
3784
+ });
3785
+ });
3786
+ });
3787
+ doc.audio = [...kept, ...clips];
1439
3788
  return doc;
1440
3789
  }
3790
+ function splitSegmentsAtSkips(segments, skips) {
3791
+ if (!skips?.length) return segments;
3792
+ const cuts = [...new Set(skips.map((s) => s.at))].sort((a, b) => a - b);
3793
+ const out = [];
3794
+ for (const seg of segments) {
3795
+ const inside = seg.media ? [] : cuts.filter((at) => at > seg.in + 1e-3 && at < seg.out - 1e-3);
3796
+ if (!inside.length) {
3797
+ out.push(seg);
3798
+ continue;
3799
+ }
3800
+ const { anim, hold, ...plain } = seg;
3801
+ const edges = [seg.in, ...inside, seg.out];
3802
+ for (let i = 0; i < edges.length - 1; i++) {
3803
+ const first = i === 0;
3804
+ const last = i === edges.length - 2;
3805
+ const pieceAnim = anim && (first || last) ? {
3806
+ ...first && anim.enter ? { enter: anim.enter } : {},
3807
+ ...last && anim.exit ? { exit: anim.exit } : {}
3808
+ } : null;
3809
+ out.push({
3810
+ ...plain,
3811
+ in: edges[i],
3812
+ out: edges[i + 1],
3813
+ ...pieceAnim && Object.keys(pieceAnim).length ? { anim: pieceAnim } : {},
3814
+ ...last && hold !== void 0 ? { hold } : {}
3815
+ });
3816
+ }
3817
+ }
3818
+ return out;
3819
+ }
1441
3820
  export {
3821
+ APP_QUIET_MS,
3822
+ APP_SETTLE_CAP_MS,
3823
+ AWAIT_HEARTBEAT_MS,
3824
+ AWAIT_POLL_S,
3825
+ AWAIT_TIMEOUT_S,
3826
+ CHANGE_AGE_PROBE,
3827
+ CLICK_BUTTONS,
1442
3828
  DEAD_SHARE_WARN_PCT,
1443
3829
  DEAD_STRETCH_WARN_MS,
3830
+ DEFAULT_READY,
3831
+ DOM_CHANGE_PROBE,
3832
+ DOM_QUIET_MS,
3833
+ DOM_SETTLE_CAP_MS,
3834
+ DOWNLOAD_TIMEOUT_S,
3835
+ DRAG_HOLD_MS,
3836
+ DRAG_MODIFIERS,
3837
+ EXPECT_TIMEOUT_S,
1444
3838
  EXPOSURE_ADVICE,
1445
3839
  EXPOSURE_MATCHERS_SRC,
1446
3840
  EXPOSURE_PROBE,
1447
3841
  ExposureLog,
3842
+ IDLE_CALL_MS,
3843
+ KEY_ROUNDTRIP_MS,
3844
+ KEY_SEQUENCE_GAP_MS,
3845
+ MAX_KEYS,
3846
+ MAX_PATH_POINTS,
3847
+ MEDIA_DRAIN_PROBE,
3848
+ MEDIA_WATCH_SCRIPT,
3849
+ MIN_SKIP_MS,
3850
+ MODIFIERS,
1448
3851
  MOTION_TICK_MS,
3852
+ MULTI_CLICK_GAP_MS,
3853
+ NAV_TIMEOUT_MS,
3854
+ NETWORKIDLE_HYDRATION_MS,
3855
+ NOTICE_SRC,
3856
+ NoticeLog,
3857
+ OUTPUT_MAX_BYTES,
3858
+ OVERRUN_MIN_MS,
3859
+ OVERRUN_MIN_PLANNED_MS,
3860
+ OVERRUN_SHARE,
3861
+ PAGE_ERROR_LINE_CHARS,
3862
+ PASTE_MAX_BYTES,
3863
+ PASTE_READ_WINDOW_MS,
3864
+ PASTE_TYPES,
3865
+ PASTE_UNREAD_MS,
3866
+ PATH_DEFAULT_MS,
1449
3867
  POINTER_MAX_MS,
1450
3868
  POINTER_MIN_MS,
1451
3869
  POINTER_MS_PER_PX,
1452
3870
  PRESS_HOLD_MS,
1453
3871
  PRESS_KEYS,
1454
3872
  PRESS_LEAD_MS,
3873
+ PageErrorLog,
3874
+ PageSoundLog,
1455
3875
  QUIET_MS,
3876
+ READY_MODES,
3877
+ READY_SETTLE_CAP_MS,
1456
3878
  READ_BEAT_MS,
1457
3879
  RESPONSE_MS,
3880
+ RecordRefusedError,
1458
3881
  SCROLL_MAX_MS,
1459
3882
  SCROLL_MIN_MS,
1460
3883
  SCROLL_MS_PER_PX,
1461
3884
  SCROLL_SETTLE_MS,
1462
3885
  SETTLE_CAP_MS,
1463
3886
  SETTLE_MS,
3887
+ SETTLE_VERBS,
3888
+ STEP_GUARDS,
3889
+ STEP_MARGIN_MS,
1464
3890
  SetupEnvError,
1465
3891
  SetupError,
3892
+ StepBound,
1466
3893
  TRAILING_HOLD_MS,
3894
+ TakeClock,
3895
+ WAIT_MARGIN_MS,
1467
3896
  WALL_PROBE,
1468
3897
  WallError,
3898
+ appSettled,
1469
3899
  askedMs,
1470
3900
  base64Bytes,
3901
+ bounded,
1471
3902
  buildEncodePage,
3903
+ buttonNumber,
1472
3904
  capReached,
1473
3905
  cappedLine,
3906
+ chordProblem,
1474
3907
  clampWait,
1475
3908
  clockMotion,
1476
3909
  clockTyping,
3910
+ conditionMiss,
1477
3911
  deadLine,
1478
3912
  deadTime,
1479
3913
  deadWarns,
3914
+ domSettled,
1480
3915
  easeInOutCubic,
1481
3916
  exposureLine,
3917
+ extForType,
3918
+ fetchInPageScript,
3919
+ fileNameProblem,
3920
+ fitSurfaceToViewport,
1482
3921
  formatDurationCap,
3922
+ guardVerdict,
3923
+ guardedSteps,
3924
+ hangReason,
1483
3925
  headersFor,
1484
3926
  holdLeftMs,
3927
+ hostedRefusals,
3928
+ inlinePoints,
3929
+ inlineScript,
1485
3930
  isIdpHost,
3931
+ isKeyName,
1486
3932
  isSignInPath,
1487
3933
  jpegDims,
3934
+ keySequencePlanMs,
3935
+ loadScriptFiles,
1488
3936
  maskInitScript,
3937
+ modifierBits,
3938
+ nameFromUrl,
3939
+ noticeAdvice,
3940
+ noticeLine,
3941
+ outputName,
3942
+ overrunLine,
3943
+ overrunOf,
1489
3944
  paceLine,
1490
3945
  paceReport,
3946
+ pageErrorLine,
1491
3947
  parseHeaders,
3948
+ pasteTypeOf,
3949
+ pathDurationMs,
3950
+ plainName,
1492
3951
  planFreshTake,
1493
3952
  pointerTravelMs,
3953
+ pollCondition,
3954
+ pressSequence,
1494
3955
  publicUrl,
3956
+ readAllBytes,
3957
+ readCondition,
3958
+ readMediaSource,
3959
+ readPath,
3960
+ readyLine,
3961
+ readyProblem,
1495
3962
  realSleep,
1496
3963
  recordTake,
1497
3964
  redirectedAway,
3965
+ reloadUntilReady,
3966
+ resolveChord,
3967
+ resolveModifier,
1498
3968
  resolveSetupText,
1499
3969
  runSetup,
3970
+ sayCondition,
3971
+ sayDuration,
3972
+ schedulePath,
3973
+ scriptFiles,
1500
3974
  scrollByScript,
1501
3975
  scrollTravelMs,
1502
3976
  settleMs,
3977
+ settleOn,
1503
3978
  settleVerdict,
1504
3979
  setupEnvNames,
3980
+ soundSrcForMeta,
3981
+ splitChord,
3982
+ splitSegmentsAtSkips,
3983
+ stageClipboardScript,
3984
+ stepBoundMs,
3985
+ syntheticPasteScript,
3986
+ typeOfName,
3987
+ untilReady,
1505
3988
  urlSchemeProblem,
1506
3989
  validateActions,
1507
3990
  validateSetup,
1508
- wallVerdict
3991
+ visibleTextScript,
3992
+ wallVerdict,
3993
+ withFittingExport,
3994
+ withPageSound
1509
3995
  };
1510
3996
  //# sourceMappingURL=index.js.map