@vosjs/render-core 0.7.0 → 0.9.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
  }
@@ -372,6 +375,113 @@ var EXPOSURE_MATCHERS_SRC = `(() => {
372
375
  }
373
376
  return { kindsIn }
374
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()`;
375
485
  var EXPOSURE_PROBE = `(() => {
376
486
  const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
377
487
  const vw = innerWidth, vh = innerHeight
@@ -383,7 +493,10 @@ var EXPOSURE_PROBE = `(() => {
383
493
  // layout's, and they come last. A value that itself holds what the scan
384
494
  // looks for is never used: the selector would quote it.
385
495
  const attr = (v) => '"' + v.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"'
386
- const quiet = (v) => !!v && v.length <= 80 && !kindsIn(v).length
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()))
387
500
  const segment = (el) => {
388
501
  const tag = el.tagName.toLowerCase()
389
502
  if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id) && !/\\d{2,}/.test(el.id) && quiet(el.id)) return tag + '#' + el.id
@@ -425,6 +538,7 @@ var EXPOSURE_PROBE = `(() => {
425
538
  if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
426
539
  return r
427
540
  }
541
+ ${NOTICE_SRC}
428
542
  const out = [], keys = new Set()
429
543
  const add = (el, text) => {
430
544
  const kinds = kindsIn(text)
@@ -451,7 +565,7 @@ var EXPOSURE_PROBE = `(() => {
451
565
  }
452
566
  const hits = {}
453
567
  for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
454
- return { exposures: out, maskHits: hits }
568
+ return { exposures: out, maskHits: hits, notices: noticeList }
455
569
  })()`;
456
570
  function maskInitScript(masks) {
457
571
  const rules = masks.map((m) => ({
@@ -536,6 +650,43 @@ function exposureLine(e) {
536
650
  return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
537
651
  }
538
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
+ }
539
690
 
540
691
  // src/record/setup.ts
541
692
  var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
@@ -620,7 +771,10 @@ async function runSetup(page, steps, opts) {
620
771
  lines.push(`setup #${i} wait ${step.ms}ms`);
621
772
  break;
622
773
  case "goto":
623
- 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);
624
778
  lines.push(`setup #${i} goto ${step.url}`);
625
779
  break;
626
780
  case "press":
@@ -689,6 +843,291 @@ function parseHeaders(given) {
689
843
  return out;
690
844
  }
691
845
 
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);
1005
+ try {
1006
+ const u = new URL(src);
1007
+ return `${u.origin}${u.pathname}`;
1008
+ } catch {
1009
+ return "";
1010
+ }
1011
+ }
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;
1028
+ }
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
+
692
1131
  // src/record/takeClock.ts
