@vosjs/render-core 0.5.3 → 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.
- package/dist/index.d.ts +11 -2
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/record/index.d.ts +420 -4
- package/dist/record/index.js +981 -160
- package/dist/record/index.js.map +1 -1
- package/package.json +2 -2
package/dist/record/index.js
CHANGED
|
@@ -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
|
|
383
|
-
|
|
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()
|
|
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
|
|
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
|
-
|
|
832
|
-
|
|
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()
|
|
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
|
-
...
|
|
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,158 +2075,53 @@ 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
|
-
|
|
1224
|
-
|
|
1225
|
-
"
|
|
1226
|
-
"
|
|
1227
|
-
"
|
|
1228
|
-
"
|
|
1229
|
-
"
|
|
1230
|
-
"
|
|
1231
|
-
"
|
|
1232
|
-
"
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
"
|
|
1236
|
-
"
|
|
1237
|
-
"
|
|
1238
|
-
"
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
"
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
"
|
|
1245
|
-
"
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
"
|
|
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
|
|
1364
2117
|
var MEDIABUNNY_URL = "https://esm.sh/mediabunny@1.55.7?target=es2022";
|
|
1365
2118
|
function buildEncodePage(o) {
|
|
1366
2119
|
const q = o.frameQuery ? `?${o.frameQuery}` : "";
|
|
2120
|
+
const mediabunnyUrl = o.mediabunnyUrl ?? MEDIABUNNY_URL;
|
|
1367
2121
|
return `<!doctype html><html><head><meta charset="utf-8"></head><body>
|
|
1368
2122
|
<script type="module">
|
|
1369
2123
|
try {
|
|
1370
|
-
const MB = await import(
|
|
2124
|
+
const MB = await import(${JSON.stringify(mediabunnyUrl)})
|
|
1371
2125
|
const frames = await (await fetch(${JSON.stringify(o.framesIndexUrl)})).json()
|
|
1372
2126
|
const meta = await (await fetch(${JSON.stringify(o.metaUrl)})).json()
|
|
1373
2127
|
const W = meta.captureWidth ?? meta.width, H = meta.captureHeight ?? meta.height
|
|
@@ -1423,6 +2177,7 @@ import {
|
|
|
1423
2177
|
function planFreshTake(artifact, recordingName, opts = {}) {
|
|
1424
2178
|
const ingest = opts.backdrop ? { frame: withBackdrop(DEFAULT_FRAME_STYLE, opts.backdrop) } : {};
|
|
1425
2179
|
const doc = projectFromArtifact(artifact, recordingName, ingest).doc;
|
|
2180
|
+
doc.segments = splitSegmentsAtSkips(doc.segments, artifact.meta.skips);
|
|
1426
2181
|
doc.zoom = planAutoZoom(doc.source.cursor, {
|
|
1427
2182
|
width: doc.source.meta.width,
|
|
1428
2183
|
height: doc.source.meta.height,
|
|
@@ -1437,32 +2192,77 @@ function planFreshTake(artifact, recordingName, opts = {}) {
|
|
|
1437
2192
|
});
|
|
1438
2193
|
return doc;
|
|
1439
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
|
+
}
|
|
1440
2225
|
export {
|
|
2226
|
+
AWAIT_HEARTBEAT_MS,
|
|
2227
|
+
AWAIT_POLL_S,
|
|
2228
|
+
AWAIT_TIMEOUT_S,
|
|
1441
2229
|
DEAD_SHARE_WARN_PCT,
|
|
1442
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,
|
|
1443
2236
|
EXPOSURE_ADVICE,
|
|
1444
2237
|
EXPOSURE_MATCHERS_SRC,
|
|
1445
2238
|
EXPOSURE_PROBE,
|
|
1446
2239
|
ExposureLog,
|
|
2240
|
+
MIN_SKIP_MS,
|
|
1447
2241
|
MOTION_TICK_MS,
|
|
2242
|
+
OUTPUT_MAX_BYTES,
|
|
2243
|
+
PAGE_ERROR_LINE_CHARS,
|
|
1448
2244
|
POINTER_MAX_MS,
|
|
1449
2245
|
POINTER_MIN_MS,
|
|
1450
2246
|
POINTER_MS_PER_PX,
|
|
1451
2247
|
PRESS_HOLD_MS,
|
|
1452
2248
|
PRESS_KEYS,
|
|
1453
2249
|
PRESS_LEAD_MS,
|
|
2250
|
+
PageErrorLog,
|
|
1454
2251
|
QUIET_MS,
|
|
1455
2252
|
READ_BEAT_MS,
|
|
1456
2253
|
RESPONSE_MS,
|
|
2254
|
+
RecordRefusedError,
|
|
1457
2255
|
SCROLL_MAX_MS,
|
|
1458
2256
|
SCROLL_MIN_MS,
|
|
1459
2257
|
SCROLL_MS_PER_PX,
|
|
1460
2258
|
SCROLL_SETTLE_MS,
|
|
1461
2259
|
SETTLE_CAP_MS,
|
|
1462
2260
|
SETTLE_MS,
|
|
2261
|
+
STEP_GUARDS,
|
|
1463
2262
|
SetupEnvError,
|
|
1464
2263
|
SetupError,
|
|
1465
2264
|
TRAILING_HOLD_MS,
|
|
2265
|
+
TakeClock,
|
|
1466
2266
|
WALL_PROBE,
|
|
1467
2267
|
WallError,
|
|
1468
2268
|
askedMs,
|
|
@@ -1473,37 +2273,58 @@ export {
|
|
|
1473
2273
|
clampWait,
|
|
1474
2274
|
clockMotion,
|
|
1475
2275
|
clockTyping,
|
|
2276
|
+
conditionMiss,
|
|
1476
2277
|
deadLine,
|
|
1477
2278
|
deadTime,
|
|
1478
2279
|
deadWarns,
|
|
2280
|
+
domSettled,
|
|
1479
2281
|
easeInOutCubic,
|
|
1480
2282
|
exposureLine,
|
|
2283
|
+
extForType,
|
|
2284
|
+
fetchInPageScript,
|
|
2285
|
+
fileNameProblem,
|
|
1481
2286
|
formatDurationCap,
|
|
2287
|
+
guardVerdict,
|
|
2288
|
+
guardedSteps,
|
|
1482
2289
|
headersFor,
|
|
1483
2290
|
holdLeftMs,
|
|
2291
|
+
hostedRefusals,
|
|
1484
2292
|
isIdpHost,
|
|
1485
2293
|
isSignInPath,
|
|
1486
2294
|
jpegDims,
|
|
1487
2295
|
maskInitScript,
|
|
2296
|
+
nameFromUrl,
|
|
2297
|
+
outputName,
|
|
1488
2298
|
paceLine,
|
|
1489
2299
|
paceReport,
|
|
2300
|
+
pageErrorLine,
|
|
1490
2301
|
parseHeaders,
|
|
2302
|
+
plainName,
|
|
1491
2303
|
planFreshTake,
|
|
1492
2304
|
pointerTravelMs,
|
|
2305
|
+
pollCondition,
|
|
1493
2306
|
publicUrl,
|
|
2307
|
+
readAllBytes,
|
|
2308
|
+
readCondition,
|
|
2309
|
+
readMediaSource,
|
|
1494
2310
|
realSleep,
|
|
1495
2311
|
recordTake,
|
|
1496
2312
|
redirectedAway,
|
|
1497
2313
|
resolveSetupText,
|
|
1498
2314
|
runSetup,
|
|
2315
|
+
sayCondition,
|
|
2316
|
+
sayDuration,
|
|
1499
2317
|
scrollByScript,
|
|
1500
2318
|
scrollTravelMs,
|
|
1501
2319
|
settleMs,
|
|
1502
2320
|
settleVerdict,
|
|
1503
2321
|
setupEnvNames,
|
|
2322
|
+
splitSegmentsAtSkips,
|
|
2323
|
+
typeOfName,
|
|
1504
2324
|
urlSchemeProblem,
|
|
1505
2325
|
validateActions,
|
|
1506
2326
|
validateSetup,
|
|
2327
|
+
visibleTextScript,
|
|
1507
2328
|
wallVerdict
|
|
1508
2329
|
};
|
|
1509
2330
|
//# sourceMappingURL=index.js.map
|