@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.
- package/dist/record/index.d.ts +1071 -117
- package/dist/record/index.js +2709 -331
- package/dist/record/index.js.map +1 -1
- package/package.json +2 -2
package/dist/record/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1318
|
+
let got;
|
|
1319
|
+
try {
|
|
1320
|
+
got = JSON.parse(raw);
|
|
1321
|
+
} catch {
|
|
1322
|
+
return { holds: false };
|
|
848
1323
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
if (
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
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 { "
|
|
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
|
|
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
|
|
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 (
|
|
989
|
-
|
|
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
|
-
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
1142
|
-
if (
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1525
|
-
|
|
1526
|
-
await
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
delay,
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1773
|
-
await gesture(() =>
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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(
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
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
|
-
`
|
|
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
|
|
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 ??
|
|
3898
|
+
const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S2) * 1e3;
|
|
1814
3899
|
const res = await pollCondition(page, cond, {
|
|
1815
3900
|
timeoutMs,
|
|
1816
|
-
everyMs:
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|