693
1132
  var TakeClock = class {
694
1133
  constructor(t0) {
@@ -719,6 +1158,32 @@ var TakeClock = class {
719
1158
  open.to = Math.max(open.from, wallMs);
720
1159
  return open.to - open.from;
721
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
+ }
722
1187
  /**
723
1188
  * A captured frame's footage ms, or null when it is not later than the
724
1189
  * last frame kept (the frame is dropped; see the module comment).
@@ -764,141 +1229,1343 @@ var PageErrorLog = class {
764
1229
  }
765
1230
  };
766
1231
 
767
- // src/record/actions.ts
768
- var STEP_GUARDS = ["spend"];
769
- var GUARDABLE = /* @__PURE__ */ new Set(["click", "press", "type"]);
770
- var EXPECT_TIMEOUT_S = 5;
771
- var AWAIT_TIMEOUT_S = 600;
772
- var AWAIT_POLL_S = 2;
773
- var DOWNLOAD_TIMEOUT_S = 30;
774
- var VERBS2 = /* @__PURE__ */ new Set([
775
- "wait",
776
- "hover",
777
- "click",
778
- "type",
779
- "press",
780
- "scroll",
781
- "move",
782
- "drag",
783
- "expect",
784
- "await",
785
- "download"
786
- ]);
787
- var PRESS_KEYS = [
788
- "Escape",
789
- "Enter",
790
- "Tab",
791
- "Backspace",
792
- "Delete",
793
- "ArrowUp",
794
- "ArrowDown",
795
- "ArrowLeft",
796
- "ArrowRight",
797
- "Home",
798
- "End",
799
- "PageUp",
800
- "PageDown",
801
- "Space"
802
- ];
803
- function validateActions(value) {
804
- const errors = [];
805
- if (typeof value !== "object" || value === null)
806
- return ["actions file must be a JSON object"];
807
- const obj = value;
808
- if (obj.url !== void 0) {
809
- if (typeof obj.url !== "string") errors.push("url must be a string");
810
- else {
811
- const problem = urlSchemeProblem(obj.url);
812
- if (problem) errors.push(`url ${problem}`);
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
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) };
1241
+ }
1242
+ async function fitSurfaceToViewport(cdp, width, height) {
1243
+ try {
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;
813
1266
  }
1267
+ return { before, after };
1268
+ } catch {
1269
+ return null;
814
1270
  }
815
- if (obj.viewport !== void 0) {
816
- const v = obj.viewport;
817
- if (typeof v !== "object" || v === null) {
818
- errors.push("viewport must be { width, height }");
819
- } else {
820
- const vp = v;
821
- if (typeof vp.width !== "number" || typeof vp.height !== "number") {
822
- errors.push("viewport must be { width, height }");
823
- }
824
- }
1271
+ }
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
+ var JS_SAID_MAX = 120;
1278
+ var JS_VALUE_MAX = 2048;
1279
+ function jsConditionScript(expr) {
1280
+ return `(async () => {
1281
+ let v;
1282
+ try { v = await (${expr}
1283
+ ); } catch (e) { return JSON.stringify({ threw: String(e && e.message ? e.message : e) }) }
1284
+ const out = { truthy: !!v };
1285
+ try { const j = JSON.stringify(v); if (j !== undefined) out.json = j } catch (e) {}
1286
+ try { out.said = typeof v === 'string' ? JSON.stringify(v) : String(v) } catch (e) { out.said = typeof v }
1287
+ if (out.json !== undefined && typeof v !== 'string') out.said = out.json
1288
+ return JSON.stringify(out);
1289
+ })()`;
1290
+ }
1291
+ function sameJson(a, b) {
1292
+ if (a === b) return true;
1293
+ if (typeof a !== typeof b || a === null || b === null) return false;
1294
+ if (typeof a !== "object") return false;
1295
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
1296
+ if (Array.isArray(a)) {
1297
+ const bb = b;
1298
+ return a.length === bb.length && a.every((v, i) => sameJson(v, bb[i]));
825
1299
  }
826
- if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
827
- if (obj.mask !== void 0) {
828
- if (!Array.isArray(obj.mask)) {
829
- errors.push("mask must be an array of { selector, as?, text? }");
830
- } else {
831
- obj.mask.forEach((raw, i) => {
832
- const at = `mask[${i}]`;
833
- const m = raw ?? {};
834
- if (typeof m.selector !== "string" || !m.selector.trim())
835
- errors.push(`${at}: needs a selector`);
836
- if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
837
- errors.push(`${at}: as must be "blur" or "text"`);
838
- if (m.as === "text" && (typeof m.text !== "string" || !m.text))
839
- errors.push(`${at}: as "text" needs the text to show instead`);
840
- if (m.as !== "text" && m.text !== void 0)
841
- errors.push(`${at}: text is only read with as "text"`);
842
- });
843
- }
1300
+ const ao = a;
1301
+ const bo = b;
1302
+ const keys = Object.keys(ao);
1303
+ if (keys.length !== Object.keys(bo).length) return false;
1304
+ return keys.every(
1305
+ (k) => Object.prototype.hasOwnProperty.call(bo, k) && sameJson(ao[k], bo[k])
1306
+ );
1307
+ }
1308
+ async function readJs(page, c) {
1309
+ let raw;
1310
+ try {
1311
+ raw = await page.evaluate(jsConditionScript(c.js));
1312
+ } catch (e) {
1313
+ const why = e instanceof Error ? e.message : String(e);
1314
+ if (/SyntaxError/.test(why) || e instanceof Error && e.name === "SyntaxError")
1315
+ return { holds: false, threw: `it does not parse: ${why.split("\n")[0]}` };
1316
+ throw e;
844
1317
  }
845
- if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
846
- errors.push("steps must be a non-empty array");
847
- return errors;
1318
+ let got;
1319
+ try {
1320
+ got = JSON.parse(raw);
1321
+ } catch {
1322
+ return { holds: false };
848
1323
  }
849
- const seenIds = /* @__PURE__ */ new Set();
850
- obj.steps.forEach((raw, i) => {
851
- const at = `steps[${i}]`;
852
- if (typeof raw !== "object" || raw === null) {
853
- errors.push(`${at}: must be an object`);
854
- return;
1324
+ if (got.threw !== void 0) return { holds: false, threw: got.threw };
1325
+ let value;
1326
+ let hasValue = false;
1327
+ if (got.json !== void 0) {
1328
+ try {
1329
+ value = JSON.parse(got.json);
1330
+ hasValue = true;
1331
+ } catch {
855
1332
  }
856
- const s = raw;
857
- if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
858
- errors.push(`${at}: read must be a selector`);
859
- if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
860
- errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
861
- return;
1333
+ }
1334
+ const holds = c.equals !== void 0 ? hasValue && sameJson(value, c.equals) : got.truthy === true;
1335
+ return {
1336
+ holds,
1337
+ ...hasValue ? { value } : {},
1338
+ said: clip(got.said ?? "undefined", JS_SAID_MAX)
1339
+ };
1340
+ }
1341
+ var clip = (s, max) => s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
1342
+ function keptValue(value) {
1343
+ const json = JSON.stringify(value);
1344
+ if (json === void 0) return void 0;
1345
+ return json.length <= JS_VALUE_MAX ? value : clip(json, JS_VALUE_MAX);
1346
+ }
1347
+ async function readCondition(page, c) {
1348
+ try {
1349
+ if (c.js !== void 0) return await readJs(page, { ...c, js: c.js });
1350
+ if (c.text !== void 0)
1351
+ return { holds: await page.evaluate(visibleTextScript(c.text)) };
1352
+ if (c.gone !== void 0)
1353
+ return { holds: !await page.locator(c.gone).first().isVisible() };
1354
+ if (c.selector !== void 0) {
1355
+ const loc = page.locator(c.selector).first();
1356
+ const visible = await loc.isVisible();
1357
+ if (!visible) return { holds: false, visible };
1358
+ if (c.enabled === void 0) return { holds: true, visible };
1359
+ const enabled = await loc.isEnabled({ timeout: 1e3 });
1360
+ return { holds: enabled === c.enabled, visible };
862
1361
  }
863
- if (s.id !== void 0) {
864
- if (typeof s.id !== "string" || !s.id.trim()) {
865
- errors.push(`${at}: id must be a non-empty string`);
866
- } else if (seenIds.has(s.id)) {
867
- errors.push(
868
- `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
869
- );
870
- } else {
871
- seenIds.add(s.id);
872
- }
1362
+ } catch {
1363
+ }
1364
+ return { holds: false };
1365
+ }
1366
+ async function pollCondition(page, c, opts) {
1367
+ const now = opts.now ?? Date.now;
1368
+ const start = now();
1369
+ const reads = Math.max(1, opts.confirm?.reads ?? 1);
1370
+ for (let first = true; ; first = false) {
1371
+ if (!first && opts.between) await opts.between();
1372
+ let last = await readCondition(page, c);
1373
+ for (let n = 1; last.holds && n < reads; n++) {
1374
+ await opts.sleep(opts.confirm?.gapMs ?? 0);
1375
+ last = await readCondition(page, c);
873
1376
  }
874
- const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
875
- if (needSelector && typeof s.selector !== "string")
876
- errors.push(`${at}: ${s.do} needs a selector`);
877
- if (s.do === "wait" && typeof s.ms !== "number")
878
- errors.push(`${at}: wait needs ms`);
879
- if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
880
- errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
881
- if (s.do === "type" && typeof s.text !== "string")
882
- errors.push(`${at}: type needs text`);
883
- if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
884
- errors.push(`${at}: type focus must be true or false`);
885
- if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
886
- errors.push(`${at}: type replace must be true or false`);
887
- if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
888
- errors.push(`${at}: type at must be "end" or "start"`);
889
- if (s.do === "press") {
890
- if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
1377
+ const ms = now() - start;
1378
+ if (last.holds) return { ok: true, ms, last };
1379
+ const left = opts.timeoutMs - ms;
1380
+ if (left <= 0) return { ok: false, ms, last };
1381
+ await opts.sleep(Math.min(opts.everyMs, left));
1382
+ }
1383
+ }
1384
+ var DOM_QUIET_MS = 800;
1385
+ var DOM_SETTLE_CAP_MS = 5e3;
1386
+ var DOM_CHANGE_PROBE = `(() => {
1387
+ if (!window.__vosDomChanges) {
1388
+ window.__vosDomChanges = { n: 0 };
1389
+ new MutationObserver((list) => { window.__vosDomChanges.n += list.length })
1390
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
1391
+ }
1392
+ return window.__vosDomChanges.n;
1393
+ })()`;
1394
+ async function domSettled(page, opts) {
1395
+ const now = opts.now ?? Date.now;
1396
+ const quiet = opts.quietMs ?? DOM_QUIET_MS;
1397
+ const cap = opts.capMs ?? DOM_SETTLE_CAP_MS;
1398
+ const start = now();
1399
+ let seen = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1400
+ let since = start;
1401
+ for (; ; ) {
1402
+ const t = now();
1403
+ if (t - since >= quiet) return { settled: true, ms: t - start };
1404
+ if (t - start >= cap) return { settled: false, ms: t - start };
1405
+ await opts.sleep(opts.tickMs ?? 100);
1406
+ const n = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1407
+ if (n !== seen) {
1408
+ seen = n;
1409
+ since = now();
1410
+ }
1411
+ }
1412
+ }
1413
+ function sayDuration(ms) {
1414
+ const s = Math.round(ms / 100) / 10;
1415
+ if (s < 60) return `${s} s`;
1416
+ const m = Math.floor(s / 60);
1417
+ const rest = Math.round(s - m * 60);
1418
+ return rest ? `${m} min ${rest} s` : `${m} min`;
1419
+ }
1420
+ function sayCondition(c) {
1421
+ if (c.js !== void 0)
1422
+ return c.equals !== void 0 ? `${sayJs(c.js)} to equal ${clip(JSON.stringify(c.equals) ?? "undefined", JS_SAID_MAX)}` : `${sayJs(c.js)} to hold`;
1423
+ if (c.text !== void 0) return `text "${c.text}" to show`;
1424
+ if (c.gone !== void 0) return `'${c.gone}' to go`;
1425
+ if (c.enabled === true) return `'${c.selector}' to be enabled`;
1426
+ if (c.enabled === false) return `'${c.selector}' to be disabled`;
1427
+ return `'${c.selector}' to appear`;
1428
+ }
1429
+ function conditionMiss(c, last, ms) {
1430
+ const t = sayDuration(ms);
1431
+ if (c.js !== void 0) {
1432
+ if (last.threw !== void 0)
1433
+ return `${sayJs(c.js)} threw for ${t} (${clip(last.threw, JS_SAID_MAX)})`;
1434
+ const was = last.said ?? "never answered";
1435
+ return c.equals !== void 0 ? `${sayJs(c.js)} was ${was}, not ${clip(JSON.stringify(c.equals) ?? "undefined", JS_SAID_MAX)}, for ${t}` : `${sayJs(c.js)} was ${was} for ${t}`;
1436
+ }
1437
+ if (c.text !== void 0)
1438
+ return `no visible text contained "${c.text}" for ${t}`;
1439
+ if (c.gone !== void 0) return `'${c.gone}' was still there after ${t}`;
1440
+ if (last.visible && c.enabled === true)
1441
+ return `'${c.selector}' stayed disabled for ${t}`;
1442
+ if (last.visible && c.enabled === false)
1443
+ return `'${c.selector}' stayed enabled for ${t}`;
1444
+ return `'${c.selector}' never appeared in ${t}`;
1445
+ }
1446
+ function sayJs(expr) {
1447
+ return `js \`${clip(expr.replace(/\s+/g, " ").trim(), 80)}\``;
1448
+ }
1449
+ function sayValue(value) {
1450
+ return clip(JSON.stringify(value) ?? "undefined", JS_SAID_MAX);
1451
+ }
1452
+
1453
+ // src/record/ready.ts
1454
+ var READY_MODES = ["dom", "networkidle"];
1455
+ var DEFAULT_READY = "dom";
1456
+ var NAV_TIMEOUT_MS = 45e3;
1457
+ var READY_SETTLE_CAP_MS = 8e3;
1458
+ var NETWORKIDLE_HYDRATION_MS = 800;
1459
+ function readyProblem(v) {
1460
+ if (v === void 0) return null;
1461
+ return typeof v === "string" && READY_MODES.includes(v) ? null : `ready must be ${READY_MODES.map((m) => `"${m}"`).join(" or ")}`;
1462
+ }
1463
+ async function untilReady(page, mode, navigate, opts) {
1464
+ const now = opts.now ?? Date.now;
1465
+ const start = now();
1466
+ const timeout = opts.timeoutMs ?? NAV_TIMEOUT_MS;
1467
+ if (mode === "networkidle") {
1468
+ let idle = true;
1469
+ const response2 = await navigate({
1470
+ waitUntil: "networkidle",
1471
+ timeout
1472
+ }).catch(() => {
1473
+ idle = false;
1474
+ return null;
1475
+ });
1476
+ await opts.sleep(NETWORKIDLE_HYDRATION_MS);
1477
+ return {
1478
+ response: response2,
1479
+ ready: { mode, ms: Math.round(now() - start), capped: !idle }
1480
+ };
1481
+ }
1482
+ const response = await navigate({ waitUntil: "load", timeout }).catch(
1483
+ () => null
1484
+ );
1485
+ const settled = await domSettled(page, {
1486
+ sleep: opts.sleep,
1487
+ now,
1488
+ quietMs: DOM_QUIET_MS,
1489
+ capMs: opts.capMs ?? READY_SETTLE_CAP_MS
1490
+ });
1491
+ return {
1492
+ response,
1493
+ ready: { mode, ms: Math.round(now() - start), capped: !settled.settled }
1494
+ };
1495
+ }
1496
+ async function reloadUntilReady(page, mode, timeoutMs, opts = {}) {
1497
+ const nav = {
1498
+ waitUntil: mode === "networkidle" ? "networkidle" : "load",
1499
+ timeout: Math.min(NAV_TIMEOUT_MS, timeoutMs)
1500
+ };
1501
+ await (page.reload ? page.reload(nav) : page.goto(page.url(), nav)).catch(
1502
+ () => null
1503
+ );
1504
+ await domSettled(page, { sleep: opts.sleep ?? realSleep });
1505
+ }
1506
+ function readyLine(r) {
1507
+ const s = (r.ms / 1e3).toFixed(1);
1508
+ if (r.mode === "networkidle")
1509
+ return r.capped ? `(networkidle timeout after ${s} s, continuing)` : `(network idle after ${s} s)`;
1510
+ return r.capped ? `(the page was still changing after ${s} s, starting anyway)` : `(the page settled after ${s} s)`;
1511
+ }
1512
+
1513
+ // src/record/gestures.ts
1514
+ var MODIFIERS = [
1515
+ "Shift",
1516
+ "Control",
1517
+ "Alt",
1518
+ "Meta",
1519
+ "Mod",
1520
+ "ControlOrMeta"
1521
+ ];
1522
+ var DRAG_MODIFIERS = [
1523
+ "Alt",
1524
+ "Shift",
1525
+ "Meta",
1526
+ "Control",
1527
+ "Mod"
1528
+ ];
1529
+ var NAMED_KEYS = /* @__PURE__ */ new Set([
1530
+ "Escape",
1531
+ "Enter",
1532
+ "Tab",
1533
+ "Backspace",
1534
+ "Delete",
1535
+ "Insert",
1536
+ "ArrowUp",
1537
+ "ArrowDown",
1538
+ "ArrowLeft",
1539
+ "ArrowRight",
1540
+ "Home",
1541
+ "End",
1542
+ "PageUp",
1543
+ "PageDown",
1544
+ "Space",
1545
+ "CapsLock",
1546
+ "ContextMenu",
1547
+ "Backquote",
1548
+ "Minus",
1549
+ "Equal",
1550
+ "BracketLeft",
1551
+ "BracketRight",
1552
+ "Backslash",
1553
+ "Semicolon",
1554
+ "Quote",
1555
+ "Comma",
1556
+ "Period",
1557
+ "Slash",
1558
+ "NumpadAdd",
1559
+ "NumpadSubtract",
1560
+ "NumpadMultiply",
1561
+ "NumpadDivide",
1562
+ "NumpadDecimal",
1563
+ "NumpadEnter"
1564
+ ]);
1565
+ var isModifier = (s) => MODIFIERS.includes(s);
1566
+ function isKeyName(k) {
1567
+ if ([...k].length === 1) return k.trim().length === 1 || k === " ";
1568
+ if (NAMED_KEYS.has(k)) return true;
1569
+ 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);
1570
+ }
1571
+ function splitChord(chord) {
1572
+ if (chord === "+") return { mods: [], key: "+" };
1573
+ const plusKey = chord.endsWith("++");
1574
+ const parts = (plusKey ? chord.slice(0, -2) : chord).split("+");
1575
+ if (plusKey) return { mods: parts, key: "+" };
1576
+ const key = parts.pop() ?? "";
1577
+ return { mods: parts, key };
1578
+ }
1579
+ function chordProblem(chord) {
1580
+ if (typeof chord !== "string" || !chord)
1581
+ return 'must be a key name or a chord like "Shift+D"';
1582
+ const { mods, key } = splitChord(chord);
1583
+ for (const m of mods) {
1584
+ if (!isModifier(m))
1585
+ return `"${chord}": "${m}" is not a modifier (one of ${MODIFIERS.join(", ")})`;
1586
+ }
1587
+ if (new Set(mods).size !== mods.length)
1588
+ return `"${chord}" names a modifier twice`;
1589
+ if (!key) return `"${chord}" ends without a key`;
1590
+ if (isModifier(key)) {
1591
+ if (mods.length) return `"${chord}" ends on a modifier, not a key`;
1592
+ return null;
1593
+ }
1594
+ if (!isKeyName(key))
1595
+ 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)`;
1596
+ return null;
1597
+ }
1598
+ function resolveChord(chord) {
1599
+ const { mods, key } = splitChord(chord);
1600
+ const k = key === "Mod" ? "ControlOrMeta" : key;
1601
+ return [...mods.map((m) => m === "Mod" ? "ControlOrMeta" : m), k].join("+");
1602
+ }
1603
+ function pressSequence(step) {
1604
+ if (step.keys !== void 0)
1605
+ return typeof step.keys === "string" ? [step.keys] : step.keys;
1606
+ return step.key ? [step.key] : [];
1607
+ }
1608
+ var KEY_SEQUENCE_GAP_MS = 150;
1609
+ var KEY_ROUNDTRIP_MS = 50;
1610
+ var keySequencePlanMs = (keys) => Math.max(0, keys - 1) * KEY_SEQUENCE_GAP_MS + keys * KEY_ROUNDTRIP_MS;
1611
+ var DRAG_HOLD_MS = 400;
1612
+ var MAX_KEYS = 50;
1613
+ function resolveModifier(m) {
1614
+ return m === "Mod" ? "ControlOrMeta" : m;
1615
+ }
1616
+ function modifierBits(mods, platform) {
1617
+ let bits = 0;
1618
+ for (const m of mods) {
1619
+ const k = m === "Mod" ? platform === "mac" ? "Meta" : "Control" : m;
1620
+ bits |= k === "Alt" ? 1 : k === "Control" ? 2 : k === "Meta" ? 4 : 8;
1621
+ }
1622
+ return bits;
1623
+ }
1624
+ var CLICK_BUTTONS = ["left", "right", "middle"];
1625
+ var buttonNumber = (b) => b === "right" ? 2 : b === "middle" ? 1 : 0;
1626
+ var MULTI_CLICK_GAP_MS = 90;
1627
+ var MAX_PATH_POINTS = 2e4;
1628
+ var PATH_DEFAULT_MS = 700;
1629
+ var finite = (v) => typeof v === "number" && Number.isFinite(v);
1630
+ function readPoint(raw) {
1631
+ if (Array.isArray(raw)) {
1632
+ if (raw.length < 2 || raw.length > 4)
1633
+ return "must be [x, y], [x, y, tMs] or [x, y, tMs, pressure]";
1634
+ const [x, y, t, pressure] = raw;
1635
+ if (!finite(x) || !finite(y)) return "x and y must be numbers (CSS px)";
1636
+ if (t !== void 0 && !finite(t)) return "its time must be a number of ms";
1637
+ if (pressure !== void 0 && !finite(pressure))
1638
+ return "its pressure must be a number from 0 to 1";
1639
+ return {
1640
+ x,
1641
+ y,
1642
+ ...t !== void 0 ? { t } : {},
1643
+ ...pressure !== void 0 ? { pressure } : {}
1644
+ };
1645
+ }
1646
+ if (typeof raw === "object" && raw !== null) {
1647
+ const o = raw;
1648
+ const extra = Object.keys(o).filter(
1649
+ (k) => !["x", "y", "t", "pressure"].includes(k)
1650
+ );
1651
+ if (extra.length)
1652
+ return `takes x, y, t and pressure, not ${extra.join(", ")}`;
1653
+ if (!finite(o.x) || !finite(o.y)) return "x and y must be numbers (CSS px)";
1654
+ if (o.t !== void 0 && !finite(o.t)) return "its t must be a number of ms";
1655
+ if (o.pressure !== void 0 && !finite(o.pressure))
1656
+ return "its pressure must be a number from 0 to 1";
1657
+ return {
1658
+ x: o.x,
1659
+ y: o.y,
1660
+ ...o.t !== void 0 ? { t: o.t } : {},
1661
+ ...o.pressure !== void 0 ? { pressure: o.pressure } : {}
1662
+ };
1663
+ }
1664
+ return "must be [x, y] or { x, y }";
1665
+ }
1666
+ function readPath(raw) {
1667
+ const list = typeof raw === "object" && raw !== null && !Array.isArray(raw) && Array.isArray(raw.points) ? raw.points : raw;
1668
+ if (!Array.isArray(list))
1669
+ return { problems: ['must be an array of points, or { "points": [...] }'] };
1670
+ if (list.length < 2) return { problems: ["needs at least two points"] };
1671
+ if (list.length > MAX_PATH_POINTS)
1672
+ return { problems: [`holds more than ${MAX_PATH_POINTS} points`] };
1673
+ const problems = [];
1674
+ const read = [];
1675
+ list.forEach((p, i) => {
1676
+ const r = readPoint(p);
1677
+ if (typeof r === "string") problems.push(`point ${i} ${r}`);
1678
+ else {
1679
+ if (r.x < 0 || r.y < 0)
1680
+ problems.push(`point ${i} is off the viewport (x and y are \u2265 0)`);
1681
+ if (r.pressure !== void 0 && (r.pressure < 0 || r.pressure > 1))
1682
+ problems.push(`point ${i} pressure must be from 0 to 1`);
1683
+ read.push(r);
1684
+ }
1685
+ });
1686
+ if (problems.length) return { problems: problems.slice(0, 5) };
1687
+ const timedCount = read.filter((p) => p.t !== void 0).length;
1688
+ if (timedCount !== 0 && timedCount !== read.length)
1689
+ return {
1690
+ problems: [
1691
+ "every point carries a time or none does: a path is replayed by its own timing, or evenly over ms"
1692
+ ]
1693
+ };
1694
+ const timed = timedCount > 0;
1695
+ if (timed) {
1696
+ for (let i = 1; i < read.length; i++)
1697
+ if (read[i].t < read[i - 1].t)
1698
+ return {
1699
+ problems: [`point ${i}'s time runs backwards (times never decrease)`]
1700
+ };
1701
+ }
1702
+ const t0 = timed ? read[0].t : 0;
1703
+ return {
1704
+ timed,
1705
+ points: read.map((p) => ({
1706
+ x: p.x,
1707
+ y: p.y,
1708
+ at: timed ? p.t - t0 : 0,
1709
+ ...p.pressure !== void 0 ? { pressure: p.pressure } : {}
1710
+ }))
1711
+ };
1712
+ }
1713
+ function schedulePath(path, ms) {
1714
+ if (path.timed) return path.points;
1715
+ const dur = ms ?? PATH_DEFAULT_MS;
1716
+ const n = path.points.length;
1717
+ return path.points.map((p, i) => ({
1718
+ ...p,
1719
+ at: n > 1 ? Math.round(dur * i / (n - 1)) : 0
1720
+ }));
1721
+ }
1722
+ var pathDurationMs = (s) => s.length ? s[s.length - 1].at : 0;
1723
+ var OVERRUN_SHARE = 0.2;
1724
+ var OVERRUN_MIN_PLANNED_MS = 100;
1725
+ var OVERRUN_MIN_MS = 100;
1726
+ function overrunOf(plannedMs, tookMs) {
1727
+ if (!(plannedMs >= OVERRUN_MIN_PLANNED_MS)) return null;
1728
+ if (tookMs <= plannedMs * (1 + OVERRUN_SHARE)) return null;
1729
+ if (tookMs - plannedMs < OVERRUN_MIN_MS) return null;
1730
+ return Math.round(tookMs - plannedMs);
1731
+ }
1732
+ function overrunLine(overruns) {
1733
+ if (!overruns.length) return null;
1734
+ const planned = overruns.reduce((a, o) => a + o.plannedMs, 0);
1735
+ const took = overruns.reduce((a, o) => a + o.tookMs, 0);
1736
+ const ratio = planned > 0 ? took / planned : 0;
1737
+ const n = overruns.length;
1738
+ const which = overruns.map((o) => `#${o.step}${o.id ? ` (${o.id})` : ""} ${o.do}`).join(", ");
1739
+ 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`;
1740
+ }
1741
+
1742
+ // src/record/paste.ts
1743
+ var PASTE_READ_WINDOW_MS = 300;
1744
+ var PASTE_UNREAD_MS = 3e3;
1745
+ var PASTE_MAX_BYTES = 10 * 1024 * 1024;
1746
+ var PASTE_TYPES = {
1747
+ ".png": "image/png",
1748
+ ".jpg": "image/jpeg",
1749
+ ".jpeg": "image/jpeg",
1750
+ ".webp": "image/webp",
1751
+ ".gif": "image/gif",
1752
+ ".svg": "image/svg+xml",
1753
+ ".txt": "text/plain",
1754
+ ".md": "text/plain",
1755
+ ".html": "text/html",
1756
+ ".htm": "text/html",
1757
+ ".json": "text/plain",
1758
+ ".csv": "text/plain"
1759
+ };
1760
+ function pasteTypeOf(name) {
1761
+ const dot = name.lastIndexOf(".");
1762
+ if (dot < 0) return null;
1763
+ return PASTE_TYPES[name.slice(dot).toLowerCase()] ?? null;
1764
+ }
1765
+ var BUILD = (item) => `
1766
+ const item = ${JSON.stringify(item)};
1767
+ const bytes = item.b64 ? Uint8Array.from(atob(item.b64), (c) => c.charCodeAt(0)) : null;
1768
+ const asText = bytes ? new TextDecoder().decode(bytes) : null;
1769
+ async function blobs() {
1770
+ if (item.text !== undefined) return { 'text/plain': new Blob([item.text], { type: 'text/plain' }) };
1771
+ const type = item.type;
1772
+ if (type === 'text/plain') return { 'text/plain': new Blob([asText], { type }) };
1773
+ if (type === 'text/html') return { 'text/html': new Blob([asText], { type }), 'text/plain': new Blob([asText], { type: 'text/plain' }) };
1774
+ if (type === 'image/svg+xml') return { 'image/svg+xml': new Blob([bytes], { type }), 'text/plain': new Blob([asText], { type: 'text/plain' }) };
1775
+ if (type === 'image/png') return { 'image/png': new Blob([bytes], { type }) };
1776
+ const bmp = await createImageBitmap(new Blob([bytes], { type }));
1777
+ const c = document.createElement('canvas');
1778
+ c.width = bmp.width; c.height = bmp.height;
1779
+ c.getContext('2d').drawImage(bmp, 0, 0);
1780
+ const png = await new Promise((r) => c.toBlob(r, 'image/png'));
1781
+ return { 'image/png': png };
1782
+ }
1783
+ `;
1784
+ function stageClipboardScript(item) {
1785
+ return `(async () => {
1786
+ ${BUILD(item)}
1787
+ try {
1788
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return 'refused: this page has no clipboard';
1789
+ const b = await blobs();
1790
+ try {
1791
+ await navigator.clipboard.write([new ClipboardItem(b)]);
1792
+ } catch (e) {
1793
+ if (!b['text/plain'] || Object.keys(b).length === 1) throw e;
1794
+ await navigator.clipboard.write([new ClipboardItem({ 'text/plain': b['text/plain'] })]);
1795
+ }
1796
+ return 'ok';
1797
+ } catch (e) {
1798
+ return 'refused: ' + (e && e.message ? e.message : String(e));
1799
+ }
1800
+ })()`;
1801
+ }
1802
+ function syntheticPasteScript(item, opts = {}) {
1803
+ return `(async () => {
1804
+ ${BUILD(item)}
1805
+ try {
1806
+ // With original, an image file is handed over as its own bytes and type
1807
+ // (an SVG also as its markup), never redrawn.
1808
+ 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()"};
1809
+ const dt = new DataTransfer();
1810
+ let file = null;
1811
+ for (const [type, blob] of Object.entries(b)) {
1812
+ if (type.startsWith('image/')) {
1813
+ file = new File([blob], item.name || 'image.png', { type: blob.type || type });
1814
+ dt.items.add(file);
1815
+ } else dt.setData(type, await blob.text());
1816
+ }
1817
+ try {
1818
+ // The async reads answer the item for THIS paste only: shortly after
1819
+ // the first read (an app may read text and items in turn), or after a
1820
+ // few seconds when nothing reads, the browser's own reads come back,
1821
+ // so a later paste through the clipboard is the clipboard's.
1822
+ const staged = [new ClipboardItem(b)];
1823
+ const clip = navigator.clipboard;
1824
+ const own = { read: Object.prototype.hasOwnProperty.call(clip, 'read'), readText: Object.prototype.hasOwnProperty.call(clip, 'readText') };
1825
+ const was = { read: clip.read, readText: clip.readText };
1826
+ let restored = false;
1827
+ const restore = () => {
1828
+ if (restored) return;
1829
+ restored = true;
1830
+ for (const k of ['read', 'readText']) {
1831
+ if (own[k]) clip[k] = was[k];
1832
+ else delete clip[k];
1833
+ }
1834
+ };
1835
+ let soon = null;
1836
+ const readOnce = () => { if (!soon) soon = setTimeout(restore, ${PASTE_READ_WINDOW_MS}) };
1837
+ clip.read = async () => { readOnce(); return staged };
1838
+ clip.readText = async () => { readOnce(); return b['text/plain'] ? b['text/plain'].text() : '' };
1839
+ setTimeout(restore, ${PASTE_UNREAD_MS});
1840
+ } catch {}
1841
+ const target = document.activeElement || document.body;
1842
+ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true, composed: true });
1843
+ const taken = !target.dispatchEvent(ev);
1844
+ const words = dt.getData('text/plain');
1845
+ if (!taken && words && (target.isContentEditable || /^(INPUT|TEXTAREA)$/.test(target.tagName)))
1846
+ document.execCommand('insertText', false, words);
1847
+ return 'ok';
1848
+ } catch (e) {
1849
+ return 'failed: ' + (e && e.message ? e.message : String(e));
1850
+ }
1851
+ })()`;
1852
+ }
1853
+
1854
+ // src/record/settle.ts
1855
+ var APP_QUIET_MS = 300;
1856
+ var APP_SETTLE_CAP_MS = 4e3;
1857
+ var SETTLE_VERBS = /* @__PURE__ */ new Set([
1858
+ "hover",
1859
+ "click",
1860
+ "type",
1861
+ "press",
1862
+ "scroll",
1863
+ "move",
1864
+ "drag",
1865
+ "paste"
1866
+ ]);
1867
+ var CHANGE_AGE_PROBE = `(() => {
1868
+ const w = window;
1869
+ if (!w.__vosChangeAt) {
1870
+ w.__vosChangeAt = { at: Date.now() };
1871
+ new MutationObserver(() => { w.__vosChangeAt.at = Date.now() })
1872
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
1873
+ return -1;
1874
+ }
1875
+ return Date.now() - w.__vosChangeAt.at;
1876
+ })()`;
1877
+ function settleOn(step, scriptDefault) {
1878
+ if (!SETTLE_VERBS.has(step.do)) return false;
1879
+ return step.settle ?? scriptDefault ?? false;
1880
+ }
1881
+ async function appSettled(page, frames, opts) {
1882
+ const quiet = opts.quietMs ?? APP_QUIET_MS;
1883
+ const age = await page.evaluate(CHANGE_AGE_PROBE).catch(() => -1);
1884
+ const frameAge = opts.sinceFrame?.() ?? null;
1885
+ if (typeof age === "number" && age >= quiet && (frameAge === null || frameAge >= quiet))
1886
+ return { settled: true, ms: 0 };
1887
+ const counted = {
1888
+ evaluate: async (script) => {
1889
+ const n = await page.evaluate(script).catch(() => 0);
1890
+ return (typeof n === "number" && n > 0 ? n : 0) + frames();
1891
+ }
1892
+ };
1893
+ return domSettled(counted, {
1894
+ sleep: opts.sleep,
1895
+ ...opts.now ? { now: opts.now } : {},
1896
+ quietMs: quiet,
1897
+ capMs: opts.capMs ?? APP_SETTLE_CAP_MS,
1898
+ tickMs: 50
1899
+ });
1900
+ }
1901
+
1902
+ // src/record/peers.ts
1903
+ var PEER_VERBS = [
1904
+ "wait",
1905
+ "move",
1906
+ "hover",
1907
+ "click",
1908
+ "type",
1909
+ "press",
1910
+ "scroll",
1911
+ "drag",
1912
+ "expect"
1913
+ ];
1914
+ var MAX_PEERS = 4;
1915
+ var MAX_PEER_STEPS = 200;
1916
+ var TAKE_ONLY = {
1917
+ guard: "a guard is pressed only on the filmed page, under --confirm",
1918
+ read: "read keeps words from the filmed page",
1919
+ caption: "a caption is a beat of the film, which is the take\u2019s",
1920
+ settle: "settle reads the filmed page\u2019s frames",
1921
+ zoom: "a zoom mark picks a moment on the cursor track, and a peer is never on it"
1922
+ };
1923
+ var PEER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
1924
+ function validatePeers(value, takeSteps, validate) {
1925
+ if (!Array.isArray(value))
1926
+ return [
1927
+ "peers must be an array of { id, url?, viewport?, storageState?, steps }"
1928
+ ];
1929
+ const errors = [];
1930
+ if (value.length > MAX_PEERS)
1931
+ errors.push(
1932
+ `peers names ${value.length} peers; a take holds at most ${MAX_PEERS} (each is a whole browser)`
1933
+ );
1934
+ const takeIds = new Set(
1935
+ (Array.isArray(takeSteps) ? takeSteps : []).map((s) => s?.id).filter((id) => typeof id === "string")
1936
+ );
1937
+ const seen = /* @__PURE__ */ new Set();
1938
+ value.forEach((raw, i) => {
1939
+ const at = `peers[${i}]`;
1940
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1941
+ errors.push(`${at}: must be an object`);
1942
+ return;
1943
+ }
1944
+ const p = raw;
1945
+ if (typeof p.id !== "string" || !PEER_ID.test(p.id))
1946
+ errors.push(
1947
+ `${at}: id must be a short word (letters, digits, - and _, at most 32), which lines and meta.peers name the peer by`
1948
+ );
1949
+ else if (seen.has(p.id)) errors.push(`${at}: duplicate peer id "${p.id}"`);
1950
+ else seen.add(p.id);
1951
+ if (p.url !== void 0) {
1952
+ if (typeof p.url !== "string") errors.push(`${at}: url must be a string`);
1953
+ else {
1954
+ const problem = urlSchemeProblem(p.url);
1955
+ if (problem) errors.push(`${at}: url ${problem}`);
1956
+ }
1957
+ }
1958
+ if (p.viewport !== void 0) {
1959
+ const v = p.viewport;
1960
+ if (typeof v !== "object" || v === null || typeof v.width !== "number" || typeof v.height !== "number")
1961
+ errors.push(`${at}: viewport must be { width, height }`);
1962
+ }
1963
+ if (p.storageState !== void 0 && (typeof p.storageState !== "string" || !p.storageState.trim()))
1964
+ errors.push(
1965
+ `${at}: storageState must name a Playwright storage state file (the second person's sign-in)`
1966
+ );
1967
+ for (const k of Object.keys(p))
1968
+ if (!["id", "url", "viewport", "storageState", "steps"].includes(k))
1969
+ errors.push(
1970
+ `${at}: a peer takes id, url, viewport, storageState and steps, not ${k}`
1971
+ );
1972
+ if (!Array.isArray(p.steps) || p.steps.length === 0) {
1973
+ errors.push(`${at}: steps must be a non-empty array`);
1974
+ return;
1975
+ }
1976
+ if (p.steps.length > MAX_PEER_STEPS)
1977
+ errors.push(
1978
+ `${at}: steps holds ${p.steps.length} steps; a peer takes at most ${MAX_PEER_STEPS}`
1979
+ );
1980
+ const steps = p.steps;
1981
+ steps.forEach((rawStep, j) => {
1982
+ const sat = `${at}.steps[${j}]`;
1983
+ if (typeof rawStep !== "object" || rawStep === null) return;
1984
+ const s = rawStep;
1985
+ if (typeof s.do === "string" && !PEER_VERBS.includes(s.do))
1986
+ errors.push(
1987
+ `${sat}: a peer step is one of ${PEER_VERBS.join(", ")}; ${/^[aeiou]/.test(s.do) ? "an" : "a"} ${s.do} belongs to the filmed page${s.do === "await" ? " (wait for what the peer did with the take\u2019s own await or expect)" : ""}`
1988
+ );
1989
+ for (const [k, why] of Object.entries(TAKE_ONLY))
1990
+ if (s[k] !== void 0)
1991
+ errors.push(`${sat}: no ${k} on a peer step: ${why}`);
1992
+ if (s.do === "drag" && typeof s.path === "string")
1993
+ errors.push(
1994
+ `${sat}: a peer draws its path inline (an array of points), never from a file`
1995
+ );
1996
+ if (s.with !== void 0 && s.after !== void 0)
1997
+ errors.push(
1998
+ `${sat}: with or after, not both (with starts as the take's step starts, after once it ends)`
1999
+ );
2000
+ for (const k of ["with", "after"]) {
2001
+ const v = s[k];
2002
+ if (v === void 0) continue;
2003
+ if (typeof v !== "string" || !v.trim())
2004
+ errors.push(`${sat}: ${k} must name a take step by its id`);
2005
+ else if (!takeIds.has(v))
2006
+ errors.push(
2007
+ `${sat}: ${k} "${v}" names no take step (the take's steps with ids: ${takeIds.size ? [...takeIds].join(", ") : "none"}); give the take step an id`
2008
+ );
2009
+ }
2010
+ });
2011
+ for (const e of validate(steps))
2012
+ errors.push(e.replace(/^steps\[/, `${at}.steps[`));
2013
+ });
2014
+ return errors;
2015
+ }
2016
+ function peersHostedRefusal(peers) {
2017
+ if (!Array.isArray(peers) || !peers.length) return null;
2018
+ return "the script has peers (more browsers on the page, off camera), and a hosted take runs one browser: record it on this machine";
2019
+ }
2020
+ var StepSignals = class {
2021
+ started = /* @__PURE__ */ new Map();
2022
+ ended = /* @__PURE__ */ new Map();
2023
+ began = deferred();
2024
+ closed = false;
2025
+ constructor(ids) {
2026
+ for (const id of ids) {
2027
+ this.started.set(id, deferred());
2028
+ this.ended.set(id, deferred());
2029
+ }
2030
+ }
2031
+ begin() {
2032
+ this.began.resolve(true);
2033
+ }
2034
+ start(id) {
2035
+ if (id) this.started.get(id)?.resolve(true);
2036
+ }
2037
+ end(id) {
2038
+ if (id) this.ended.get(id)?.resolve(true);
2039
+ }
2040
+ close() {
2041
+ this.closed = true;
2042
+ this.began.resolve(false);
2043
+ for (const d of [...this.started.values(), ...this.ended.values()])
2044
+ d.resolve(false);
2045
+ }
2046
+ get isClosed() {
2047
+ return this.closed;
2048
+ }
2049
+ /** When a step with this trigger may start: true, or false for never. */
2050
+ when(trigger) {
2051
+ const d = trigger.with ? this.started.get(trigger.with) : trigger.after ? this.ended.get(trigger.after) : this.began;
2052
+ if (!d) return { ready: Promise.resolve(false), already: false };
2053
+ return { ready: d.promise, already: d.settled };
2054
+ }
2055
+ };
2056
+ function deferred() {
2057
+ let resolve = () => {
2058
+ };
2059
+ const d = {
2060
+ promise: new Promise((r) => {
2061
+ resolve = r;
2062
+ }),
2063
+ resolve: (v) => {
2064
+ if (d.settled) return;
2065
+ d.settled = true;
2066
+ resolve(v);
2067
+ },
2068
+ settled: false
2069
+ };
2070
+ return d;
2071
+ }
2072
+ var PEER_VALUE_MAX = 2048;
2073
+ function peerValue(last) {
2074
+ if (!("value" in last)) return void 0;
2075
+ const value = last.value;
2076
+ const json = JSON.stringify(value);
2077
+ if (json === void 0) return void 0;
2078
+ return json.length <= PEER_VALUE_MAX ? value : `${json.slice(0, PEER_VALUE_MAX - 1)}\u2026`;
2079
+ }
2080
+ var EXPECT_TIMEOUT_S = 5;
2081
+ var EXPECT_EVERY_MS = 150;
2082
+ async function runPeer(peer, rawPage, signals, opts) {
2083
+ const bound = new StepBound();
2084
+ const page = bounded(rawPage, bound);
2085
+ const { clock, log } = opts;
2086
+ const run = { id: peer.id, steps: [], notRun: [] };
2087
+ const cur = { x: 24, y: 24 };
2088
+ const moveTo = async (tx, ty) => {
2089
+ const from = { ...cur };
2090
+ const dist = Math.hypot(tx - from.x, ty - from.y);
2091
+ await clockMotion(
2092
+ opts.dry ? 0 : pointerTravelMs(dist),
2093
+ async (u) => {
2094
+ cur.x = from.x + (tx - from.x) * u;
2095
+ cur.y = from.y + (ty - from.y) * u;
2096
+ await page.mouse.move(cur.x, cur.y);
2097
+ },
2098
+ clock
2099
+ );
2100
+ };
2101
+ const spotOf = async (s) => {
2102
+ if (s.selector === void 0) return { x: s.x ?? cur.x, y: s.y ?? cur.y };
2103
+ const loc = page.locator(s.selector).first();
2104
+ try {
2105
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
2106
+ await loc.scrollIntoViewIfNeeded();
2107
+ const bb = await loc.boundingBox();
2108
+ return bb ? { x: bb.x + bb.width / 2, y: bb.y + bb.height / 2 } : null;
2109
+ } catch {
2110
+ return null;
2111
+ }
2112
+ };
2113
+ const press = async (how) => {
2114
+ const held = [];
2115
+ try {
2116
+ for (const m of how.modifiers ?? []) {
2117
+ const key = resolveModifier(m);
2118
+ await page.keyboard.down(key);
2119
+ held.push(key);
2120
+ }
2121
+ await clock.sleep(PRESS_LEAD_MS);
2122
+ const count = how.count ?? 1;
2123
+ for (let n = 1; n <= count; n++) {
2124
+ if (n > 1) await clock.sleep(MULTI_CLICK_GAP_MS);
2125
+ const o = {
2126
+ ...how.button && how.button !== "left" ? { button: how.button } : {},
2127
+ ...n > 1 ? { clickCount: n } : {}
2128
+ };
2129
+ const has = Object.keys(o).length > 0;
2130
+ await (has ? page.mouse.down(o) : page.mouse.down());
2131
+ await clock.sleep(PRESS_HOLD_MS);
2132
+ await (has ? page.mouse.up(o) : page.mouse.up());
2133
+ }
2134
+ } finally {
2135
+ for (const key of held.reverse())
2136
+ await page.keyboard.up(key).catch(() => void 0);
2137
+ }
2138
+ };
2139
+ for (const [i, step] of peer.steps.entries()) {
2140
+ const tag = `peer ${peer.id} #${i}${step.id ? ` ${step.id}` : ""}`;
2141
+ const trigger = signals.when(step);
2142
+ const go = await trigger.ready;
2143
+ if (!go) {
2144
+ run.notRun = peer.steps.slice(i).map((_, k) => i + k);
2145
+ log(
2146
+ ` (${tag} ${step.do}: not run: ${step.with ? `the take's step ${step.with} never started` : step.after ? `the take's step ${step.after} never ended` : "the take ended first"})`
2147
+ );
2148
+ break;
2149
+ }
2150
+ const late = (step.with !== void 0 || step.after !== void 0) && trigger.already;
2151
+ const tStart = opts.footageMs();
2152
+ let selector;
2153
+ let reason = null;
2154
+ let value;
2155
+ bound.start(
2156
+ stepBoundMs(
2157
+ step,
2158
+ askedMs(step),
2159
+ { expectS: EXPECT_TIMEOUT_S, awaitS: 0, downloadS: 0 }
2160
+ )
2161
+ );
2162
+ try {
2163
+ switch (step.do) {
2164
+ case "wait":
2165
+ await clock.sleep(step.ms);
2166
+ break;
2167
+ case "move":
2168
+ await moveTo(step.x, step.y);
2169
+ break;
2170
+ case "hover": {
2171
+ selector = step.selector;
2172
+ const spot = await spotOf(step);
2173
+ if (!spot) reason = `'${step.selector}' never appeared`;
2174
+ else {
2175
+ await moveTo(spot.x, spot.y);
2176
+ await clock.sleep(step.ms ?? 700);
2177
+ }
2178
+ break;
2179
+ }
2180
+ case "click": {
2181
+ selector = step.selector;
2182
+ const spot = await spotOf(step);
2183
+ if (!spot) {
2184
+ reason = `'${step.selector}' never appeared`;
2185
+ break;
2186
+ }
2187
+ await moveTo(spot.x, spot.y);
2188
+ await press(step);
2189
+ await clock.sleep(step.ms ?? SETTLE_MS.click);
2190
+ break;
2191
+ }
2192
+ case "type": {
2193
+ selector = step.selector;
2194
+ if (step.focus !== false) {
2195
+ const spot = await spotOf(step);
2196
+ if (!spot) {
2197
+ reason = `'${step.selector}' never appeared`;
2198
+ break;
2199
+ }
2200
+ await moveTo(spot.x, spot.y);
2201
+ await press({});
2202
+ }
2203
+ if (step.at === "end") await page.keyboard.press("End");
2204
+ else if (step.at === "start") await page.keyboard.press("Home");
2205
+ if (step.replace) await page.keyboard.press("ControlOrMeta+a");
2206
+ await clockTyping(
2207
+ [...step.text],
2208
+ opts.dry ? 0 : step.delayMs ?? 40,
2209
+ (ch) => page.keyboard.type(ch),
2210
+ clock
2211
+ );
2212
+ await clock.sleep(step.ms ?? SETTLE_MS.type);
2213
+ break;
2214
+ }
2215
+ case "press": {
2216
+ selector = step.selector;
2217
+ if (step.selector !== void 0) {
2218
+ const spot = await spotOf(step);
2219
+ if (!spot) {
2220
+ reason = `'${step.selector}' never appeared`;
2221
+ break;
2222
+ }
2223
+ }
2224
+ const keys = pressSequence(step);
2225
+ for (const [k, key] of keys.entries()) {
2226
+ if (k > 0) await clock.sleep(KEY_SEQUENCE_GAP_MS);
2227
+ await page.keyboard.press(resolveChord(key));
2228
+ }
2229
+ await clock.sleep(step.ms ?? SETTLE_MS.press);
2230
+ break;
2231
+ }
2232
+ case "scroll":
2233
+ await page.mouse.wheel(0, step.dy);
2234
+ await clock.sleep(step.ms ?? 200);
2235
+ break;
2236
+ case "drag": {
2237
+ selector = step.selector;
2238
+ const read = step.path !== void 0 ? readPath(step.path) : null;
2239
+ if (read && "problems" in read) {
2240
+ reason = `its path ${read.problems.join("; ")}`;
2241
+ break;
2242
+ }
2243
+ const held = [];
2244
+ try {
2245
+ if (read) {
2246
+ const sched = schedulePath(read, step.ms);
2247
+ await moveTo(sched[0].x, sched[0].y);
2248
+ for (const m of step.modifiers ?? []) {
2249
+ const key = resolveModifier(m);
2250
+ await page.keyboard.down(key);
2251
+ held.push(key);
2252
+ }
2253
+ await page.mouse.down();
2254
+ const t0 = clock.now();
2255
+ for (const p of sched.slice(1)) {
2256
+ const wait = t0 + (opts.dry ? 0 : p.at) - clock.now();
2257
+ if (wait > 0) await clock.sleep(wait);
2258
+ cur.x = p.x;
2259
+ cur.y = p.y;
2260
+ await page.mouse.move(p.x, p.y);
2261
+ }
2262
+ } else {
2263
+ const start = await spotOf(step);
2264
+ if (!start) {
2265
+ reason = `'${step.selector}' never appeared`;
2266
+ break;
2267
+ }
2268
+ await moveTo(start.x, start.y);
2269
+ for (const m of step.modifiers ?? []) {
2270
+ const key = resolveModifier(m);
2271
+ await page.keyboard.down(key);
2272
+ held.push(key);
2273
+ }
2274
+ await page.mouse.down();
2275
+ const from = { ...cur };
2276
+ const tx = step.tx;
2277
+ const ty = step.ty;
2278
+ await clockMotion(
2279
+ opts.dry ? 0 : step.ms ?? 700,
2280
+ async (u) => {
2281
+ cur.x = from.x + (tx - from.x) * u;
2282
+ cur.y = from.y + (ty - from.y) * u;
2283
+ await page.mouse.move(cur.x, cur.y);
2284
+ },
2285
+ clock
2286
+ );
2287
+ }
2288
+ await page.mouse.up();
2289
+ } finally {
2290
+ for (const key of held.reverse())
2291
+ await page.keyboard.up(key).catch(() => void 0);
2292
+ }
2293
+ await clock.sleep(DRAG_HOLD_MS);
2294
+ break;
2295
+ }
2296
+ case "expect": {
2297
+ const cond = step;
2298
+ selector = step.selector ?? step.gone;
2299
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
2300
+ const res = await pollCondition(page, cond, {
2301
+ timeoutMs,
2302
+ everyMs: EXPECT_EVERY_MS,
2303
+ sleep: clock.sleep
2304
+ });
2305
+ value = peerValue(res.last);
2306
+ if (!res.ok) reason = conditionMiss(cond, res.last, timeoutMs);
2307
+ else log(` ${tag} expect ${sayCondition(cond)}: holds`);
2308
+ break;
2309
+ }
2310
+ default:
2311
+ reason = `a peer does not ${step.do}`;
2312
+ }
2313
+ } catch (e) {
2314
+ reason = e instanceof Error ? e.message.split("\n")[0] : String(e);
2315
+ }
2316
+ const hang = bound.end();
2317
+ if (hang.hung)
2318
+ reason = `the page stopped answering: this ${step.do} ran past its bound`;
2319
+ if (reason) log(` (${tag} ${step.do}: ${reason})`);
2320
+ else if (step.do !== "expect")
2321
+ log(` ${tag} ${step.do}${selector ? ` ${selector}` : ""}`);
2322
+ run.steps.push({
2323
+ step: i,
2324
+ ...step.id ? { id: step.id } : {},
2325
+ do: step.do,
2326
+ ...step.with ? { with: step.with } : {},
2327
+ ...step.after ? { after: step.after } : {},
2328
+ ...selector ? { selector } : {},
2329
+ tStart: +(tStart / 1e3).toFixed(3),
2330
+ tEnd: +(opts.footageMs() / 1e3).toFixed(3),
2331
+ ...reason ? { skipped: true, reason } : {},
2332
+ ...late ? { late: true } : {},
2333
+ ...value !== void 0 ? { value } : {}
2334
+ });
2335
+ }
2336
+ return run;
2337
+ }
2338
+
2339
+ // src/record/actions.ts
2340
+ var STEP_GUARDS = ["spend"];
2341
+ var ZOOM_MARK_VERBS = [
2342
+ "click",
2343
+ "drag",
2344
+ "type",
2345
+ "paste",
2346
+ "press",
2347
+ "hover"
2348
+ ];
2349
+ var GUARDABLE = /* @__PURE__ */ new Set(["click", "press", "type"]);
2350
+ var EXPECT_TIMEOUT_S2 = 5;
2351
+ var AWAIT_TIMEOUT_S = 600;
2352
+ var AWAIT_POLL_S = 2;
2353
+ var DOWNLOAD_TIMEOUT_S = 30;
2354
+ var VERBS2 = /* @__PURE__ */ new Set([
2355
+ "wait",
2356
+ "hover",
2357
+ "click",
2358
+ "type",
2359
+ "press",
2360
+ "scroll",
2361
+ "move",
2362
+ "drag",
2363
+ "expect",
2364
+ "await",
2365
+ "download",
2366
+ "paste"
2367
+ ]);
2368
+ var PRESS_KEYS = [
2369
+ "Escape",
2370
+ "Enter",
2371
+ "Tab",
2372
+ "Backspace",
2373
+ "Delete",
2374
+ "ArrowUp",
2375
+ "ArrowDown",
2376
+ "ArrowLeft",
2377
+ "ArrowRight",
2378
+ "Home",
2379
+ "End",
2380
+ "PageUp",
2381
+ "PageDown",
2382
+ "Space"
2383
+ ];
2384
+ function validateActions(value) {
2385
+ const errors = [];
2386
+ if (typeof value !== "object" || value === null)
2387
+ return ["actions file must be a JSON object"];
2388
+ const obj = value;
2389
+ if (obj.url !== void 0) {
2390
+ if (typeof obj.url !== "string") errors.push("url must be a string");
2391
+ else {
2392
+ const problem = urlSchemeProblem(obj.url);
2393
+ if (problem) errors.push(`url ${problem}`);
2394
+ }
2395
+ }
2396
+ if (obj.viewport !== void 0) {
2397
+ const v = obj.viewport;
2398
+ if (typeof v !== "object" || v === null) {
2399
+ errors.push("viewport must be { width, height }");
2400
+ } else {
2401
+ const vp = v;
2402
+ if (typeof vp.width !== "number" || typeof vp.height !== "number") {
2403
+ errors.push("viewport must be { width, height }");
2404
+ }
2405
+ }
2406
+ }
2407
+ if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
2408
+ const ready = readyProblem(obj.ready);
2409
+ if (ready) errors.push(ready);
2410
+ if (obj.settle !== void 0 && typeof obj.settle !== "boolean")
2411
+ errors.push("settle must be true or false");
2412
+ if (obj.mask !== void 0) {
2413
+ if (!Array.isArray(obj.mask)) {
2414
+ errors.push("mask must be an array of { selector, as?, text? }");
2415
+ } else {
2416
+ obj.mask.forEach((raw, i) => {
2417
+ const at = `mask[${i}]`;
2418
+ const m = raw ?? {};
2419
+ if (typeof m.selector !== "string" || !m.selector.trim())
2420
+ errors.push(`${at}: needs a selector`);
2421
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
2422
+ errors.push(`${at}: as must be "blur" or "text"`);
2423
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
2424
+ errors.push(`${at}: as "text" needs the text to show instead`);
2425
+ if (m.as !== "text" && m.text !== void 0)
2426
+ errors.push(`${at}: text is only read with as "text"`);
2427
+ });
2428
+ }
2429
+ }
2430
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
2431
+ errors.push("steps must be a non-empty array");
2432
+ return errors;
2433
+ }
2434
+ if (obj.peers !== void 0)
2435
+ errors.push(
2436
+ ...validatePeers(
2437
+ obj.peers,
2438
+ obj.steps,
2439
+ (steps) => validateActions({ steps })
2440
+ )
2441
+ );
2442
+ const seenIds = /* @__PURE__ */ new Set();
2443
+ obj.steps.forEach((raw, i) => {
2444
+ const at = `steps[${i}]`;
2445
+ if (typeof raw !== "object" || raw === null) {
2446
+ errors.push(`${at}: must be an object`);
2447
+ return;
2448
+ }
2449
+ const s = raw;
2450
+ if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
2451
+ errors.push(`${at}: read must be a selector`);
2452
+ if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
2453
+ errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
2454
+ return;
2455
+ }
2456
+ if (s.id !== void 0) {
2457
+ if (typeof s.id !== "string" || !s.id.trim()) {
2458
+ errors.push(`${at}: id must be a non-empty string`);
2459
+ } else if (seenIds.has(s.id)) {
2460
+ errors.push(
2461
+ `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
2462
+ );
2463
+ } else {
2464
+ seenIds.add(s.id);
2465
+ }
2466
+ }
2467
+ if (s.do === "type" && typeof s.selector !== "string")
2468
+ errors.push(`${at}: type needs a selector`);
2469
+ if (s.do === "hover" || s.do === "click") {
2470
+ const point = s.x !== void 0 || s.y !== void 0;
2471
+ if (point && s.selector !== void 0)
2472
+ errors.push(
2473
+ `${at}: ${s.do} takes a selector or x and y (a viewport point), not both`
2474
+ );
2475
+ else if (point) {
2476
+ if (!isCoord(s.x) || !isCoord(s.y))
2477
+ errors.push(
2478
+ `${at}: ${s.do} x and y must both be numbers of CSS px, 0 or more`
2479
+ );
2480
+ } else if (typeof s.selector !== "string")
2481
+ errors.push(
2482
+ `${at}: ${s.do} needs a selector, or x and y (a viewport point)`
2483
+ );
2484
+ }
2485
+ if (s.do === "wait" && typeof s.ms !== "number")
2486
+ errors.push(`${at}: wait needs ms`);
2487
+ if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
2488
+ errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
2489
+ if (s.do === "type" && typeof s.text !== "string")
2490
+ errors.push(`${at}: type needs text`);
2491
+ if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
2492
+ errors.push(`${at}: type focus must be true or false`);
2493
+ if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
2494
+ errors.push(`${at}: type replace must be true or false`);
2495
+ if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
2496
+ errors.push(`${at}: type at must be "end" or "start"`);
2497
+ if (s.do === "press") {
2498
+ if (s.key !== void 0 && s.keys !== void 0)
2499
+ errors.push(
2500
+ `${at}: press takes key or keys, not both (keys says everything key does)`
2501
+ );
2502
+ else if (s.keys !== void 0) {
2503
+ const list = Array.isArray(s.keys) ? s.keys : [s.keys];
2504
+ if (!list.length)
2505
+ errors.push(`${at}: press keys must name at least one key`);
2506
+ else if (list.length > MAX_KEYS)
2507
+ errors.push(`${at}: press keys names more than ${MAX_KEYS} keys`);
2508
+ list.forEach((k, j) => {
2509
+ const problem = chordProblem(k);
2510
+ if (problem)
2511
+ errors.push(
2512
+ `${at}: press keys${Array.isArray(s.keys) ? `[${j}]` : ""} ${problem}`
2513
+ );
2514
+ });
2515
+ } else if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
891
2516
  errors.push(
892
- `${at}: press needs a key, one of ${PRESS_KEYS.join(", ")}; words are typed with type`
2517
+ `${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`
893
2518
  );
894
2519
  if (s.selector !== void 0 && typeof s.selector !== "string")
895
2520
  errors.push(`${at}: press selector must be a string`);
896
2521
  }
2522
+ if (s.do === "click") {
2523
+ errors.push(...modifiersProblem(at, "click", s.modifiers));
2524
+ if (s.count !== void 0 && s.count !== 1 && s.count !== 2 && s.count !== 3)
2525
+ errors.push(
2526
+ `${at}: click count must be 1, 2 (a double click) or 3 (a triple click)`
2527
+ );
2528
+ if (s.button !== void 0 && !CLICK_BUTTONS.includes(s.button))
2529
+ errors.push(`${at}: click button must be "left", "right" or "middle"`);
2530
+ }
2531
+ if (s.settle !== void 0) {
2532
+ if (typeof s.settle !== "boolean")
2533
+ errors.push(`${at}: settle must be true or false`);
2534
+ else if (!SETTLE_VERBS.has(s.do))
2535
+ errors.push(
2536
+ `${at}: settle follows a gesture (hover, click, type, press, scroll, move, drag, paste); a ${s.do} sends the page nothing to catch up on`
2537
+ );
2538
+ }
2539
+ if (s.do === "paste") {
2540
+ const given = ["text", "file"].filter(
2541
+ (k) => s[k] !== void 0
2542
+ );
2543
+ if (given.length !== 1)
2544
+ errors.push(
2545
+ `${at}: paste needs exactly one of text (words) or file (an image, an SVG or a text file beside the actions file)`
2546
+ );
2547
+ if (s.text !== void 0 && typeof s.text !== "string")
2548
+ errors.push(`${at}: paste text must be a string`);
2549
+ if (s.file !== void 0) {
2550
+ 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";
2551
+ if (problem) errors.push(`${at}: paste file ${problem}`);
2552
+ }
2553
+ if (s.selector !== void 0 && !isFilled(s.selector))
2554
+ errors.push(`${at}: paste selector must be a non-empty string`);
2555
+ }
897
2556
  if (s.do === "scroll" && typeof s.dy !== "number")
898
2557
  errors.push(`${at}: scroll needs dy`);
899
2558
  if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
900
2559
  errors.push(`${at}: move needs x and y (CSS px)`);
901
2560
  }
2561
+ if (s.zoom !== void 0) {
2562
+ if (typeof s.zoom !== "boolean")
2563
+ errors.push(`${at}: zoom must be true or false`);
2564
+ else if (!ZOOM_MARK_VERBS.includes(s.do))
2565
+ errors.push(
2566
+ `${at}: zoom marks a gesture (${ZOOM_MARK_VERBS.join(", ")}); a ${s.do} shows nothing to zoom on`
2567
+ );
2568
+ }
902
2569
  if (s.guard !== void 0) {
903
2570
  if (!STEP_GUARDS.includes(s.guard))
904
2571
  errors.push(
@@ -914,16 +2581,17 @@ function validateActions(value) {
914
2581
  );
915
2582
  }
916
2583
  if (s.do === "expect") {
917
- const given = ["selector", "text", "gone"].filter(
2584
+ const given = ["selector", "text", "gone", "js"].filter(
918
2585
  (k) => s[k] !== void 0
919
2586
  );
920
2587
  if (given.length !== 1)
921
2588
  errors.push(
922
- `${at}: expect needs exactly one of selector, text or gone${given.length ? ` (it has ${given.join(" and ")})` : ""}`
2589
+ `${at}: expect needs exactly one of selector, text, gone or js${given.length ? ` (it has ${given.join(" and ")})` : ""}`
923
2590
  );
924
2591
  for (const k of given)
925
2592
  if (!isFilled(s[k]))
926
2593
  errors.push(`${at}: expect ${k} must be a non-empty string`);
2594
+ errors.push(...equalsProblem(`${at}: expect`, s));
927
2595
  if (s.enabled !== void 0) {
928
2596
  if (typeof s.enabled !== "boolean")
929
2597
  errors.push(`${at}: expect enabled must be true or false`);
@@ -938,28 +2606,29 @@ function validateActions(value) {
938
2606
  const u = s.until;
939
2607
  if (typeof u !== "object" || u === null || Array.isArray(u)) {
940
2608
  errors.push(
941
- `${at}: await needs until, one of { "selector" }, { "text" } or { "gone" }`
2609
+ `${at}: await needs until, one of { "selector" }, { "text" }, { "gone" } or { "js" }`
942
2610
  );
943
2611
  } else {
944
2612
  const until = u;
945
2613
  const keys = Object.keys(until);
946
2614
  const known = keys.filter(
947
- (k) => k === "selector" || k === "text" || k === "gone"
2615
+ (k) => k === "selector" || k === "text" || k === "gone" || k === "js"
948
2616
  );
949
- const unknown = keys.filter((k) => !known.includes(k));
2617
+ const unknown = keys.filter((k) => !known.includes(k) && k !== "equals");
950
2618
  if (unknown.length)
951
2619
  errors.push(
952
- `${at}: await until takes selector, text or gone, not ${unknown.join(", ")}`
2620
+ `${at}: await until takes selector, text, gone or js (with equals), not ${unknown.join(", ")}`
953
2621
  );
954
2622
  if (known.length !== 1)
955
2623
  errors.push(
956
- `${at}: await until needs exactly one of selector, text or gone${known.length ? ` (it has ${known.join(" and ")})` : ""}`
2624
+ `${at}: await until needs exactly one of selector, text, gone or js${known.length ? ` (it has ${known.join(" and ")})` : ""}`
957
2625
  );
958
2626
  for (const k of known) {
959
2627
  const v = until[k];
960
2628
  if (typeof v !== "string" || !v.trim())
961
2629
  errors.push(`${at}: await until.${k} must be a non-empty string`);
962
2630
  }
2631
+ errors.push(...equalsProblem(`${at}: await until`, until));
963
2632
  }
964
2633
  errors.push(...secondsProblem(at, "await timeout", s.timeout));
965
2634
  errors.push(...secondsProblem(at, "await poll", s.poll));
@@ -985,18 +2654,74 @@ function validateActions(value) {
985
2654
  }
986
2655
  }
987
2656
  if (s.do === "drag") {
988
- if (typeof s.tx !== "number" || typeof s.ty !== "number") {
989
- errors.push(`${at}: drag needs tx and ty (CSS px)`);
990
- }
991
- const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
992
- if (!hasStart)
993
- errors.push(
994
- `${at}: drag needs a selector OR x and y as the start point`
2657
+ if (s.path !== void 0) {
2658
+ const clash = ["selector", "x", "y", "tx", "ty"].filter(
2659
+ (k) => s[k] !== void 0
995
2660
  );
2661
+ if (clash.length)
2662
+ errors.push(
2663
+ `${at}: a drag with a path starts at its first point and ends at its last, so it takes no ${clash.join(", ")}`
2664
+ );
2665
+ if (typeof s.path === "string") {
2666
+ const problem = fileNameProblem(s.path);
2667
+ if (problem)
2668
+ errors.push(
2669
+ `${at}: drag path ${problem} (a file beside the actions file)`
2670
+ );
2671
+ } else {
2672
+ const read = readPath(s.path);
2673
+ if ("problems" in read)
2674
+ for (const p of read.problems) errors.push(`${at}: drag path ${p}`);
2675
+ }
2676
+ } else {
2677
+ if (typeof s.tx !== "number" || typeof s.ty !== "number") {
2678
+ errors.push(`${at}: drag needs tx and ty (CSS px), or a path`);
2679
+ }
2680
+ const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
2681
+ if (!hasStart)
2682
+ errors.push(
2683
+ `${at}: drag needs a selector OR x and y as the start point`
2684
+ );
2685
+ }
2686
+ errors.push(...modifiersProblem(at, "drag", s.modifiers));
996
2687
  }
997
2688
  });
998
2689
  return errors;
999
2690
  }
2691
+ var isCoord = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
2692
+ function modifiersProblem(at, verb, v) {
2693
+ if (v === void 0) return [];
2694
+ if (!Array.isArray(v) || v.some((m) => !DRAG_MODIFIERS.includes(m)))
2695
+ return [
2696
+ `${at}: ${verb} modifiers must be a list of ${DRAG_MODIFIERS.map((m) => `"${m}"`).join(", ")} (Mod is Meta on macOS, Control elsewhere)`
2697
+ ];
2698
+ if (new Set(v).size !== v.length)
2699
+ return [`${at}: ${verb} modifiers names a modifier twice`];
2700
+ return [];
2701
+ }
2702
+ function equalsProblem(at, s) {
2703
+ if (s.equals === void 0) return [];
2704
+ if (s.js === void 0)
2705
+ return [
2706
+ `${at} equals is the value a js expression must answer, so it needs js`
2707
+ ];
2708
+ if (!isJsonValue(s.equals, 0))
2709
+ return [
2710
+ `${at} equals must be a JSON value (a number, a string, true, false, null, a list or an object of them)`
2711
+ ];
2712
+ return [];
2713
+ }
2714
+ function isJsonValue(v, depth) {
2715
+ if (depth > 32) return false;
2716
+ if (v === null || typeof v === "string" || typeof v === "boolean") return true;
2717
+ if (typeof v === "number") return Number.isFinite(v);
2718
+ if (Array.isArray(v)) return v.every((x) => isJsonValue(x, depth + 1));
2719
+ if (typeof v === "object")
2720
+ return Object.values(v).every(
2721
+ (x) => isJsonValue(x, depth + 1)
2722
+ );
2723
+ return false;
2724
+ }
1000
2725
  var isFilled = (v) => typeof v === "string" && v.trim().length > 0;
1001
2726
  function secondsProblem(at, what, v) {
1002
2727
  if (v === void 0) return [];
@@ -1049,6 +2774,8 @@ function guardVerdict(actions, confirm) {
1049
2774
  }
1050
2775
  function hostedRefusals(actions) {
1051
2776
  const out = [];
2777
+ const peers = peersHostedRefusal(actions.peers);
2778
+ if (peers) out.push(peers);
1052
2779
  for (const g of guardedSteps(actions))
1053
2780
  out.push(
1054
2781
  `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}`
@@ -1058,103 +2785,141 @@ function hostedRefusals(actions) {
1058
2785
  out.push(
1059
2786
  `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/`
1060
2787
  );
1061
- });
1062
- return out;
1063
- }
1064
-
1065
- // src/record/waits.ts
1066
- function visibleTextScript(text) {
1067
- return `(() => { const b = document.body; return !!b && (b.innerText || '').includes(${JSON.stringify(text)}) })()`;
1068
- }
1069
- async function readCondition(page, c) {
1070
- try {
1071
- if (c.text !== void 0)
1072
- return { holds: await page.evaluate(visibleTextScript(c.text)) };
1073
- if (c.gone !== void 0)
1074
- return { holds: !await page.locator(c.gone).first().isVisible() };
1075
- if (c.selector !== void 0) {
1076
- const loc = page.locator(c.selector).first();
1077
- const visible = await loc.isVisible();
1078
- if (!visible) return { holds: false, visible };
1079
- if (c.enabled === void 0) return { holds: true, visible };
1080
- const enabled = await loc.isEnabled({ timeout: 1e3 });
1081
- return { holds: enabled === c.enabled, visible };
1082
- }
1083
- } catch {
1084
- }
1085
- return { holds: false };
2788
+ if (s.do === "paste")
2789
+ out.push(
2790
+ `steps[${i}] is a paste, and a hosted browser has no clipboard to paste from: record it on this machine`
2791
+ );
2792
+ if (s.do === "expect" && s.js !== void 0 || s.do === "await" && s.until.js !== void 0)
2793
+ out.push(
2794
+ `steps[${i}] ${s.do === "expect" ? "checks" : "waits on"} a js expression, which runs as code in the page, and a hosted browser runs no script it was handed: record it on this machine, or check what the page shows (selector, text or gone)`
2795
+ );
2796
+ if (s.do === "drag" && typeof s.path === "string")
2797
+ out.push(
2798
+ `steps[${i}] draws a path from the file "${s.path}", and a hosted take carries the script alone: write the points inline as path`
2799
+ );
2800
+ });
2801
+ return out;
1086
2802
  }
1087
- async function pollCondition(page, c, opts) {
1088
- const now = opts.now ?? Date.now;
1089
- const start = now();
1090
- const reads = Math.max(1, opts.confirm?.reads ?? 1);
1091
- for (let first = true; ; first = false) {
1092
- if (!first && opts.between) await opts.between();
1093
- let last = await readCondition(page, c);
1094
- for (let n = 1; last.holds && n < reads; n++) {
1095
- await opts.sleep(opts.confirm?.gapMs ?? 0);
1096
- last = await readCondition(page, c);
1097
- }
1098
- const ms = now() - start;
1099
- if (last.holds) return { ok: true, ms, last };
1100
- const left = opts.timeoutMs - ms;
1101
- if (left <= 0) return { ok: false, ms, last };
1102
- await opts.sleep(Math.min(opts.everyMs, left));
1103
- }
2803
+ function scriptFiles(actions) {
2804
+ const out = [];
2805
+ actions.steps.forEach((s, i) => {
2806
+ if (s.do === "drag" && typeof s.path === "string")
2807
+ out.push({ step: i, name: s.path, as: "path" });
2808
+ if (s.do === "paste" && typeof s.file === "string")
2809
+ out.push({ step: i, name: s.file, as: "paste" });
2810
+ });
2811
+ return out;
1104
2812
  }
1105
- var DOM_QUIET_MS = 800;
1106
- var DOM_SETTLE_CAP_MS = 5e3;
1107
- var DOM_CHANGE_PROBE = `(() => {
1108
- if (!window.__vosDomChanges) {
1109
- window.__vosDomChanges = { n: 0 };
1110
- new MutationObserver((list) => { window.__vosDomChanges.n += list.length })
1111
- .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
2813
+
2814
+ // src/record/scriptFiles.ts
2815
+ async function loadScriptFiles(actions, source) {
2816
+ const loaded = { paths: /* @__PURE__ */ new Map(), pastes: /* @__PURE__ */ new Map() };
2817
+ const named = scriptFiles(actions);
2818
+ if (!named.length) return loaded;
2819
+ if (!source) {
2820
+ const f = named[0];
2821
+ return {
2822
+ 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.`
2823
+ };
1112
2824
  }
1113
- return window.__vosDomChanges.n;
1114
- })()`;
1115
- async function domSettled(page, opts) {
1116
- const now = opts.now ?? Date.now;
1117
- const quiet = opts.quietMs ?? DOM_QUIET_MS;
1118
- const cap = opts.capMs ?? DOM_SETTLE_CAP_MS;
1119
- const start = now();
1120
- let seen = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1121
- let since = start;
1122
- for (; ; ) {
1123
- const t = now();
1124
- if (t - since >= quiet) return { settled: true, ms: t - start };
1125
- if (t - start >= cap) return { settled: false, ms: t - start };
1126
- await opts.sleep(opts.tickMs ?? 100);
1127
- const n = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1128
- if (n !== seen) {
1129
- seen = n;
1130
- since = now();
2825
+ for (const f of named) {
2826
+ const at = `steps[${f.step}]`;
2827
+ const bad = fileNameProblem(f.name);
2828
+ if (bad) return { problem: `${at}: ${bad}. Nothing was recorded.` };
2829
+ let bytes;
2830
+ try {
2831
+ bytes = await source.read(f.name);
2832
+ } catch (e) {
2833
+ const why = e instanceof Error ? e.message : String(e);
2834
+ return {
2835
+ 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 with its media brings its pasted files home beside its script; one pushed before they travelled with it does not). Nothing was recorded.` : `${at} names "${f.name}", which could not be read beside the actions file (${why}). Nothing was recorded.`
2836
+ };
2837
+ }
2838
+ if (f.as === "path") {
2839
+ let raw;
2840
+ try {
2841
+ raw = JSON.parse(new TextDecoder().decode(bytes));
2842
+ } catch {
2843
+ return {
2844
+ problem: `${at}: the path file "${f.name}" is not JSON (an array of [x, y] or [x, y, tMs] points). Nothing was recorded.`
2845
+ };
2846
+ }
2847
+ const path = readPath(raw);
2848
+ if ("problems" in path)
2849
+ return {
2850
+ problem: `${at}: the path file "${f.name}" ${path.problems.join("; ")}. Nothing was recorded.`
2851
+ };
2852
+ loaded.paths.set(f.step, path);
2853
+ } else {
2854
+ if (bytes.length > PASTE_MAX_BYTES)
2855
+ return {
2856
+ problem: `${at}: "${f.name}" is larger than ${PASTE_MAX_BYTES / 1024 / 1024} MB, more than a paste carries. Nothing was recorded.`
2857
+ };
2858
+ loaded.pastes.set(f.step, {
2859
+ bytes,
2860
+ type: pasteTypeOf(f.name) ?? "application/octet-stream",
2861
+ name: f.name
2862
+ });
1131
2863
  }
1132
2864
  }
2865
+ return loaded;
1133
2866
  }
1134
- function sayDuration(ms) {
1135
- const s = Math.round(ms / 100) / 10;
1136
- if (s < 60) return `${s} s`;
1137
- const m = Math.floor(s / 60);
1138
- const rest = Math.round(s - m * 60);
1139
- return rest ? `${m} min ${rest} s` : `${m} min`;
2867
+ function inlinePoints(path) {
2868
+ return path.points.map((p) => {
2869
+ if (path.timed)
2870
+ return p.pressure !== void 0 ? [p.x, p.y, p.at, p.pressure] : [p.x, p.y, p.at];
2871
+ return p.pressure !== void 0 ? { x: p.x, y: p.y, pressure: p.pressure } : [p.x, p.y];
2872
+ });
1140
2873
  }
1141
- function sayCondition(c) {
1142
- if (c.text !== void 0) return `text "${c.text}" to show`;
1143
- if (c.gone !== void 0) return `'${c.gone}' to go`;
1144
- if (c.enabled === true) return `'${c.selector}' to be enabled`;
1145
- if (c.enabled === false) return `'${c.selector}' to be disabled`;
1146
- return `'${c.selector}' to appear`;
2874
+ function inlineScript(actions, files) {
2875
+ if (!files.paths.size) return actions;
2876
+ return {
2877
+ ...actions,
2878
+ steps: actions.steps.map((s, i) => {
2879
+ const read = files.paths.get(i);
2880
+ if (s.do !== "drag" || typeof s.path !== "string" || !read) return s;
2881
+ return { ...s, path: inlinePoints(read) };
2882
+ })
2883
+ };
1147
2884
  }
1148
- function conditionMiss(c, last, ms) {
1149
- const t = sayDuration(ms);
1150
- if (c.text !== void 0)
1151
- return `no visible text contained "${c.text}" for ${t}`;
1152
- if (c.gone !== void 0) return `'${c.gone}' was still there after ${t}`;
1153
- if (last.visible && c.enabled === true)
1154
- return `'${c.selector}' stayed disabled for ${t}`;
1155
- if (last.visible && c.enabled === false)
1156
- return `'${c.selector}' stayed enabled for ${t}`;
1157
- return `'${c.selector}' never appeared in ${t}`;
2885
+
2886
+ // src/record/pasteStep.ts
2887
+ function toBase64(bytes) {
2888
+ let bin = "";
2889
+ for (let i = 0; i < bytes.length; i += 32768)
2890
+ bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
2891
+ return btoa(bin);
2892
+ }
2893
+ function pasteItemOf(step, stepIdx, files) {
2894
+ if (step.text !== void 0) return { text: step.text };
2895
+ const f = files.pastes.get(stepIdx);
2896
+ if (!f) return null;
2897
+ return { b64: toBase64(f.bytes), type: f.type, name: f.name };
2898
+ }
2899
+ async function runPaste(page, context, item, press) {
2900
+ if (item.b64 !== void 0 && item.type?.startsWith("image/")) {
2901
+ const done2 = await page.evaluate(syntheticPasteScript(item, { original: true })).catch((e) => `failed: ${e instanceof Error ? e.message : e}`);
2902
+ return done2 === "ok" ? { via: "file" } : { failed: `the page's paste failed (${done2.replace(/^failed: /, "")})` };
2903
+ }
2904
+ let origin;
2905
+ try {
2906
+ origin = new URL(page.url()).origin;
2907
+ } catch {
2908
+ origin = void 0;
2909
+ }
2910
+ if (context.grantPermissions && origin && origin !== "null")
2911
+ await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin }).catch(() => void 0);
2912
+ const staged = await page.evaluate(stageClipboardScript(item)).catch((e) => `refused: ${e instanceof Error ? e.message : e}`);
2913
+ if (staged === "ok") {
2914
+ await press("ControlOrMeta+v");
2915
+ return { via: "clipboard" };
2916
+ }
2917
+ const why = staged.replace(/^refused: /, "");
2918
+ const done = await page.evaluate(syntheticPasteScript(item)).catch((e) => `failed: ${e instanceof Error ? e.message : e}`);
2919
+ if (done === "ok") return { via: "page", why };
2920
+ return {
2921
+ failed: `this browser has no clipboard to paste from (${why}), and the page's own paste failed too (${done.replace(/^failed: /, "")})`
2922
+ };
1158
2923
  }
1159
2924
 
1160
2925
  // src/record/outputs.ts
@@ -1228,7 +2993,7 @@ function fetchInPageScript(url) {
1228
2993
  }
1229
2994
 
1230
2995
  // src/record/recorder.ts
1231
- var EXPECT_EVERY_MS = 100;
2996
+ var EXPECT_EVERY_MS2 = 100;
1232
2997
  var SRC_WAIT_MS = 8e3;
1233
2998
  var AWAIT_HEARTBEAT_MS = 3e4;
1234
2999
  var MIN_SKIP_MS = 250;
@@ -1318,6 +3083,25 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1318
3083
  `steps[${download}] is a download, and this recorder has nowhere to keep a file. Nothing was recorded.`
1319
3084
  );
1320
3085
  }
3086
+ const read = await loadScriptFiles(actions, opts.files);
3087
+ if ("problem" in read) throw new RecordRefusedError(read.problem);
3088
+ const files = read;
3089
+ const peers = actions.peers ?? [];
3090
+ const peerStates = /* @__PURE__ */ new Map();
3091
+ for (const p of peers) {
3092
+ if (p.storageState === void 0) continue;
3093
+ if (!opts.peerStorageState)
3094
+ throw new RecordRefusedError(
3095
+ `peer ${p.id} signs in from the storage state "${p.storageState}", and this recorder has nowhere to read one from. Nothing was recorded.`
3096
+ );
3097
+ try {
3098
+ peerStates.set(p.id, await opts.peerStorageState(p.storageState));
3099
+ } catch (e) {
3100
+ throw new RecordRefusedError(
3101
+ `peer ${p.id}'s storage state "${p.storageState}" could not be read (${e instanceof Error ? e.message : String(e)}). Nothing was recorded.`
3102
+ );
3103
+ }
3104
+ }
1321
3105
  const vw = actions.viewport?.width ?? 1280;
1322
3106
  const vh = actions.viewport?.height ?? 720;
1323
3107
  const context = opts.context ?? await browser.newContext({
@@ -1329,11 +3113,17 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1329
3113
  await applyHeaders(context, opts.headers, url, log);
1330
3114
  const masks = actions.mask ?? [];
1331
3115
  if (masks.length) await context.addInitScript(maskInitScript(masks));
3116
+ await context.addInitScript(MEDIA_WATCH_SCRIPT);
3117
+ const soundLog = new PageSoundLog();
1332
3118
  const exposureLog = new ExposureLog(masks);
1333
- const page = await context.newPage();
3119
+ const noticeLog = new NoticeLog();
3120
+ const rawPage = await context.newPage();
3121
+ const pageBound = new StepBound();
3122
+ const page = bounded(rawPage, pageBound);
1334
3123
  const scanExposures = async (step) => {
1335
3124
  const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
1336
3125
  exposureLog.add(step, scan);
3126
+ noticeLog.add(step, scan?.notices);
1337
3127
  };
1338
3128
  const pageErrors = new PageErrorLog(opts.verbose === true);
1339
3129
  page.on("console", (m) => {
@@ -1341,20 +3131,23 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1341
3131
  const line = pageErrors.add(m.text());
1342
3132
  if (line) log(line);
1343
3133
  });
1344
- log(`goto ${url}`);
1345
- let navTimeout = false;
1346
- let response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
1347
- navTimeout = true;
1348
- log(" (networkidle timeout \u2014 continuing)");
1349
- return null;
3134
+ const readyMode = actions.ready ?? DEFAULT_READY;
3135
+ const open = (target) => untilReady(page, readyMode, (o) => page.goto(target, o), {
3136
+ sleep: realSleep
1350
3137
  });
1351
- await sleep(800);
3138
+ log(`goto ${url}`);
3139
+ const opened = await open(url);
3140
+ let response = opened.response;
3141
+ const ready = opened.ready;
3142
+ log(` ${readyLine(ready)}`);
3143
+ const navTimeout = ready.mode === "networkidle" && ready.capped;
1352
3144
  const setup = actions.setup ?? [];
1353
3145
  if (setup.length) {
1354
3146
  log(`setup: ${setup.length} step(s), off camera`);
1355
3147
  const result = await runSetup(page, setup, {
1356
3148
  sleep: realSleep,
1357
- env: opts.env ?? {}
3149
+ env: opts.env ?? {},
3150
+ goto: (target) => open(target)
1358
3151
  });
1359
3152
  for (const line of result.lines) log(` ${line}`);
1360
3153
  if (result.failed) {
@@ -1363,8 +3156,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1363
3156
  throw new SetupError(result.failed);
1364
3157
  }
1365
3158
  log(`goto ${url} (after setup)`);
1366
- response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
1367
- await sleep(800);
3159
+ response = (await open(url)).response;
1368
3160
  }
1369
3161
  let wall = null;
1370
3162
  if (opts.onArrival) {
@@ -1385,7 +3177,64 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1385
3177
  }
1386
3178
  }
1387
3179
  await scanExposures(-1);
1388
- const cdp = await context.newCDPSession(page);
3180
+ await page.evaluate(MEDIA_DRAIN_PROBE).catch(() => null);
3181
+ if (actions.steps.some((s) => settleOn(s, actions.settle)))
3182
+ await page.evaluate(CHANGE_AGE_PROBE).catch(() => void 0);
3183
+ const peerPages = [];
3184
+ const closePeers = async () => {
3185
+ const bound = new StepBound();
3186
+ bound.start(CLOSE_BOUND_MS);
3187
+ await Promise.all(
3188
+ peerPages.map((p) => bound.race(p.context.close().catch(() => {
3189
+ })))
3190
+ );
3191
+ bound.end();
3192
+ };
3193
+ for (const peer of peers) {
3194
+ const peerUrl = peer.url ?? url;
3195
+ const pctx = await browser.newContext({
3196
+ viewport: peer.viewport ?? { width: vw, height: vh },
3197
+ deviceScaleFactor: 1,
3198
+ ...peerStates.has(peer.id) ? { storageState: peerStates.get(peer.id) } : {}
3199
+ });
3200
+ if (opts.headers && Object.keys(opts.headers).length)
3201
+ await applyHeaders(pctx, opts.headers, peerUrl, log);
3202
+ const ppage = await pctx.newPage();
3203
+ peerPages.push({ peer, context: pctx, page: ppage });
3204
+ const peerErrors = new PageErrorLog(opts.verbose === true);
3205
+ ppage.on("console", (m) => {
3206
+ if (m.type() !== "error") return;
3207
+ const line = peerErrors.add(m.text());
3208
+ if (line) log(` peer ${peer.id}: ${line.trim()}`);
3209
+ });
3210
+ log(`peer ${peer.id}: goto ${peerUrl} (off camera)`);
3211
+ const popened = await untilReady(
3212
+ ppage,
3213
+ readyMode,
3214
+ (o) => ppage.goto(peerUrl, o),
3215
+ { sleep: realSleep }
3216
+ );
3217
+ const seen = await ppage.evaluate(WALL_PROBE).catch(() => null);
3218
+ const peerWall = wallVerdict({
3219
+ askedUrl: peerUrl,
3220
+ landedUrl: ppage.url(),
3221
+ ...popened.response ? { status: popened.response.status() } : {},
3222
+ passwordFields: seen?.passwordFields ?? 0,
3223
+ newPasswordField: seen?.newPasswordField ?? false,
3224
+ oneTimeCodeField: seen?.oneTimeCodeField ?? false
3225
+ });
3226
+ if (peerWall?.level === "hard") {
3227
+ await closePeers();
3228
+ await context.close().catch(() => {
3229
+ });
3230
+ throw new RecordRefusedError(
3231
+ `peer ${peer.id} landed on ${peerWall.landed}, not ${peerWall.asked} (${peerWall.kind}): a peer acts as someone signed in, so give it a storageState of the second person's sign-in (beside actions.json). Nothing was recorded.`
3232
+ );
3233
+ }
3234
+ log(` peer ${peer.id}: ${readyLine(popened.ready)}`);
3235
+ }
3236
+ const cdp = await context.newCDPSession(rawPage);
3237
+ if (!dry) await fitSurfaceToViewport(cdp, vw, vh);
1389
3238
  const frames = [];
1390
3239
  let frameIdx = 0;
1391
3240
  let t0 = 0;
@@ -1430,6 +3279,10 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1430
3279
  return { out, at, ms };
1431
3280
  };
1432
3281
  const emit = (e) => events.push({ t: now(), ...e });
3282
+ const drainSound = async (step) => {
3283
+ const drain = await page.evaluate(MEDIA_DRAIN_PROBE).catch(() => null);
3284
+ soundLog.add(drain, Date.now(), step);
3285
+ };
1433
3286
  await page.mouse.move(cur.x, cur.y);
1434
3287
  emit({ x: cur.x, y: cur.y, type: "move" });
1435
3288
  const clock = { now: () => Date.now(), sleep };
@@ -1521,27 +3374,58 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1521
3374
  ...type ? { contentType: type } : {}
1522
3375
  };
1523
3376
  };
1524
- const clickAt = async (rect) => {
1525
- await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
1526
- await sleep(PRESS_LEAD_MS);
1527
- emit({
1528
- x: Math.round(cur.x),
1529
- y: Math.round(cur.y),
1530
- type: "down",
1531
- button: 0,
1532
- rect
1533
- });
1534
- await page.mouse.down();
1535
- await sleep(PRESS_HOLD_MS);
1536
- await page.mouse.up();
1537
- emit({
1538
- x: Math.round(cur.x),
1539
- y: Math.round(cur.y),
1540
- type: "up",
1541
- button: 0,
1542
- rect
1543
- });
3377
+ const clickAt = async (at, how = {}) => {
3378
+ const rect = "w" in at ? at : void 0;
3379
+ await moveTo(
3380
+ rect ? rect.x + rect.w / 2 : at.x,
3381
+ rect ? rect.y + rect.h / 2 : at.y
3382
+ );
3383
+ const held = [];
3384
+ try {
3385
+ for (const m of how.modifiers ?? []) {
3386
+ const key = resolveModifier(m);
3387
+ await page.keyboard.down(key);
3388
+ held.push(key);
3389
+ }
3390
+ await sleep(PRESS_LEAD_MS);
3391
+ const count = how.count ?? 1;
3392
+ const button = buttonNumber(how.button);
3393
+ for (let n = 1; n <= count; n++) {
3394
+ if (n > 1) await sleep(MULTI_CLICK_GAP_MS);
3395
+ const press = {
3396
+ ...how.button && how.button !== "left" ? { button: how.button } : {},
3397
+ ...n > 1 ? { clickCount: n } : {}
3398
+ };
3399
+ const hasOpts = Object.keys(press).length > 0;
3400
+ emit({
3401
+ x: Math.round(cur.x),
3402
+ y: Math.round(cur.y),
3403
+ type: "down",
3404
+ button,
3405
+ ...rect ? { rect } : {}
3406
+ });
3407
+ await (hasOpts ? page.mouse.down(press) : page.mouse.down());
3408
+ await sleep(n < count ? PRESS_HOLD_MS / 2 : PRESS_HOLD_MS);
3409
+ await (hasOpts ? page.mouse.up(press) : page.mouse.up());
3410
+ emit({
3411
+ x: Math.round(cur.x),
3412
+ y: Math.round(cur.y),
3413
+ type: "up",
3414
+ button,
3415
+ ...rect ? { rect } : {}
3416
+ });
3417
+ }
3418
+ } finally {
3419
+ for (const key of held.reverse())
3420
+ await page.keyboard.up(key).catch(() => void 0);
3421
+ }
1544
3422
  };
3423
+ const keyPing = (rect) => emit({
3424
+ x: Math.round(rect ? rect.x + rect.w / 2 : cur.x),
3425
+ y: Math.round(rect ? rect.y + rect.h / 2 : cur.y),
3426
+ type: "key",
3427
+ ...rect ? { rect } : {}
3428
+ });
1545
3429
  const waitSettled = async () => {
1546
3430
  if (dry) return null;
1547
3431
  const pressAt = now();
@@ -1563,9 +3447,27 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1563
3447
  const skips = [];
1564
3448
  const outputs = [];
1565
3449
  const outputNames = /* @__PURE__ */ new Set();
3450
+ const overruns = [];
1566
3451
  let stopped = null;
1567
3452
  let unpressedGuard = null;
1568
3453
  let capped = false;
3454
+ const signals = new StepSignals(
3455
+ actions.steps.map((s) => s.id).filter((id) => !!id)
3456
+ );
3457
+ const lanes = peerPages.map(
3458
+ ({ peer, page: ppage }) => runPeer(peer, ppage, signals, {
3459
+ clock: { now: () => Date.now(), sleep },
3460
+ footageMs: () => now(),
3461
+ log,
3462
+ dry
3463
+ }).catch((e) => {
3464
+ log(
3465
+ ` (peer ${peer.id}: stopped: ${e instanceof Error ? e.message : String(e)})`
3466
+ );
3467
+ return { id: peer.id, steps: [], notRun: [] };
3468
+ })
3469
+ );
3470
+ signals.begin();
1569
3471
  for (const [stepIdx, step] of actions.steps.entries()) {
1570
3472
  if (capReached(now(), maxSeconds)) {
1571
3473
  capped = true;
@@ -1580,6 +3482,16 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1580
3482
  return out;
1581
3483
  };
1582
3484
  const skippedBefore = skipped.length;
3485
+ const paced = {
3486
+ v: null
3487
+ };
3488
+ const timed = async (plannedMs, fn) => {
3489
+ const t = Date.now();
3490
+ const out = await fn();
3491
+ paced.v = { plannedMs, tookMs: Date.now() - t };
3492
+ return out;
3493
+ };
3494
+ let askedOverride = null;
1583
3495
  const urlBefore = page.url();
1584
3496
  let stepRect = null;
1585
3497
  const extra = {};
@@ -1595,7 +3507,13 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1595
3507
  if (reason) extra.reason = reason;
1596
3508
  };
1597
3509
  if (step.guard) extra.guard = step.guard;
1598
- const rehearseGuard = async (sel) => {
3510
+ if (typeof step.zoom === "boolean") extra.zoom = step.zoom;
3511
+ const keepValue = (last) => {
3512
+ if (!("value" in last)) return;
3513
+ const kept = keptValue(last.value);
3514
+ if (kept !== void 0) extra.value = kept;
3515
+ };
3516
+ const rehearseGuard = async (sel, point) => {
1599
3517
  if (!step.guard || !dry) return false;
1600
3518
  const rect = sel ? await boxOf(sel) : null;
1601
3519
  stepRect = rect;
@@ -1603,8 +3521,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1603
3521
  else {
1604
3522
  if (rect)
1605
3523
  await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
3524
+ else if (point) await gesture(() => moveTo(point.x, point.y));
1606
3525
  log(
1607
- `${tag}: guarded (${step.guard}), ${rect ? "hovered" : "left alone"}, not pressed`
3526
+ `${tag}: guarded (${step.guard}), ${rect || point ? "hovered" : "left alone"}, not pressed`
1608
3527
  );
1609
3528
  }
1610
3529
  unpressedGuard = stepIdx;
@@ -1618,14 +3537,41 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1618
3537
  );
1619
3538
  return true;
1620
3539
  };
3540
+ const boundMs = stepBoundMs(
3541
+ step,
3542
+ askedMs(step),
3543
+ {
3544
+ expectS: EXPECT_TIMEOUT_S2,
3545
+ awaitS: AWAIT_TIMEOUT_S,
3546
+ downloadS: DOWNLOAD_TIMEOUT_S
3547
+ }
3548
+ );
3549
+ opts.onEvent?.({
3550
+ event: "step",
3551
+ phase: "start",
3552
+ i: stepIdx,
3553
+ ...step.id ? { id: step.id } : {},
3554
+ do: step.do
3555
+ });
3556
+ signals.start(step.id);
3557
+ const stepWall = Date.now();
3558
+ pageBound.start(boundMs);
1621
3559
  switch (step.do) {
1622
3560
  case "wait":
1623
3561
  await sleep(clampWait(step.ms, now(), maxSeconds));
1624
3562
  break;
1625
3563
  case "move":
1626
- await moveTo(step.x, step.y);
3564
+ await gesture(() => moveTo(step.x, step.y));
1627
3565
  break;
1628
3566
  case "hover": {
3567
+ if (step.selector === void 0) {
3568
+ const px = step.x ?? cur.x;
3569
+ const py = step.y ?? cur.y;
3570
+ await gesture(() => moveTo(px, py));
3571
+ log(`hover ${Math.round(px)},${Math.round(py)}`);
3572
+ await sleep(step.ms ?? 700);
3573
+ break;
3574
+ }
1629
3575
  const rect = await boxOf(step.selector);
1630
3576
  stepRect = rect;
1631
3577
  if (rect) {
@@ -1637,6 +3583,21 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1637
3583
  break;
1638
3584
  }
1639
3585
  case "click": {
3586
+ const how = {
3587
+ count: step.count,
3588
+ button: step.button,
3589
+ modifiers: step.modifiers
3590
+ };
3591
+ 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("+")}` : ""}`;
3592
+ if (step.selector === void 0) {
3593
+ const point = { x: step.x ?? cur.x, y: step.y ?? cur.y };
3594
+ if (await rehearseGuard(void 0, point)) break;
3595
+ await gesture(() => clickAt(point, how));
3596
+ log(`${said} at ${Math.round(point.x)},${Math.round(point.y)}`);
3597
+ const sinceChange = await gesture(() => waitSettled());
3598
+ await sleep(holdLeftMs(settleMs(step), sinceChange));
3599
+ break;
3600
+ }
1640
3601
  if (await rehearseGuard(step.selector)) break;
1641
3602
  const rect = await boxOf(step.selector);
1642
3603
  stepRect = rect;
@@ -1652,8 +3613,8 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1652
3613
  ` (${tag} click: ${step.selector} is disabled; pressed anyway)`
1653
3614
  );
1654
3615
  }
1655
- await gesture(() => clickAt(rect));
1656
- log(`click ${step.selector}`);
3616
+ await gesture(() => clickAt(rect, how));
3617
+ log(`${said} ${step.selector}`);
1657
3618
  const sinceChange = await gesture(() => waitSettled());
1658
3619
  await sleep(holdLeftMs(settleMs(step), sinceChange));
1659
3620
  } else
@@ -1677,17 +3638,21 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1677
3638
  rect
1678
3639
  });
1679
3640
  let lastPing = -Infinity;
1680
- await clockTyping(
1681
- [...step.text],
1682
- delay,
1683
- async (ch) => {
1684
- if (now() - lastPing >= 350) {
1685
- lastPing = now();
1686
- ping();
1687
- }
1688
- await page.keyboard.type(ch);
1689
- },
1690
- clock
3641
+ const chars = [...step.text];
3642
+ await timed(
3643
+ Math.max(0, chars.length - 1) * delay,
3644
+ () => clockTyping(
3645
+ chars,
3646
+ delay,
3647
+ async (ch) => {
3648
+ if (now() - lastPing >= 350) {
3649
+ lastPing = now();
3650
+ ping();
3651
+ }
3652
+ await page.keyboard.type(ch);
3653
+ },
3654
+ clock
3655
+ )
1691
3656
  );
1692
3657
  ping();
1693
3658
  log(`type ${step.text.length} chars into ${step.selector}`);
@@ -1711,14 +3676,18 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1711
3676
  break;
1712
3677
  }
1713
3678
  }
1714
- emit({
1715
- x: Math.round(rect ? rect.x + rect.w / 2 : cur.x),
1716
- y: Math.round(rect ? rect.y + rect.h / 2 : cur.y),
1717
- type: "key",
1718
- ...rect ? { rect } : {}
3679
+ const seq = pressSequence(step);
3680
+ const chords = step.keys !== void 0 ? seq.map(resolveChord) : seq;
3681
+ const plan = keySequencePlanMs(chords.length);
3682
+ if (chords.length > 1) askedOverride = plan + settleMs(step);
3683
+ await timed(plan, async () => {
3684
+ for (const [i, chord] of chords.entries()) {
3685
+ if (i > 0) await sleep(KEY_SEQUENCE_GAP_MS);
3686
+ keyPing(rect);
3687
+ await page.keyboard.press(chord);
3688
+ }
1719
3689
  });
1720
- await page.keyboard.press(step.key);
1721
- log(`press ${step.key}`);
3690
+ log(`press ${seq.join(", ")}`);
1722
3691
  const sinceChange = await gesture(() => waitSettled());
1723
3692
  await sleep(holdLeftMs(settleMs(step), sinceChange));
1724
3693
  break;
@@ -1749,9 +3718,19 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1749
3718
  break;
1750
3719
  }
1751
3720
  case "drag": {
3721
+ let path = null;
3722
+ if (typeof step.path === "string") {
3723
+ const read2 = files.paths.get(stepIdx);
3724
+ if (read2) path = schedulePath(read2, step.ms);
3725
+ } else if (step.path !== void 0) {
3726
+ const read2 = readPath(step.path);
3727
+ if (!("problems" in read2)) path = schedulePath(read2, step.ms);
3728
+ }
1752
3729
  let start = null;
1753
3730
  let rect;
1754
- if (step.selector) {
3731
+ if (path) {
3732
+ start = { x: path[0].x, y: path[0].y };
3733
+ } else if (step.selector) {
1755
3734
  const box = await boxOf(step.selector);
1756
3735
  stepRect = box;
1757
3736
  if (box) {
@@ -1769,53 +3748,160 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1769
3748
  start = { x: step.x, y: step.y };
1770
3749
  }
1771
3750
  if (!start) break;
1772
- await gesture(() => moveTo(start.x, start.y));
1773
- await gesture(() => sleep(PRESS_LEAD_MS));
1774
- emit({
1775
- x: Math.round(cur.x),
1776
- y: Math.round(cur.y),
1777
- type: "down",
1778
- button: 0,
1779
- rect
1780
- });
1781
- await page.mouse.down();
1782
- await gesture(() => sleep(PRESS_HOLD_MS));
1783
- const dur = step.ms ?? 700;
1784
- const from = { ...cur };
1785
- await clockMotion(
1786
- dur,
1787
- async (u) => {
1788
- cur.x = from.x + (step.tx - from.x) * u;
1789
- cur.y = from.y + (step.ty - from.y) * u;
1790
- await page.mouse.move(cur.x, cur.y);
1791
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
1792
- },
1793
- clock
3751
+ const from0 = start;
3752
+ await gesture(() => moveTo(from0.x, from0.y));
3753
+ const mods = step.modifiers ?? [];
3754
+ const held = [];
3755
+ try {
3756
+ for (const m of mods) {
3757
+ const key = resolveModifier(m);
3758
+ await page.keyboard.down(key);
3759
+ held.push(key);
3760
+ }
3761
+ await gesture(() => sleep(PRESS_LEAD_MS));
3762
+ const bits = modifierBits(mods, opts.platform);
3763
+ const penAt = (type, p) => cdp.send("Input.dispatchMouseEvent", {
3764
+ type,
3765
+ x: p.x,
3766
+ y: p.y,
3767
+ button: "left",
3768
+ buttons: type === "mouseReleased" ? 0 : 1,
3769
+ ...type === "mouseMoved" ? {} : { clickCount: 1 },
3770
+ pointerType: "pen",
3771
+ force: type === "mouseReleased" ? 0 : p.pressure ?? 0.5,
3772
+ modifiers: bits
3773
+ });
3774
+ let pen = path?.some((p) => p.pressure !== void 0) ?? false;
3775
+ if (pen && path) {
3776
+ try {
3777
+ await penAt("mousePressed", path[0]);
3778
+ } catch {
3779
+ pen = false;
3780
+ log(
3781
+ ` (${tag} drag: this browser takes no pen input; the path is drawn with the mouse and its pressure dropped)`
3782
+ );
3783
+ }
3784
+ }
3785
+ emit({
3786
+ x: Math.round(cur.x),
3787
+ y: Math.round(cur.y),
3788
+ type: "down",
3789
+ button: 0,
3790
+ rect
3791
+ });
3792
+ if (!pen) await page.mouse.down();
3793
+ await gesture(() => sleep(PRESS_HOLD_MS));
3794
+ if (path) {
3795
+ const pts = path;
3796
+ const total = pathDurationMs(pts);
3797
+ askedOverride = total;
3798
+ await timed(total, async () => {
3799
+ const from = Date.now();
3800
+ for (let i = 1; i < pts.length; i++) {
3801
+ const p = pts[i];
3802
+ if (!dry) {
3803
+ const wait = from + p.at - Date.now();
3804
+ if (wait > 0) await realSleep(wait);
3805
+ }
3806
+ if (pen) await penAt("mouseMoved", p);
3807
+ else await page.mouse.move(p.x, p.y);
3808
+ cur.x = p.x;
3809
+ cur.y = p.y;
3810
+ emit({ x: Math.round(p.x), y: Math.round(p.y), type: "move" });
3811
+ }
3812
+ });
3813
+ } else {
3814
+ const dur = step.ms ?? 700;
3815
+ const from = { ...cur };
3816
+ const tx = step.tx ?? from.x;
3817
+ const ty = step.ty ?? from.y;
3818
+ await timed(
3819
+ dur,
3820
+ () => clockMotion(
3821
+ dur,
3822
+ async (u) => {
3823
+ cur.x = from.x + (tx - from.x) * u;
3824
+ cur.y = from.y + (ty - from.y) * u;
3825
+ await page.mouse.move(cur.x, cur.y);
3826
+ emit({
3827
+ x: Math.round(cur.x),
3828
+ y: Math.round(cur.y),
3829
+ type: "move"
3830
+ });
3831
+ },
3832
+ clock
3833
+ )
3834
+ );
3835
+ }
3836
+ await gesture(() => sleep(settleMs({ do: "drag" })));
3837
+ if (pen) await penAt("mouseReleased", { x: cur.x, y: cur.y });
3838
+ else await page.mouse.up();
3839
+ emit({
3840
+ x: Math.round(cur.x),
3841
+ y: Math.round(cur.y),
3842
+ type: "up",
3843
+ button: 0,
3844
+ rect
3845
+ });
3846
+ } finally {
3847
+ for (const key of held.reverse())
3848
+ await page.keyboard.up(key).catch(() => void 0);
3849
+ }
3850
+ const holding = mods.length ? ` holding ${mods.join("+")}` : "";
3851
+ log(
3852
+ 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}`
1794
3853
  );
1795
- await gesture(() => sleep(settleMs({ do: "drag" })));
1796
- await page.mouse.up();
1797
- emit({
1798
- x: Math.round(cur.x),
1799
- y: Math.round(cur.y),
1800
- type: "up",
1801
- button: 0,
1802
- rect
3854
+ await gesture(() => sleep(DRAG_HOLD_MS));
3855
+ break;
3856
+ }
3857
+ case "paste": {
3858
+ const item = pasteItemOf(step, stepIdx, files);
3859
+ if (!item) {
3860
+ skip(step.file ?? "", `"${step.file}" was not read`);
3861
+ break;
3862
+ }
3863
+ let rect = null;
3864
+ if (step.selector) {
3865
+ rect = await boxOf(step.selector);
3866
+ stepRect = rect;
3867
+ if (!rect) {
3868
+ skip(step.selector);
3869
+ break;
3870
+ }
3871
+ const at = rect;
3872
+ await gesture(() => clickAt(at));
3873
+ }
3874
+ const ping = { done: false };
3875
+ const outcome = await runPaste(page, context, item, async (chord) => {
3876
+ keyPing(rect);
3877
+ ping.done = true;
3878
+ await page.keyboard.press(chord);
1803
3879
  });
3880
+ if ("failed" in outcome) {
3881
+ skip(step.selector ?? step.file ?? "paste", outcome.failed);
3882
+ log(` (${tag} paste: ${outcome.failed})`);
3883
+ break;
3884
+ }
3885
+ if (!ping.done) keyPing(rect);
3886
+ const what = step.text !== void 0 ? `${[...step.text].length} chars` : `the file ${step.file}`;
3887
+ const into = step.selector ? ` into ${step.selector}` : "";
1804
3888
  log(
1805
- `drag ${step.selector ?? `${start.x},${start.y}`} \u2192 ${step.tx},${step.ty}`
3889
+ 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}`
1806
3890
  );
1807
- await sleep(400);
3891
+ const sinceChange = await gesture(() => waitSettled());
3892
+ await sleep(holdLeftMs(settleMs(step), sinceChange));
1808
3893
  break;
1809
3894
  }
1810
3895
  case "expect": {
1811
3896
  if (notWaited()) break;
1812
3897
  const cond = step;
1813
- const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
3898
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S2) * 1e3;
1814
3899
  const res = await pollCondition(page, cond, {
1815
3900
  timeoutMs,
1816
- everyMs: EXPECT_EVERY_MS,
3901
+ everyMs: EXPECT_EVERY_MS2,
1817
3902
  sleep: realSleep
1818
3903
  });
3904
+ keepValue(res.last);
1819
3905
  if (res.ok) log(`expect ${sayCondition(cond)}: holds`);
1820
3906
  else {
1821
3907
  const reason = conditionMiss(cond, res.last, timeoutMs);
@@ -1828,14 +3914,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1828
3914
  if (notWaited()) break;
1829
3915
  const cond = step.until;
1830
3916
  const timeoutMs = (step.timeout ?? AWAIT_TIMEOUT_S) * 1e3;
1831
- const reload = step.reload ? async () => {
1832
- const opts2 = {
1833
- waitUntil: "networkidle",
1834
- timeout: Math.min(45e3, timeoutMs)
1835
- };
1836
- await (page.reload ? page.reload(opts2) : page.goto(page.url(), opts2)).catch(() => null);
1837
- await domSettled(page, { sleep: realSleep });
1838
- } : void 0;
3917
+ const reload = step.reload ? () => reloadUntilReady(page, readyMode, timeoutMs) : void 0;
1839
3918
  log(`await ${sayCondition(cond)} (off camera)\u2026`);
1840
3919
  const awaitEvent = {
1841
3920
  event: "await",
@@ -1867,6 +3946,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1867
3946
  clearInterval(beat);
1868
3947
  }
1869
3948
  const { out, at, ms } = waited;
3949
+ keepValue(out.last);
1870
3950
  opts.onEvent?.({ ...awaitEvent, phase: "end", ms, held: out.ok });
1871
3951
  extra.offCameraMs = ms;
1872
3952
  if (!out.ok) extra.timedOut = true;
@@ -1965,6 +4045,50 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1965
4045
  break;
1966
4046
  }
1967
4047
  }
4048
+ if (settleOn(step, actions.settle) && skipped.length === skippedBefore && !stopped) {
4049
+ const res = await gesture(
4050
+ () => appSettled(page, () => frames.length, {
4051
+ sleep: realSleep,
4052
+ sinceFrame: () => frames.length ? now() - frames[frames.length - 1].tMs : null
4053
+ })
4054
+ );
4055
+ extra.settleMs = res.ms;
4056
+ if (!res.settled)
4057
+ log(
4058
+ ` (${tag} settle: the page was still changing after ${sayDuration(res.ms)}; going on)`
4059
+ );
4060
+ }
4061
+ if (paced.v && !dry) {
4062
+ const over = overrunOf(paced.v.plannedMs, paced.v.tookMs);
4063
+ if (over !== null) {
4064
+ overruns.push({
4065
+ step: stepIdx,
4066
+ ...step.id ? { id: step.id } : {},
4067
+ do: step.do,
4068
+ plannedMs: Math.round(paced.v.plannedMs),
4069
+ tookMs: Math.round(paced.v.tookMs),
4070
+ overrunMs: over
4071
+ });
4072
+ extra.overrunMs = over;
4073
+ }
4074
+ }
4075
+ const hang = pageBound.end();
4076
+ if (hang.hung) {
4077
+ const reason = hangReason(step.do, hang.ms, hang.boundMs);
4078
+ const said = skipped.length > skippedBefore ? skipped.at(-1) : void 0;
4079
+ if (said) {
4080
+ said.reason = reason;
4081
+ extra.reason = reason;
4082
+ } else skip(step.selector ?? "", reason);
4083
+ log(` (${tag} ${step.do}: ${reason})`);
4084
+ if (opts.stopOnHang && !stopped)
4085
+ stopped = {
4086
+ step: stepIdx,
4087
+ ...step.id ? { id: step.id } : {},
4088
+ reason: `${tag} ${step.do}: ${reason}, so the take stopped there`
4089
+ };
4090
+ }
4091
+ pageBound.start(POST_STEP_READ_MS);
1968
4092
  if (step.guard && !dry && skipped.length === skippedBefore) {
1969
4093
  confirmed.push(step.id ?? String(stepIdx));
1970
4094
  extra.confirmed = true;
@@ -1999,25 +4123,67 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1999
4123
  paces.push({
2000
4124
  step: stepIdx,
2001
4125
  do: step.do,
2002
- askedMs: askedMs(step),
4126
+ askedMs: askedOverride ?? askedMs(step),
2003
4127
  gestureMs,
2004
4128
  wallMs: now() - stepStart
2005
4129
  });
2006
4130
  await scanExposures(stepIdx);
4131
+ await drainSound(stepIdx);
4132
+ pageBound.end();
4133
+ const stepSpan = stepSpans[stepSpans.length - 1];
4134
+ signals.end(step.id);
4135
+ opts.onEvent?.({
4136
+ event: "step",
4137
+ phase: "end",
4138
+ i: stepIdx,
4139
+ ...step.id ? { id: step.id } : {},
4140
+ do: step.do,
4141
+ ms: Date.now() - stepWall,
4142
+ ...stepSpan?.skipped ? { skipped: true } : {},
4143
+ ...hang.hung ? { hung: true } : {}
4144
+ });
2007
4145
  if (stopped) break;
2008
4146
  }
4147
+ signals.close();
4148
+ const peerRuns = await Promise.all(lanes);
4149
+ for (const pr of peerRuns)
4150
+ for (const ps of pr.steps)
4151
+ if (ps.skipped)
4152
+ skipped.push({
4153
+ step: ps.step,
4154
+ ...ps.id ? { id: ps.id } : {},
4155
+ do: ps.do,
4156
+ selector: ps.selector ?? "",
4157
+ ...ps.reason ? { reason: ps.reason } : {},
4158
+ peer: pr.id
4159
+ });
2009
4160
  if (!capped && !stopped) await sleep(TRAILING_HOLD_MS);
2010
4161
  if (capped || capReached(now(), maxSeconds)) {
2011
4162
  capped = true;
2012
4163
  log(` ${cappedLine(maxSeconds)}`);
2013
4164
  }
4165
+ pageBound.start(POST_STEP_READ_MS * 2);
4166
+ await drainSound();
4167
+ const endWall = Date.now();
2014
4168
  const durationMs = capped ? Math.min(now(), maxSeconds * 1e3) : now();
4169
+ const heard = soundLog.heard(takeClock, endWall, durationMs);
2015
4170
  await cdp.send("Page.stopScreencast").catch(() => {
2016
4171
  });
4172
+ const media = await keepPageSound(
4173
+ heard,
4174
+ dry ? void 0 : opts.sounds,
4175
+ fetchSource,
4176
+ log
4177
+ );
2017
4178
  await sleep(200);
2018
- const pageTitle = await page.title().catch(() => "");
4179
+ const pageTitle = await page.title().catch(() => "") ?? "";
2019
4180
  for (const line of pageErrors.repeats()) log(line);
2020
- await context.close();
4181
+ pageBound.end();
4182
+ pageBound.start(CLOSE_BOUND_MS);
4183
+ await pageBound.race(context.close().catch(() => {
4184
+ }));
4185
+ pageBound.end();
4186
+ await closePeers();
2021
4187
  await sinkChain;
2022
4188
  const meta = {
2023
4189
  dpr: 1,
@@ -2040,9 +4206,18 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2040
4206
  steps: stepSpans,
2041
4207
  ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
2042
4208
  ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
4209
+ ...noticeLog.notices().length ? { notices: noticeLog.notices() } : {},
2043
4210
  ...masks.length ? { masks: exposureLog.masks(masks) } : {},
2044
4211
  ...skips.length ? { skips } : {},
2045
- ...outputs.length ? { outputs } : {}
4212
+ ...outputs.length ? { outputs } : {},
4213
+ ...media.length ? { media } : {},
4214
+ ...peerRuns.length ? {
4215
+ peers: peerRuns.map((pr) => ({
4216
+ id: pr.id,
4217
+ steps: pr.steps,
4218
+ ...pr.notRun.length ? { notRun: pr.notRun } : {}
4219
+ }))
4220
+ } : {}
2046
4221
  };
2047
4222
  const freezes = [];
2048
4223
  for (let i = 1; i < frames.length; i++) {
@@ -2058,9 +4233,11 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2058
4233
  const frozenMs = freezes.reduce((sum, f) => sum + f.ms, 0);
2059
4234
  const freezePct = durationMs > 0 ? Math.round(frozenMs / durationMs * 100) : 0;
2060
4235
  const dead = deadTime(stepSpans, frames, events, durationMs);
2061
- if (deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
4236
+ if (!dry && deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
2062
4237
  const pace = paceReport(paces);
2063
4238
  if (!dry) log(` ${paceLine(pace)}`);
4239
+ const overrun = overrunLine(overruns);
4240
+ if (overrun) log(` WARNING: ${overrun}`);
2064
4241
  return {
2065
4242
  events,
2066
4243
  frames,
@@ -2068,8 +4245,10 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2068
4245
  pace,
2069
4246
  skipped,
2070
4247
  navTimeout,
4248
+ ready,
2071
4249
  wall,
2072
4250
  exposures: exposureLog.exposures(),
4251
+ notices: noticeLog.notices(),
2073
4252
  masks: exposureLog.masks(masks),
2074
4253
  freezes,
2075
4254
  freezePct,
@@ -2079,11 +4258,51 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2079
4258
  confirmed,
2080
4259
  skips,
2081
4260
  outputs,
4261
+ media,
2082
4262
  stopped,
2083
4263
  pageErrors: pageErrors.entries(),
2084
- actions: { ...actions, url }
4264
+ overruns,
4265
+ // Self-contained: a path a file held is inline, so the take re-records
4266
+ // from its own script with the file gone.
4267
+ actions: { ...inlineScript(actions, files), url },
4268
+ peers: peerRuns
2085
4269
  };
2086
4270
  }
4271
+ async function keepPageSound(heard, sink, fetchSource, log) {
4272
+ const kept = /* @__PURE__ */ new Map();
4273
+ const used = /* @__PURE__ */ new Set();
4274
+ for (const h of heard) {
4275
+ if (!h.meta.intervals.length) continue;
4276
+ const stretches = h.meta.intervals.length;
4277
+ log(
4278
+ ` page sound: ${h.meta.src || "a media element"} heard ${stretches === 1 ? "once" : `${stretches} times`}`
4279
+ );
4280
+ if (!sink || kept.has(h.src)) continue;
4281
+ try {
4282
+ const got = await fetchSource(h.src);
4283
+ const name = outputName(
4284
+ void 0,
4285
+ got.suggested,
4286
+ `sound-${kept.size + 1}${extForType(got.contentType)}`,
4287
+ used
4288
+ );
4289
+ used.add(name);
4290
+ kept.set(h.src, {
4291
+ file: await sink.save(name, got.bytes, got.contentType)
4292
+ });
4293
+ } catch (e) {
4294
+ const why = e instanceof Error ? e.message : String(e);
4295
+ kept.set(h.src, { fetchError: why });
4296
+ log(` (page sound not kept: ${why})`);
4297
+ }
4298
+ }
4299
+ return heard.map((h) => ({ ...h.meta, ...kept.get(h.src) ?? {} }));
4300
+ }
4301
+ var POST_STEP_READ_MS = 5e3;
4302
+ var CLOSE_BOUND_MS = 1e4;
4303
+ function hangReason(verb, ms, boundMs) {
4304
+ return `the page stopped answering: this ${verb} ran ${sayDuration(ms)}, past its bound of ${sayDuration(boundMs)}`;
4305
+ }
2087
4306
  var EXT_FOR_TYPE = {
2088
4307
  "video/mp4": ".mp4",
2089
4308
  "video/webm": ".webm",
@@ -2145,7 +4364,15 @@ try {
2145
4364
  fi = next
2146
4365
  const blob = await (await fetch(${JSON.stringify(o.framesBaseUrl)} + frames[fi].file + ${JSON.stringify(q)})).blob()
2147
4366
  const bmp = await createImageBitmap(blob)
2148
- c2.drawImage(bmp, 0, 0, W, H)
4367
+ // A frame is drawn at its own size, never stretched to the take's: a
4368
+ // frame of another size means the surface moved mid-take, and a
4369
+ // picture scaled to fit would be a distorted take nobody was told of.
4370
+ if (bmp.width !== W || bmp.height !== H) {
4371
+ const size = bmp.width + 'x' + bmp.height
4372
+ bmp.close()
4373
+ 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')
4374
+ }
4375
+ c2.drawImage(bmp, 0, 0)
2149
4376
  bmp.close()
2150
4377
  }
2151
4378
  await src.add(t, 1 / FPS)
@@ -2169,9 +4396,12 @@ try {
2169
4396
  // src/record/plan.ts
2170
4397
  import {
2171
4398
  DEFAULT_FRAME_STYLE,
4399
+ EXPORT_RESOLUTION_OPTIONS,
2172
4400
  planAutoSpeed,
2173
4401
  planAutoZoom,
2174
4402
  projectFromArtifact,
4403
+ ratedSegments,
4404
+ recommendedExportResolution,
2175
4405
  withBackdrop
2176
4406
  } from "@vosjs/studio-core";
2177
4407
  function planFreshTake(artifact, recordingName, opts = {}) {
@@ -2190,6 +4420,66 @@ function planFreshTake(artifact, recordingName, opts = {}) {
2190
4420
  params: doc.speedParams,
2191
4421
  activity: opts.activity
2192
4422
  });
4423
+ return withFittingExport(withPageSound(doc, artifact.meta.media));
4424
+ }
4425
+ function withFittingExport(doc) {
4426
+ if (!doc.export) return doc;
4427
+ const fits = recommendedExportResolution(doc);
4428
+ const order = EXPORT_RESOLUTION_OPTIONS;
4429
+ if (order.indexOf(fits) < order.indexOf(doc.export.resolution))
4430
+ doc.export = { ...doc.export, resolution: fits };
4431
+ return doc;
4432
+ }
4433
+ function sourceToOutput(pieces, s) {
4434
+ let acc = 0;
4435
+ for (const p of pieces) {
4436
+ const rate = p.rate && p.rate > 0 ? p.rate : 1;
4437
+ if (p.media) {
4438
+ acc += Math.max(0, p.out - p.in) / rate;
4439
+ continue;
4440
+ }
4441
+ if (s < p.in) return acc;
4442
+ if (s <= p.out) return acc + (s - p.in) / rate;
4443
+ acc += Math.max(0, p.out - p.in) / rate;
4444
+ }
4445
+ return acc;
4446
+ }
4447
+ function withPageSound(doc, media) {
4448
+ const kept = (doc.audio ?? []).filter((a) => a.source !== "page");
4449
+ const heard = (media ?? []).filter((m) => m.file && m.intervals.length);
4450
+ if (!heard.length) {
4451
+ doc.audio = kept;
4452
+ return doc;
4453
+ }
4454
+ const spans = heard.flatMap(
4455
+ (m) => m.intervals.map((iv) => ({ in: iv.at, out: iv.at + (iv.to - iv.from) }))
4456
+ );
4457
+ doc.speed = (doc.speed ?? []).filter(
4458
+ (sp) => sp.source !== "auto" || !!sp.media || !spans.some((h) => sp.in < h.out && h.in < sp.out)
4459
+ );
4460
+ const pieces = ratedSegments(doc);
4461
+ const clips = [];
4462
+ heard.forEach((m, i) => {
4463
+ const file = m.file;
4464
+ const name = file.split("/").pop() || file;
4465
+ const length = Math.max(m.duration ?? 0, ...m.intervals.map((iv) => iv.to));
4466
+ m.intervals.forEach((iv, j) => {
4467
+ clips.push({
4468
+ id: `page-${i + 1}-${j + 1}`,
4469
+ key: file,
4470
+ name,
4471
+ start: +sourceToOutput(pieces, iv.at).toFixed(3),
4472
+ in: iv.from,
4473
+ out: iv.to,
4474
+ duration: +length.toFixed(3),
4475
+ gain: m.volume ?? 1,
4476
+ fadeIn: 0,
4477
+ fadeOut: 0,
4478
+ source: "page"
4479
+ });
4480
+ });
4481
+ });
4482
+ doc.audio = [...kept, ...clips];
2193
4483
  return doc;
2194
4484
  }
2195
4485
  function splitSegmentsAtSkips(segments, skips) {
@@ -2223,24 +4513,58 @@ function splitSegmentsAtSkips(segments, skips) {
2223
4513
  return out;
2224
4514
  }
2225
4515
  export {
4516
+ APP_QUIET_MS,
4517
+ APP_SETTLE_CAP_MS,
2226
4518
  AWAIT_HEARTBEAT_MS,
2227
4519
  AWAIT_POLL_S,
2228
4520
  AWAIT_TIMEOUT_S,
4521
+ CHANGE_AGE_PROBE,
4522
+ CLICK_BUTTONS,
2229
4523
  DEAD_SHARE_WARN_PCT,
2230
4524
  DEAD_STRETCH_WARN_MS,
4525
+ DEFAULT_READY,
2231
4526
  DOM_CHANGE_PROBE,
2232
4527
  DOM_QUIET_MS,
2233
4528
  DOM_SETTLE_CAP_MS,
2234
4529
  DOWNLOAD_TIMEOUT_S,
2235
- EXPECT_TIMEOUT_S,
4530
+ DRAG_HOLD_MS,
4531
+ DRAG_MODIFIERS,
4532
+ EXPECT_TIMEOUT_S2 as EXPECT_TIMEOUT_S,
2236
4533
  EXPOSURE_ADVICE,
2237
4534
  EXPOSURE_MATCHERS_SRC,
2238
4535
  EXPOSURE_PROBE,
2239
4536
  ExposureLog,
4537
+ IDLE_CALL_MS,
4538
+ JS_SAID_MAX,
4539
+ JS_VALUE_MAX,
4540
+ KEY_ROUNDTRIP_MS,
4541
+ KEY_SEQUENCE_GAP_MS,
4542
+ MAX_KEYS,
4543
+ MAX_PATH_POINTS,
4544
+ MAX_PEERS,
4545
+ MAX_PEER_STEPS,
4546
+ MEDIA_DRAIN_PROBE,
4547
+ MEDIA_WATCH_SCRIPT,
2240
4548
  MIN_SKIP_MS,
4549
+ MODIFIERS,
2241
4550
  MOTION_TICK_MS,
4551
+ MULTI_CLICK_GAP_MS,
4552
+ NAV_TIMEOUT_MS,
4553
+ NETWORKIDLE_HYDRATION_MS,
4554
+ NOTICE_SRC,
4555
+ NoticeLog,
2242
4556
  OUTPUT_MAX_BYTES,
4557
+ OVERRUN_MIN_MS,
4558
+ OVERRUN_MIN_PLANNED_MS,
4559
+ OVERRUN_SHARE,
2243
4560
  PAGE_ERROR_LINE_CHARS,
4561
+ PASTE_MAX_BYTES,
4562
+ PASTE_READ_WINDOW_MS,
4563
+ PASTE_TYPES,
4564
+ PASTE_UNREAD_MS,
4565
+ PATH_DEFAULT_MS,
4566
+ PEER_VALUE_MAX,
4567
+ PEER_VERBS,
2244
4568
  POINTER_MAX_MS,
2245
4569
  POINTER_MIN_MS,
2246
4570
  POINTER_MS_PER_PX,
@@ -2248,7 +4572,10 @@ export {
2248
4572
  PRESS_KEYS,
2249
4573
  PRESS_LEAD_MS,
2250
4574
  PageErrorLog,
4575
+ PageSoundLog,
2251
4576
  QUIET_MS,
4577
+ READY_MODES,
4578
+ READY_SETTLE_CAP_MS,
2252
4579
  READ_BEAT_MS,
2253
4580
  RESPONSE_MS,
2254
4581
  RecordRefusedError,
@@ -2258,18 +4585,28 @@ export {
2258
4585
  SCROLL_SETTLE_MS,
2259
4586
  SETTLE_CAP_MS,
2260
4587
  SETTLE_MS,
4588
+ SETTLE_VERBS,
2261
4589
  STEP_GUARDS,
4590
+ STEP_MARGIN_MS,
2262
4591
  SetupEnvError,
2263
4592
  SetupError,
4593
+ StepBound,
4594
+ StepSignals,
2264
4595
  TRAILING_HOLD_MS,
2265
4596
  TakeClock,
4597
+ WAIT_MARGIN_MS,
2266
4598
  WALL_PROBE,
2267
4599
  WallError,
4600
+ ZOOM_MARK_VERBS,
4601
+ appSettled,
2268
4602
  askedMs,
2269
4603
  base64Bytes,
4604
+ bounded,
2270
4605
  buildEncodePage,
4606
+ buttonNumber,
2271
4607
  capReached,
2272
4608
  cappedLine,
4609
+ chordProblem,
2273
4610
  clampWait,
2274
4611
  clockMotion,
2275
4612
  clockTyping,
@@ -2283,48 +4620,89 @@ export {
2283
4620
  extForType,
2284
4621
  fetchInPageScript,
2285
4622
  fileNameProblem,
4623
+ fitSurfaceToViewport,
2286
4624
  formatDurationCap,
2287
4625
  guardVerdict,
2288
4626
  guardedSteps,
4627
+ hangReason,
2289
4628
  headersFor,
2290
4629
  holdLeftMs,
2291
4630
  hostedRefusals,
4631
+ inlinePoints,
4632
+ inlineScript,
2292
4633
  isIdpHost,
4634
+ isKeyName,
2293
4635
  isSignInPath,
2294
4636
  jpegDims,
4637
+ jsConditionScript,
4638
+ keptValue,
4639
+ keySequencePlanMs,
4640
+ loadScriptFiles,
2295
4641
  maskInitScript,
4642
+ modifierBits,
2296
4643
  nameFromUrl,
4644
+ noticeAdvice,
4645
+ noticeLine,
2297
4646
  outputName,
4647
+ overrunLine,
4648
+ overrunOf,
2298
4649
  paceLine,
2299
4650
  paceReport,
2300
4651
  pageErrorLine,
2301
4652
  parseHeaders,
4653
+ pasteTypeOf,
4654
+ pathDurationMs,
4655
+ peerValue,
4656
+ peersHostedRefusal,
2302
4657
  plainName,
2303
4658
  planFreshTake,
2304
4659
  pointerTravelMs,
2305
4660
  pollCondition,
4661
+ pressSequence,
2306
4662
  publicUrl,
2307
4663
  readAllBytes,
2308
4664
  readCondition,
2309
4665
  readMediaSource,
4666
+ readPath,
4667
+ readyLine,
4668
+ readyProblem,
2310
4669
  realSleep,
2311
4670
  recordTake,
2312
4671
  redirectedAway,
4672
+ reloadUntilReady,
4673
+ resolveChord,
4674
+ resolveModifier,
2313
4675
  resolveSetupText,
4676
+ runPeer,
2314
4677
  runSetup,
4678
+ sameJson,
2315
4679
  sayCondition,
2316
4680
  sayDuration,
4681
+ sayJs,
4682
+ sayValue,
4683
+ schedulePath,
4684
+ scriptFiles,
2317
4685
  scrollByScript,
2318
4686
  scrollTravelMs,
2319
4687
  settleMs,
4688
+ settleOn,
2320
4689
  settleVerdict,
2321
4690
  setupEnvNames,
4691
+ soundSrcForMeta,
4692
+ splitChord,
2322
4693
  splitSegmentsAtSkips,
4694
+ stageClipboardScript,
4695
+ stepBoundMs,
4696
+ syntheticPasteScript,
2323
4697
  typeOfName,
4698
+ untilReady,
2324
4699
  urlSchemeProblem,
2325
4700
  validateActions,
4701
+ validatePeers,
2326
4702
  validateSetup,
2327
4703
  visibleTextScript,
2328
- wallVerdict
4704
+ wallVerdict,
4705
+ withFittingExport,
4706
+ withPageSound
2329
4707
  };
2330
4708
  //# sourceMappingURL=index.js.map