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