@vosjs/render-core 0.8.0 → 0.10.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.
@@ -701,11 +701,11 @@ function urlSchemeProblem(url) {
701
701
  return `${url} is not an http(s) address, and the recorder opens only http and https pages`;
702
702
  return null;
703
703
  }
704
- function validateSetup(value) {
704
+ function validateSetup(value, phase = "setup") {
705
705
  const errors = [];
706
- if (!Array.isArray(value)) return ["setup must be an array of steps"];
706
+ if (!Array.isArray(value)) return [`${phase} must be an array of steps`];
707
707
  value.forEach((raw, i) => {
708
- const at = `setup[${i}]`;
708
+ const at = `${phase}[${i}]`;
709
709
  if (typeof raw !== "object" || raw === null) {
710
710
  errors.push(`${at}: must be an object`);
711
711
  return;
@@ -750,73 +750,141 @@ function setupEnvNames(steps) {
750
750
  }
751
751
  return [...out];
752
752
  }
753
- function resolveSetupText(t, env) {
753
+ function scriptEnvNames(script) {
754
+ return [
755
+ .../* @__PURE__ */ new Set([
756
+ ...setupEnvNames(script.setup),
757
+ ...setupEnvNames(script.teardown)
758
+ ])
759
+ ];
760
+ }
761
+ function resolveSetupText(t, env, phase = "setup") {
754
762
  if (typeof t === "string") return { value: t, secret: false };
755
763
  const v = env[t.env];
756
764
  if (v === void 0 || v === "")
757
765
  throw new SetupEnvError(
758
- `setup: the environment variable ${t.env} is not set, and a type step reads it. Export it in the shell that runs vos record; it is never written to a file`
766
+ `${phase}: the environment variable ${t.env} is not set, and a type step reads it. Export it in the shell that runs vos record; it is never written to a file`
759
767
  );
760
768
  return { value: v, secret: true };
761
769
  }
770
+ function teardownEnvProblem(steps, env) {
771
+ for (const [i, s] of (steps ?? []).entries()) {
772
+ if (s.do !== "type" || typeof s.text !== "object") continue;
773
+ const v = env[s.text.env];
774
+ if (v === void 0 || v === "")
775
+ return `teardown[${i}] reads the environment variable ${s.text.env}, which is not set, so the take could leave behind what its teardown removes. Export it in the shell that runs vos record; it is never written to a file. Nothing was recorded.`;
776
+ }
777
+ return null;
778
+ }
779
+ var TEARDOWN_BOUND_MS = 6e4;
780
+ function teardownLine(report) {
781
+ const f = report.failed;
782
+ if (!f) return `teardown: ${report.ran} step(s) ran`;
783
+ const reason = f.error.replace(/[.\s]+$/, "");
784
+ return `TEARDOWN FAILED at teardown[${f.step}] (${f.do}${f.selector ? ` ${f.selector}` : ""}): ${reason}. What the take created may still be in the product; remove it by hand.`;
785
+ }
762
786
  async function runSetup(page, steps, opts) {
763
787
  const env = opts.env ?? {};
788
+ const phase = opts.phase ?? "setup";
789
+ const now = opts.now ?? Date.now;
790
+ const deadline = opts.boundMs === void 0 ? void 0 : now() + opts.boundMs;
764
791
  const lines = [];
765
- for (const [i, step] of steps.entries()) {
792
+ let typed = "";
793
+ const runStep = async (step, i) => {
766
794
  const settle = "ms" in step && typeof step.ms === "number" ? step.ms : 150;
767
- try {
768
- switch (step.do) {
769
- case "wait":
770
- await opts.sleep(step.ms);
771
- lines.push(`setup #${i} wait ${step.ms}ms`);
772
- break;
773
- case "goto":
774
- await (opts.goto ? opts.goto(step.url) : page.goto(step.url, {
775
- waitUntil: "networkidle",
776
- timeout: 45e3
777
- })).catch(() => null);
778
- lines.push(`setup #${i} goto ${step.url}`);
779
- break;
780
- case "press":
781
- await page.keyboard.press(step.key);
782
- await opts.sleep(settle);
783
- lines.push(`setup #${i} press ${step.key}`);
784
- break;
785
- case "click": {
786
- const loc = page.locator(step.selector).first();
787
- await loc.waitFor({ state: "visible", timeout: 8e3 });
788
- await loc.click();
789
- await opts.sleep(settle);
790
- lines.push(`setup #${i} click ${step.selector}`);
791
- break;
792
- }
793
- case "type": {
794
- const loc = page.locator(step.selector).first();
795
- await loc.waitFor({ state: "visible", timeout: 8e3 });
796
- const { value, secret } = resolveSetupText(step.text, env);
797
- await loc.fill(value);
798
- await opts.sleep(settle);
799
- lines.push(
800
- `setup #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`
801
- );
802
- break;
803
- }
795
+ switch (step.do) {
796
+ case "wait":
797
+ await opts.sleep(step.ms);
798
+ return `${phase} #${i} wait ${step.ms}ms`;
799
+ case "goto":
800
+ await (opts.goto ? opts.goto(step.url) : page.goto(step.url, {
801
+ waitUntil: "networkidle",
802
+ timeout: 45e3
803
+ })).catch(() => null);
804
+ return `${phase} #${i} goto ${step.url}`;
805
+ case "press":
806
+ await page.keyboard.press(step.key);
807
+ await opts.sleep(settle);
808
+ return `${phase} #${i} press ${step.key}`;
809
+ case "click": {
810
+ const loc = page.locator(step.selector).first();
811
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
812
+ await loc.click();
813
+ await opts.sleep(settle);
814
+ return `${phase} #${i} click ${step.selector}`;
804
815
  }
816
+ case "type": {
817
+ const loc = page.locator(step.selector).first();
818
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
819
+ const { value, secret } = resolveSetupText(step.text, env, phase);
820
+ typed = value;
821
+ await loc.fill(value);
822
+ await opts.sleep(settle);
823
+ return `${phase} #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`;
824
+ }
825
+ }
826
+ };
827
+ for (const [i, step] of steps.entries()) {
828
+ typed = "";
829
+ try {
830
+ lines.push(
831
+ await withinDeadline(
832
+ runStep(step, i),
833
+ deadline,
834
+ now,
835
+ `the ${phase} ran past its ${sayBound(opts.boundMs ?? 0)} bound`
836
+ )
837
+ );
805
838
  } catch (e) {
806
- if (e instanceof SetupEnvError) throw e;
839
+ if (e instanceof SetupEnvError && phase === "setup") throw e;
807
840
  return {
808
841
  ran: i,
809
842
  lines,
810
843
  failed: {
811
844
  step: i,
812
845
  do: step.do,
813
- ..."selector" in step ? { selector: step.selector } : {}
846
+ ..."selector" in step ? { selector: step.selector } : {},
847
+ error: reasonWords(e, typed)
814
848
  }
815
849
  };
816
850
  }
817
851
  }
818
852
  return { ran: steps.length, lines };
819
853
  }
854
+ function sayBound(ms) {
855
+ return ms >= 1e3 ? `${Math.round(ms / 1e3)} s` : `${ms} ms`;
856
+ }
857
+ function withinDeadline(p, deadline, now, message) {
858
+ if (deadline === void 0) return p;
859
+ const left = deadline - now();
860
+ return new Promise((resolve, reject) => {
861
+ const timer = setTimeout(
862
+ () => {
863
+ p.catch(() => {
864
+ });
865
+ reject(new Error(message));
866
+ },
867
+ Math.max(0, left)
868
+ );
869
+ p.then(
870
+ (v) => {
871
+ clearTimeout(timer);
872
+ resolve(v);
873
+ },
874
+ (e) => {
875
+ clearTimeout(timer);
876
+ reject(e);
877
+ }
878
+ );
879
+ });
880
+ }
881
+ var ANSI_COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
882
+ function reasonWords(e, typed) {
883
+ const raw = e instanceof Error ? e.message : String(e);
884
+ const line = (raw.replace(ANSI_COLOUR, "").split("\n")[0] ?? "").trim();
885
+ if (typed && line.includes(typed)) return "the field could not be filled";
886
+ return line || "the step failed";
887
+ }
820
888
  var SetupEnvError = class extends Error {
821
889
  constructor(message) {
822
890
  super(message);
@@ -1274,8 +1342,79 @@ async function fitSurfaceToViewport(cdp, width, height) {
1274
1342
  function visibleTextScript(text) {
1275
1343
  return `(() => { const b = document.body; return !!b && (b.innerText || '').includes(${JSON.stringify(text)}) })()`;
1276
1344
  }
1345
+ var JS_SAID_MAX = 120;
1346
+ var JS_VALUE_MAX = 2048;
1347
+ function jsConditionScript(expr) {
1348
+ return `(async () => {
1349
+ let v;
1350
+ try { v = await (${expr}
1351
+ ); } catch (e) { return JSON.stringify({ threw: String(e && e.message ? e.message : e) }) }
1352
+ const out = { truthy: !!v };
1353
+ try { const j = JSON.stringify(v); if (j !== undefined) out.json = j } catch (e) {}
1354
+ try { out.said = typeof v === 'string' ? JSON.stringify(v) : String(v) } catch (e) { out.said = typeof v }
1355
+ if (out.json !== undefined && typeof v !== 'string') out.said = out.json
1356
+ return JSON.stringify(out);
1357
+ })()`;
1358
+ }
1359
+ function sameJson(a, b) {
1360
+ if (a === b) return true;
1361
+ if (typeof a !== typeof b || a === null || b === null) return false;
1362
+ if (typeof a !== "object") return false;
1363
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
1364
+ if (Array.isArray(a)) {
1365
+ const bb = b;
1366
+ return a.length === bb.length && a.every((v, i) => sameJson(v, bb[i]));
1367
+ }
1368
+ const ao = a;
1369
+ const bo = b;
1370
+ const keys = Object.keys(ao);
1371
+ if (keys.length !== Object.keys(bo).length) return false;
1372
+ return keys.every(
1373
+ (k) => Object.prototype.hasOwnProperty.call(bo, k) && sameJson(ao[k], bo[k])
1374
+ );
1375
+ }
1376
+ async function readJs(page, c) {
1377
+ let raw;
1378
+ try {
1379
+ raw = await page.evaluate(jsConditionScript(c.js));
1380
+ } catch (e) {
1381
+ const why = e instanceof Error ? e.message : String(e);
1382
+ if (/SyntaxError/.test(why) || e instanceof Error && e.name === "SyntaxError")
1383
+ return { holds: false, threw: `it does not parse: ${why.split("\n")[0]}` };
1384
+ throw e;
1385
+ }
1386
+ let got;
1387
+ try {
1388
+ got = JSON.parse(raw);
1389
+ } catch {
1390
+ return { holds: false };
1391
+ }
1392
+ if (got.threw !== void 0) return { holds: false, threw: got.threw };
1393
+ let value;
1394
+ let hasValue = false;
1395
+ if (got.json !== void 0) {
1396
+ try {
1397
+ value = JSON.parse(got.json);
1398
+ hasValue = true;
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ const holds = c.equals !== void 0 ? hasValue && sameJson(value, c.equals) : got.truthy === true;
1403
+ return {
1404
+ holds,
1405
+ ...hasValue ? { value } : {},
1406
+ said: clip(got.said ?? "undefined", JS_SAID_MAX)
1407
+ };
1408
+ }
1409
+ var clip = (s, max) => s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
1410
+ function keptValue(value) {
1411
+ const json = JSON.stringify(value);
1412
+ if (json === void 0) return void 0;
1413
+ return json.length <= JS_VALUE_MAX ? value : clip(json, JS_VALUE_MAX);
1414
+ }
1277
1415
  async function readCondition(page, c) {
1278
1416
  try {
1417
+ if (c.js !== void 0) return await readJs(page, { ...c, js: c.js });
1279
1418
  if (c.text !== void 0)
1280
1419
  return { holds: await page.evaluate(visibleTextScript(c.text)) };
1281
1420
  if (c.gone !== void 0)
@@ -1347,6 +1486,8 @@ function sayDuration(ms) {
1347
1486
  return rest ? `${m} min ${rest} s` : `${m} min`;
1348
1487
  }
1349
1488
  function sayCondition(c) {
1489
+ if (c.js !== void 0)
1490
+ return c.equals !== void 0 ? `${sayJs(c.js)} to equal ${clip(JSON.stringify(c.equals) ?? "undefined", JS_SAID_MAX)}` : `${sayJs(c.js)} to hold`;
1350
1491
  if (c.text !== void 0) return `text "${c.text}" to show`;
1351
1492
  if (c.gone !== void 0) return `'${c.gone}' to go`;
1352
1493
  if (c.enabled === true) return `'${c.selector}' to be enabled`;
@@ -1355,6 +1496,12 @@ function sayCondition(c) {
1355
1496
  }
1356
1497
  function conditionMiss(c, last, ms) {
1357
1498
  const t = sayDuration(ms);
1499
+ if (c.js !== void 0) {
1500
+ if (last.threw !== void 0)
1501
+ return `${sayJs(c.js)} threw for ${t} (${clip(last.threw, JS_SAID_MAX)})`;
1502
+ const was = last.said ?? "never answered";
1503
+ return c.equals !== void 0 ? `${sayJs(c.js)} was ${was}, not ${clip(JSON.stringify(c.equals) ?? "undefined", JS_SAID_MAX)}, for ${t}` : `${sayJs(c.js)} was ${was} for ${t}`;
1504
+ }
1358
1505
  if (c.text !== void 0)
1359
1506
  return `no visible text contained "${c.text}" for ${t}`;
1360
1507
  if (c.gone !== void 0) return `'${c.gone}' was still there after ${t}`;
@@ -1364,6 +1511,12 @@ function conditionMiss(c, last, ms) {
1364
1511
  return `'${c.selector}' stayed enabled for ${t}`;
1365
1512
  return `'${c.selector}' never appeared in ${t}`;
1366
1513
  }
1514
+ function sayJs(expr) {
1515
+ return `js \`${clip(expr.replace(/\s+/g, " ").trim(), 80)}\``;
1516
+ }
1517
+ function sayValue(value) {
1518
+ return clip(JSON.stringify(value) ?? "undefined", JS_SAID_MAX);
1519
+ }
1367
1520
 
1368
1521
  // src/record/ready.ts
1369
1522
  var READY_MODES = ["dom", "networkidle"];
@@ -1814,10 +1967,455 @@ async function appSettled(page, frames, opts) {
1814
1967
  });
1815
1968
  }
1816
1969
 
1970
+ // src/record/peers.ts
1971
+ var PEER_VERBS = [
1972
+ "wait",
1973
+ "move",
1974
+ "hover",
1975
+ "click",
1976
+ "type",
1977
+ "press",
1978
+ "scroll",
1979
+ "drag",
1980
+ "expect"
1981
+ ];
1982
+ var MAX_PEERS = 4;
1983
+ var MAX_PEER_STEPS = 200;
1984
+ var TAKE_ONLY = {
1985
+ guard: "a guard is pressed only on the filmed page, under --confirm",
1986
+ read: "read keeps words from the filmed page",
1987
+ caption: "a caption is a beat of the film, which is the take\u2019s",
1988
+ settle: "settle reads the filmed page\u2019s frames",
1989
+ zoom: "a zoom mark picks a moment on the cursor track, and a peer is never on it"
1990
+ };
1991
+ var PEER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
1992
+ function validatePeers(value, takeSteps, validate) {
1993
+ if (!Array.isArray(value))
1994
+ return [
1995
+ "peers must be an array of { id, url?, viewport?, storageState?, steps }"
1996
+ ];
1997
+ const errors = [];
1998
+ if (value.length > MAX_PEERS)
1999
+ errors.push(
2000
+ `peers names ${value.length} peers; a take holds at most ${MAX_PEERS} (each is a whole browser)`
2001
+ );
2002
+ const takeIds = new Set(
2003
+ (Array.isArray(takeSteps) ? takeSteps : []).map((s) => s?.id).filter((id) => typeof id === "string")
2004
+ );
2005
+ const seen = /* @__PURE__ */ new Set();
2006
+ value.forEach((raw, i) => {
2007
+ const at = `peers[${i}]`;
2008
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
2009
+ errors.push(`${at}: must be an object`);
2010
+ return;
2011
+ }
2012
+ const p = raw;
2013
+ if (typeof p.id !== "string" || !PEER_ID.test(p.id))
2014
+ errors.push(
2015
+ `${at}: id must be a short word (letters, digits, - and _, at most 32), which lines and meta.peers name the peer by`
2016
+ );
2017
+ else if (seen.has(p.id)) errors.push(`${at}: duplicate peer id "${p.id}"`);
2018
+ else seen.add(p.id);
2019
+ if (p.url !== void 0) {
2020
+ if (typeof p.url !== "string") errors.push(`${at}: url must be a string`);
2021
+ else {
2022
+ const problem = urlSchemeProblem(p.url);
2023
+ if (problem) errors.push(`${at}: url ${problem}`);
2024
+ }
2025
+ }
2026
+ if (p.viewport !== void 0) {
2027
+ const v = p.viewport;
2028
+ if (typeof v !== "object" || v === null || typeof v.width !== "number" || typeof v.height !== "number")
2029
+ errors.push(`${at}: viewport must be { width, height }`);
2030
+ }
2031
+ if (p.storageState !== void 0 && (typeof p.storageState !== "string" || !p.storageState.trim()))
2032
+ errors.push(
2033
+ `${at}: storageState must name a Playwright storage state file (the second person's sign-in)`
2034
+ );
2035
+ for (const k of Object.keys(p))
2036
+ if (!["id", "url", "viewport", "storageState", "steps"].includes(k))
2037
+ errors.push(
2038
+ `${at}: a peer takes id, url, viewport, storageState and steps, not ${k}`
2039
+ );
2040
+ if (!Array.isArray(p.steps) || p.steps.length === 0) {
2041
+ errors.push(`${at}: steps must be a non-empty array`);
2042
+ return;
2043
+ }
2044
+ if (p.steps.length > MAX_PEER_STEPS)
2045
+ errors.push(
2046
+ `${at}: steps holds ${p.steps.length} steps; a peer takes at most ${MAX_PEER_STEPS}`
2047
+ );
2048
+ const steps = p.steps;
2049
+ steps.forEach((rawStep, j) => {
2050
+ const sat = `${at}.steps[${j}]`;
2051
+ if (typeof rawStep !== "object" || rawStep === null) return;
2052
+ const s = rawStep;
2053
+ if (typeof s.do === "string" && !PEER_VERBS.includes(s.do))
2054
+ errors.push(
2055
+ `${sat}: a peer step is one of ${PEER_VERBS.join(", ")}; ${/^[aeiou]/.test(s.do) ? "an" : "a"} ${s.do} belongs to the filmed page${s.do === "await" ? " (wait for what the peer did with the take\u2019s own await or expect)" : ""}`
2056
+ );
2057
+ for (const [k, why] of Object.entries(TAKE_ONLY))
2058
+ if (s[k] !== void 0)
2059
+ errors.push(`${sat}: no ${k} on a peer step: ${why}`);
2060
+ if (s.do === "drag" && typeof s.path === "string")
2061
+ errors.push(
2062
+ `${sat}: a peer draws its path inline (an array of points), never from a file`
2063
+ );
2064
+ if (s.with !== void 0 && s.after !== void 0)
2065
+ errors.push(
2066
+ `${sat}: with or after, not both (with starts as the take's step starts, after once it ends)`
2067
+ );
2068
+ for (const k of ["with", "after"]) {
2069
+ const v = s[k];
2070
+ if (v === void 0) continue;
2071
+ if (typeof v !== "string" || !v.trim())
2072
+ errors.push(`${sat}: ${k} must name a take step by its id`);
2073
+ else if (!takeIds.has(v))
2074
+ errors.push(
2075
+ `${sat}: ${k} "${v}" names no take step (the take's steps with ids: ${takeIds.size ? [...takeIds].join(", ") : "none"}); give the take step an id`
2076
+ );
2077
+ }
2078
+ });
2079
+ for (const e of validate(steps))
2080
+ errors.push(e.replace(/^steps\[/, `${at}.steps[`));
2081
+ });
2082
+ return errors;
2083
+ }
2084
+ function peersHostedRefusal(peers) {
2085
+ if (!Array.isArray(peers) || !peers.length) return null;
2086
+ return "the script has peers (more browsers on the page, off camera), and a hosted take runs one browser: record it on this machine";
2087
+ }
2088
+ var StepSignals = class {
2089
+ started = /* @__PURE__ */ new Map();
2090
+ ended = /* @__PURE__ */ new Map();
2091
+ began = deferred();
2092
+ closed = false;
2093
+ constructor(ids) {
2094
+ for (const id of ids) {
2095
+ this.started.set(id, deferred());
2096
+ this.ended.set(id, deferred());
2097
+ }
2098
+ }
2099
+ begin() {
2100
+ this.began.resolve(true);
2101
+ }
2102
+ start(id) {
2103
+ if (id) this.started.get(id)?.resolve(true);
2104
+ }
2105
+ end(id) {
2106
+ if (id) this.ended.get(id)?.resolve(true);
2107
+ }
2108
+ close() {
2109
+ this.closed = true;
2110
+ this.began.resolve(false);
2111
+ for (const d of [...this.started.values(), ...this.ended.values()])
2112
+ d.resolve(false);
2113
+ }
2114
+ get isClosed() {
2115
+ return this.closed;
2116
+ }
2117
+ /** When a step with this trigger may start: true, or false for never. */
2118
+ when(trigger) {
2119
+ const d = trigger.with ? this.started.get(trigger.with) : trigger.after ? this.ended.get(trigger.after) : this.began;
2120
+ if (!d) return { ready: Promise.resolve(false), already: false };
2121
+ return { ready: d.promise, already: d.settled };
2122
+ }
2123
+ };
2124
+ function deferred() {
2125
+ let resolve = () => {
2126
+ };
2127
+ const d = {
2128
+ promise: new Promise((r) => {
2129
+ resolve = r;
2130
+ }),
2131
+ resolve: (v) => {
2132
+ if (d.settled) return;
2133
+ d.settled = true;
2134
+ resolve(v);
2135
+ },
2136
+ settled: false
2137
+ };
2138
+ return d;
2139
+ }
2140
+ var PEER_VALUE_MAX = 2048;
2141
+ function peerValue(last) {
2142
+ if (!("value" in last)) return void 0;
2143
+ const value = last.value;
2144
+ const json = JSON.stringify(value);
2145
+ if (json === void 0) return void 0;
2146
+ return json.length <= PEER_VALUE_MAX ? value : `${json.slice(0, PEER_VALUE_MAX - 1)}\u2026`;
2147
+ }
2148
+ var EXPECT_TIMEOUT_S = 5;
2149
+ var EXPECT_EVERY_MS = 150;
2150
+ async function runPeer(peer, rawPage, signals, opts) {
2151
+ const bound = new StepBound();
2152
+ const page = bounded(rawPage, bound);
2153
+ const { clock, log } = opts;
2154
+ const run = { id: peer.id, steps: [], notRun: [] };
2155
+ const cur = { x: 24, y: 24 };
2156
+ const moveTo = async (tx, ty) => {
2157
+ const from = { ...cur };
2158
+ const dist = Math.hypot(tx - from.x, ty - from.y);
2159
+ await clockMotion(
2160
+ opts.dry ? 0 : pointerTravelMs(dist),
2161
+ async (u) => {
2162
+ cur.x = from.x + (tx - from.x) * u;
2163
+ cur.y = from.y + (ty - from.y) * u;
2164
+ await page.mouse.move(cur.x, cur.y);
2165
+ },
2166
+ clock
2167
+ );
2168
+ };
2169
+ const spotOf = async (s) => {
2170
+ if (s.selector === void 0) return { x: s.x ?? cur.x, y: s.y ?? cur.y };
2171
+ const loc = page.locator(s.selector).first();
2172
+ try {
2173
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
2174
+ await loc.scrollIntoViewIfNeeded();
2175
+ const bb = await loc.boundingBox();
2176
+ return bb ? { x: bb.x + bb.width / 2, y: bb.y + bb.height / 2 } : null;
2177
+ } catch {
2178
+ return null;
2179
+ }
2180
+ };
2181
+ const press = async (how) => {
2182
+ const held = [];
2183
+ try {
2184
+ for (const m of how.modifiers ?? []) {
2185
+ const key = resolveModifier(m);
2186
+ await page.keyboard.down(key);
2187
+ held.push(key);
2188
+ }
2189
+ await clock.sleep(PRESS_LEAD_MS);
2190
+ const count = how.count ?? 1;
2191
+ for (let n = 1; n <= count; n++) {
2192
+ if (n > 1) await clock.sleep(MULTI_CLICK_GAP_MS);
2193
+ const o = {
2194
+ ...how.button && how.button !== "left" ? { button: how.button } : {},
2195
+ ...n > 1 ? { clickCount: n } : {}
2196
+ };
2197
+ const has = Object.keys(o).length > 0;
2198
+ await (has ? page.mouse.down(o) : page.mouse.down());
2199
+ await clock.sleep(PRESS_HOLD_MS);
2200
+ await (has ? page.mouse.up(o) : page.mouse.up());
2201
+ }
2202
+ } finally {
2203
+ for (const key of held.reverse())
2204
+ await page.keyboard.up(key).catch(() => void 0);
2205
+ }
2206
+ };
2207
+ for (const [i, step] of peer.steps.entries()) {
2208
+ const tag = `peer ${peer.id} #${i}${step.id ? ` ${step.id}` : ""}`;
2209
+ const trigger = signals.when(step);
2210
+ const go = await trigger.ready;
2211
+ if (!go) {
2212
+ run.notRun = peer.steps.slice(i).map((_, k) => i + k);
2213
+ log(
2214
+ ` (${tag} ${step.do}: not run: ${step.with ? `the take's step ${step.with} never started` : step.after ? `the take's step ${step.after} never ended` : "the take ended first"})`
2215
+ );
2216
+ break;
2217
+ }
2218
+ const late = (step.with !== void 0 || step.after !== void 0) && trigger.already;
2219
+ const tStart = opts.footageMs();
2220
+ let selector;
2221
+ let reason = null;
2222
+ let value;
2223
+ bound.start(
2224
+ stepBoundMs(
2225
+ step,
2226
+ askedMs(step),
2227
+ { expectS: EXPECT_TIMEOUT_S, awaitS: 0, downloadS: 0 }
2228
+ )
2229
+ );
2230
+ try {
2231
+ switch (step.do) {
2232
+ case "wait":
2233
+ await clock.sleep(step.ms);
2234
+ break;
2235
+ case "move":
2236
+ await moveTo(step.x, step.y);
2237
+ break;
2238
+ case "hover": {
2239
+ selector = step.selector;
2240
+ const spot = await spotOf(step);
2241
+ if (!spot) reason = `'${step.selector}' never appeared`;
2242
+ else {
2243
+ await moveTo(spot.x, spot.y);
2244
+ await clock.sleep(step.ms ?? 700);
2245
+ }
2246
+ break;
2247
+ }
2248
+ case "click": {
2249
+ selector = step.selector;
2250
+ const spot = await spotOf(step);
2251
+ if (!spot) {
2252
+ reason = `'${step.selector}' never appeared`;
2253
+ break;
2254
+ }
2255
+ await moveTo(spot.x, spot.y);
2256
+ await press(step);
2257
+ await clock.sleep(step.ms ?? SETTLE_MS.click);
2258
+ break;
2259
+ }
2260
+ case "type": {
2261
+ selector = step.selector;
2262
+ if (step.focus !== false) {
2263
+ const spot = await spotOf(step);
2264
+ if (!spot) {
2265
+ reason = `'${step.selector}' never appeared`;
2266
+ break;
2267
+ }
2268
+ await moveTo(spot.x, spot.y);
2269
+ await press({});
2270
+ }
2271
+ if (step.at === "end") await page.keyboard.press("End");
2272
+ else if (step.at === "start") await page.keyboard.press("Home");
2273
+ if (step.replace) await page.keyboard.press("ControlOrMeta+a");
2274
+ await clockTyping(
2275
+ [...step.text],
2276
+ opts.dry ? 0 : step.delayMs ?? 40,
2277
+ (ch) => page.keyboard.type(ch),
2278
+ clock
2279
+ );
2280
+ await clock.sleep(step.ms ?? SETTLE_MS.type);
2281
+ break;
2282
+ }
2283
+ case "press": {
2284
+ selector = step.selector;
2285
+ if (step.selector !== void 0) {
2286
+ const spot = await spotOf(step);
2287
+ if (!spot) {
2288
+ reason = `'${step.selector}' never appeared`;
2289
+ break;
2290
+ }
2291
+ }
2292
+ const keys = pressSequence(step);
2293
+ for (const [k, key] of keys.entries()) {
2294
+ if (k > 0) await clock.sleep(KEY_SEQUENCE_GAP_MS);
2295
+ await page.keyboard.press(resolveChord(key));
2296
+ }
2297
+ await clock.sleep(step.ms ?? SETTLE_MS.press);
2298
+ break;
2299
+ }
2300
+ case "scroll":
2301
+ await page.mouse.wheel(0, step.dy);
2302
+ await clock.sleep(step.ms ?? 200);
2303
+ break;
2304
+ case "drag": {
2305
+ selector = step.selector;
2306
+ const read = step.path !== void 0 ? readPath(step.path) : null;
2307
+ if (read && "problems" in read) {
2308
+ reason = `its path ${read.problems.join("; ")}`;
2309
+ break;
2310
+ }
2311
+ const held = [];
2312
+ try {
2313
+ if (read) {
2314
+ const sched = schedulePath(read, step.ms);
2315
+ await moveTo(sched[0].x, sched[0].y);
2316
+ for (const m of step.modifiers ?? []) {
2317
+ const key = resolveModifier(m);
2318
+ await page.keyboard.down(key);
2319
+ held.push(key);
2320
+ }
2321
+ await page.mouse.down();
2322
+ const t0 = clock.now();
2323
+ for (const p of sched.slice(1)) {
2324
+ const wait = t0 + (opts.dry ? 0 : p.at) - clock.now();
2325
+ if (wait > 0) await clock.sleep(wait);
2326
+ cur.x = p.x;
2327
+ cur.y = p.y;
2328
+ await page.mouse.move(p.x, p.y);
2329
+ }
2330
+ } else {
2331
+ const start = await spotOf(step);
2332
+ if (!start) {
2333
+ reason = `'${step.selector}' never appeared`;
2334
+ break;
2335
+ }
2336
+ await moveTo(start.x, start.y);
2337
+ for (const m of step.modifiers ?? []) {
2338
+ const key = resolveModifier(m);
2339
+ await page.keyboard.down(key);
2340
+ held.push(key);
2341
+ }
2342
+ await page.mouse.down();
2343
+ const from = { ...cur };
2344
+ const tx = step.tx;
2345
+ const ty = step.ty;
2346
+ await clockMotion(
2347
+ opts.dry ? 0 : step.ms ?? 700,
2348
+ async (u) => {
2349
+ cur.x = from.x + (tx - from.x) * u;
2350
+ cur.y = from.y + (ty - from.y) * u;
2351
+ await page.mouse.move(cur.x, cur.y);
2352
+ },
2353
+ clock
2354
+ );
2355
+ }
2356
+ await page.mouse.up();
2357
+ } finally {
2358
+ for (const key of held.reverse())
2359
+ await page.keyboard.up(key).catch(() => void 0);
2360
+ }
2361
+ await clock.sleep(DRAG_HOLD_MS);
2362
+ break;
2363
+ }
2364
+ case "expect": {
2365
+ const cond = step;
2366
+ selector = step.selector ?? step.gone;
2367
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
2368
+ const res = await pollCondition(page, cond, {
2369
+ timeoutMs,
2370
+ everyMs: EXPECT_EVERY_MS,
2371
+ sleep: clock.sleep
2372
+ });
2373
+ value = peerValue(res.last);
2374
+ if (!res.ok) reason = conditionMiss(cond, res.last, timeoutMs);
2375
+ else log(` ${tag} expect ${sayCondition(cond)}: holds`);
2376
+ break;
2377
+ }
2378
+ default:
2379
+ reason = `a peer does not ${step.do}`;
2380
+ }
2381
+ } catch (e) {
2382
+ reason = e instanceof Error ? e.message.split("\n")[0] : String(e);
2383
+ }
2384
+ const hang = bound.end();
2385
+ if (hang.hung)
2386
+ reason = `the page stopped answering: this ${step.do} ran past its bound`;
2387
+ if (reason) log(` (${tag} ${step.do}: ${reason})`);
2388
+ else if (step.do !== "expect")
2389
+ log(` ${tag} ${step.do}${selector ? ` ${selector}` : ""}`);
2390
+ run.steps.push({
2391
+ step: i,
2392
+ ...step.id ? { id: step.id } : {},
2393
+ do: step.do,
2394
+ ...step.with ? { with: step.with } : {},
2395
+ ...step.after ? { after: step.after } : {},
2396
+ ...selector ? { selector } : {},
2397
+ tStart: +(tStart / 1e3).toFixed(3),
2398
+ tEnd: +(opts.footageMs() / 1e3).toFixed(3),
2399
+ ...reason ? { skipped: true, reason } : {},
2400
+ ...late ? { late: true } : {},
2401
+ ...value !== void 0 ? { value } : {}
2402
+ });
2403
+ }
2404
+ return run;
2405
+ }
2406
+
1817
2407
  // src/record/actions.ts
1818
2408
  var STEP_GUARDS = ["spend"];
2409
+ var ZOOM_MARK_VERBS = [
2410
+ "click",
2411
+ "drag",
2412
+ "type",
2413
+ "paste",
2414
+ "press",
2415
+ "hover"
2416
+ ];
1819
2417
  var GUARDABLE = /* @__PURE__ */ new Set(["click", "press", "type"]);
1820
- var EXPECT_TIMEOUT_S = 5;
2418
+ var EXPECT_TIMEOUT_S2 = 5;
1821
2419
  var AWAIT_TIMEOUT_S = 600;
1822
2420
  var AWAIT_POLL_S = 2;
1823
2421
  var DOWNLOAD_TIMEOUT_S = 30;
@@ -1875,6 +2473,8 @@ function validateActions(value) {
1875
2473
  }
1876
2474
  }
1877
2475
  if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
2476
+ if (obj.teardown !== void 0)
2477
+ errors.push(...validateSetup(obj.teardown, "teardown"));
1878
2478
  const ready = readyProblem(obj.ready);
1879
2479
  if (ready) errors.push(ready);
1880
2480
  if (obj.settle !== void 0 && typeof obj.settle !== "boolean")
@@ -1901,6 +2501,14 @@ function validateActions(value) {
1901
2501
  errors.push("steps must be a non-empty array");
1902
2502
  return errors;
1903
2503
  }
2504
+ if (obj.peers !== void 0)
2505
+ errors.push(
2506
+ ...validatePeers(
2507
+ obj.peers,
2508
+ obj.steps,
2509
+ (steps) => validateActions({ steps })
2510
+ )
2511
+ );
1904
2512
  const seenIds = /* @__PURE__ */ new Set();
1905
2513
  obj.steps.forEach((raw, i) => {
1906
2514
  const at = `steps[${i}]`;
@@ -2020,6 +2628,14 @@ function validateActions(value) {
2020
2628
  if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
2021
2629
  errors.push(`${at}: move needs x and y (CSS px)`);
2022
2630
  }
2631
+ if (s.zoom !== void 0) {
2632
+ if (typeof s.zoom !== "boolean")
2633
+ errors.push(`${at}: zoom must be true or false`);
2634
+ else if (!ZOOM_MARK_VERBS.includes(s.do))
2635
+ errors.push(
2636
+ `${at}: zoom marks a gesture (${ZOOM_MARK_VERBS.join(", ")}); a ${s.do} shows nothing to zoom on`
2637
+ );
2638
+ }
2023
2639
  if (s.guard !== void 0) {
2024
2640
  if (!STEP_GUARDS.includes(s.guard))
2025
2641
  errors.push(
@@ -2035,16 +2651,17 @@ function validateActions(value) {
2035
2651
  );
2036
2652
  }
2037
2653
  if (s.do === "expect") {
2038
- const given = ["selector", "text", "gone"].filter(
2654
+ const given = ["selector", "text", "gone", "js"].filter(
2039
2655
  (k) => s[k] !== void 0
2040
2656
  );
2041
2657
  if (given.length !== 1)
2042
2658
  errors.push(
2043
- `${at}: expect needs exactly one of selector, text or gone${given.length ? ` (it has ${given.join(" and ")})` : ""}`
2659
+ `${at}: expect needs exactly one of selector, text, gone or js${given.length ? ` (it has ${given.join(" and ")})` : ""}`
2044
2660
  );
2045
2661
  for (const k of given)
2046
2662
  if (!isFilled(s[k]))
2047
2663
  errors.push(`${at}: expect ${k} must be a non-empty string`);
2664
+ errors.push(...equalsProblem(`${at}: expect`, s));
2048
2665
  if (s.enabled !== void 0) {
2049
2666
  if (typeof s.enabled !== "boolean")
2050
2667
  errors.push(`${at}: expect enabled must be true or false`);
@@ -2059,28 +2676,29 @@ function validateActions(value) {
2059
2676
  const u = s.until;
2060
2677
  if (typeof u !== "object" || u === null || Array.isArray(u)) {
2061
2678
  errors.push(
2062
- `${at}: await needs until, one of { "selector" }, { "text" } or { "gone" }`
2679
+ `${at}: await needs until, one of { "selector" }, { "text" }, { "gone" } or { "js" }`
2063
2680
  );
2064
2681
  } else {
2065
2682
  const until = u;
2066
2683
  const keys = Object.keys(until);
2067
2684
  const known = keys.filter(
2068
- (k) => k === "selector" || k === "text" || k === "gone"
2685
+ (k) => k === "selector" || k === "text" || k === "gone" || k === "js"
2069
2686
  );
2070
- const unknown = keys.filter((k) => !known.includes(k));
2687
+ const unknown = keys.filter((k) => !known.includes(k) && k !== "equals");
2071
2688
  if (unknown.length)
2072
2689
  errors.push(
2073
- `${at}: await until takes selector, text or gone, not ${unknown.join(", ")}`
2690
+ `${at}: await until takes selector, text, gone or js (with equals), not ${unknown.join(", ")}`
2074
2691
  );
2075
2692
  if (known.length !== 1)
2076
2693
  errors.push(
2077
- `${at}: await until needs exactly one of selector, text or gone${known.length ? ` (it has ${known.join(" and ")})` : ""}`
2694
+ `${at}: await until needs exactly one of selector, text, gone or js${known.length ? ` (it has ${known.join(" and ")})` : ""}`
2078
2695
  );
2079
2696
  for (const k of known) {
2080
2697
  const v = until[k];
2081
2698
  if (typeof v !== "string" || !v.trim())
2082
2699
  errors.push(`${at}: await until.${k} must be a non-empty string`);
2083
2700
  }
2701
+ errors.push(...equalsProblem(`${at}: await until`, until));
2084
2702
  }
2085
2703
  errors.push(...secondsProblem(at, "await timeout", s.timeout));
2086
2704
  errors.push(...secondsProblem(at, "await poll", s.poll));
@@ -2151,6 +2769,29 @@ function modifiersProblem(at, verb, v) {
2151
2769
  return [`${at}: ${verb} modifiers names a modifier twice`];
2152
2770
  return [];
2153
2771
  }
2772
+ function equalsProblem(at, s) {
2773
+ if (s.equals === void 0) return [];
2774
+ if (s.js === void 0)
2775
+ return [
2776
+ `${at} equals is the value a js expression must answer, so it needs js`
2777
+ ];
2778
+ if (!isJsonValue(s.equals, 0))
2779
+ return [
2780
+ `${at} equals must be a JSON value (a number, a string, true, false, null, a list or an object of them)`
2781
+ ];
2782
+ return [];
2783
+ }
2784
+ function isJsonValue(v, depth) {
2785
+ if (depth > 32) return false;
2786
+ if (v === null || typeof v === "string" || typeof v === "boolean") return true;
2787
+ if (typeof v === "number") return Number.isFinite(v);
2788
+ if (Array.isArray(v)) return v.every((x) => isJsonValue(x, depth + 1));
2789
+ if (typeof v === "object")
2790
+ return Object.values(v).every(
2791
+ (x) => isJsonValue(x, depth + 1)
2792
+ );
2793
+ return false;
2794
+ }
2154
2795
  var isFilled = (v) => typeof v === "string" && v.trim().length > 0;
2155
2796
  function secondsProblem(at, what, v) {
2156
2797
  if (v === void 0) return [];
@@ -2203,6 +2844,8 @@ function guardVerdict(actions, confirm) {
2203
2844
  }
2204
2845
  function hostedRefusals(actions) {
2205
2846
  const out = [];
2847
+ const peers = peersHostedRefusal(actions.peers);
2848
+ if (peers) out.push(peers);
2206
2849
  for (const g of guardedSteps(actions))
2207
2850
  out.push(
2208
2851
  `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}`
@@ -2216,6 +2859,10 @@ function hostedRefusals(actions) {
2216
2859
  out.push(
2217
2860
  `steps[${i}] is a paste, and a hosted browser has no clipboard to paste from: record it on this machine`
2218
2861
  );
2862
+ if (s.do === "expect" && s.js !== void 0 || s.do === "await" && s.until.js !== void 0)
2863
+ out.push(
2864
+ `steps[${i}] ${s.do === "expect" ? "checks" : "waits on"} a js expression, which runs as code in the page, and a hosted browser runs no script it was handed: record it on this machine, or check what the page shows (selector, text or gone)`
2865
+ );
2219
2866
  if (s.do === "drag" && typeof s.path === "string")
2220
2867
  out.push(
2221
2868
  `steps[${i}] draws a path from the file "${s.path}", and a hosted take carries the script alone: write the points inline as path`
@@ -2255,7 +2902,7 @@ async function loadScriptFiles(actions, source) {
2255
2902
  } catch (e) {
2256
2903
  const why = e instanceof Error ? e.message : String(e);
2257
2904
  return {
2258
- problem: f.as === "paste" ? `${at} pastes the file "${f.name}", which could not be read beside the actions file (${why}): a file paste needs its file beside the script, so put ${f.name} next to it (a take fetched from vos.so carries its paths and its words, never a pasted file). Nothing was recorded.` : `${at} names "${f.name}", which could not be read beside the actions file (${why}). Nothing was recorded.`
2905
+ problem: f.as === "paste" ? `${at} pastes the file "${f.name}", which could not be read beside the actions file (${why}): a file paste needs its file beside the script, so put ${f.name} next to it (a take fetched with its media brings its pasted files home beside its script; one pushed before they travelled with it does not). Nothing was recorded.` : `${at} names "${f.name}", which could not be read beside the actions file (${why}). Nothing was recorded.`
2259
2906
  };
2260
2907
  }
2261
2908
  if (f.as === "path") {
@@ -2416,7 +3063,7 @@ function fetchInPageScript(url) {
2416
3063
  }
2417
3064
 
2418
3065
  // src/record/recorder.ts
2419
- var EXPECT_EVERY_MS = 100;
3066
+ var EXPECT_EVERY_MS2 = 100;
2420
3067
  var SRC_WAIT_MS = 8e3;
2421
3068
  var AWAIT_HEARTBEAT_MS = 3e4;
2422
3069
  var MIN_SKIP_MS = 250;
@@ -2494,6 +3141,18 @@ function base64Bytes(data) {
2494
3141
  return out;
2495
3142
  }
2496
3143
  async function recordTake(browser, url, actions, sink, log, opts = {}) {
3144
+ const late = {};
3145
+ try {
3146
+ return await recordTakeSteps(browser, url, actions, sink, log, opts, late);
3147
+ } catch (e) {
3148
+ const teardown = late.teardown;
3149
+ late.teardown = void 0;
3150
+ if (teardown) await teardown().catch(() => {
3151
+ });
3152
+ throw e;
3153
+ }
3154
+ }
3155
+ async function recordTakeSteps(browser, url, actions, sink, log, opts, late) {
2497
3156
  const dry = opts.dryRun === true;
2498
3157
  const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
2499
3158
  const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
@@ -2506,9 +3165,27 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2506
3165
  `steps[${download}] is a download, and this recorder has nowhere to keep a file. Nothing was recorded.`
2507
3166
  );
2508
3167
  }
3168
+ const teardownEnv = teardownEnvProblem(actions.teardown, opts.env ?? {});
3169
+ if (teardownEnv) throw new SetupEnvError(teardownEnv);
2509
3170
  const read = await loadScriptFiles(actions, opts.files);
2510
3171
  if ("problem" in read) throw new RecordRefusedError(read.problem);
2511
3172
  const files = read;
3173
+ const peers = actions.peers ?? [];
3174
+ const peerStates = /* @__PURE__ */ new Map();
3175
+ for (const p of peers) {
3176
+ if (p.storageState === void 0) continue;
3177
+ if (!opts.peerStorageState)
3178
+ throw new RecordRefusedError(
3179
+ `peer ${p.id} signs in from the storage state "${p.storageState}", and this recorder has nowhere to read one from. Nothing was recorded.`
3180
+ );
3181
+ try {
3182
+ peerStates.set(p.id, await opts.peerStorageState(p.storageState));
3183
+ } catch (e) {
3184
+ throw new RecordRefusedError(
3185
+ `peer ${p.id}'s storage state "${p.storageState}" could not be read (${e instanceof Error ? e.message : String(e)}). Nothing was recorded.`
3186
+ );
3187
+ }
3188
+ }
2512
3189
  const vw = actions.viewport?.width ?? 1280;
2513
3190
  const vh = actions.viewport?.height ?? 720;
2514
3191
  const context = opts.context ?? await browser.newContext({
@@ -2533,8 +3210,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2533
3210
  noticeLog.add(step, scan?.notices);
2534
3211
  };
2535
3212
  const pageErrors = new PageErrorLog(opts.verbose === true);
3213
+ let tearingDown = false;
2536
3214
  page.on("console", (m) => {
2537
- if (m.type() !== "error") return;
3215
+ if (m.type() !== "error" || tearingDown) return;
2538
3216
  const line = pageErrors.add(m.text());
2539
3217
  if (line) log(line);
2540
3218
  });
@@ -2565,6 +3243,49 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2565
3243
  log(`goto ${url} (after setup)`);
2566
3244
  response = (await open(url)).response;
2567
3245
  }
3246
+ const teardownSteps = actions.teardown ?? [];
3247
+ let teardown = null;
3248
+ let teardownRan = false;
3249
+ const runTeardown = async () => {
3250
+ if (!teardownSteps.length || teardownRan) return teardown;
3251
+ teardownRan = true;
3252
+ tearingDown = true;
3253
+ log(`teardown: ${teardownSteps.length} step(s), off camera`);
3254
+ try {
3255
+ const result = await runSetup(page, teardownSteps, {
3256
+ phase: "teardown",
3257
+ sleep: realSleep,
3258
+ env: opts.env ?? {},
3259
+ goto: (target) => open(target),
3260
+ boundMs: TEARDOWN_BOUND_MS
3261
+ });
3262
+ for (const line of result.lines) log(` ${line}`);
3263
+ teardown = {
3264
+ ran: result.ran,
3265
+ of: teardownSteps.length,
3266
+ ...result.failed ? {
3267
+ failed: {
3268
+ step: result.failed.step,
3269
+ do: result.failed.do,
3270
+ ...result.failed.selector !== void 0 ? { selector: result.failed.selector } : {},
3271
+ error: result.failed.error ?? "the step failed"
3272
+ }
3273
+ } : {}
3274
+ };
3275
+ } catch (e) {
3276
+ teardown = {
3277
+ ran: 0,
3278
+ of: teardownSteps.length,
3279
+ failed: {
3280
+ step: 0,
3281
+ do: teardownSteps[0].do,
3282
+ error: e instanceof Error ? e.message : String(e)
3283
+ }
3284
+ };
3285
+ }
3286
+ if (teardown.failed) log(` ${teardownLine(teardown)}`);
3287
+ return teardown;
3288
+ };
2568
3289
  let wall = null;
2569
3290
  if (opts.onArrival) {
2570
3291
  const seen = await page.evaluate(WALL_PROBE).catch(() => null);
@@ -2587,6 +3308,59 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2587
3308
  await page.evaluate(MEDIA_DRAIN_PROBE).catch(() => null);
2588
3309
  if (actions.steps.some((s) => settleOn(s, actions.settle)))
2589
3310
  await page.evaluate(CHANGE_AGE_PROBE).catch(() => void 0);
3311
+ const peerPages = [];
3312
+ const closePeers = async () => {
3313
+ const bound = new StepBound();
3314
+ bound.start(CLOSE_BOUND_MS);
3315
+ await Promise.all(
3316
+ peerPages.map((p) => bound.race(p.context.close().catch(() => {
3317
+ })))
3318
+ );
3319
+ bound.end();
3320
+ };
3321
+ for (const peer of peers) {
3322
+ const peerUrl = peer.url ?? url;
3323
+ const pctx = await browser.newContext({
3324
+ viewport: peer.viewport ?? { width: vw, height: vh },
3325
+ deviceScaleFactor: 1,
3326
+ ...peerStates.has(peer.id) ? { storageState: peerStates.get(peer.id) } : {}
3327
+ });
3328
+ if (opts.headers && Object.keys(opts.headers).length)
3329
+ await applyHeaders(pctx, opts.headers, peerUrl, log);
3330
+ const ppage = await pctx.newPage();
3331
+ peerPages.push({ peer, context: pctx, page: ppage });
3332
+ const peerErrors = new PageErrorLog(opts.verbose === true);
3333
+ ppage.on("console", (m) => {
3334
+ if (m.type() !== "error") return;
3335
+ const line = peerErrors.add(m.text());
3336
+ if (line) log(` peer ${peer.id}: ${line.trim()}`);
3337
+ });
3338
+ log(`peer ${peer.id}: goto ${peerUrl} (off camera)`);
3339
+ const popened = await untilReady(
3340
+ ppage,
3341
+ readyMode,
3342
+ (o) => ppage.goto(peerUrl, o),
3343
+ { sleep: realSleep }
3344
+ );
3345
+ const seen = await ppage.evaluate(WALL_PROBE).catch(() => null);
3346
+ const peerWall = wallVerdict({
3347
+ askedUrl: peerUrl,
3348
+ landedUrl: ppage.url(),
3349
+ ...popened.response ? { status: popened.response.status() } : {},
3350
+ passwordFields: seen?.passwordFields ?? 0,
3351
+ newPasswordField: seen?.newPasswordField ?? false,
3352
+ oneTimeCodeField: seen?.oneTimeCodeField ?? false
3353
+ });
3354
+ if (peerWall?.level === "hard") {
3355
+ await closePeers();
3356
+ await context.close().catch(() => {
3357
+ });
3358
+ throw new RecordRefusedError(
3359
+ `peer ${peer.id} landed on ${peerWall.landed}, not ${peerWall.asked} (${peerWall.kind}): a peer acts as someone signed in, so give it a storageState of the second person's sign-in (beside actions.json). Nothing was recorded.`
3360
+ );
3361
+ }
3362
+ log(` peer ${peer.id}: ${readyLine(popened.ready)}`);
3363
+ }
2590
3364
  const cdp = await context.newCDPSession(rawPage);
2591
3365
  if (!dry) await fitSurfaceToViewport(cdp, vw, vh);
2592
3366
  const frames = [];
@@ -2805,6 +3579,30 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2805
3579
  let stopped = null;
2806
3580
  let unpressedGuard = null;
2807
3581
  let capped = false;
3582
+ const signals = new StepSignals(
3583
+ actions.steps.map((s) => s.id).filter((id) => !!id)
3584
+ );
3585
+ const lanes = peerPages.map(
3586
+ ({ peer, page: ppage }) => runPeer(peer, ppage, signals, {
3587
+ clock: { now: () => Date.now(), sleep },
3588
+ footageMs: () => now(),
3589
+ log,
3590
+ dry
3591
+ }).catch((e) => {
3592
+ log(
3593
+ ` (peer ${peer.id}: stopped: ${e instanceof Error ? e.message : String(e)})`
3594
+ );
3595
+ return { id: peer.id, steps: [], notRun: [] };
3596
+ })
3597
+ );
3598
+ signals.begin();
3599
+ late.teardown = async () => {
3600
+ if (!dry) await cdp.send("Page.stopScreencast").catch(() => {
3601
+ });
3602
+ pageBound.end();
3603
+ const report = await runTeardown();
3604
+ if (report && !report.failed) log(` ${teardownLine(report)}`);
3605
+ };
2808
3606
  for (const [stepIdx, step] of actions.steps.entries()) {
2809
3607
  if (capReached(now(), maxSeconds)) {
2810
3608
  capped = true;
@@ -2844,6 +3642,12 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2844
3642
  if (reason) extra.reason = reason;
2845
3643
  };
2846
3644
  if (step.guard) extra.guard = step.guard;
3645
+ if (typeof step.zoom === "boolean") extra.zoom = step.zoom;
3646
+ const keepValue = (last) => {
3647
+ if (!("value" in last)) return;
3648
+ const kept = keptValue(last.value);
3649
+ if (kept !== void 0) extra.value = kept;
3650
+ };
2847
3651
  const rehearseGuard = async (sel, point) => {
2848
3652
  if (!step.guard || !dry) return false;
2849
3653
  const rect = sel ? await boxOf(sel) : null;
@@ -2872,7 +3676,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2872
3676
  step,
2873
3677
  askedMs(step),
2874
3678
  {
2875
- expectS: EXPECT_TIMEOUT_S,
3679
+ expectS: EXPECT_TIMEOUT_S2,
2876
3680
  awaitS: AWAIT_TIMEOUT_S,
2877
3681
  downloadS: DOWNLOAD_TIMEOUT_S
2878
3682
  }
@@ -2884,6 +3688,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
2884
3688
  ...step.id ? { id: step.id } : {},
2885
3689
  do: step.do
2886
3690
  });
3691
+ signals.start(step.id);
2887
3692
  const stepWall = Date.now();
2888
3693
  pageBound.start(boundMs);
2889
3694
  switch (step.do) {
@@ -3225,12 +4030,13 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3225
4030
  case "expect": {
3226
4031
  if (notWaited()) break;
3227
4032
  const cond = step;
3228
- const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S) * 1e3;
4033
+ const timeoutMs = (step.timeout ?? EXPECT_TIMEOUT_S2) * 1e3;
3229
4034
  const res = await pollCondition(page, cond, {
3230
4035
  timeoutMs,
3231
- everyMs: EXPECT_EVERY_MS,
4036
+ everyMs: EXPECT_EVERY_MS2,
3232
4037
  sleep: realSleep
3233
4038
  });
4039
+ keepValue(res.last);
3234
4040
  if (res.ok) log(`expect ${sayCondition(cond)}: holds`);
3235
4041
  else {
3236
4042
  const reason = conditionMiss(cond, res.last, timeoutMs);
@@ -3275,6 +4081,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3275
4081
  clearInterval(beat);
3276
4082
  }
3277
4083
  const { out, at, ms } = waited;
4084
+ keepValue(out.last);
3278
4085
  opts.onEvent?.({ ...awaitEvent, phase: "end", ms, held: out.ok });
3279
4086
  extra.offCameraMs = ms;
3280
4087
  if (!out.ok) extra.timedOut = true;
@@ -3459,6 +4266,7 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3459
4266
  await drainSound(stepIdx);
3460
4267
  pageBound.end();
3461
4268
  const stepSpan = stepSpans[stepSpans.length - 1];
4269
+ signals.end(step.id);
3462
4270
  opts.onEvent?.({
3463
4271
  event: "step",
3464
4272
  phase: "end",
@@ -3471,6 +4279,19 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3471
4279
  });
3472
4280
  if (stopped) break;
3473
4281
  }
4282
+ signals.close();
4283
+ const peerRuns = await Promise.all(lanes);
4284
+ for (const pr of peerRuns)
4285
+ for (const ps of pr.steps)
4286
+ if (ps.skipped)
4287
+ skipped.push({
4288
+ step: ps.step,
4289
+ ...ps.id ? { id: ps.id } : {},
4290
+ do: ps.do,
4291
+ selector: ps.selector ?? "",
4292
+ ...ps.reason ? { reason: ps.reason } : {},
4293
+ peer: pr.id
4294
+ });
3474
4295
  if (!capped && !stopped) await sleep(TRAILING_HOLD_MS);
3475
4296
  if (capped || capReached(now(), maxSeconds)) {
3476
4297
  capped = true;
@@ -3493,10 +4314,13 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3493
4314
  const pageTitle = await page.title().catch(() => "") ?? "";
3494
4315
  for (const line of pageErrors.repeats()) log(line);
3495
4316
  pageBound.end();
4317
+ late.teardown = void 0;
4318
+ await runTeardown();
3496
4319
  pageBound.start(CLOSE_BOUND_MS);
3497
4320
  await pageBound.race(context.close().catch(() => {
3498
4321
  }));
3499
4322
  pageBound.end();
4323
+ await closePeers();
3500
4324
  await sinkChain;
3501
4325
  const meta = {
3502
4326
  dpr: 1,
@@ -3523,7 +4347,14 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3523
4347
  ...masks.length ? { masks: exposureLog.masks(masks) } : {},
3524
4348
  ...skips.length ? { skips } : {},
3525
4349
  ...outputs.length ? { outputs } : {},
3526
- ...media.length ? { media } : {}
4350
+ ...media.length ? { media } : {},
4351
+ ...peerRuns.length ? {
4352
+ peers: peerRuns.map((pr) => ({
4353
+ id: pr.id,
4354
+ steps: pr.steps,
4355
+ ...pr.notRun.length ? { notRun: pr.notRun } : {}
4356
+ }))
4357
+ } : {}
3527
4358
  };
3528
4359
  const freezes = [];
3529
4360
  for (let i = 1; i < frames.length; i++) {
@@ -3570,7 +4401,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3570
4401
  overruns,
3571
4402
  // Self-contained: a path a file held is inline, so the take re-records
3572
4403
  // from its own script with the file gone.
3573
- actions: { ...inlineScript(actions, files), url }
4404
+ actions: { ...inlineScript(actions, files), url },
4405
+ peers: peerRuns,
4406
+ teardown
3574
4407
  };
3575
4408
  }
3576
4409
  async function keepPageSound(heard, sink, fetchSource, log) {
@@ -3834,16 +4667,20 @@ export {
3834
4667
  DOWNLOAD_TIMEOUT_S,
3835
4668
  DRAG_HOLD_MS,
3836
4669
  DRAG_MODIFIERS,
3837
- EXPECT_TIMEOUT_S,
4670
+ EXPECT_TIMEOUT_S2 as EXPECT_TIMEOUT_S,
3838
4671
  EXPOSURE_ADVICE,
3839
4672
  EXPOSURE_MATCHERS_SRC,
3840
4673
  EXPOSURE_PROBE,
3841
4674
  ExposureLog,
3842
4675
  IDLE_CALL_MS,
4676
+ JS_SAID_MAX,
4677
+ JS_VALUE_MAX,
3843
4678
  KEY_ROUNDTRIP_MS,
3844
4679
  KEY_SEQUENCE_GAP_MS,
3845
4680
  MAX_KEYS,
3846
4681
  MAX_PATH_POINTS,
4682
+ MAX_PEERS,
4683
+ MAX_PEER_STEPS,
3847
4684
  MEDIA_DRAIN_PROBE,
3848
4685
  MEDIA_WATCH_SCRIPT,
3849
4686
  MIN_SKIP_MS,
@@ -3864,6 +4701,8 @@ export {
3864
4701
  PASTE_TYPES,
3865
4702
  PASTE_UNREAD_MS,
3866
4703
  PATH_DEFAULT_MS,
4704
+ PEER_VALUE_MAX,
4705
+ PEER_VERBS,
3867
4706
  POINTER_MAX_MS,
3868
4707
  POINTER_MIN_MS,
3869
4708
  POINTER_MS_PER_PX,
@@ -3890,11 +4729,14 @@ export {
3890
4729
  SetupEnvError,
3891
4730
  SetupError,
3892
4731
  StepBound,
4732
+ StepSignals,
4733
+ TEARDOWN_BOUND_MS,
3893
4734
  TRAILING_HOLD_MS,
3894
4735
  TakeClock,
3895
4736
  WAIT_MARGIN_MS,
3896
4737
  WALL_PROBE,
3897
4738
  WallError,
4739
+ ZOOM_MARK_VERBS,
3898
4740
  appSettled,
3899
4741
  askedMs,
3900
4742
  base64Bytes,
@@ -3931,6 +4773,8 @@ export {
3931
4773
  isKeyName,
3932
4774
  isSignInPath,
3933
4775
  jpegDims,
4776
+ jsConditionScript,
4777
+ keptValue,
3934
4778
  keySequencePlanMs,
3935
4779
  loadScriptFiles,
3936
4780
  maskInitScript,
@@ -3947,6 +4791,8 @@ export {
3947
4791
  parseHeaders,
3948
4792
  pasteTypeOf,
3949
4793
  pathDurationMs,
4794
+ peerValue,
4795
+ peersHostedRefusal,
3950
4796
  plainName,
3951
4797
  planFreshTake,
3952
4798
  pointerTravelMs,
@@ -3966,10 +4812,15 @@ export {
3966
4812
  resolveChord,
3967
4813
  resolveModifier,
3968
4814
  resolveSetupText,
4815
+ runPeer,
3969
4816
  runSetup,
4817
+ sameJson,
3970
4818
  sayCondition,
3971
4819
  sayDuration,
4820
+ sayJs,
4821
+ sayValue,
3972
4822
  schedulePath,
4823
+ scriptEnvNames,
3973
4824
  scriptFiles,
3974
4825
  scrollByScript,
3975
4826
  scrollTravelMs,
@@ -3983,10 +4834,13 @@ export {
3983
4834
  stageClipboardScript,
3984
4835
  stepBoundMs,
3985
4836
  syntheticPasteScript,
4837
+ teardownEnvProblem,
4838
+ teardownLine,
3986
4839
  typeOfName,
3987
4840
  untilReady,
3988
4841
  urlSchemeProblem,
3989
4842
  validateActions,
4843
+ validatePeers,
3990
4844
  validateSetup,
3991
4845
  visibleTextScript,
3992
4846
  wallVerdict,