@vosjs/render-core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -149,6 +149,7 @@ function deadTime(steps, frames, events, durationMs) {
149
149
  let ms = 0;
150
150
  let longestMs = 0;
151
151
  for (const s of steps) {
152
+ if (s.do === "expect" || s.do === "await" || s.do === "download") continue;
152
153
  const t0 = s.tStart * 1e3;
153
154
  const t1 = s.tEnd * 1e3;
154
155
  if (!(t1 > t0)) continue;
@@ -376,11 +377,26 @@ var EXPOSURE_PROBE = `(() => {
376
377
  const vw = innerWidth, vh = innerHeight
377
378
  // A selector that reaches THIS element and no other, so it can be pasted
378
379
  // 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.
380
+ // sibling wherever a tag-and-class alone would match several. What the
381
+ // page wrote for people and tests comes first (an id, a test id, an
382
+ // aria-label, a field's name or placeholder); utility classes are a
383
+ // layout's, and they come last. A value that itself holds what the scan
384
+ // looks for is never used: the selector would quote it.
385
+ const attr = (v) => '"' + v.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"'
386
+ const quiet = (v) => !!v && v.length <= 80 && !kindsIn(v).length
380
387
  const segment = (el) => {
388
+ const tag = el.tagName.toLowerCase()
389
+ if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id) && !/\\d{2,}/.test(el.id) && quiet(el.id)) return tag + '#' + el.id
381
390
  const tid = el.getAttribute('data-testid')
382
- if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
383
- if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
391
+ if (quiet(tid)) return tag + '[data-testid=' + attr(tid) + ']'
392
+ const label = el.getAttribute('aria-label')
393
+ if (quiet(label)) return tag + '[aria-label=' + attr(label) + ']'
394
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {
395
+ const nm = el.getAttribute('name')
396
+ if (quiet(nm)) return tag + '[name=' + attr(nm) + ']'
397
+ const ph = el.getAttribute('placeholder')
398
+ if (quiet(ph)) return tag + '[placeholder=' + attr(ph) + ']'
399
+ }
384
400
  const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
385
401
  let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
386
402
  const p = el.parentElement
@@ -673,7 +689,549 @@ function parseHeaders(given) {
673
689
  return out;
674
690
  }
675
691
 
692
+ // src/record/takeClock.ts
693
+ var TakeClock = class {
694
+ constructor(t0) {
695
+ this.t0 = t0;
696
+ }
697
+ t0;
698
+ pauses = [];
699
+ lastFrameMs = -1;
700
+ /** Footage ms at a wall-clock instant (`Date.now()` space). */
701
+ footageMs(wallMs) {
702
+ let off = 0;
703
+ for (const p of this.pauses) {
704
+ if (wallMs <= p.from) break;
705
+ off += Math.min(wallMs, p.to ?? Infinity) - p.from;
706
+ }
707
+ return wallMs - this.t0 - off;
708
+ }
709
+ /** The camera stops at this instant; returns the cut's footage ms. */
710
+ pause(wallMs) {
711
+ const at = this.footageMs(wallMs);
712
+ this.pauses.push({ from: wallMs, to: null });
713
+ return at;
714
+ }
715
+ /** The camera rolls again; returns the wall ms the stretch lasted. */
716
+ resume(wallMs) {
717
+ const open = this.pauses[this.pauses.length - 1];
718
+ if (!open || open.to !== null) return 0;
719
+ open.to = Math.max(open.from, wallMs);
720
+ return open.to - open.from;
721
+ }
722
+ /**
723
+ * A captured frame's footage ms, or null when it is not later than the
724
+ * last frame kept (the frame is dropped; see the module comment).
725
+ */
726
+ placeFrame(captureWallMs) {
727
+ const t = Math.max(0, Math.round(this.footageMs(captureWallMs)));
728
+ if (t <= this.lastFrameMs) return null;
729
+ this.lastFrameMs = t;
730
+ return t;
731
+ }
732
+ };
733
+
734
+ // src/record/pageErrors.ts
735
+ var PAGE_ERROR_LINE_CHARS = 160;
736
+ function pageErrorLine(text) {
737
+ const one = text.replace(/\s+/g, " ").trim();
738
+ return one.length > PAGE_ERROR_LINE_CHARS ? `${one.slice(0, PAGE_ERROR_LINE_CHARS - 1)}\u2026` : one;
739
+ }
740
+ var PageErrorLog = class {
741
+ constructor(verbose = false) {
742
+ this.verbose = verbose;
743
+ }
744
+ verbose;
745
+ seen = /* @__PURE__ */ new Map();
746
+ /** The line to log for this error now, or null when it repeats one already said. */
747
+ add(text) {
748
+ const known = this.seen.get(text);
749
+ if (known) {
750
+ known.count++;
751
+ return this.verbose ? ` [page error] ${text}` : null;
752
+ }
753
+ this.seen.set(text, { text, count: 1 });
754
+ return ` [page error] ${this.verbose ? text : pageErrorLine(text)}`;
755
+ }
756
+ /** Every distinct error with how often it came, in the order first seen. */
757
+ entries() {
758
+ return [...this.seen.values()].map((e) => ({ ...e }));
759
+ }
760
+ /** The closing lines: each error that repeated, with its count. */
761
+ repeats() {
762
+ if (this.verbose) return [];
763
+ return [...this.seen.values()].filter((e) => e.count > 1).map((e) => ` [page error \xD7${e.count}] ${pageErrorLine(e.text)}`);
764
+ }
765
+ };
766
+
767
+ // src/record/actions.ts
768
+ var STEP_GUARDS = ["spend"];
769
+ var GUARDABLE = /* @__PURE__ */ new Set(["click", "press", "type"]);
770
+ var EXPECT_TIMEOUT_S = 5;
771
+ var AWAIT_TIMEOUT_S = 600;
772
+ var AWAIT_POLL_S = 2;
773
+ var DOWNLOAD_TIMEOUT_S = 30;
774
+ var VERBS2 = /* @__PURE__ */ new Set([
775
+ "wait",
776
+ "hover",
777
+ "click",
778
+ "type",
779
+ "press",
780
+ "scroll",
781
+ "move",
782
+ "drag",
783
+ "expect",
784
+ "await",
785
+ "download"
786
+ ]);
787
+ var PRESS_KEYS = [
788
+ "Escape",
789
+ "Enter",
790
+ "Tab",
791
+ "Backspace",
792
+ "Delete",
793
+ "ArrowUp",
794
+ "ArrowDown",
795
+ "ArrowLeft",
796
+ "ArrowRight",
797
+ "Home",
798
+ "End",
799
+ "PageUp",
800
+ "PageDown",
801
+ "Space"
802
+ ];
803
+ function validateActions(value) {
804
+ const errors = [];
805
+ if (typeof value !== "object" || value === null)
806
+ return ["actions file must be a JSON object"];
807
+ const obj = value;
808
+ if (obj.url !== void 0) {
809
+ if (typeof obj.url !== "string") errors.push("url must be a string");
810
+ else {
811
+ const problem = urlSchemeProblem(obj.url);
812
+ if (problem) errors.push(`url ${problem}`);
813
+ }
814
+ }
815
+ if (obj.viewport !== void 0) {
816
+ const v = obj.viewport;
817
+ if (typeof v !== "object" || v === null) {
818
+ errors.push("viewport must be { width, height }");
819
+ } else {
820
+ const vp = v;
821
+ if (typeof vp.width !== "number" || typeof vp.height !== "number") {
822
+ errors.push("viewport must be { width, height }");
823
+ }
824
+ }
825
+ }
826
+ if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
827
+ if (obj.mask !== void 0) {
828
+ if (!Array.isArray(obj.mask)) {
829
+ errors.push("mask must be an array of { selector, as?, text? }");
830
+ } else {
831
+ obj.mask.forEach((raw, i) => {
832
+ const at = `mask[${i}]`;
833
+ const m = raw ?? {};
834
+ if (typeof m.selector !== "string" || !m.selector.trim())
835
+ errors.push(`${at}: needs a selector`);
836
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
837
+ errors.push(`${at}: as must be "blur" or "text"`);
838
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
839
+ errors.push(`${at}: as "text" needs the text to show instead`);
840
+ if (m.as !== "text" && m.text !== void 0)
841
+ errors.push(`${at}: text is only read with as "text"`);
842
+ });
843
+ }
844
+ }
845
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
846
+ errors.push("steps must be a non-empty array");
847
+ return errors;
848
+ }
849
+ const seenIds = /* @__PURE__ */ new Set();
850
+ obj.steps.forEach((raw, i) => {
851
+ const at = `steps[${i}]`;
852
+ if (typeof raw !== "object" || raw === null) {
853
+ errors.push(`${at}: must be an object`);
854
+ return;
855
+ }
856
+ const s = raw;
857
+ if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
858
+ errors.push(`${at}: read must be a selector`);
859
+ if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
860
+ errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
861
+ return;
862
+ }
863
+ if (s.id !== void 0) {
864
+ if (typeof s.id !== "string" || !s.id.trim()) {
865
+ errors.push(`${at}: id must be a non-empty string`);
866
+ } else if (seenIds.has(s.id)) {
867
+ errors.push(
868
+ `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
869
+ );
870
+ } else {
871
+ seenIds.add(s.id);
872
+ }
873
+ }
874
+ const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
875
+ if (needSelector && typeof s.selector !== "string")
876
+ errors.push(`${at}: ${s.do} needs a selector`);
877
+ if (s.do === "wait" && typeof s.ms !== "number")
878
+ errors.push(`${at}: wait needs ms`);
879
+ if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
880
+ errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
881
+ if (s.do === "type" && typeof s.text !== "string")
882
+ errors.push(`${at}: type needs text`);
883
+ if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
884
+ errors.push(`${at}: type focus must be true or false`);
885
+ if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
886
+ errors.push(`${at}: type replace must be true or false`);
887
+ if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
888
+ errors.push(`${at}: type at must be "end" or "start"`);
889
+ if (s.do === "press") {
890
+ if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
891
+ errors.push(
892
+ `${at}: press needs a key, one of ${PRESS_KEYS.join(", ")}; words are typed with type`
893
+ );
894
+ if (s.selector !== void 0 && typeof s.selector !== "string")
895
+ errors.push(`${at}: press selector must be a string`);
896
+ }
897
+ if (s.do === "scroll" && typeof s.dy !== "number")
898
+ errors.push(`${at}: scroll needs dy`);
899
+ if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
900
+ errors.push(`${at}: move needs x and y (CSS px)`);
901
+ }
902
+ if (s.guard !== void 0) {
903
+ if (!STEP_GUARDS.includes(s.guard))
904
+ errors.push(
905
+ `${at}: guard must be ${STEP_GUARDS.map((g) => `"${g}"`).join(" or ")}`
906
+ );
907
+ else if (!GUARDABLE.has(s.do))
908
+ errors.push(
909
+ `${at}: guard sits on a click, a press or a type, the steps that commit something; a ${s.do} spends nothing`
910
+ );
911
+ else if (typeof s.id !== "string" || !s.id.trim())
912
+ errors.push(
913
+ `${at}: a guarded step needs an id, which is what --confirm <id> names`
914
+ );
915
+ }
916
+ if (s.do === "expect") {
917
+ const given = ["selector", "text", "gone"].filter(
918
+ (k) => s[k] !== void 0
919
+ );
920
+ if (given.length !== 1)
921
+ errors.push(
922
+ `${at}: expect needs exactly one of selector, text or gone${given.length ? ` (it has ${given.join(" and ")})` : ""}`
923
+ );
924
+ for (const k of given)
925
+ if (!isFilled(s[k]))
926
+ errors.push(`${at}: expect ${k} must be a non-empty string`);
927
+ if (s.enabled !== void 0) {
928
+ if (typeof s.enabled !== "boolean")
929
+ errors.push(`${at}: expect enabled must be true or false`);
930
+ else if (s.selector === void 0)
931
+ errors.push(
932
+ `${at}: expect enabled is about the selector's element, so it needs a selector`
933
+ );
934
+ }
935
+ errors.push(...secondsProblem(at, "expect timeout", s.timeout));
936
+ }
937
+ if (s.do === "await") {
938
+ const u = s.until;
939
+ if (typeof u !== "object" || u === null || Array.isArray(u)) {
940
+ errors.push(
941
+ `${at}: await needs until, one of { "selector" }, { "text" } or { "gone" }`
942
+ );
943
+ } else {
944
+ const until = u;
945
+ const keys = Object.keys(until);
946
+ const known = keys.filter(
947
+ (k) => k === "selector" || k === "text" || k === "gone"
948
+ );
949
+ const unknown = keys.filter((k) => !known.includes(k));
950
+ if (unknown.length)
951
+ errors.push(
952
+ `${at}: await until takes selector, text or gone, not ${unknown.join(", ")}`
953
+ );
954
+ if (known.length !== 1)
955
+ errors.push(
956
+ `${at}: await until needs exactly one of selector, text or gone${known.length ? ` (it has ${known.join(" and ")})` : ""}`
957
+ );
958
+ for (const k of known) {
959
+ const v = until[k];
960
+ if (typeof v !== "string" || !v.trim())
961
+ errors.push(`${at}: await until.${k} must be a non-empty string`);
962
+ }
963
+ }
964
+ errors.push(...secondsProblem(at, "await timeout", s.timeout));
965
+ errors.push(...secondsProblem(at, "await poll", s.poll));
966
+ if (s.reload !== void 0 && typeof s.reload !== "boolean")
967
+ errors.push(`${at}: await reload must be true or false`);
968
+ if (s.onTimeout !== void 0 && s.onTimeout !== "fail" && s.onTimeout !== "continue")
969
+ errors.push(`${at}: await onTimeout must be "fail" or "continue"`);
970
+ }
971
+ if (s.do === "download") {
972
+ const given = ["selector", "src"].filter(
973
+ (k) => s[k] !== void 0
974
+ );
975
+ if (given.length !== 1)
976
+ errors.push(
977
+ `${at}: download needs exactly one of selector (a control that downloads) or src (a video, audio or img element whose source is fetched)`
978
+ );
979
+ for (const k of given)
980
+ if (!isFilled(s[k]))
981
+ errors.push(`${at}: download ${k} must be a non-empty string`);
982
+ if (s.as !== void 0) {
983
+ const problem = typeof s.as === "string" ? fileNameProblem(s.as) : "is not a string";
984
+ if (problem) errors.push(`${at}: download as ${problem}`);
985
+ }
986
+ }
987
+ if (s.do === "drag") {
988
+ if (typeof s.tx !== "number" || typeof s.ty !== "number") {
989
+ errors.push(`${at}: drag needs tx and ty (CSS px)`);
990
+ }
991
+ const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
992
+ if (!hasStart)
993
+ errors.push(
994
+ `${at}: drag needs a selector OR x and y as the start point`
995
+ );
996
+ }
997
+ });
998
+ return errors;
999
+ }
1000
+ var isFilled = (v) => typeof v === "string" && v.trim().length > 0;
1001
+ function secondsProblem(at, what, v) {
1002
+ if (v === void 0) return [];
1003
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0)
1004
+ return [`${at}: ${what} must be a number of seconds above 0`];
1005
+ return [];
1006
+ }
1007
+ function fileNameProblem(name) {
1008
+ if (!name.trim()) return "must be a file name";
1009
+ if (/[/\\]/.test(name))
1010
+ return `"${name}" must be a plain file name, with no path separator`;
1011
+ if (name.includes(".."))
1012
+ return `"${name}" must be a plain file name, with no ".."`;
1013
+ if (name.startsWith(".")) return `"${name}" must not be a hidden file`;
1014
+ if (/[\u0000-\u001f\u007f]/.test(name))
1015
+ return `"${name}" must not hold control characters`;
1016
+ if (name.length > 200) return "must be at most 200 characters";
1017
+ return null;
1018
+ }
1019
+ function guardedSteps(actions) {
1020
+ const out = [];
1021
+ actions.steps.forEach((s, i) => {
1022
+ if (s.guard)
1023
+ out.push({ step: i, id: s.id ?? String(i), do: s.do, guard: s.guard });
1024
+ });
1025
+ return out;
1026
+ }
1027
+ function guardVerdict(actions, confirm) {
1028
+ const guarded = guardedSteps(actions);
1029
+ const unconfirmed = guarded.filter((g) => !confirm.includes(g.id));
1030
+ const unknown = confirm.filter((c) => !guarded.some((g) => g.id === c));
1031
+ if (!unconfirmed.length && !unknown.length) return { ok: true };
1032
+ const parts = [];
1033
+ if (unconfirmed.length) {
1034
+ const one = unconfirmed.length === 1;
1035
+ parts.push(
1036
+ `${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`
1037
+ );
1038
+ }
1039
+ if (unknown.length)
1040
+ parts.push(
1041
+ `${unknown.map((c) => `--confirm ${c}`).join(", ")} names no guarded step${guarded.length ? ` (the guarded ones: ${guarded.map((g) => g.id).join(", ")})` : ""}`
1042
+ );
1043
+ return {
1044
+ ok: false,
1045
+ unconfirmed: unconfirmed.map((g) => g.id),
1046
+ unknown,
1047
+ message: `${parts.join(". ")}. Nothing was recorded.`
1048
+ };
1049
+ }
1050
+ function hostedRefusals(actions) {
1051
+ const out = [];
1052
+ for (const g of guardedSteps(actions))
1053
+ out.push(
1054
+ `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}`
1055
+ );
1056
+ actions.steps.forEach((s, i) => {
1057
+ if (s.do === "download")
1058
+ out.push(
1059
+ `steps[${i}] is a download, which a hosted take does not keep: record it on this machine, where the file lands in the take's outputs/`
1060
+ );
1061
+ });
1062
+ return out;
1063
+ }
1064
+
1065
+ // src/record/waits.ts
1066
+ function visibleTextScript(text) {
1067
+ return `(() => { const b = document.body; return !!b && (b.innerText || '').includes(${JSON.stringify(text)}) })()`;
1068
+ }
1069
+ async function readCondition(page, c) {
1070
+ try {
1071
+ if (c.text !== void 0)
1072
+ return { holds: await page.evaluate(visibleTextScript(c.text)) };
1073
+ if (c.gone !== void 0)
1074
+ return { holds: !await page.locator(c.gone).first().isVisible() };
1075
+ if (c.selector !== void 0) {
1076
+ const loc = page.locator(c.selector).first();
1077
+ const visible = await loc.isVisible();
1078
+ if (!visible) return { holds: false, visible };
1079
+ if (c.enabled === void 0) return { holds: true, visible };
1080
+ const enabled = await loc.isEnabled({ timeout: 1e3 });
1081
+ return { holds: enabled === c.enabled, visible };
1082
+ }
1083
+ } catch {
1084
+ }
1085
+ return { holds: false };
1086
+ }
1087
+ async function pollCondition(page, c, opts) {
1088
+ const now = opts.now ?? Date.now;
1089
+ const start = now();
1090
+ const reads = Math.max(1, opts.confirm?.reads ?? 1);
1091
+ for (let first = true; ; first = false) {
1092
+ if (!first && opts.between) await opts.between();
1093
+ let last = await readCondition(page, c);
1094
+ for (let n = 1; last.holds && n < reads; n++) {
1095
+ await opts.sleep(opts.confirm?.gapMs ?? 0);
1096
+ last = await readCondition(page, c);
1097
+ }
1098
+ const ms = now() - start;
1099
+ if (last.holds) return { ok: true, ms, last };
1100
+ const left = opts.timeoutMs - ms;
1101
+ if (left <= 0) return { ok: false, ms, last };
1102
+ await opts.sleep(Math.min(opts.everyMs, left));
1103
+ }
1104
+ }
1105
+ var DOM_QUIET_MS = 800;
1106
+ var DOM_SETTLE_CAP_MS = 5e3;
1107
+ var DOM_CHANGE_PROBE = `(() => {
1108
+ if (!window.__vosDomChanges) {
1109
+ window.__vosDomChanges = { n: 0 };
1110
+ new MutationObserver((list) => { window.__vosDomChanges.n += list.length })
1111
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
1112
+ }
1113
+ return window.__vosDomChanges.n;
1114
+ })()`;
1115
+ async function domSettled(page, opts) {
1116
+ const now = opts.now ?? Date.now;
1117
+ const quiet = opts.quietMs ?? DOM_QUIET_MS;
1118
+ const cap = opts.capMs ?? DOM_SETTLE_CAP_MS;
1119
+ const start = now();
1120
+ let seen = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1121
+ let since = start;
1122
+ for (; ; ) {
1123
+ const t = now();
1124
+ if (t - since >= quiet) return { settled: true, ms: t - start };
1125
+ if (t - start >= cap) return { settled: false, ms: t - start };
1126
+ await opts.sleep(opts.tickMs ?? 100);
1127
+ const n = await page.evaluate(DOM_CHANGE_PROBE).catch(() => -1);
1128
+ if (n !== seen) {
1129
+ seen = n;
1130
+ since = now();
1131
+ }
1132
+ }
1133
+ }
1134
+ function sayDuration(ms) {
1135
+ const s = Math.round(ms / 100) / 10;
1136
+ if (s < 60) return `${s} s`;
1137
+ const m = Math.floor(s / 60);
1138
+ const rest = Math.round(s - m * 60);
1139
+ return rest ? `${m} min ${rest} s` : `${m} min`;
1140
+ }
1141
+ function sayCondition(c) {
1142
+ if (c.text !== void 0) return `text "${c.text}" to show`;
1143
+ if (c.gone !== void 0) return `'${c.gone}' to go`;
1144
+ if (c.enabled === true) return `'${c.selector}' to be enabled`;
1145
+ if (c.enabled === false) return `'${c.selector}' to be disabled`;
1146
+ return `'${c.selector}' to appear`;
1147
+ }
1148
+ function conditionMiss(c, last, ms) {
1149
+ const t = sayDuration(ms);
1150
+ if (c.text !== void 0)
1151
+ return `no visible text contained "${c.text}" for ${t}`;
1152
+ if (c.gone !== void 0) return `'${c.gone}' was still there after ${t}`;
1153
+ if (last.visible && c.enabled === true)
1154
+ return `'${c.selector}' stayed disabled for ${t}`;
1155
+ if (last.visible && c.enabled === false)
1156
+ return `'${c.selector}' stayed enabled for ${t}`;
1157
+ return `'${c.selector}' never appeared in ${t}`;
1158
+ }
1159
+
1160
+ // src/record/outputs.ts
1161
+ var OUTPUT_MAX_BYTES = 512 * 1024 * 1024;
1162
+ function plainName(suggested, fallback) {
1163
+ const last = suggested.split(/[/\\]/).pop() ?? "";
1164
+ const clean = last.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\.\.+/g, ".").replace(/^\.+/, "").trim().slice(0, 200);
1165
+ return clean && !fileNameProblem(clean) ? clean : fallback;
1166
+ }
1167
+ function nameFromUrl(url) {
1168
+ try {
1169
+ const u = new URL(url);
1170
+ if (u.protocol === "blob:" || u.protocol === "data:") return "";
1171
+ const seg = u.pathname.split("/").filter(Boolean).pop() ?? "";
1172
+ return decodeURIComponent(seg);
1173
+ } catch {
1174
+ return "";
1175
+ }
1176
+ }
1177
+ function outputName(asked, suggested, fallback, used) {
1178
+ const base = asked ?? plainName(suggested, fallback);
1179
+ if (!used.has(base)) return base;
1180
+ const dot = base.lastIndexOf(".");
1181
+ const stem = dot > 0 ? base.slice(0, dot) : base;
1182
+ const ext = dot > 0 ? base.slice(dot) : "";
1183
+ for (let n = 2; ; n++) {
1184
+ const next = `${stem}-${n}${ext}`;
1185
+ if (!used.has(next)) return next;
1186
+ }
1187
+ }
1188
+ function chunkBytes(c) {
1189
+ if (c instanceof Uint8Array) return c;
1190
+ if (typeof c === "string") return new TextEncoder().encode(c);
1191
+ if (c instanceof ArrayBuffer) return new Uint8Array(c);
1192
+ throw new Error("the download stream gave something that is not bytes");
1193
+ }
1194
+ async function readAllBytes(stream, max = OUTPUT_MAX_BYTES) {
1195
+ const parts = [];
1196
+ let n = 0;
1197
+ for await (const c of stream) {
1198
+ const u = chunkBytes(c);
1199
+ n += u.length;
1200
+ if (n > max)
1201
+ throw new Error(
1202
+ `the file is larger than ${Math.round(max / 1024 / 1024)} MB`
1203
+ );
1204
+ parts.push(u);
1205
+ }
1206
+ const out = new Uint8Array(n);
1207
+ let at = 0;
1208
+ for (const p of parts) {
1209
+ out.set(p, at);
1210
+ at += p.length;
1211
+ }
1212
+ return out;
1213
+ }
1214
+ var readMediaSource = (el) => el.currentSrc || el.src || el.getAttribute("src") || "";
1215
+ function fetchInPageScript(url) {
1216
+ return `(async () => {
1217
+ try {
1218
+ const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
1219
+ if (!res.ok) return { error: 'answered ' + res.status };
1220
+ const buf = new Uint8Array(await res.arrayBuffer());
1221
+ let bin = '';
1222
+ for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
1223
+ return { b64: btoa(bin), type: res.headers.get('content-type') || '' };
1224
+ } catch (e) {
1225
+ return { error: String((e && e.message) || e) };
1226
+ }
1227
+ })()`;
1228
+ }
1229
+
676
1230
  // src/record/recorder.ts
