@vosjs/render-core 0.9.0 → 0.11.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.
@@ -689,7 +689,8 @@ function noticeAdvice(notices) {
689
689
  }
690
690
 
691
691
  // src/record/setup.ts
692
- var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
692
+ var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto", "if"]);
693
+ var IF_LOOK_MS = 3e3;
693
694
  function urlSchemeProblem(url) {
694
695
  let u;
695
696
  try {
@@ -701,11 +702,11 @@ function urlSchemeProblem(url) {
701
702
  return `${url} is not an http(s) address, and the recorder opens only http and https pages`;
702
703
  return null;
703
704
  }
704
- function validateSetup(value) {
705
+ function validateSetup(value, phase = "setup") {
705
706
  const errors = [];
706
- if (!Array.isArray(value)) return ["setup must be an array of steps"];
707
+ if (!Array.isArray(value)) return [`${phase} must be an array of steps`];
707
708
  value.forEach((raw, i) => {
708
- const at = `setup[${i}]`;
709
+ const at = `${phase}[${i}]`;
709
710
  if (typeof raw !== "object" || raw === null) {
710
711
  errors.push(`${at}: must be an object`);
711
712
  return;
@@ -715,7 +716,7 @@ function validateSetup(value) {
715
716
  errors.push(`${at}: "do" must be one of ${[...VERBS].join(", ")}`);
716
717
  return;
717
718
  }
718
- if ((s.do === "click" || s.do === "type") && typeof s.selector !== "string")
719
+ if ((s.do === "click" || s.do === "type" || s.do === "if") && typeof s.selector !== "string")
719
720
  errors.push(`${at}: ${s.do} needs a selector`);
720
721
  if (s.do === "wait" && typeof s.ms !== "number")
721
722
  errors.push(`${at}: wait needs ms`);
@@ -750,73 +751,158 @@ function setupEnvNames(steps) {
750
751
  }
751
752
  return [...out];
752
753
  }
753
- function resolveSetupText(t, env) {
754
+ function scriptEnvNames(script) {
755
+ return [
756
+ .../* @__PURE__ */ new Set([
757
+ ...setupEnvNames(script.setup),
758
+ ...setupEnvNames(script.teardown)
759
+ ])
760
+ ];
761
+ }
762
+ function resolveSetupText(t, env, phase = "setup") {
754
763
  if (typeof t === "string") return { value: t, secret: false };
755
764
  const v = env[t.env];
756
765
  if (v === void 0 || v === "")
757
766
  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`
767
+ `${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
768
  );
760
769
  return { value: v, secret: true };
761
770
  }
771
+ function teardownEnvProblem(steps, env) {
772
+ for (const [i, s] of (steps ?? []).entries()) {
773
+ if (s.do !== "type" || typeof s.text !== "object") continue;
774
+ const v = env[s.text.env];
775
+ if (v === void 0 || v === "")
776
+ 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.`;
777
+ }
778
+ return null;
779
+ }
780
+ var TEARDOWN_BOUND_MS = 6e4;
781
+ function teardownLine(report) {
782
+ const f = report.failed;
783
+ const k = report.skipped;
784
+ if (!f && k)
785
+ return `teardown: nothing to undo, ${k.selector} is not on the page (teardown[${k.step}] if); ${report.ran} step(s) ran, ${k.rest} skipped`;
786
+ if (!f) return `teardown: ${report.ran} step(s) ran`;
787
+ const reason = f.error.replace(/[.\s]+$/, "");
788
+ 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.`;
789
+ }
762
790
  async function runSetup(page, steps, opts) {
763
791
  const env = opts.env ?? {};
792
+ const phase = opts.phase ?? "setup";
793
+ const now = opts.now ?? Date.now;
794
+ const deadline = opts.boundMs === void 0 ? void 0 : now() + opts.boundMs;
764
795
  const lines = [];
765
- for (const [i, step] of steps.entries()) {
796
+ let typed = "";
797
+ let gone = null;
798
+ const runStep = async (step, i) => {
766
799
  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
- }
800
+ switch (step.do) {
801
+ case "wait":
802
+ await opts.sleep(step.ms);
803
+ return `${phase} #${i} wait ${step.ms}ms`;
804
+ case "goto":
805
+ await (opts.goto ? opts.goto(step.url) : page.goto(step.url, {
806
+ waitUntil: "networkidle",
807
+ timeout: 45e3
808
+ })).catch(() => null);
809
+ return `${phase} #${i} goto ${step.url}`;
810
+ case "press":
811
+ await page.keyboard.press(step.key);
812
+ await opts.sleep(settle);
813
+ return `${phase} #${i} press ${step.key}`;
814
+ case "if": {
815
+ const present = await page.locator(step.selector).first().waitFor({ state: "visible", timeout: step.ms ?? IF_LOOK_MS }).then(() => true).catch(() => false);
816
+ if (present) return `${phase} #${i} if ${step.selector}: on the page`;
817
+ gone = { step: i, selector: step.selector };
818
+ return `${phase} #${i} if ${step.selector}: not on the page, the ${steps.length - i - 1} step(s) after it skipped`;
819
+ }
820
+ case "click": {
821
+ const loc = page.locator(step.selector).first();
822
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
823
+ await loc.click();
824
+ await opts.sleep(settle);
825
+ return `${phase} #${i} click ${step.selector}`;
804
826
  }
827
+ case "type": {
828
+ const loc = page.locator(step.selector).first();
829
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
830
+ const { value, secret } = resolveSetupText(step.text, env, phase);
831
+ typed = value;
832
+ await loc.fill(value);
833
+ await opts.sleep(settle);
834
+ return `${phase} #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`;
835
+ }
836
+ }
837
+ };
838
+ for (const [i, step] of steps.entries()) {
839
+ typed = "";
840
+ try {
841
+ lines.push(
842
+ await withinDeadline(
843
+ runStep(step, i),
844
+ deadline,
845
+ now,
846
+ `the ${phase} ran past its ${sayBound(opts.boundMs ?? 0)} bound`
847
+ )
848
+ );
805
849
  } catch (e) {
806
- if (e instanceof SetupEnvError) throw e;
850
+ if (e instanceof SetupEnvError && phase === "setup") throw e;
807
851
  return {
808
852
  ran: i,
809
853
  lines,
810
854
  failed: {
811
855
  step: i,
812
856
  do: step.do,
813
- ..."selector" in step ? { selector: step.selector } : {}
857
+ ..."selector" in step ? { selector: step.selector } : {},
858
+ error: reasonWords(e, typed)
814
859
  }
815
860
  };
816
861
  }
862
+ const ended = gone;
863
+ if (ended)
864
+ return {
865
+ ran: i + 1,
866
+ lines,
867
+ skipped: { ...ended, rest: steps.length - i - 1 }
868
+ };
817
869
  }
818
870
  return { ran: steps.length, lines };
819
871
  }
872
+ function sayBound(ms) {
873
+ return ms >= 1e3 ? `${Math.round(ms / 1e3)} s` : `${ms} ms`;
874
+ }
875
+ function withinDeadline(p, deadline, now, message) {
876
+ if (deadline === void 0) return p;
877
+ const left = deadline - now();
878
+ return new Promise((resolve, reject) => {
879
+ const timer = setTimeout(
880
+ () => {
881
+ p.catch(() => {
882
+ });
883
+ reject(new Error(message));
884
+ },
885
+ Math.max(0, left)
886
+ );
887
+ p.then(
888
+ (v) => {
889
+ clearTimeout(timer);
890
+ resolve(v);
891
+ },
892
+ (e) => {
893
+ clearTimeout(timer);
894
+ reject(e);
895
+ }
896
+ );
897
+ });
898
+ }
899
+ var ANSI_COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
900
+ function reasonWords(e, typed) {
901
+ const raw = e instanceof Error ? e.message : String(e);
902
+ const line = (raw.replace(ANSI_COLOUR, "").split("\n")[0] ?? "").trim();
903
+ if (typed && line.includes(typed)) return "the field could not be filled";
904
+ return line || "the step failed";
905
+ }
820
906
  var SetupEnvError = class extends Error {
821
907
  constructor(message) {
822
908
  super(message);
@@ -2405,6 +2491,8 @@ function validateActions(value) {
2405
2491
  }
2406
2492
  }
2407
2493
  if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
2494
+ if (obj.teardown !== void 0)
2495
+ errors.push(...validateSetup(obj.teardown, "teardown"));
2408
2496
  const ready = readyProblem(obj.ready);
2409
2497
  if (ready) errors.push(ready);
2410
2498
  if (obj.settle !== void 0 && typeof obj.settle !== "boolean")
@@ -3071,6 +3159,18 @@ function base64Bytes(data) {
3071
3159
  return out;
3072
3160
  }
3073
3161
  async function recordTake(browser, url, actions, sink, log, opts = {}) {
3162
+ const late = {};
3163
+ try {
3164
+ return await recordTakeSteps(browser, url, actions, sink, log, opts, late);
3165
+ } catch (e) {
3166
+ const teardown = late.teardown;
3167
+ late.teardown = void 0;
3168
+ if (teardown) await teardown().catch(() => {
3169
+ });
3170
+ throw e;
3171
+ }
3172
+ }
3173
+ async function recordTakeSteps(browser, url, actions, sink, log, opts, late) {
3074
3174
  const dry = opts.dryRun === true;
3075
3175
  const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
3076
3176
  const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
@@ -3083,6 +3183,8 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3083
3183
  `steps[${download}] is a download, and this recorder has nowhere to keep a file. Nothing was recorded.`
3084
3184
  );
3085
3185
  }
3186
+ const teardownEnv = teardownEnvProblem(actions.teardown, opts.env ?? {});
3187
+ if (teardownEnv) throw new SetupEnvError(teardownEnv);
3086
3188
  const read = await loadScriptFiles(actions, opts.files);
3087
3189
  if ("problem" in read) throw new RecordRefusedError(read.problem);
3088
3190
  const files = read;
@@ -3126,8 +3228,9 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3126
3228
  noticeLog.add(step, scan?.notices);
3127
3229
  };
3128
3230
  const pageErrors = new PageErrorLog(opts.verbose === true);
3231
+ let tearingDown = false;
3129
3232
  page.on("console", (m) => {
3130
- if (m.type() !== "error") return;
3233
+ if (m.type() !== "error" || tearingDown) return;
3131
3234
  const line = pageErrors.add(m.text());
3132
3235
  if (line) log(line);
3133
3236
  });
@@ -3158,6 +3261,50 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3158
3261
  log(`goto ${url} (after setup)`);
3159
3262
  response = (await open(url)).response;
3160
3263
  }
3264
+ const teardownSteps = actions.teardown ?? [];
3265
+ let teardown = null;
3266
+ let teardownRan = false;
3267
+ const runTeardown = async () => {
3268
+ if (!teardownSteps.length || teardownRan) return teardown;
3269
+ teardownRan = true;
3270
+ tearingDown = true;
3271
+ log(`teardown: ${teardownSteps.length} step(s), off camera`);
3272
+ try {
3273
+ const result = await runSetup(page, teardownSteps, {
3274
+ phase: "teardown",
3275
+ sleep: realSleep,
3276
+ env: opts.env ?? {},
3277
+ goto: (target) => open(target),
3278
+ boundMs: TEARDOWN_BOUND_MS
3279
+ });
3280
+ for (const line of result.lines) log(` ${line}`);
3281
+ teardown = {
3282
+ ran: result.ran,
3283
+ of: teardownSteps.length,
3284
+ ...result.failed ? {
3285
+ failed: {
3286
+ step: result.failed.step,
3287
+ do: result.failed.do,
3288
+ ...result.failed.selector !== void 0 ? { selector: result.failed.selector } : {},
3289
+ error: result.failed.error ?? "the step failed"
3290
+ }
3291
+ } : {},
3292
+ ...result.skipped ? { skipped: result.skipped } : {}
3293
+ };
3294
+ } catch (e) {
3295
+ teardown = {
3296
+ ran: 0,
3297
+ of: teardownSteps.length,
3298
+ failed: {
3299
+ step: 0,
3300
+ do: teardownSteps[0].do,
3301
+ error: e instanceof Error ? e.message : String(e)
3302
+ }
3303
+ };
3304
+ }
3305
+ if (teardown.failed) log(` ${teardownLine(teardown)}`);
3306
+ return teardown;
3307
+ };
3161
3308
  let wall = null;
3162
3309
  if (opts.onArrival) {
3163
3310
  const seen = await page.evaluate(WALL_PROBE).catch(() => null);
@@ -3468,6 +3615,13 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
3468
3615
  })
3469
3616
  );
3470
3617
  signals.begin();
3618
+ late.teardown = async () => {
3619
+ if (!dry) await cdp.send("Page.stopScreencast").catch(() => {
3620
+ });
3621
+ pageBound.end();
3622
+ const report = await runTeardown();
3623
+ if (report && !report.failed) log(` ${teardownLine(report)}`);
3624
+ };
3471
3625
  for (const [stepIdx, step] of actions.steps.entries()) {
3472
3626
  if (capReached(now(), maxSeconds)) {
3473
3627
  capped = true;
@@ -4179,6 +4333,8 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
4179
4333
  const pageTitle = await page.title().catch(() => "") ?? "";
4180
4334
  for (const line of pageErrors.repeats()) log(line);
4181
4335
  pageBound.end();
4336
+ late.teardown = void 0;
4337
+ await runTeardown();
4182
4338
  pageBound.start(CLOSE_BOUND_MS);
4183
4339
  await pageBound.race(context.close().catch(() => {
4184
4340
  }));
@@ -4265,7 +4421,8 @@ async function recordTake(browser, url, actions, sink, log, opts = {}) {
4265
4421
  // Self-contained: a path a file held is inline, so the take re-records
4266
4422
  // from its own script with the file gone.
4267
4423
  actions: { ...inlineScript(actions, files), url },
4268
- peers: peerRuns
4424
+ peers: peerRuns,
4425
+ teardown
4269
4426
  };
4270
4427
  }
4271
4428
  async function keepPageSound(heard, sink, fetchSource, log) {
@@ -4535,6 +4692,7 @@ export {
4535
4692
  EXPOSURE_PROBE,
4536
4693
  ExposureLog,
4537
4694
  IDLE_CALL_MS,
4695
+ IF_LOOK_MS,
4538
4696
  JS_SAID_MAX,
4539
4697
  JS_VALUE_MAX,
4540
4698
  KEY_ROUNDTRIP_MS,
@@ -4592,6 +4750,7 @@ export {
4592
4750
  SetupError,
4593
4751
  StepBound,
4594
4752
  StepSignals,
4753
+ TEARDOWN_BOUND_MS,
4595
4754
  TRAILING_HOLD_MS,
4596
4755
  TakeClock,
4597
4756
  WAIT_MARGIN_MS,
@@ -4681,6 +4840,7 @@ export {
4681
4840
  sayJs,
4682
4841
  sayValue,
4683
4842
  schedulePath,
4843
+ scriptEnvNames,
4684
4844
  scriptFiles,
4685
4845
  scrollByScript,
4686
4846
  scrollTravelMs,
@@ -4694,6 +4854,8 @@ export {
4694
4854
  stageClipboardScript,
4695
4855
  stepBoundMs,
4696
4856
  syntheticPasteScript,
4857
+ teardownEnvProblem,
4858
+ teardownLine,
4697
4859
  typeOfName,
4698
4860
  untilReady,
4699
4861
  urlSchemeProblem,