1231
+ var EXPECT_EVERY_MS = 100;
1232
+ var SRC_WAIT_MS = 8e3;
1233
+ var AWAIT_HEARTBEAT_MS = 3e4;
1234
+ var MIN_SKIP_MS = 250;
677
1235
  var READ_WAIT_MS = 1500;
678
1236
  var READ_MAX_CHARS = 2048;
679
1237
  function headersFor(requestUrl, entryUrl, headers) {
@@ -705,6 +1263,12 @@ function publicUrl(u) {
705
1263
  return "";
706
1264
  }
707
1265
  }
1266
+ var RecordRefusedError = class extends Error {
1267
+ constructor(message) {
1268
+ super(message);
1269
+ this.name = "RecordRefusedError";
1270
+ }
1271
+ };
708
1272
  var DRY_PAUSE_MS = 120;
709
1273
  function scrollByScript(x, y, dy) {
710
1274
  return `(() => {
@@ -745,6 +1309,15 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
745
1309
  const dry = opts.dryRun === true;
746
1310
  const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
747
1311
  const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
1312
+ if (!dry) {
1313
+ const guard = guardVerdict(actions, opts.confirm ?? []);
1314
+ if (!guard.ok) throw new RecordRefusedError(guard.message);
1315
+ const download = actions.steps.findIndex((s) => s.do === "download");
1316
+ if (download >= 0 && !opts.outputs)
1317
+ throw new RecordRefusedError(
1318
+ `steps[${download}] is a download, and this recorder has nowhere to keep a file. Nothing was recorded.`
1319
+ );
1320
+ }
748
1321
  const vw = actions.viewport?.width ?? 1280;
749
1322
  const vh = actions.viewport?.height ?? 720;
750
1323
  const context = opts.context ?? await browser.newContext({
@@ -762,8 +1335,11 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
762
1335
  const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
763
1336
  exposureLog.add(step, scan);
764
1337
  };
1338
+ const pageErrors = new PageErrorLog(opts.verbose === true);
765
1339
  page.on("console", (m) => {
766
- if (m.type() === "error") log(` [page error] ${m.text()}`);
1340
+ if (m.type() !== "error") return;
1341
+ const line = pageErrors.add(m.text());
1342
+ if (line) log(line);
767
1343
  });
768
1344
  log(`goto ${url}`);
769
1345
  let navTimeout = false;
@@ -813,32 +1389,46 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
813
1389
  const frames = [];
814
1390
  let frameIdx = 0;
815
1391
  let t0 = 0;
1392
+ let takeClock = new TakeClock(0);
816
1393
  const first = { dims: null };
817
1394
  let sinkChain = Promise.resolve();
818
1395
  if (!dry)
819
1396
  cdp.on("Page.screencastFrame", (ev) => {
1397
+ cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
1398
+ });
820
1399
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
1400
+ const tMs = takeClock.placeFrame(tsMs);
1401
+ if (tMs === null) return;
821
1402
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
822
1403
  const bytes = base64Bytes(ev.data);
823
1404
  if (!first.dims) first.dims = jpegDims(bytes);
824
- frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
1405
+ frames.push({ file, tMs });
825
1406
  sinkChain = sinkChain.then(() => sink.frame(file, bytes)).catch(() => {
826
1407
  });
827
- cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
828
- });
829
1408
  });
1409
+ const screencast = {
1410
+ format: "jpeg",
1411
+ quality: 90,
1412
+ maxWidth: vw,
1413
+ maxHeight: vh,
1414
+ everyNthFrame: 1
1415
+ };
830
1416
  t0 = Date.now();
831
- if (!dry)
832
- await cdp.send("Page.startScreencast", {
833
- format: "jpeg",
834
- quality: 90,
835
- maxWidth: vw,
836
- maxHeight: vh,
837
- everyNthFrame: 1
838
- });
1417
+ takeClock = new TakeClock(t0);
1418
+ if (!dry) await cdp.send("Page.startScreencast", screencast);
839
1419
  const events = [];
840
1420
  const cur = { x: 48, y: 48 };
841
- const now = () => Date.now() - t0;
1421
+ const now = () => takeClock.footageMs(Date.now());
1422
+ const offCamera = async (fn) => {
1423
+ const at = takeClock.pause(Date.now());
1424
+ if (!dry) await cdp.send("Page.stopScreencast").catch(() => {
1425
+ });
1426
+ const out = await fn();
1427
+ const ms = takeClock.resume(Date.now());
1428
+ if (!dry) await cdp.send("Page.startScreencast", screencast).catch(() => {
1429
+ });
1430
+ return { out, at, ms };
1431
+ };
842
1432
  const emit = (e) => events.push({ t: now(), ...e });
843
1433
  await page.mouse.move(cur.x, cur.y);
844
1434
  emit({ x: cur.x, y: cur.y, type: "move" });
@@ -886,6 +1476,51 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
886
1476
  return null;
887
1477
  }
888
1478
  };
1479
+ const srcOf = async (selector) => {
1480
+ const deadline = Date.now() + SRC_WAIT_MS;
1481
+ const loc = page.locator(selector).first();
1482
+ try {
1483
+ await loc.waitFor({ state: "attached", timeout: SRC_WAIT_MS });
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ for (; ; ) {
1488
+ const v = await loc.evaluate(readMediaSource).catch(() => "");
1489
+ if (v || Date.now() >= deadline) return v;
1490
+ await realSleep(200);
1491
+ }
1492
+ };
1493
+ const fetchSource = async (src) => {
1494
+ const abs = new URL(src, page.url()).href;
1495
+ const suggested = nameFromUrl(abs);
1496
+ if (page.request && /^https?:/i.test(abs)) {
1497
+ const res = await page.request.get(abs, {
1498
+ timeout: DOWNLOAD_TIMEOUT_S * 1e3
1499
+ });
1500
+ if (!res.ok())
1501
+ throw new Error(
1502
+ `fetching ${publicUrl(abs) || "the source"} answered ${res.status()}`
1503
+ );
1504
+ const bytes = await res.body();
1505
+ if (bytes.length > OUTPUT_MAX_BYTES)
1506
+ throw new Error(
1507
+ `the file is larger than ${OUTPUT_MAX_BYTES / 1024 / 1024} MB`
1508
+ );
1509
+ const type2 = res.headers()["content-type"]?.split(";")[0].trim();
1510
+ return { bytes, suggested, ...type2 ? { contentType: type2 } : {} };
1511
+ }
1512
+ const r = await page.evaluate(fetchInPageScript(abs));
1513
+ if (!r || r.error || r.b64 === void 0)
1514
+ throw new Error(
1515
+ `fetching the source in the page failed: ${r?.error ?? "no answer"}`
1516
+ );
1517
+ const type = r.type?.split(";")[0].trim();
1518
+ return {
1519
+ bytes: base64Bytes(r.b64),
1520
+ suggested,
1521
+ ...type ? { contentType: type } : {}
1522
+ };
1523
+ };
889
1524
  const clickAt = async (rect) => {
890
1525
  await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
891
1526
  await sleep(PRESS_LEAD_MS);
@@ -923,6 +1558,13 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
923
1558
  const skipped = [];
924
1559
  const stepSpans = [];
925
1560
  const paces = [];
1561
+ const disabled = [];
1562
+ const confirmed = [];
1563
+ const skips = [];
1564
+ const outputs = [];
1565
+ const outputNames = /* @__PURE__ */ new Set();
1566
+ let stopped = null;
1567
+ let unpressedGuard = null;
926
1568
  let capped = false;
927
1569
  for (const [stepIdx, step] of actions.steps.entries()) {
928
1570
  if (capReached(now(), maxSeconds)) {
@@ -940,6 +1582,42 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
940
1582
  const skippedBefore = skipped.length;
941
1583
  const urlBefore = page.url();
942
1584
  let stepRect = null;
1585
+ const extra = {};
1586
+ const tag = `#${stepIdx}${step.id ? ` ${step.id}` : ""}`;
1587
+ const skip = (selector, reason) => {
1588
+ skipped.push({
1589
+ step: stepIdx,
1590
+ ...step.id ? { id: step.id } : {},
1591
+ do: step.do,
1592
+ selector,
1593
+ ...reason ? { reason } : {}
1594
+ });
1595
+ if (reason) extra.reason = reason;
1596
+ };
1597
+ if (step.guard) extra.guard = step.guard;
1598
+ const rehearseGuard = async (sel) => {
1599
+ if (!step.guard || !dry) return false;
1600
+ const rect = sel ? await boxOf(sel) : null;
1601
+ stepRect = rect;
1602
+ if (sel && !rect) skip(sel);
1603
+ else {
1604
+ if (rect)
1605
+ await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
1606
+ log(
1607
+ `${tag}: guarded (${step.guard}), ${rect ? "hovered" : "left alone"}, not pressed`
1608
+ );
1609
+ }
1610
+ unpressedGuard = stepIdx;
1611
+ return true;
1612
+ };
1613
+ const notWaited = () => {
1614
+ if (!dry || unpressedGuard === null) return false;
1615
+ extra.followsGuard = unpressedGuard;
1616
+ log(
1617
+ `${tag} ${step.do}: not waited: it follows #${unpressedGuard} (guarded, not pressed)`
1618
+ );
1619
+ return true;
1620
+ };
943
1621
  switch (step.do) {
944
1622
  case "wait":
945
1623
  await sleep(clampWait(step.ms, now(), maxSeconds));
@@ -959,9 +1637,21 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
959
1637
  break;
960
1638
  }
961
1639
  case "click": {
1640
+ if (await rehearseGuard(step.selector)) break;
962
1641
  const rect = await boxOf(step.selector);
963
1642
  stepRect = rect;
964
1643
  if (rect) {
1644
+ const enabled = await page.locator(step.selector).first().isEnabled({ timeout: 1e3 }).catch(() => true);
1645
+ if (!enabled) {
1646
+ disabled.push({
1647
+ step: stepIdx,
1648
+ ...step.id ? { id: step.id } : {},
1649
+ selector: step.selector
1650
+ });
1651
+ log(
1652
+ ` (${tag} click: ${step.selector} is disabled; pressed anyway)`
1653
+ );
1654
+ }
965
1655
  await gesture(() => clickAt(rect));
966
1656
  log(`click ${step.selector}`);
967
1657
  const sinceChange = await gesture(() => waitSettled());
@@ -971,6 +1661,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
971
1661
  break;
972
1662
  }
973
1663
  case "type": {
1664
+ if (await rehearseGuard(step.selector)) break;
974
1665
  const rect = await boxOf(step.selector);
975
1666
  stepRect = rect;
976
1667
  if (rect) {
@@ -1006,6 +1697,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1006
1697
  break;
1007
1698
  }
1008
1699
  case "press": {
1700
+ if (await rehearseGuard(step.selector)) break;
1009
1701
  let rect = null;
1010
1702
  if (step.selector) {
1011
1703
  rect = await boxOf(step.selector);
@@ -1115,8 +1807,170 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1115
1807
  await sleep(400);
1116
1808
  break;
1117
1809
  }
1810
+ case "expect": {
1811
+ if (notWaited()) break;
1812
+ const cond = step;
1813
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
1814
+ const res = await pollCondition(page, cond, {
1815
+ timeoutMs,
1816
+ everyMs: EXPECT_EVERY_MS,
1817
+ sleep: realSleep
1818
+ });
1819
+ if (res.ok) log(`expect ${sayCondition(cond)}: holds`);
1820
+ else {
1821
+ const reason = conditionMiss(cond, res.last, timeoutMs);
1822
+ skip(step.selector ?? step.gone ?? step.text ?? "", reason);
1823
+ log(` (${tag} expect: ${reason})`);
1824
+ }
1825
+ break;
1826
+ }
1827
+ case "await": {
1828
+ if (notWaited()) break;
1829
+ const cond = step.until;
1830
+ const timeoutMs = (step.timeout ?? AWAIT_TIMEOUT_S) * 1e3;
1831
+ const reload = step.reload ? async () => {
1832
+ const opts2 = {
1833
+ waitUntil: "networkidle",
1834
+ timeout: Math.min(45e3, timeoutMs)
1835
+ };
1836
+ await (page.reload ? page.reload(opts2) : page.goto(page.url(), opts2)).catch(() => null);
1837
+ await domSettled(page, { sleep: realSleep });
1838
+ } : void 0;
1839
+ log(`await ${sayCondition(cond)} (off camera)\u2026`);
1840
+ const awaitEvent = {
1841
+ event: "await",
1842
+ step: stepIdx,
1843
+ ...step.id ? { id: step.id } : {}
1844
+ };
1845
+ opts.onEvent?.({ ...awaitEvent, phase: "start" });
1846
+ const beatMs = opts.awaitHeartbeatMs ?? AWAIT_HEARTBEAT_MS;
1847
+ const waitFrom = Date.now();
1848
+ const beat = setInterval(() => {
1849
+ const elapsedMs = Date.now() - waitFrom;
1850
+ opts.onEvent?.({ ...awaitEvent, phase: "wait", elapsedMs });
1851
+ log(` still waiting, ${sayDuration(elapsedMs)} so far`);
1852
+ }, beatMs);
1853
+ let waited;
1854
+ try {
1855
+ waited = await offCamera(
1856
+ () => pollCondition(page, cond, {
1857
+ timeoutMs,
1858
+ everyMs: (step.poll ?? AWAIT_POLL_S) * 1e3,
1859
+ sleep: realSleep,
1860
+ between: reload,
1861
+ // After a reload the condition must hold on two reads in a
1862
+ // row, or a half-built page ends the wait early.
1863
+ ...reload ? { confirm: { reads: 2, gapMs: 500 } } : {}
1864
+ })
1865
+ );
1866
+ } finally {
1867
+ clearInterval(beat);
1868
+ }
1869
+ const { out, at, ms } = waited;
1870
+ opts.onEvent?.({ ...awaitEvent, phase: "end", ms, held: out.ok });
1871
+ extra.offCameraMs = ms;
1872
+ if (!out.ok) extra.timedOut = true;
1873
+ if (!dry && (ms >= MIN_SKIP_MS || !out.ok))
1874
+ skips.push({
1875
+ step: stepIdx,
1876
+ ...step.id ? { id: step.id } : {},
1877
+ at: +(at / 1e3).toFixed(3),
1878
+ ms,
1879
+ ...out.ok ? {} : { timedOut: true }
1880
+ });
1881
+ if (out.ok) log(` held after ${sayDuration(ms)} off camera`);
1882
+ else if ((step.onTimeout ?? "fail") === "continue")
1883
+ log(
1884
+ ` (${tag} await: ${conditionMiss(cond, out.last, ms)}; going on, as onTimeout says)`
1885
+ );
1886
+ else {
1887
+ stopped = {
1888
+ step: stepIdx,
1889
+ ...step.id ? { id: step.id } : {},
1890
+ 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`
1891
+ };
1892
+ extra.reason = stopped.reason;
1893
+ log(` ${stopped.reason}`);
1894
+ }
1895
+ break;
1896
+ }
1897
+ case "download": {
1898
+ let got = null;
1899
+ let reason = null;
1900
+ const target = step.selector ?? step.src ?? "";
1901
+ try {
1902
+ if (step.selector !== void 0) {
1903
+ const rect = await boxOf(step.selector);
1904
+ stepRect = rect;
1905
+ if (!rect) {
1906
+ skip(step.selector);
1907
+ break;
1908
+ }
1909
+ if (!page.waitForEvent) {
1910
+ reason = "this browser reports no downloads";
1911
+ } else {
1912
+ const waitMs = DOWNLOAD_TIMEOUT_S * 1e3;
1913
+ const arriving = page.waitForEvent("download", { timeout: waitMs }).catch(() => null);
1914
+ await gesture(() => clickAt(rect));
1915
+ log(`click ${step.selector} (download)`);
1916
+ const dl = await arriving;
1917
+ if (!dl)
1918
+ reason = `no download arrived within ${sayDuration(waitMs)} of the click on '${step.selector}'`;
1919
+ else {
1920
+ const stream = await dl.createReadStream();
1921
+ if (!stream) reason = "the download failed in the browser";
1922
+ else
1923
+ got = {
1924
+ bytes: await readAllBytes(stream),
1925
+ suggested: dl.suggestedFilename(),
1926
+ // A browser download carries no headers here: the type is
1927
+ // read from the name the page gave it.
1928
+ ...typeOfName(dl.suggestedFilename())
1929
+ };
1930
+ }
1931
+ }
1932
+ } else if (step.src !== void 0) {
1933
+ const src = await srcOf(step.src);
1934
+ if (src === null) reason = `no element matches '${step.src}'`;
1935
+ else if (!src) reason = `'${step.src}' has no source to fetch`;
1936
+ else got = await fetchSource(src);
1937
+ }
1938
+ } catch (e) {
1939
+ reason = e instanceof Error ? e.message : String(e);
1940
+ }
1941
+ if (got) {
1942
+ const name = outputName(
1943
+ step.as,
1944
+ got.suggested,
1945
+ `download-${stepIdx}${extForType(got.contentType)}`,
1946
+ outputNames
1947
+ );
1948
+ outputNames.add(name);
1949
+ if (!dry) await opts.outputs?.save(name, got.bytes, got.contentType);
1950
+ outputs.push({
1951
+ step: stepIdx,
1952
+ ...step.id ? { id: step.id } : {},
1953
+ name,
1954
+ bytes: got.bytes.length,
1955
+ ...got.contentType ? { contentType: got.contentType } : {}
1956
+ });
1957
+ log(
1958
+ `download \u2192 outputs/${name} (${got.bytes.length} bytes)${dry ? ", not kept in a rehearsal" : ""}`
1959
+ );
1960
+ } else {
1961
+ const why = reason ?? "nothing came";
1962
+ skip(target, why);
1963
+ log(` (${tag} download: ${why})`);
1964
+ }
1965
+ break;
1966
+ }
1967
+ }
1968
+ if (step.guard && !dry && skipped.length === skippedBefore) {
1969
+ confirmed.push(step.id ?? String(stepIdx));
1970
+ extra.confirmed = true;
1118
1971
  }
1119
1972
  const stepInfo = step;
1973
+ const about = stepInfo.selector ?? stepInfo.until?.selector ?? stepInfo.until?.gone ?? stepInfo.gone ?? stepInfo.src;
1120
1974
  const urlAfter = publicUrl(page.url());
1121
1975
  const text = stepInfo.read ? await readText(page, stepInfo.read) : null;
1122
1976
  if (stepInfo.read && text === null)
@@ -1125,7 +1979,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1125
1979
  step: stepIdx,
1126
1980
  ...stepInfo.id ? { id: stepInfo.id } : {},
1127
1981
  do: step.do,
1128
- ...stepInfo.selector ? { selector: stepInfo.selector } : {},
1982
+ ...about ? { selector: about } : {},
1129
1983
  tStart: +(stepStart / 1e3).toFixed(3),
1130
1984
  tEnd: +(now() / 1e3).toFixed(3),
1131
1985
  ...skipped.length > skippedBefore ? { skipped: true } : {},
@@ -1139,7 +1993,8 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1139
1993
  w: +stepRect.w.toFixed(1),
1140
1994
  h: +stepRect.h.toFixed(1)
1141
1995
  }
1142
- } : {}
1996
+ } : {},
1997
+ ...extra
1143
1998
  });
1144
1999
  paces.push({
1145
2000
  step: stepIdx,
@@ -1149,8 +2004,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1149
2004
  wallMs: now() - stepStart
1150
2005
  });
1151
2006
  await scanExposures(stepIdx);
2007
+ if (stopped) break;
1152
2008
  }
1153
- if (!capped) await sleep(TRAILING_HOLD_MS);
2009
+ if (!capped && !stopped) await sleep(TRAILING_HOLD_MS);
1154
2010
  if (capped || capReached(now(), maxSeconds)) {
1155
2011
  capped = true;
1156
2012
  log(` ${cappedLine(maxSeconds)}`);
@@ -1160,6 +2016,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1160
2016
  });
1161
2017
  await sleep(200);
1162
2018
  const pageTitle = await page.title().catch(() => "");
2019
+ for (const line of pageErrors.repeats()) log(line);
1163
2020
  await context.close();
1164
2021
  await sinkChain;
1165
2022
  const meta = {
@@ -1183,7 +2040,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1183
2040
  steps: stepSpans,
1184
2041
  ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
1185
2042
  ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
1186
- ...masks.length ? { masks: exposureLog.masks(masks) } : {}
2043
+ ...masks.length ? { masks: exposureLog.masks(masks) } : {},
2044
+ ...skips.length ? { skips } : {},
2045
+ ...outputs.length ? { outputs } : {}
1187
2046
  };
1188
2047
  const freezes = [];
1189
2048
  for (let i = 1; i < frames.length; i++) {
@@ -1216,148 +2075,42 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
1216
2075
  freezePct,
1217
2076
  dead,
1218
2077
  capped,
2078
+ disabled,
2079
+ confirmed,
2080
+ skips,
2081
+ outputs,
2082
+ stopped,
2083
+ pageErrors: pageErrors.entries(),
1219
2084
  actions: { ...actions, url }
1220
2085
  };
1221
2086
  }
1222
-
1223
- // src/record/actions.ts
1224
- var VERBS2 = /* @__PURE__ */ new Set([
1225
- "wait",
1226
- "hover",
1227
- "click",
1228
- "type",
1229
- "press",
1230
- "scroll",
1231
- "move",
1232
- "drag"
1233
- ]);
1234
- var PRESS_KEYS = [
1235
- "Escape",
1236
- "Enter",
1237
- "Tab",
1238
- "Backspace",
1239
- "Delete",
1240
- "ArrowUp",
1241
- "ArrowDown",
1242
- "ArrowLeft",
1243
- "ArrowRight",
1244
- "Home",
1245
- "End",
1246
- "PageUp",
1247
- "PageDown",
1248
- "Space"
1249
- ];
1250
- function validateActions(value) {
1251
- const errors = [];
1252
- if (typeof value !== "object" || value === null)
1253
- return ["actions file must be a JSON object"];
1254
- const obj = value;
1255
- if (obj.url !== void 0) {
1256
- if (typeof obj.url !== "string") errors.push("url must be a string");
1257
- else {
1258
- const problem = urlSchemeProblem(obj.url);
1259
- if (problem) errors.push(`url ${problem}`);
1260
- }
1261
- }
1262
- if (obj.viewport !== void 0) {
1263
- const v = obj.viewport;
1264
- if (typeof v !== "object" || v === null) {
1265
- errors.push("viewport must be { width, height }");
1266
- } else {
1267
- const vp = v;
1268
- if (typeof vp.width !== "number" || typeof vp.height !== "number") {
1269
- errors.push("viewport must be { width, height }");
1270
- }
1271
- }
1272
- }
1273
- if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
1274
- if (obj.mask !== void 0) {
1275
- if (!Array.isArray(obj.mask)) {
1276
- errors.push("mask must be an array of { selector, as?, text? }");
1277
- } else {
1278
- obj.mask.forEach((raw, i) => {
1279
- const at = `mask[${i}]`;
1280
- const m = raw ?? {};
1281
- if (typeof m.selector !== "string" || !m.selector.trim())
1282
- errors.push(`${at}: needs a selector`);
1283
- if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
1284
- errors.push(`${at}: as must be "blur" or "text"`);
1285
- if (m.as === "text" && (typeof m.text !== "string" || !m.text))
1286
- errors.push(`${at}: as "text" needs the text to show instead`);
1287
- if (m.as !== "text" && m.text !== void 0)
1288
- errors.push(`${at}: text is only read with as "text"`);
1289
- });
1290
- }
1291
- }
1292
- if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
1293
- errors.push("steps must be a non-empty array");
1294
- return errors;
1295
- }
1296
- const seenIds = /* @__PURE__ */ new Set();
1297
- obj.steps.forEach((raw, i) => {
1298
- const at = `steps[${i}]`;
1299
- if (typeof raw !== "object" || raw === null) {
1300
- errors.push(`${at}: must be an object`);
1301
- return;
1302
- }
1303
- const s = raw;
1304
- if (s.read !== void 0 && (typeof s.read !== "string" || !s.read.trim()))
1305
- errors.push(`${at}: read must be a selector`);
1306
- if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
1307
- errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
1308
- return;
1309
- }
1310
- if (s.id !== void 0) {
1311
- if (typeof s.id !== "string" || !s.id.trim()) {
1312
- errors.push(`${at}: id must be a non-empty string`);
1313
- } else if (seenIds.has(s.id)) {
1314
- errors.push(
1315
- `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
1316
- );
1317
- } else {
1318
- seenIds.add(s.id);
1319
- }
1320
- }
1321
- const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
1322
- if (needSelector && typeof s.selector !== "string")
1323
- errors.push(`${at}: ${s.do} needs a selector`);
1324
- if (s.do === "wait" && typeof s.ms !== "number")
1325
- errors.push(`${at}: wait needs ms`);
1326
- if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
1327
- errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
1328
- if (s.do === "type" && typeof s.text !== "string")
1329
- errors.push(`${at}: type needs text`);
1330
- if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
1331
- errors.push(`${at}: type focus must be true or false`);
1332
- if (s.do === "type" && s.replace !== void 0 && typeof s.replace !== "boolean")
1333
- errors.push(`${at}: type replace must be true or false`);
1334
- if (s.do === "type" && s.at !== void 0 && s.at !== "end" && s.at !== "start")
1335
- errors.push(`${at}: type at must be "end" or "start"`);
1336
- if (s.do === "press") {
1337
- if (typeof s.key !== "string" || !PRESS_KEYS.includes(s.key))
1338
- errors.push(
1339
- `${at}: press needs a key, one of ${PRESS_KEYS.join(", ")}; words are typed with type`
1340
- );
1341
- if (s.selector !== void 0 && typeof s.selector !== "string")
1342
- errors.push(`${at}: press selector must be a string`);
1343
- }
1344
- if (s.do === "scroll" && typeof s.dy !== "number")
1345
- errors.push(`${at}: scroll needs dy`);
1346
- if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
1347
- errors.push(`${at}: move needs x and y (CSS px)`);
1348
- }
1349
- if (s.do === "drag") {
1350
- if (typeof s.tx !== "number" || typeof s.ty !== "number") {
1351
- errors.push(`${at}: drag needs tx and ty (CSS px)`);
1352
- }
1353
- const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
1354
- if (!hasStart)
1355
- errors.push(
1356
- `${at}: drag needs a selector OR x and y as the start point`
1357
- );
1358
- }
1359
- });
1360
- return errors;
2087
+ var EXT_FOR_TYPE = {
2088
+ "video/mp4": ".mp4",
2089
+ "video/webm": ".webm",
2090
+ "video/quicktime": ".mov",
2091
+ "audio/mpeg": ".mp3",
2092
+ "audio/mp4": ".m4a",
2093
+ "audio/wav": ".wav",
2094
+ "audio/ogg": ".ogg",
2095
+ "image/png": ".png",
2096
+ "image/jpeg": ".jpg",
2097
+ "image/webp": ".webp",
2098
+ "image/gif": ".gif",
2099
+ "image/svg+xml": ".svg",
2100
+ "application/pdf": ".pdf",
2101
+ "application/json": ".json",
2102
+ "text/csv": ".csv",
2103
+ "application/zip": ".zip"
2104
+ };
2105
+ function extForType(type) {
2106
+ return (type && EXT_FOR_TYPE[type.split(";")[0].trim().toLowerCase()]) ?? "";
2107
+ }
2108
+ function typeOfName(name) {
2109
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
2110
+ if (!name.includes(".")) return {};
2111
+ for (const [type, e] of Object.entries(EXT_FOR_TYPE))
2112
+ if (e === ext) return { contentType: type };
2113
+ return ext === ".jpeg" ? { contentType: "image/jpeg" } : {};
1361
2114
  }
1362
2115
 
1363
2116
  // src/record/encodePage.ts
@@ -1424,6 +2177,7 @@ import {
1424
2177
  function planFreshTake(artifact, recordingName, opts = {}) {
1425
2178
  const ingest = opts.backdrop ? { frame: withBackdrop(DEFAULT_FRAME_STYLE, opts.backdrop) } : {};
1426
2179
  const doc = projectFromArtifact(artifact, recordingName, ingest).doc;
2180
+ doc.segments = splitSegmentsAtSkips(doc.segments, artifact.meta.skips);
1427
2181
  doc.zoom = planAutoZoom(doc.source.cursor, {
1428
2182
  width: doc.source.meta.width,
1429
2183
  height: doc.source.meta.height,
@@ -1438,32 +2192,77 @@ function planFreshTake(artifact, recordingName, opts = {}) {
1438
2192
  });
1439
2193
  return doc;
1440
2194
  }
2195
+ function splitSegmentsAtSkips(segments, skips) {
2196
+ if (!skips?.length) return segments;
2197
+ const cuts = [...new Set(skips.map((s) => s.at))].sort((a, b) => a - b);
2198
+ const out = [];
2199
+ for (const seg of segments) {
2200
+ const inside = seg.media ? [] : cuts.filter((at) => at > seg.in + 1e-3 && at < seg.out - 1e-3);
2201
+ if (!inside.length) {
2202
+ out.push(seg);
2203
+ continue;
2204
+ }
2205
+ const { anim, hold, ...plain } = seg;
2206
+ const edges = [seg.in, ...inside, seg.out];
2207
+ for (let i = 0; i < edges.length - 1; i++) {
2208
+ const first = i === 0;
2209
+ const last = i === edges.length - 2;
2210
+ const pieceAnim = anim && (first || last) ? {
2211
+ ...first && anim.enter ? { enter: anim.enter } : {},
2212
+ ...last && anim.exit ? { exit: anim.exit } : {}
2213
+ } : null;
2214
+ out.push({
2215
+ ...plain,
2216
+ in: edges[i],
2217
+ out: edges[i + 1],
2218
+ ...pieceAnim && Object.keys(pieceAnim).length ? { anim: pieceAnim } : {},
2219
+ ...last && hold !== void 0 ? { hold } : {}
2220
+ });
2221
+ }
2222
+ }
2223
+ return out;
2224
+ }
1441
2225
  export {
2226
+ AWAIT_HEARTBEAT_MS,
2227
+ AWAIT_POLL_S,
2228
+ AWAIT_TIMEOUT_S,
1442
2229
  DEAD_SHARE_WARN_PCT,
1443
2230
  DEAD_STRETCH_WARN_MS,
2231
+ DOM_CHANGE_PROBE,
2232
+ DOM_QUIET_MS,
2233
+ DOM_SETTLE_CAP_MS,
2234
+ DOWNLOAD_TIMEOUT_S,
2235
+ EXPECT_TIMEOUT_S,
1444
2236
  EXPOSURE_ADVICE,
1445
2237
  EXPOSURE_MATCHERS_SRC,
1446
2238
  EXPOSURE_PROBE,
1447
2239
  ExposureLog,
2240
+ MIN_SKIP_MS,
1448
2241
  MOTION_TICK_MS,
2242
+ OUTPUT_MAX_BYTES,
2243
+ PAGE_ERROR_LINE_CHARS,
1449
2244
  POINTER_MAX_MS,
1450
2245
  POINTER_MIN_MS,
1451
2246
  POINTER_MS_PER_PX,
1452
2247
  PRESS_HOLD_MS,
1453
2248
  PRESS_KEYS,
1454
2249
  PRESS_LEAD_MS,
2250
+ PageErrorLog,
1455
2251
  QUIET_MS,
1456
2252
  READ_BEAT_MS,
1457
2253
  RESPONSE_MS,
2254
+ RecordRefusedError,
1458
2255
  SCROLL_MAX_MS,
1459
2256
  SCROLL_MIN_MS,
1460
2257
  SCROLL_MS_PER_PX,
1461
2258
  SCROLL_SETTLE_MS,
1462
2259
  SETTLE_CAP_MS,
1463
2260
  SETTLE_MS,
2261
+ STEP_GUARDS,
1464
2262
  SetupEnvError,
1465
2263
  SetupError,
1466
2264
  TRAILING_HOLD_MS,
2265
+ TakeClock,
1467
2266
  WALL_PROBE,
1468
2267
  WallError,
1469
2268
  askedMs,
@@ -1474,37 +2273,58 @@ export {
1474
2273
  clampWait,
1475
2274
  clockMotion,
1476
2275
  clockTyping,
2276
+ conditionMiss,
1477
2277
  deadLine,
1478
2278
  deadTime,
1479
2279
  deadWarns,
2280
+ domSettled,
1480
2281
  easeInOutCubic,
1481
2282
  exposureLine,
2283
+ extForType,
2284
+ fetchInPageScript,
2285
+ fileNameProblem,
1482
2286
  formatDurationCap,
2287
+ guardVerdict,
2288
+ guardedSteps,
1483
2289
  headersFor,
1484
2290
  holdLeftMs,
2291
+ hostedRefusals,
1485
2292
  isIdpHost,
1486
2293
  isSignInPath,
1487
2294
  jpegDims,
1488
2295
  maskInitScript,
2296
+ nameFromUrl,
2297
+ outputName,
1489
2298
  paceLine,
1490
2299
  paceReport,
2300
+ pageErrorLine,
1491
2301
  parseHeaders,
2302
+ plainName,
1492
2303
  planFreshTake,
1493
2304
  pointerTravelMs,
2305
+ pollCondition,
1494
2306
  publicUrl,
2307
+ readAllBytes,
2308
+ readCondition,
2309
+ readMediaSource,
1495
2310
  realSleep,
1496
2311
  recordTake,
1497
2312
  redirectedAway,
1498
2313
  resolveSetupText,
1499
2314
  runSetup,
2315
+ sayCondition,
2316
+ sayDuration,
1500
2317
  scrollByScript,
1501
2318
  scrollTravelMs,
1502
2319
  settleMs,
1503
2320
  settleVerdict,
1504
2321
  setupEnvNames,
2322
+ splitSegmentsAtSkips,
2323
+ typeOfName,
1505
2324
  urlSchemeProblem,
1506
2325
  validateActions,
1507
2326
  validateSetup,
2327
+ visibleTextScript,
1508
2328
  wallVerdict
1509
2329
  };
1510
2330
  //# sourceMappingURL=index.js.map