doc-detective 4.41.6 → 4.42.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.
Files changed (155) hide show
  1. package/README.md +3 -4
  2. package/bin/doc-detective-lsp.js +2 -0
  3. package/dist/agents/adapters/claude-code.js +1 -1
  4. package/dist/agents/adapters/claude-code.js.map +1 -1
  5. package/dist/agents/adapters/copilot-cli.js +2 -2
  6. package/dist/agents/adapters/copilot-cli.js.map +1 -1
  7. package/dist/agents/adapters/gemini-cli.js +1 -1
  8. package/dist/agents/adapters/gemini-cli.js.map +1 -1
  9. package/dist/agents/adapters/qwen-code.js +1 -1
  10. package/dist/agents/adapters/qwen-code.js.map +1 -1
  11. package/dist/cli.js +27 -6
  12. package/dist/cli.js.map +1 -1
  13. package/dist/common/src/schemas/schemas.json +2 -2
  14. package/dist/common/src/schemas/schemasReferenced.json +448 -294
  15. package/dist/common/src/schemas/validators.cjs +140 -124
  16. package/dist/common/src/schemas/validatorsCheck.cjs +1 -1
  17. package/dist/common/src/types/generated/annotate_v3.d.ts +12 -12
  18. package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
  19. package/dist/common/src/types/generated/annotation_v3.d.ts +6 -6
  20. package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
  21. package/dist/common/src/types/generated/checkLink_v3.d.ts +4 -0
  22. package/dist/common/src/types/generated/checkLink_v3.d.ts.map +1 -1
  23. package/dist/common/src/types/generated/click_v3.d.ts +2 -2
  24. package/dist/common/src/types/generated/click_v3.d.ts.map +1 -1
  25. package/dist/common/src/types/generated/config_v3.d.ts +260 -108
  26. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  27. package/dist/common/src/types/generated/context_v3.d.ts +10 -2
  28. package/dist/common/src/types/generated/context_v3.d.ts.map +1 -1
  29. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts +2 -2
  30. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts.map +1 -1
  31. package/dist/common/src/types/generated/find_v3.d.ts +6 -6
  32. package/dist/common/src/types/generated/find_v3.d.ts.map +1 -1
  33. package/dist/common/src/types/generated/goTo_v3.d.ts +1 -1
  34. package/dist/common/src/types/generated/goTo_v3.d.ts.map +1 -1
  35. package/dist/common/src/types/generated/httpRequest_v3.d.ts +2 -2
  36. package/dist/common/src/types/generated/httpRequest_v3.d.ts.map +1 -1
  37. package/dist/common/src/types/generated/record_v3.d.ts +5 -5
  38. package/dist/common/src/types/generated/record_v3.d.ts.map +1 -1
  39. package/dist/common/src/types/generated/report_v3.d.ts +529 -207
  40. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  41. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +754 -314
  42. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  43. package/dist/common/src/types/generated/runCode_v3.d.ts +11 -2
  44. package/dist/common/src/types/generated/runCode_v3.d.ts.map +1 -1
  45. package/dist/common/src/types/generated/runShell_v3.d.ts +11 -2
  46. package/dist/common/src/types/generated/runShell_v3.d.ts.map +1 -1
  47. package/dist/common/src/types/generated/screenshot_v3.d.ts +12 -12
  48. package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
  49. package/dist/common/src/types/generated/spec_v3.d.ts +494 -206
  50. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  51. package/dist/common/src/types/generated/startSurface_v3.d.ts +24 -6
  52. package/dist/common/src/types/generated/startSurface_v3.d.ts.map +1 -1
  53. package/dist/common/src/types/generated/step_v3.d.ts +223 -100
  54. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  55. package/dist/common/src/types/generated/test_v3.d.ts +482 -202
  56. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  57. package/dist/common/src/types/generated/type_v3.d.ts +3 -3
  58. package/dist/common/src/types/generated/type_v3.d.ts.map +1 -1
  59. package/dist/common/src/types/generated/waitUntil_v3.d.ts +2 -2
  60. package/dist/common/src/types/generated/waitUntil_v3.d.ts.map +1 -1
  61. package/dist/common/src/types/generated/wait_v3.d.ts +2 -3
  62. package/dist/common/src/types/generated/wait_v3.d.ts.map +1 -1
  63. package/dist/core/config.d.ts.map +1 -1
  64. package/dist/core/config.js +6 -4
  65. package/dist/core/config.js.map +1 -1
  66. package/dist/core/files.d.ts +18 -3
  67. package/dist/core/files.d.ts.map +1 -1
  68. package/dist/core/files.js +58 -18
  69. package/dist/core/files.js.map +1 -1
  70. package/dist/core/index.d.ts.map +1 -1
  71. package/dist/core/index.js +16 -0
  72. package/dist/core/index.js.map +1 -1
  73. package/dist/core/integrations/index.d.ts +2 -1
  74. package/dist/core/integrations/index.d.ts.map +1 -1
  75. package/dist/core/integrations/index.js +15 -5
  76. package/dist/core/integrations/index.js.map +1 -1
  77. package/dist/core/openapi.d.ts.map +1 -1
  78. package/dist/core/openapi.js +122 -6
  79. package/dist/core/openapi.js.map +1 -1
  80. package/dist/core/ptyWatchdog.d.ts.map +1 -1
  81. package/dist/core/ptyWatchdog.js +6 -3
  82. package/dist/core/ptyWatchdog.js.map +1 -1
  83. package/dist/core/resolveTests.d.ts.map +1 -1
  84. package/dist/core/resolveTests.js +5 -3
  85. package/dist/core/resolveTests.js.map +1 -1
  86. package/dist/core/telem.d.ts.map +1 -1
  87. package/dist/core/telem.js +5 -7
  88. package/dist/core/telem.js.map +1 -1
  89. package/dist/core/tests/checkLink.d.ts.map +1 -1
  90. package/dist/core/tests/checkLink.js +12 -7
  91. package/dist/core/tests/checkLink.js.map +1 -1
  92. package/dist/core/tests/click.d.ts.map +1 -1
  93. package/dist/core/tests/click.js +7 -0
  94. package/dist/core/tests/click.js.map +1 -1
  95. package/dist/core/tests/findElement.d.ts.map +1 -1
  96. package/dist/core/tests/findElement.js +3 -2
  97. package/dist/core/tests/findElement.js.map +1 -1
  98. package/dist/core/tests/goTo.d.ts.map +1 -1
  99. package/dist/core/tests/goTo.js +2 -1
  100. package/dist/core/tests/goTo.js.map +1 -1
  101. package/dist/core/tests/httpRequest.d.ts.map +1 -1
  102. package/dist/core/tests/httpRequest.js +4 -5
  103. package/dist/core/tests/httpRequest.js.map +1 -1
  104. package/dist/core/tests/processSurface.d.ts +4 -1
  105. package/dist/core/tests/processSurface.d.ts.map +1 -1
  106. package/dist/core/tests/processSurface.js +3 -0
  107. package/dist/core/tests/processSurface.js.map +1 -1
  108. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  109. package/dist/core/tests/saveScreenshot.js +8 -3
  110. package/dist/core/tests/saveScreenshot.js.map +1 -1
  111. package/dist/core/tests/startRecording.d.ts.map +1 -1
  112. package/dist/core/tests/startRecording.js +2 -1
  113. package/dist/core/tests/startRecording.js.map +1 -1
  114. package/dist/core/tests/stopRecording.d.ts.map +1 -1
  115. package/dist/core/tests/stopRecording.js +2 -1
  116. package/dist/core/tests/stopRecording.js.map +1 -1
  117. package/dist/core/tests/timing.d.ts +4 -0
  118. package/dist/core/tests/timing.d.ts.map +1 -0
  119. package/dist/core/tests/timing.js +8 -0
  120. package/dist/core/tests/timing.js.map +1 -0
  121. package/dist/core/tests.d.ts +15 -0
  122. package/dist/core/tests.d.ts.map +1 -1
  123. package/dist/core/tests.js +77 -30
  124. package/dist/core/tests.js.map +1 -1
  125. package/dist/core/utils.d.ts +42 -2
  126. package/dist/core/utils.d.ts.map +1 -1
  127. package/dist/core/utils.js +281 -89
  128. package/dist/core/utils.js.map +1 -1
  129. package/dist/debug/index.d.ts.map +1 -1
  130. package/dist/debug/index.js +8 -36
  131. package/dist/debug/index.js.map +1 -1
  132. package/dist/hints/hints.js +2 -2
  133. package/dist/hints/hints.js.map +1 -1
  134. package/dist/index.cjs +684 -235
  135. package/dist/reporters/htmlReporter.js +1 -1
  136. package/dist/runtime/cacheDir.d.ts.map +1 -1
  137. package/dist/runtime/cacheDir.js +6 -11
  138. package/dist/runtime/cacheDir.js.map +1 -1
  139. package/dist/runtime/heavyDeps.d.ts +12 -0
  140. package/dist/runtime/heavyDeps.d.ts.map +1 -1
  141. package/dist/runtime/heavyDeps.js +31 -7
  142. package/dist/runtime/heavyDeps.js.map +1 -1
  143. package/dist/runtime/selfUpdate.d.ts +3 -3
  144. package/dist/runtime/selfUpdate.d.ts.map +1 -1
  145. package/dist/runtime/selfUpdate.js +34 -5
  146. package/dist/runtime/selfUpdate.js.map +1 -1
  147. package/dist/upgradeNotice.d.ts +41 -0
  148. package/dist/upgradeNotice.d.ts.map +1 -0
  149. package/dist/upgradeNotice.js +356 -0
  150. package/dist/upgradeNotice.js.map +1 -0
  151. package/dist/utils.d.ts.map +1 -1
  152. package/dist/utils.js +17 -8
  153. package/dist/utils.js.map +1 -1
  154. package/package.json +2 -2
  155. package/scripts/postinstall.js +1 -1
package/dist/index.cjs CHANGED
@@ -518,12 +518,27 @@ function resolveManagedDepNames(pkg) {
518
518
  ]);
519
519
  return withPeerCompanions([...names]);
520
520
  }
521
+ function packageRoot() {
522
+ for (let dir = __dirname; !cachedRoot; dir = import_node_path.default.dirname(dir)) {
523
+ try {
524
+ const pkg = JSON.parse(import_node_fs.default.readFileSync(import_node_path.default.join(dir, "package.json"), "utf8"));
525
+ if (pkg.name === "doc-detective") {
526
+ cachedRoot = dir;
527
+ cachedPkg = pkg;
528
+ }
529
+ } catch {
530
+ }
531
+ if (import_node_path.default.dirname(dir) === dir)
532
+ break;
533
+ }
534
+ return cachedRoot;
535
+ }
536
+ function ownVersion() {
537
+ return packageRoot() && cachedPkg.version;
538
+ }
521
539
  function readShimPackageJson() {
522
- if (cachedPkg)
523
- return cachedPkg;
524
- const pkgPath = import_node_path.default.resolve(__dirname, "../../package.json");
525
- const raw = import_node_fs.default.readFileSync(pkgPath, "utf8");
526
- cachedPkg = JSON.parse(raw);
540
+ if (!packageRoot())
541
+ throw new Error("doc-detective's package.json not found");
527
542
  return cachedPkg;
528
543
  }
529
544
  function getDeclaredVersion(name) {
@@ -583,7 +598,7 @@ function compareTuple(a, b) {
583
598
  }
584
599
  return 0;
585
600
  }
586
- var import_node_fs, import_node_path, import_node_url, __dirname, HEAVY_NPM_DEPS, RUNTIME_PEER_COMPANIONS, cachedPkg;
601
+ var import_node_fs, import_node_path, import_node_url, __dirname, HEAVY_NPM_DEPS, RUNTIME_PEER_COMPANIONS, cachedRoot, cachedPkg;
587
602
  var init_heavyDeps = __esm({
588
603
  "dist/runtime/heavyDeps.js"() {
589
604
  "use strict";
@@ -675,9 +690,9 @@ function getBrowsersDir(ctx = {}) {
675
690
  }
676
691
  function legacyBrowserSnapshotCandidates() {
677
692
  const out = [import_node_path2.default.resolve("browser-snapshots")];
678
- const shimRoot = import_node_path2.default.resolve(__dirname2, "..", "..");
679
- const fromShim = import_node_path2.default.join(shimRoot, "browser-snapshots");
680
- if (fromShim !== out[0])
693
+ const shimRoot = packageRoot();
694
+ const fromShim = shimRoot && import_node_path2.default.join(shimRoot, "browser-snapshots");
695
+ if (fromShim && fromShim !== out[0])
681
696
  out.push(fromShim);
682
697
  return out;
683
698
  }
@@ -734,15 +749,14 @@ function writeInstalledRecord(record, ctx = {}) {
734
749
  }
735
750
  }
736
751
  }
737
- var import_node_fs2, import_node_os, import_node_path2, import_node_url2, __dirname2, SHELL_METAS;
752
+ var import_node_fs2, import_node_os, import_node_path2, SHELL_METAS;
738
753
  var init_cacheDir = __esm({
739
754
  "dist/runtime/cacheDir.js"() {
740
755
  "use strict";
741
756
  import_node_fs2 = __toESM(require("node:fs"), 1);
742
757
  import_node_os = __toESM(require("node:os"), 1);
743
758
  import_node_path2 = __toESM(require("node:path"), 1);
744
- import_node_url2 = require("node:url");
745
- __dirname2 = import_node_path2.default.dirname((0, import_node_url2.fileURLToPath)(importMetaUrl));
759
+ init_heavyDeps();
746
760
  SHELL_METAS = /[;&|`$<>"'\n\r%^!]/;
747
761
  }
748
762
  });
@@ -810,7 +824,7 @@ function tryResolveFromCache(name, ctx = {}) {
810
824
  const pkgJsonAnchor = import_node_path3.default.join(runtimeDir, "package.json");
811
825
  if (!import_node_fs3.default.existsSync(pkgJsonAnchor))
812
826
  return null;
813
- const requireFromCache = (0, import_node_module.createRequire)((0, import_node_url3.pathToFileURL)(pkgJsonAnchor).href);
827
+ const requireFromCache = (0, import_node_module.createRequire)((0, import_node_url2.pathToFileURL)(pkgJsonAnchor).href);
814
828
  return resolveEntry(requireFromCache, name);
815
829
  } catch (err) {
816
830
  const detail = err instanceof Error ? err.stack ?? `${err.name}: ${err.message}` : (0, import_node_util.inspect)(err);
@@ -870,7 +884,7 @@ async function loadHeavyDep(name, options = {}) {
870
884
  throw new Error(`Failed to resolve heavy dep '${name}' even after install. Inspect <cacheDir>/runtime/node_modules/${name}.`);
871
885
  }
872
886
  }
873
- return await import((0, import_node_url3.pathToFileURL)(resolved).href);
887
+ return await import((0, import_node_url2.pathToFileURL)(resolved).href);
874
888
  }
875
889
  function heavyDepPrefetchTarget(name, ctx = {}) {
876
890
  const entry = resolveHeavyDepPath(name, ctx);
@@ -883,7 +897,7 @@ function prefetchHeavyDep(name, ctx = {}) {
883
897
  try {
884
898
  const target = heavyDepPrefetchTarget(name, ctx);
885
899
  if (target)
886
- import((0, import_node_url3.pathToFileURL)(target).href).catch(() => {
900
+ import((0, import_node_url2.pathToFileURL)(target).href).catch(() => {
887
901
  });
888
902
  } catch {
889
903
  }
@@ -1099,14 +1113,14 @@ async function ensureRuntimeInstalled(packages, options = {}) {
1099
1113
  }
1100
1114
  recordRuntimeDependencies(runtimeDir, ctx);
1101
1115
  }
1102
- var import_node_path3, import_node_fs3, import_node_child_process, import_node_url3, import_node_module, import_node_util, RUNTIME_DEBUG, defaultLogger, requireFromShim, PREFETCH_SIBLING, DEFAULT_INSTALL_TIMEOUT_MS, RUNTIME_PACKAGE_JSON_CONTENTS;
1116
+ var import_node_path3, import_node_fs3, import_node_child_process, import_node_url2, import_node_module, import_node_util, RUNTIME_DEBUG, defaultLogger, requireFromShim, PREFETCH_SIBLING, DEFAULT_INSTALL_TIMEOUT_MS, RUNTIME_PACKAGE_JSON_CONTENTS;
1103
1117
  var init_loader = __esm({
1104
1118
  "dist/runtime/loader.js"() {
1105
1119
  "use strict";
1106
1120
  import_node_path3 = __toESM(require("node:path"), 1);
1107
1121
  import_node_fs3 = __toESM(require("node:fs"), 1);
1108
1122
  import_node_child_process = require("node:child_process");
1109
- import_node_url3 = require("node:url");
1123
+ import_node_url2 = require("node:url");
1110
1124
  import_node_module = require("node:module");
1111
1125
  import_node_util = require("node:util");
1112
1126
  init_heavyDeps();
@@ -1532,8 +1546,8 @@ var init_windowsBash = __esm({
1532
1546
 
1533
1547
  // dist/core/ptyWatchdog.js
1534
1548
  function defaultCreateWorker(data) {
1535
- const url = new URL("./ptyProbeWorker.js", importMetaUrl);
1536
- return new import_node_worker_threads.Worker(url, { workerData: data });
1549
+ const worker = import_node_path5.default.join(packageRoot(), "dist", "core", "ptyProbeWorker.js");
1550
+ return new import_node_worker_threads.Worker(worker, { workerData: data });
1537
1551
  }
1538
1552
  function probePtyAllocation(opts) {
1539
1553
  const timeoutMs = opts.timeoutMs ?? PTY_PROBE_TIMEOUT_MS;
@@ -1615,12 +1629,14 @@ async function assertConptyAllocatable(opts) {
1615
1629
  throw err;
1616
1630
  }
1617
1631
  }
1618
- var import_node_fs6, import_node_worker_threads, PTY_PROBE_TIMEOUT_MS;
1632
+ var import_node_fs6, import_node_path5, import_node_worker_threads, PTY_PROBE_TIMEOUT_MS;
1619
1633
  var init_ptyWatchdog = __esm({
1620
1634
  "dist/core/ptyWatchdog.js"() {
1621
1635
  "use strict";
1622
1636
  import_node_fs6 = __toESM(require("node:fs"), 1);
1637
+ import_node_path5 = __toESM(require("node:path"), 1);
1623
1638
  import_node_worker_threads = require("node:worker_threads");
1639
+ init_heavyDeps();
1624
1640
  PTY_PROBE_TIMEOUT_MS = Number(process.env.DOC_DETECTIVE_PTY_PROBE_TIMEOUT_MS) || 15e3;
1625
1641
  }
1626
1642
  });
@@ -2388,6 +2404,24 @@ var init_verification = __esm({
2388
2404
  function loadAxios() {
2389
2405
  return axiosModule ??= import("axios").then((m) => m.default);
2390
2406
  }
2407
+ function sessionVersions({ capabilities, app }) {
2408
+ const result = {};
2409
+ const str = (value) => typeof value === "string" && value ? value : void 0;
2410
+ const caps = capabilities ?? {};
2411
+ const browserVersion = str(caps.browserVersion) ?? str(app?.version)?.replace(/^[a-z]+_/, "");
2412
+ if (browserVersion)
2413
+ result.browserVersion = browserVersion;
2414
+ const chromedriver = str(caps.chrome?.chromedriverVersion);
2415
+ if (chromedriver) {
2416
+ result.driver = { name: "chromedriver", version: chromedriver.split(" ")[0] };
2417
+ } else if (app?.name === "firefox") {
2418
+ const version = str(caps["moz:geckodriverVersion"]) ?? /geckodriver-(\d+(?:\.\d+)*)/i.exec(import_node_path6.default.basename(str(app.driver) ?? ""))?.[1];
2419
+ result.driver = { name: "geckodriver", ...version && { version } };
2420
+ } else if (app?.name === "safari") {
2421
+ result.driver = { name: "safaridriver" };
2422
+ }
2423
+ return result;
2424
+ }
2391
2425
  function createAppiumPool(ports) {
2392
2426
  const available = [...ports];
2393
2427
  const waiters = [];
@@ -2603,11 +2637,9 @@ async function spawnPtyBackgroundCommand(cmd, args = [], options = {}) {
2603
2637
  try {
2604
2638
  ptyProcess = pty.spawn(shell, shellArgs, {
2605
2639
  name: "xterm-color",
2606
- // TODO(phase): expose as `background.tty.cols` / `background.tty.rows` so
2607
- // layout-sensitive TUIs (Ink reads process.stdout.columns) can match an
2608
- // expected width when authoring `waitUntil.stdio` patterns.
2609
- cols: 120,
2610
- rows: 30,
2640
+ // `tty: {cols, rows}`; Ajv skips defaults inside the schema's anyOf.
2641
+ cols: options.cols ?? 120,
2642
+ rows: options.rows ?? 30,
2611
2643
  cwd: options.cwd || process.cwd(),
2612
2644
  env: shellSpawnEnv(options.shell) ?? process.env
2613
2645
  });
@@ -2969,13 +3001,13 @@ function appendQueryParams(url, params) {
2969
3001
  return pathAndAuthority + (query ? "?" + query : "") + fragment;
2970
3002
  }
2971
3003
  function cleanTemp() {
2972
- const tempDir = import_node_path5.default.join(import_node_os2.default.tmpdir(), "doc-detective");
3004
+ const tempDir = import_node_path6.default.join(import_node_os2.default.tmpdir(), "doc-detective");
2973
3005
  if (!import_node_fs7.default.existsSync(tempDir))
2974
3006
  return;
2975
3007
  for (const entry of import_node_fs7.default.readdirSync(tempDir)) {
2976
3008
  if (PRESERVED_TEMP_ENTRIES.has(entry))
2977
3009
  continue;
2978
- const curPath = import_node_path5.default.join(tempDir, entry);
3010
+ const curPath = import_node_path6.default.join(tempDir, entry);
2979
3011
  try {
2980
3012
  const stat = import_node_fs7.default.statSync(curPath);
2981
3013
  if (stat.isDirectory()) {
@@ -3003,7 +3035,7 @@ function safeFilenameFromUrl(fileURL, fallback) {
3003
3035
  raw = fileURL;
3004
3036
  }
3005
3037
  raw = raw.split("?")[0].split("#")[0];
3006
- const base = import_node_path5.default.basename(raw.replace(/\\/g, "/"));
3038
+ const base = import_node_path6.default.basename(raw.replace(/\\/g, "/"));
3007
3039
  return sanitizeFilesystemName(base, fallback);
3008
3040
  }
3009
3041
  function redactUrlForOutput(value) {
@@ -3041,36 +3073,56 @@ function isPrivateOrLoopbackAddress(ip) {
3041
3073
  return false;
3042
3074
  }
3043
3075
  if (import_node_net.default.isIPv6(ip)) {
3044
- const normalized = ip.toLowerCase();
3045
- if (normalized === "::1")
3046
- return true;
3047
- if (normalized === "::")
3076
+ const g = ipv6Groups(ip);
3077
+ const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
3078
+ const v4 = `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
3079
+ if ((g[0] & 65024) === 64512)
3048
3080
  return true;
3049
- if (normalized.startsWith("fc") || normalized.startsWith("fd"))
3081
+ if ((g[0] & 65472) === 65152)
3050
3082
  return true;
3051
- if (normalized.startsWith("fe80:"))
3083
+ if ((g[0] & 65472) === 65216)
3052
3084
  return true;
3053
- if (normalized.startsWith("::ffff:")) {
3054
- const tail = normalized.slice("::ffff:".length);
3055
- if (import_node_net.default.isIPv4(tail)) {
3056
- return isPrivateOrLoopbackAddress(tail);
3057
- }
3058
- const hexMatch = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
3059
- if (hexMatch) {
3060
- const hi = parseInt(hexMatch[1], 16);
3061
- const lo = parseInt(hexMatch[2], 16);
3062
- const dotted = `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
3063
- return isPrivateOrLoopbackAddress(dotted);
3064
- }
3065
- return false;
3085
+ if (g[0] === 8194) {
3086
+ return isPrivateOrLoopbackAddress(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`);
3087
+ }
3088
+ if (zero(0, 4) && g[4] === 65535 && g[5] === 0)
3089
+ return isPrivateOrLoopbackAddress(v4);
3090
+ if (zero(0, 6))
3091
+ return isPrivateOrLoopbackAddress(v4);
3092
+ if (zero(0, 5) && g[5] === 65535)
3093
+ return isPrivateOrLoopbackAddress(v4);
3094
+ if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) {
3095
+ return isPrivateOrLoopbackAddress(v4);
3066
3096
  }
3067
3097
  return false;
3068
3098
  }
3069
3099
  return false;
3070
3100
  }
3071
- async function assertUrlHostIsPublic(fileURL) {
3072
- if (process.env.DOC_DETECTIVE_ALLOW_LOCAL_URLS === "true")
3073
- return;
3101
+ function ipv6Groups(ip) {
3102
+ let text = ip.toLowerCase().split("%")[0];
3103
+ const dotted = text.match(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
3104
+ if (dotted) {
3105
+ const [a, b, c, d] = dotted.slice(1).map(Number);
3106
+ text = text.slice(0, dotted.index) + `${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
3107
+ }
3108
+ const [head, tail] = text.split("::");
3109
+ const left = head ? head.split(":") : [];
3110
+ const right = tail ? tail.split(":") : [];
3111
+ const groups = tail === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
3112
+ return groups.map((group) => parseInt(group, 16));
3113
+ }
3114
+ function envFlag(name) {
3115
+ return /^(1|true|yes|on)$/i.test((process.env[name] ?? "").trim());
3116
+ }
3117
+ function localUrlPolicy() {
3118
+ const allow = envFlag("DOC_DETECTIVE_ALLOW_LOCAL_URLS");
3119
+ const block = envFlag("DOC_DETECTIVE_BLOCK_LOCAL_URLS");
3120
+ if (allow && block) {
3121
+ throw new UrlGuardError("DOC_DETECTIVE_ALLOW_LOCAL_URLS and DOC_DETECTIVE_BLOCK_LOCAL_URLS can't both be set.");
3122
+ }
3123
+ return allow ? "allow" : block ? "block" : "hops";
3124
+ }
3125
+ async function privateAddressOf(fileURL, { every = false } = {}) {
3074
3126
  let parsed;
3075
3127
  try {
3076
3128
  parsed = new URL(fileURL);
@@ -3082,33 +3134,117 @@ async function assertUrlHostIsPublic(fileURL) {
3082
3134
  }
3083
3135
  const host = parsed.hostname.replace(/^\[|\]$/g, "");
3084
3136
  if (import_node_net.default.isIP(host)) {
3085
- if (isPrivateOrLoopbackAddress(host)) {
3086
- throw new Error(`Refusing to fetch private/loopback address (${host}). Set DOC_DETECTIVE_ALLOW_LOCAL_URLS=true to allow.`);
3087
- }
3088
- return;
3137
+ return isPrivateOrLoopbackAddress(host) ? host : void 0;
3089
3138
  }
3090
3139
  const lower = host.toLowerCase();
3091
3140
  if (lower === "localhost" || lower.endsWith(".localhost")) {
3092
- throw new Error(`Refusing to fetch localhost (${host}). Set DOC_DETECTIVE_ALLOW_LOCAL_URLS=true to allow.`);
3141
+ return host;
3093
3142
  }
3094
3143
  let addresses;
3095
3144
  try {
3096
3145
  addresses = await import_promises.default.lookup(host, { all: true });
3097
3146
  } catch (error) {
3098
- throw new Error(`Couldn't resolve host ${host} for SSRF check: ${error.message}`);
3147
+ throw Object.assign(new Error(`Couldn't resolve host ${host}: ${error.message}`), { host, code: error.code ?? error.message });
3099
3148
  }
3100
- for (const { address } of addresses) {
3101
- if (isPrivateOrLoopbackAddress(address)) {
3102
- throw new Error(`Host ${host} resolves to a private/loopback address (${address}); refusing to fetch. Set DOC_DETECTIVE_ALLOW_LOCAL_URLS=true to allow.`);
3103
- }
3149
+ const privates = addresses.filter(({ address }) => isPrivateOrLoopbackAddress(address));
3150
+ const isPrivate = every ? privates.length === addresses.length : privates.length > 0;
3151
+ return isPrivate ? privates[0]?.address : void 0;
3152
+ }
3153
+ function strictModeError(url) {
3154
+ return new UrlGuardError(`Couldn't fetch ${redactUrlForOutput(url)}: DOC_DETECTIVE_BLOCK_LOCAL_URLS refuses private and loopback addresses.
3155
+ Hint: Unset DOC_DETECTIVE_BLOCK_LOCAL_URLS to allow local URLs.`);
3156
+ }
3157
+ function redirectError(from, address) {
3158
+ return new UrlGuardError(`Couldn't follow the redirect from ${redactUrlForOutput(from)}: it leads to ${address}, a private address, from a public one.
3159
+ Hint: If you trust this redirect, set DOC_DETECTIVE_ALLOW_LOCAL_URLS=1.`);
3160
+ }
3161
+ function hostKey(hostname) {
3162
+ return hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
3163
+ }
3164
+ function proxyConfigured() {
3165
+ return ["http_proxy", "https_proxy", "all_proxy"].some((key) => process.env[key] || process.env[key.toUpperCase()]);
3166
+ }
3167
+ async function getGuarded(url, opts = FETCH_BINARY_DEFAULTS, refuseFirst, refuseRedirect = redirectError) {
3168
+ const policy = localUrlPolicy();
3169
+ const axios = await loadAxios();
3170
+ const [{ default: http3 }, { default: https4 }] = await Promise.all([
3171
+ import("node:http"),
3172
+ import("node:https")
3173
+ ]);
3174
+ let current = url;
3175
+ let refuse = policy === "hops" ? refuseFirst : void 0;
3176
+ let reachedPublic = false;
3177
+ for (let hops = 0; ; hops++) {
3178
+ if (policy === "block") {
3179
+ if (await privateAddressOf(current))
3180
+ throw strictModeError(current);
3181
+ const hopUrl = current;
3182
+ refuse = () => strictModeError(hopUrl);
3183
+ }
3184
+ const seen = {};
3185
+ const refuseAtConnect = refuse;
3186
+ const target = hostKey(new URL(current).hostname);
3187
+ const lookup = (hostname, options, callback) => {
3188
+ if (proxyConfigured() && hostKey(hostname) !== target) {
3189
+ return import_node_dns.default.lookup(hostname, options, callback);
3190
+ }
3191
+ return import_node_dns.default.lookup(hostname, options, (error, address, family) => {
3192
+ if (error)
3193
+ return callback(error, address, family);
3194
+ const answers = Array.isArray(address) ? address : [{ address }];
3195
+ seen.resolved = true;
3196
+ seen.private = answers.find((a) => isPrivateOrLoopbackAddress(a.address))?.address;
3197
+ seen.public = answers.some((a) => !isPrivateOrLoopbackAddress(a.address));
3198
+ if (seen.private && refuseAtConnect)
3199
+ return callback(refuseAtConnect(seen.private));
3200
+ callback(null, address, family);
3201
+ });
3202
+ };
3203
+ const response = await axios.get(current, {
3204
+ ...opts,
3205
+ maxRedirects: 0,
3206
+ httpAgent: new http3.Agent({ lookup }),
3207
+ httpsAgent: new https4.Agent({ lookup }),
3208
+ validateStatus: (status) => status >= 200 && status < 300 || REDIRECT_STATUSES.includes(status)
3209
+ }).catch((error) => {
3210
+ throw error?.cause instanceof UrlGuardError ? error.cause : error;
3211
+ });
3212
+ let hopPublic = seen.resolved ? seen.public : import_node_net.default.isIP(target) ? !isPrivateOrLoopbackAddress(target) : void 0;
3213
+ let fresh;
3214
+ const freshPublic = () => fresh ??= privateAddressOf(current, { every: true }).then((a) => !a, () => true);
3215
+ if (hopPublic === void 0 && policy !== "allow")
3216
+ hopPublic = await freshPublic();
3217
+ if (hopPublic)
3218
+ reachedPublic = true;
3219
+ if (!REDIRECT_STATUSES.includes(response.status)) {
3220
+ return { data: response.data, url: current, reachedPublic };
3221
+ }
3222
+ const location = response.headers?.location;
3223
+ if (!location || hops >= 5) {
3224
+ throw new UrlGuardError(`Couldn't fetch ${redactUrlForOutput(url)}: ${hops >= 5 ? "it needs more than 5 redirects" : `the ${response.status} redirect has no Location header`}.`);
3225
+ }
3226
+ const next = new URL(location, current).toString();
3227
+ const scheme = new URL(next).protocol;
3228
+ if (scheme !== "http:" && scheme !== "https:") {
3229
+ throw new UrlGuardError(`Couldn't fetch ${redactUrlForOutput(current)}: the redirect leads to an unsupported scheme (\`${scheme.slice(0, -1)}\`).`);
3230
+ }
3231
+ if (policy === "hops") {
3232
+ const fromPublic = hopPublic || await freshPublic();
3233
+ refuse = void 0;
3234
+ if (fromPublic) {
3235
+ const address = await privateAddressOf(next);
3236
+ if (address)
3237
+ throw refuseRedirect(current, address);
3238
+ const from = current;
3239
+ refuse = (connectAddress) => refuseRedirect(from, connectAddress);
3240
+ }
3241
+ }
3242
+ current = next;
3104
3243
  }
3105
3244
  }
3106
3245
  async function fetchFile(fileURL, opts = {}) {
3107
3246
  try {
3108
- if (opts.binary) {
3109
- await assertUrlHostIsPublic(fileURL);
3110
- }
3111
- const response = await (await loadAxios()).get(fileURL, opts.binary ? FETCH_BINARY_DEFAULTS : void 0);
3247
+ const response = opts.binary ? await getGuarded(fileURL) : await (await loadAxios()).get(fileURL);
3112
3248
  let data;
3113
3249
  if (opts.binary) {
3114
3250
  data = Buffer.from(response.data);
@@ -3119,11 +3255,11 @@ async function fetchFile(fileURL, opts = {}) {
3119
3255
  }
3120
3256
  const fileName = safeFilenameFromUrl(fileURL, "fetched_file");
3121
3257
  const hash = import_node_crypto3.default.createHash("md5").update(data).digest("hex");
3122
- const ddTempDir = import_node_path5.default.join(import_node_os2.default.tmpdir(), "doc-detective");
3123
- const filePath = import_node_path5.default.join(ddTempDir, `${hash}_${fileName}`);
3124
- const resolvedDir = import_node_path5.default.resolve(ddTempDir);
3125
- const resolvedFile = import_node_path5.default.resolve(filePath);
3126
- if (!resolvedFile.startsWith(resolvedDir + import_node_path5.default.sep)) {
3258
+ const ddTempDir = import_node_path6.default.join(import_node_os2.default.tmpdir(), "doc-detective");
3259
+ const filePath = import_node_path6.default.join(ddTempDir, `${hash}_${fileName}`);
3260
+ const resolvedDir = import_node_path6.default.resolve(ddTempDir);
3261
+ const resolvedFile = import_node_path6.default.resolve(filePath);
3262
+ if (!resolvedFile.startsWith(resolvedDir + import_node_path6.default.sep)) {
3127
3263
  return {
3128
3264
  result: "error",
3129
3265
  message: new Error(`Refusing to write outside temp dir: ${resolvedFile}`)
@@ -3302,10 +3438,10 @@ function expandRequirementPath(entry, env) {
3302
3438
  function commandOnPath(command, env, existsSync) {
3303
3439
  const hasSeparator = command.includes("/") || command.includes("\\");
3304
3440
  const pathValue = env.PATH ?? env.Path ?? "";
3305
- const directories = hasSeparator ? [""] : pathValue.split(import_node_path5.default.delimiter).filter(Boolean);
3441
+ const directories = hasSeparator ? [""] : pathValue.split(import_node_path6.default.delimiter).filter(Boolean);
3306
3442
  const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
3307
3443
  for (const directory of directories) {
3308
- const base = hasSeparator ? command : import_node_path5.default.join(directory, command);
3444
+ const base = hasSeparator ? command : import_node_path6.default.join(directory, command);
3309
3445
  for (const extension of extensions) {
3310
3446
  if (existsSync(base + extension))
3311
3447
  return true;
@@ -3396,7 +3532,7 @@ function shieldsBadge(format, date, { label = "Last verified", color = "brightgr
3396
3532
  }
3397
3533
  function writeFileAtomic(file, content) {
3398
3534
  const target = import_node_fs7.default.realpathSync(file);
3399
- const tmp = import_node_path5.default.join(import_node_path5.default.dirname(target), `.${import_node_path5.default.basename(target)}.${import_node_crypto3.default.randomBytes(6).toString("hex")}.tmp`);
3535
+ const tmp = import_node_path6.default.join(import_node_path6.default.dirname(target), `.${import_node_path6.default.basename(target)}.${import_node_crypto3.default.randomBytes(6).toString("hex")}.tmp`);
3400
3536
  const fd = import_node_fs7.default.openSync(tmp, "wx", 384);
3401
3537
  let keepTmp = false;
3402
3538
  try {
@@ -3462,7 +3598,7 @@ function applyVerifiedMarkers({ config, results, files: detectedFiles = [], init
3462
3598
  }
3463
3599
  }
3464
3600
  const today = verifiedDate();
3465
- const version = (0, import_node_module3.createRequire)(importMetaUrl)("../../package.json").version;
3601
+ const version = ownVersion() ?? "unknown";
3466
3602
  for (const file of files) {
3467
3603
  try {
3468
3604
  if (!import_node_fs7.default.existsSync(file))
@@ -3481,7 +3617,7 @@ function applyVerifiedMarkers({ config, results, files: detectedFiles = [], init
3481
3617
  version,
3482
3618
  resolve: (check) => resolveVerifiedId(results, check),
3483
3619
  onUnknown: (check) => log(config, "warning", `verified-by check '${check}' in ${file} matches no test or spec; skipping.`),
3484
- onSkipped: (line, reason) => log(config, "warning", `Skipping the meta statement at ${file}:${line}: ${reason}.`)
3620
+ onSkipped: (line, reason2) => log(config, "warning", `Skipping the meta statement at ${file}:${line}: ${reason2}.`)
3485
3621
  });
3486
3622
  if (content !== original)
3487
3623
  writeFileAtomic(file, content);
@@ -3641,11 +3777,11 @@ function getRunOutputDir(config, { create = true } = {}) {
3641
3777
  let base = String(config?.output || ".");
3642
3778
  const reportFileExtensions = [".json", ".html", ".htm", ".xml", ".md"];
3643
3779
  if (reportFileExtensions.some((ext) => base.toLowerCase().endsWith(ext))) {
3644
- base = import_node_path5.default.dirname(base);
3780
+ base = import_node_path6.default.dirname(base);
3645
3781
  }
3646
- const runsRoot = import_node_path5.default.resolve(base, ".doc-detective", "runs");
3782
+ const runsRoot = import_node_path6.default.resolve(base, ".doc-detective", "runs");
3647
3783
  const runId = getOrInitRunTimestamp(config);
3648
- let dir = import_node_path5.default.join(runsRoot, runId);
3784
+ let dir = import_node_path6.default.join(runsRoot, runId);
3649
3785
  if (create) {
3650
3786
  import_node_fs7.default.mkdirSync(runsRoot, { recursive: true });
3651
3787
  let suffix = 2;
@@ -3656,7 +3792,7 @@ function getRunOutputDir(config, { create = true } = {}) {
3656
3792
  } catch (error) {
3657
3793
  if (error?.code !== "EEXIST")
3658
3794
  throw error;
3659
- dir = import_node_path5.default.join(runsRoot, `${runId}-${suffix++}`);
3795
+ dir = import_node_path6.default.join(runsRoot, `${runId}-${suffix++}`);
3660
3796
  }
3661
3797
  }
3662
3798
  }
@@ -3733,15 +3869,15 @@ function shellSpawnEnv(shellExecutable, deps = {}) {
3733
3869
  return void 0;
3734
3870
  if (!/bash(\.exe)?$/i.test(shellExecutable))
3735
3871
  return void 0;
3736
- const dir = import_node_path5.default.win32.dirname(shellExecutable);
3737
- if (!import_node_path5.default.win32.isAbsolute(dir))
3872
+ const dir = import_node_path6.default.win32.dirname(shellExecutable);
3873
+ if (!import_node_path6.default.win32.isAbsolute(dir))
3738
3874
  return void 0;
3739
3875
  const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "Path";
3740
3876
  const existing = env[pathKey];
3741
3877
  return {
3742
3878
  ...env,
3743
3879
  // No trailing delimiter (an empty PATH segment) when PATH was unset.
3744
- [pathKey]: existing ? `${dir}${import_node_path5.default.win32.delimiter}${existing}` : dir
3880
+ [pathKey]: existing ? `${dir}${import_node_path6.default.win32.delimiter}${existing}` : dir
3745
3881
  };
3746
3882
  }
3747
3883
  async function spawnCommand(cmd, args = [], options = {}) {
@@ -3842,22 +3978,30 @@ function llevenshteinDistance(s, t) {
3842
3978
  }
3843
3979
  return arr[t.length][s.length];
3844
3980
  }
3845
- var import_node_fs7, import_node_os2, import_node_path5, import_node_crypto3, import_promises, import_node_net, import_node_child_process4, import_node_module3, axiosModule, BACKGROUND_BUFFER_LIMIT, PTY_PACKAGE, READY_POLL_INTERVAL_MS, TRANSIENT_SESSION_ERROR, SESSION_TIMEOUT_ABORT, BROWSER_ERROR_PAGE, INITIAL_BLANK_DOCUMENT, TRANSIENT_PROCESS_INIT_EXIT_CODES, SETTLE_CEILING_MAX_MS, PRESERVED_TEMP_ENTRIES, FETCH_BINARY_DEFAULTS, VIEWPORT_TOLERANCE_PX, VIEWPORT_SETTLE_MS, DEFAULT_VIEWPORT, ENV_VAR_REGEX, VERIFIED_FORMAT_BY_EXT, VERIFIED_IMG_BODY, posixBashProbe;
3981
+ var import_node_fs7, import_node_os2, import_node_path6, import_node_crypto3, import_promises, import_node_dns, import_node_net, import_node_child_process4, axiosModule, FETCH_BINARY_DEFAULTS, FETCH_TEXT_DEFAULTS, BACKGROUND_BUFFER_LIMIT, PTY_PACKAGE, READY_POLL_INTERVAL_MS, TRANSIENT_SESSION_ERROR, SESSION_TIMEOUT_ABORT, BROWSER_ERROR_PAGE, INITIAL_BLANK_DOCUMENT, TRANSIENT_PROCESS_INIT_EXIT_CODES, SETTLE_CEILING_MAX_MS, PRESERVED_TEMP_ENTRIES, UrlGuardError, REDIRECT_STATUSES, VIEWPORT_TOLERANCE_PX, VIEWPORT_SETTLE_MS, DEFAULT_VIEWPORT, ENV_VAR_REGEX, VERIFIED_FORMAT_BY_EXT, VERIFIED_IMG_BODY, posixBashProbe;
3846
3982
  var init_utils = __esm({
3847
3983
  "dist/core/utils.js"() {
3848
3984
  "use strict";
3849
3985
  import_node_fs7 = __toESM(require("node:fs"), 1);
3850
3986
  import_node_os2 = __toESM(require("node:os"), 1);
3851
- import_node_path5 = __toESM(require("node:path"), 1);
3987
+ import_node_path6 = __toESM(require("node:path"), 1);
3852
3988
  import_node_crypto3 = __toESM(require("node:crypto"), 1);
3853
3989
  import_promises = __toESM(require("node:dns/promises"), 1);
3990
+ import_node_dns = __toESM(require("node:dns"), 1);
3854
3991
  import_node_net = __toESM(require("node:net"), 1);
3855
3992
  import_node_child_process4 = require("node:child_process");
3856
3993
  init_loader();
3857
3994
  init_windowsBash();
3995
+ init_heavyDeps();
3858
3996
  init_ptyWatchdog();
3859
- import_node_module3 = require("node:module");
3860
3997
  init_verification();
3998
+ FETCH_BINARY_DEFAULTS = {
3999
+ responseType: "arraybuffer",
4000
+ timeout: 3e4,
4001
+ maxContentLength: 50 * 1024 * 1024,
4002
+ maxBodyLength: 50 * 1024 * 1024
4003
+ };
4004
+ FETCH_TEXT_DEFAULTS = { ...FETCH_BINARY_DEFAULTS, responseType: "text" };
3861
4005
  BACKGROUND_BUFFER_LIMIT = 256 * 1024;
3862
4006
  PTY_PACKAGE = "@homebridge/node-pty-prebuilt-multiarch";
3863
4007
  READY_POLL_INTERVAL_MS = 500;
@@ -3884,13 +4028,9 @@ var init_utils = __esm({
3884
4028
  "jre",
3885
4029
  "ios"
3886
4030
  ]);
3887
- FETCH_BINARY_DEFAULTS = {
3888
- responseType: "arraybuffer",
3889
- timeout: 3e4,
3890
- maxContentLength: 50 * 1024 * 1024,
3891
- maxBodyLength: 50 * 1024 * 1024,
3892
- maxRedirects: 5
4031
+ UrlGuardError = class extends Error {
3893
4032
  };
4033
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308];
3894
4034
  VIEWPORT_TOLERANCE_PX = 16;
3895
4035
  VIEWPORT_SETTLE_MS = 2e3;
3896
4036
  DEFAULT_VIEWPORT = { width: 1200, height: 800 };
@@ -3932,7 +4072,7 @@ __export(browsers_exports, {
3932
4072
  verifyDriverBinary: () => verifyDriverBinary
3933
4073
  });
3934
4074
  function isAllowedDriverPath(binaryPath) {
3935
- return typeof binaryPath === "string" && import_node_path6.default.isAbsolute(binaryPath) && ALLOWED_DRIVER_PATH.test(binaryPath);
4075
+ return typeof binaryPath === "string" && import_node_path7.default.isAbsolute(binaryPath) && ALLOWED_DRIVER_PATH.test(binaryPath);
3936
4076
  }
3937
4077
  function parseDriverVersion(output) {
3938
4078
  const m = output.match(/(\d+\.\d+(?:\.\d+)*)/);
@@ -4104,12 +4244,12 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4104
4244
  }
4105
4245
  if (!force && existing && isStillFresh(existing.latestCheckedAt, now)) {
4106
4246
  if (existing.latestKnownVersion === existing.installedVersion) {
4107
- const path33 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4108
- return { path: path33, version: existing.installedVersion, outdated: false };
4247
+ const path34 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4248
+ return { path: path34, version: existing.installedVersion, outdated: false };
4109
4249
  }
4110
- const path32 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4250
+ const path33 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4111
4251
  logger(`${name} ${existing.installedVersion} installed in ${cacheDir}; "${BROWSER_CHANNELS[name]}" channel is now ${existing.latestKnownVersion}. Run \`doc-detective install browsers ${name} --force\` to update.`, "warn");
4112
- return { path: path32, version: existing.installedVersion, outdated: true };
4252
+ return { path: path33, version: existing.installedVersion, outdated: true };
4113
4253
  }
4114
4254
  let latest;
4115
4255
  try {
@@ -4117,8 +4257,8 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4117
4257
  } catch (err) {
4118
4258
  logger(`Channel resolution for ${name} skipped: ${String(err)}`, "debug");
4119
4259
  if (existing) {
4120
- const path32 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4121
- return { path: path32, version: existing.installedVersion, outdated: false };
4260
+ const path33 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4261
+ return { path: path33, version: existing.installedVersion, outdated: false };
4122
4262
  }
4123
4263
  throw err;
4124
4264
  }
@@ -4129,8 +4269,8 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4129
4269
  latestCheckedAt: now.toISOString()
4130
4270
  };
4131
4271
  writeInstalledRecord(record, ctx);
4132
- const path32 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4133
- return { path: path32, version: latest, outdated: false };
4272
+ const path33 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4273
+ return { path: path33, version: latest, outdated: false };
4134
4274
  }
4135
4275
  if (!force && existing) {
4136
4276
  record.browsers[name] = {
@@ -4140,8 +4280,8 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4140
4280
  };
4141
4281
  writeInstalledRecord(record, ctx);
4142
4282
  logger(`${name} ${existing.installedVersion} installed in ${cacheDir}; "${BROWSER_CHANNELS[name]}" channel is now ${latest}. Run \`doc-detective install browsers ${name} --force\` to update.`, "warn");
4143
- const path32 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4144
- return { path: path32, version: existing.installedVersion, outdated: true };
4283
+ const path33 = await locateExecutable(browsersModule, name, existing.installedVersion, cacheDir, platform);
4284
+ return { path: path33, version: existing.installedVersion, outdated: true };
4145
4285
  }
4146
4286
  logger(`Installing ${name} ${latest} into ${cacheDir}`, "info");
4147
4287
  await browsersModule.install({
@@ -4149,9 +4289,9 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4149
4289
  buildId: latest,
4150
4290
  cacheDir
4151
4291
  });
4152
- let path31 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4153
- if (name === "chromedriver" && isAllowedDriverPath(path31)) {
4154
- let verify = await verifyDriverBinary(name, path31, { exec: deps.verifyExec });
4292
+ let path32 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4293
+ if (name === "chromedriver" && isAllowedDriverPath(path32)) {
4294
+ let verify = await verifyDriverBinary(name, path32, { exec: deps.verifyExec });
4155
4295
  if (!verify.ok) {
4156
4296
  logger(`Installed ${name} ${latest} failed validation (${verify.error}); reinstalling once.`, "warn");
4157
4297
  try {
@@ -4160,11 +4300,11 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4160
4300
  logger(`Failed to prune broken ${name} ${latest}: ${String(err)}`, "debug");
4161
4301
  }
4162
4302
  await browsersModule.install({ browser: name, buildId: latest, cacheDir });
4163
- path31 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4164
- verify = await verifyDriverBinary(name, path31, { exec: deps.verifyExec });
4303
+ path32 = await locateExecutable(browsersModule, name, latest, cacheDir, platform);
4304
+ verify = await verifyDriverBinary(name, path32, { exec: deps.verifyExec });
4165
4305
  }
4166
4306
  if (!verify.ok) {
4167
- throw new Error(`${name} ${latest} is present but non-functional after a reinstall (${verify.error}). It may be a partial or corrupt download; delete ${path31} or reinstall.`);
4307
+ throw new Error(`${name} ${latest} is present but non-functional after a reinstall (${verify.error}). It may be a partial or corrupt download; delete ${path32} or reinstall.`);
4168
4308
  }
4169
4309
  }
4170
4310
  if (existing && existing.installedVersion !== latest) {
@@ -4185,7 +4325,7 @@ async function ensureBrowserInstalledImpl(name, options = {}) {
4185
4325
  latestCheckedAt: now.toISOString()
4186
4326
  };
4187
4327
  writeInstalledRecord(record, ctx);
4188
- return { path: path31, version: latest, outdated: false };
4328
+ return { path: path32, version: latest, outdated: false };
4189
4329
  }
4190
4330
  async function locateExecutable(browsersModule, name, buildId, cacheDir, platform) {
4191
4331
  if (typeof browsersModule.computeExecutablePath === "function") {
@@ -4218,9 +4358,9 @@ async function withGeckodriverCacheDir(cacheDir, fn) {
4218
4358
  }
4219
4359
  }
4220
4360
  function isPathInside(child, dir) {
4221
- const resolvedDir = import_node_path6.default.resolve(dir);
4222
- const resolvedChild = import_node_path6.default.resolve(child);
4223
- return resolvedChild === resolvedDir || resolvedChild.startsWith(resolvedDir + import_node_path6.default.sep);
4361
+ const resolvedDir = import_node_path7.default.resolve(dir);
4362
+ const resolvedChild = import_node_path7.default.resolve(child);
4363
+ return resolvedChild === resolvedDir || resolvedChild.startsWith(resolvedDir + import_node_path7.default.sep);
4224
4364
  }
4225
4365
  function compareGeckodriverVersionsDesc(a, b) {
4226
4366
  const pa = a.split(".");
@@ -4239,7 +4379,7 @@ function isRegularFileEntry(dir, entry) {
4239
4379
  if (!entry.isSymbolicLink())
4240
4380
  return false;
4241
4381
  try {
4242
- return import_node_fs8.default.statSync(import_node_path6.default.join(dir, entry.name)).isFile();
4382
+ return import_node_fs8.default.statSync(import_node_path7.default.join(dir, entry.name)).isFile();
4243
4383
  } catch {
4244
4384
  return false;
4245
4385
  }
@@ -4253,7 +4393,7 @@ function readDirEntries(dir) {
4253
4393
  }
4254
4394
  function bareBinaryIn(dir, binName, entries) {
4255
4395
  const match = entries.find((entry) => entry.name === binName && isRegularFileEntry(dir, entry));
4256
- return match ? import_node_path6.default.join(dir, binName) : void 0;
4396
+ return match ? import_node_path7.default.join(dir, binName) : void 0;
4257
4397
  }
4258
4398
  function newestVersionedIn(dir, entries) {
4259
4399
  const wantExe = process.platform === "win32";
@@ -4267,7 +4407,7 @@ function newestVersionedIn(dir, entries) {
4267
4407
  if (wantExe !== Boolean(match[2]))
4268
4408
  continue;
4269
4409
  if (!best || compareGeckodriverVersionsDesc(match[1], best.version) < 0) {
4270
- best = { version: match[1], file: import_node_path6.default.join(dir, entry.name) };
4410
+ best = { version: match[1], file: import_node_path7.default.join(dir, entry.name) };
4271
4411
  }
4272
4412
  }
4273
4413
  return best;
@@ -4280,7 +4420,7 @@ function geckodriverBinaryInCache(cacheDir) {
4280
4420
  for (const entry of rootEntries) {
4281
4421
  if (!entry.isDirectory())
4282
4422
  continue;
4283
- const dir = import_node_path6.default.join(cacheDir, entry.name);
4423
+ const dir = import_node_path7.default.join(cacheDir, entry.name);
4284
4424
  const nestedEntries = readDirEntries(dir);
4285
4425
  const nestedVersioned = newestVersionedIn(dir, nestedEntries);
4286
4426
  if (nestedVersioned && (!bestVersioned || compareGeckodriverVersionsDesc(nestedVersioned.version, bestVersioned.version) < 0)) {
@@ -4360,14 +4500,14 @@ function getInstalledBrowsers(ctx = {}) {
4360
4500
  latestKnownVersion: entry.latestKnownVersion
4361
4501
  }));
4362
4502
  }
4363
- var import_node_child_process5, import_node_fs8, import_node_os3, import_node_path6, DRIVER_VERSION_ARGS, DRIVER_VERIFY_TIMEOUT_MS, ALLOWED_DRIVER_PATH, EXECUTABLE_HEADER_BYTES, ELF_MACHINE_BY_NODE_ARCH, ELF_MACHINE_LABELS, defaultDriverExec, BROWSER_CHANNELS, FRESHNESS_TTL_MS, RESOLVE_TIMEOUT_MS, RUNTIME_DEBUG2, defaultLogger2, inFlightInstalls2, geckodriverEnvQueue, GECKODRIVER_VERSIONED_NAME;
4503
+ var import_node_child_process5, import_node_fs8, import_node_os3, import_node_path7, DRIVER_VERSION_ARGS, DRIVER_VERIFY_TIMEOUT_MS, ALLOWED_DRIVER_PATH, EXECUTABLE_HEADER_BYTES, ELF_MACHINE_BY_NODE_ARCH, ELF_MACHINE_LABELS, defaultDriverExec, BROWSER_CHANNELS, FRESHNESS_TTL_MS, RESOLVE_TIMEOUT_MS, RUNTIME_DEBUG2, defaultLogger2, inFlightInstalls2, geckodriverEnvQueue, GECKODRIVER_VERSIONED_NAME;
4364
4504
  var init_browsers = __esm({
4365
4505
  "dist/runtime/browsers.js"() {
4366
4506
  "use strict";
4367
4507
  import_node_child_process5 = require("node:child_process");
4368
4508
  import_node_fs8 = __toESM(require("node:fs"), 1);
4369
4509
  import_node_os3 = __toESM(require("node:os"), 1);
4370
- import_node_path6 = __toESM(require("node:path"), 1);
4510
+ import_node_path7 = __toESM(require("node:path"), 1);
4371
4511
  init_cacheDir();
4372
4512
  init_loader();
4373
4513
  DRIVER_VERSION_ARGS = {
@@ -4429,7 +4569,7 @@ var init_browsers = __esm({
4429
4569
  function useDriversArgs(appiumHome, packages) {
4430
4570
  let manifestText;
4431
4571
  try {
4432
- manifestText = import_node_fs9.default.readFileSync(import_node_path7.default.join(appiumHome, "node_modules", ".cache", "appium", "extensions.yaml"), "utf8");
4572
+ manifestText = import_node_fs9.default.readFileSync(import_node_path8.default.join(appiumHome, "node_modules", ".cache", "appium", "extensions.yaml"), "utf8");
4433
4573
  } catch {
4434
4574
  }
4435
4575
  let registered;
@@ -4442,7 +4582,7 @@ function useDriversArgs(appiumHome, packages) {
4442
4582
  }
4443
4583
  const names = packages.flatMap((pkg) => {
4444
4584
  try {
4445
- const name = JSON.parse(import_node_fs9.default.readFileSync(import_node_path7.default.join(appiumHome, "node_modules", pkg, "package.json"), "utf8"))?.appium?.driverName;
4585
+ const name = JSON.parse(import_node_fs9.default.readFileSync(import_node_path8.default.join(appiumHome, "node_modules", pkg, "package.json"), "utf8"))?.appium?.driverName;
4446
4586
  return typeof name === "string" && name ? [{ name, pkg }] : [];
4447
4587
  } catch {
4448
4588
  return [];
@@ -4461,26 +4601,26 @@ function browserPoolDriverPackages(platform) {
4461
4601
  ];
4462
4602
  }
4463
4603
  function appiumHomeForDriverPath(driverEntry) {
4464
- const marker = `${import_node_path7.default.sep}node_modules${import_node_path7.default.sep}`;
4604
+ const marker = `${import_node_path8.default.sep}node_modules${import_node_path8.default.sep}`;
4465
4605
  const idx = driverEntry.lastIndexOf(marker);
4466
4606
  return idx === -1 ? null : driverEntry.slice(0, idx);
4467
4607
  }
4468
4608
  function setAppiumHome(ctx = {}) {
4469
4609
  process.env.APPIUM_HOME = getRuntimeDir({ cacheDir: ctx.cacheDir });
4470
4610
  }
4471
- var import_node_fs9, import_node_path7;
4611
+ var import_node_fs9, import_node_path8;
4472
4612
  var init_appium = __esm({
4473
4613
  "dist/core/appium.js"() {
4474
4614
  "use strict";
4475
4615
  import_node_fs9 = __toESM(require("node:fs"), 1);
4476
- import_node_path7 = __toESM(require("node:path"), 1);
4616
+ import_node_path8 = __toESM(require("node:path"), 1);
4477
4617
  init_cacheDir();
4478
4618
  init_lazyYaml();
4479
4619
  }
4480
4620
  });
4481
4621
 
4482
4622
  // dist/core/files.js
4483
- async function readFile({ fileURLOrPath }) {
4623
+ async function readFile({ fileURLOrPath, throwOnError = false }) {
4484
4624
  if (!fileURLOrPath) {
4485
4625
  throw new Error("fileURLOrPath is required");
4486
4626
  }
@@ -4497,40 +4637,55 @@ async function readFile({ fileURLOrPath }) {
4497
4637
  isRemote = parsedURL.protocol === "http:" || parsedURL.protocol === "https:";
4498
4638
  } catch (error) {
4499
4639
  }
4640
+ const fail = (message) => {
4641
+ if (throwOnError)
4642
+ throw new Error(message);
4643
+ log({}, "warning", message);
4644
+ return null;
4645
+ };
4500
4646
  if (isRemote) {
4501
4647
  try {
4502
4648
  const response = await (await import("axios")).default.get(fileURLOrPath);
4503
4649
  content = response.data;
4504
4650
  } catch (error) {
4505
- console.warn(`Error reading remote file from ${fileURLOrPath}: ${error.message}`);
4506
- return null;
4651
+ return fail(`Couldn't read ${redactUrlForOutput(fileURLOrPath)}${reason(error.message)}`);
4507
4652
  }
4508
4653
  } else {
4509
4654
  try {
4510
4655
  content = await import_node_fs10.default.promises.readFile(fileURLOrPath, "utf8");
4511
4656
  } catch (error) {
4512
- if (error.code === "ENOENT") {
4513
- console.warn(`File not found: ${fileURLOrPath}`);
4514
- } else {
4515
- console.warn(`Error reading file: ${error.message}`);
4516
- }
4517
- return null;
4657
+ return fail(`Couldn't read ${import_node_path9.default.resolve(fileURLOrPath)}${error.code === "ENOENT" ? ": the file doesn't exist." : reason(error.message)}`);
4518
4658
  }
4519
4659
  }
4520
- const ext = fileURLOrPath.split(".").pop()?.toLowerCase();
4660
+ return parseFileContent(content, fileURLOrPath, { throwOnError });
4661
+ }
4662
+ function locationOf(fileURLOrPath) {
4663
+ return /^https?:\/\//i.test(fileURLOrPath) ? redactUrlForOutput(fileURLOrPath) : import_node_path9.default.resolve(fileURLOrPath);
4664
+ }
4665
+ function parseFileContent(content, fileURLOrPath, { throwOnError = false } = {}) {
4666
+ if (typeof content !== "string")
4667
+ return content;
4668
+ const name = /^https?:\/\//i.test(fileURLOrPath) ? new URL(fileURLOrPath).pathname : fileURLOrPath;
4669
+ const ext = name.split(".").pop()?.toLowerCase();
4670
+ const failed = (format, error) => {
4671
+ if (throwOnError) {
4672
+ const cause = String(error.message).split("\n")[0].replace(/:$/, "");
4673
+ throw new Error(`Couldn't read ${locationOf(fileURLOrPath)}: it isn't valid ${format} (${cause}).`);
4674
+ }
4675
+ log({}, "warning", `Failed to parse ${format}: ${error.message}`);
4676
+ return content;
4677
+ };
4521
4678
  if (ext === "json") {
4522
4679
  try {
4523
4680
  return JSON.parse(content);
4524
4681
  } catch (error) {
4525
- console.warn(`Failed to parse JSON: ${error.message}`);
4526
- return content;
4682
+ return failed("JSON", error);
4527
4683
  }
4528
4684
  } else if (ext === "yaml" || ext === "yml") {
4529
4685
  try {
4530
4686
  return requireYaml().parse(content);
4531
4687
  } catch (error) {
4532
- console.warn(`Failed to parse YAML: ${error.message}`);
4533
- return content;
4688
+ return failed("YAML", error);
4534
4689
  }
4535
4690
  } else {
4536
4691
  return content;
@@ -4579,13 +4734,13 @@ async function resolvePaths({ config, object, filePath, nested = false, objectTy
4579
4734
  if (relativePath.startsWith("https://") || relativePath.startsWith("http://") || relativePath.startsWith("heretto:")) {
4580
4735
  return relativePath;
4581
4736
  }
4582
- if (import_node_path8.default.isAbsolute(relativePath)) {
4737
+ if (import_node_path9.default.isAbsolute(relativePath)) {
4583
4738
  return relativePath;
4584
4739
  }
4585
4740
  const fileExists = import_node_fs10.default.existsSync(filePath2);
4586
- const isFile = fileExists ? import_node_fs10.default.lstatSync(filePath2).isFile() : import_node_path8.default.parse(filePath2).ext !== "";
4587
- const basePath = isFile ? import_node_path8.default.dirname(filePath2) : filePath2;
4588
- return baseType === "file" ? import_node_path8.default.resolve(basePath, relativePath) : import_node_path8.default.resolve(relativePath);
4741
+ const isFile = fileExists ? import_node_fs10.default.lstatSync(filePath2).isFile() : import_node_path9.default.parse(filePath2).ext !== "";
4742
+ const basePath = isFile ? import_node_path9.default.dirname(filePath2) : filePath2;
4743
+ return baseType === "file" ? import_node_path9.default.resolve(basePath, relativePath) : import_node_path9.default.resolve(relativePath);
4589
4744
  }
4590
4745
  const relativePathBase = config.relativePathBase;
4591
4746
  let pathProperties;
@@ -4635,7 +4790,7 @@ async function resolvePaths({ config, object, filePath, nested = false, objectTy
4635
4790
  isStep: property === "steps"
4636
4791
  });
4637
4792
  } else if (typeof item === "string" && pathProperties.includes(property)) {
4638
- const resolved = property === "path" && object.directory && import_node_path8.default.isAbsolute(object.directory) ? resolve(relativePathBase, item, object.directory) : resolve(relativePathBase, item, filePath);
4793
+ const resolved = property === "path" && object.directory && import_node_path9.default.isAbsolute(object.directory) ? resolve(relativePathBase, item, object.directory) : resolve(relativePathBase, item, filePath);
4639
4794
  object[property][i] = resolved;
4640
4795
  }
4641
4796
  }
@@ -4653,7 +4808,7 @@ async function resolvePaths({ config, object, filePath, nested = false, objectTy
4653
4808
  }
4654
4809
  if (pathProperties.includes(property) || isStep && stepShorthandPathProperties.includes(property)) {
4655
4810
  if (property === "path" && object.directory) {
4656
- const directory = import_node_path8.default.isAbsolute(object.directory) ? object.directory : resolve(relativePathBase, object.directory, filePath);
4811
+ const directory = import_node_path9.default.isAbsolute(object.directory) ? object.directory : resolve(relativePathBase, object.directory, filePath);
4657
4812
  object[property] = resolve(relativePathBase, object[property], directory);
4658
4813
  } else {
4659
4814
  object[property] = resolve(relativePathBase, object[property], filePath);
@@ -4663,14 +4818,19 @@ async function resolvePaths({ config, object, filePath, nested = false, objectTy
4663
4818
  }
4664
4819
  return object;
4665
4820
  }
4666
- var import_node_fs10, import_node_path8;
4821
+ var import_node_fs10, import_node_path9, reason;
4667
4822
  var init_files = __esm({
4668
4823
  "dist/core/files.js"() {
4669
4824
  "use strict";
4670
4825
  import_node_fs10 = __toESM(require("node:fs"), 1);
4671
- import_node_path8 = __toESM(require("node:path"), 1);
4826
+ import_node_path9 = __toESM(require("node:path"), 1);
4672
4827
  init_validate();
4673
4828
  init_lazyYaml();
4829
+ init_utils();
4830
+ reason = (text) => {
4831
+ const trimmed2 = text?.trim();
4832
+ return !trimmed2 ? "." : `: ${/[.!?]$/.test(trimmed2) ? trimmed2 : `${trimmed2}.`}`;
4833
+ };
4674
4834
  }
4675
4835
  });
4676
4836
 
@@ -4685,9 +4845,75 @@ async function loadDescription(descriptionPath = "") {
4685
4845
  if (!descriptionPath) {
4686
4846
  throw new Error("Description is required.");
4687
4847
  }
4688
- const definition = await readFile({ fileURLOrPath: descriptionPath });
4689
- const parser = (await import("@apidevtools/json-schema-ref-parser")).default;
4690
- const dereferencedDefinition = await parser.dereference(definition);
4848
+ const guard = localUrlPolicy() !== "allow";
4849
+ let lastPublic;
4850
+ const docName = (given, final) => {
4851
+ const [a, b] = [given, final].map(redactUrlForOutput);
4852
+ return a === b ? a : `${a} (redirected to ${b})`;
4853
+ };
4854
+ let definition;
4855
+ if (guard && /^https?:\/\//i.test(descriptionPath)) {
4856
+ const root = await getGuarded(descriptionPath, FETCH_TEXT_DEFAULTS);
4857
+ const text = root.data;
4858
+ let content = text;
4859
+ try {
4860
+ content = JSON.parse(text);
4861
+ } catch {
4862
+ }
4863
+ definition = parseFileContent(content, descriptionPath, { throwOnError: true });
4864
+ if (root.reachedPublic)
4865
+ lastPublic = docName(descriptionPath, root.url);
4866
+ } else {
4867
+ definition = await readFile({ fileURLOrPath: descriptionPath, throwOnError: true });
4868
+ }
4869
+ if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
4870
+ throw new Error(`Couldn't read ${locationOf(descriptionPath)}: it's empty or isn't a JSON or YAML object.`);
4871
+ }
4872
+ const refused = (ref, doc, target) => new UrlGuardError(`Couldn't resolve $ref ${ref} in ${doc}: a public document can't refer to ${target}.
4873
+ Hint: If you trust this document, set DOC_DETECTIVE_ALLOW_LOCAL_URLS=1, or download it and point descriptionPath at the local copy.`);
4874
+ const { default: parser, FileResolver } = await import("@apidevtools/json-schema-ref-parser");
4875
+ if (typeof FileResolver.read !== "function") {
4876
+ throw new Error("Couldn't load the OpenAPI description: json-schema-ref-parser's FileResolver.read isn't a function.");
4877
+ }
4878
+ const readLocalFile = FileResolver.read.bind(FileResolver);
4879
+ const dereferencedDefinition = await parser.dereference(definition, {
4880
+ resolve: {
4881
+ file: {
4882
+ async read(file) {
4883
+ if (lastPublic) {
4884
+ throw refused(redactUrlForOutput(file.url.split("#")[0]), lastPublic, "a local file");
4885
+ }
4886
+ return readLocalFile(file);
4887
+ }
4888
+ },
4889
+ http: {
4890
+ // The parser's own guard refuses every private address, even from a
4891
+ // local description. getGuarded applies Doc Detective's policy instead.
4892
+ safeUrlResolver: false,
4893
+ async read(file) {
4894
+ const ref = redactUrlForOutput(file.url);
4895
+ const doc = lastPublic;
4896
+ if (doc) {
4897
+ let address;
4898
+ try {
4899
+ address = await privateAddressOf(file.url);
4900
+ } catch (error) {
4901
+ if (!error.host)
4902
+ throw error;
4903
+ throw new Error(`Couldn't resolve $ref ${ref} in ${doc}: couldn't resolve host ${error.host} (${error.code}).`);
4904
+ }
4905
+ if (address)
4906
+ throw refused(ref, doc, `${address}, a private address`);
4907
+ }
4908
+ const refuseRef = doc ? (privateAddress) => refused(ref, doc, `${privateAddress}, a private address`) : void 0;
4909
+ const { data, url, reachedPublic } = await getGuarded(file.url, FETCH_TEXT_DEFAULTS, refuseRef, refuseRef && ((_from, privateAddress) => refuseRef(privateAddress)));
4910
+ if (guard && reachedPublic)
4911
+ lastPublic = docName(file.url, url);
4912
+ return data;
4913
+ }
4914
+ }
4915
+ }
4916
+ });
4691
4917
  return dereferencedDefinition;
4692
4918
  }
4693
4919
  async function getOperation(definition = {}, operationId = "", responseCode = "", exampleKey = "", server = "") {
@@ -4697,10 +4923,10 @@ async function getOperation(definition = {}, operationId = "", responseCode = ""
4697
4923
  if (!operationId) {
4698
4924
  throw new Error("OperationId is required.");
4699
4925
  }
4700
- for (const path31 in definition.paths) {
4701
- for (const method in definition.paths[path31]) {
4702
- if (definition.paths[path31][method].operationId === operationId) {
4703
- const operation = definition.paths[path31][method];
4926
+ for (const path32 in definition.paths) {
4927
+ for (const method in definition.paths[path32]) {
4928
+ if (definition.paths[path32][method].operationId === operationId) {
4929
+ const operation = definition.paths[path32][method];
4704
4930
  if (!server) {
4705
4931
  if (definition.servers && definition.servers.length > 0) {
4706
4932
  server = definition.servers[0].url;
@@ -4708,9 +4934,9 @@ async function getOperation(definition = {}, operationId = "", responseCode = ""
4708
4934
  throw new Error("No server URL provided and no servers defined in the OpenAPI definition.");
4709
4935
  }
4710
4936
  }
4711
- const example = await compileExample(operation, server + path31, responseCode, exampleKey);
4937
+ const example = await compileExample(operation, server + path32, responseCode, exampleKey);
4712
4938
  const schemas = getSchemas(operation, responseCode);
4713
- return { path: path31, method, definition: operation, schemas, example };
4939
+ return { path: path32, method, definition: operation, schemas, example };
4714
4940
  }
4715
4941
  }
4716
4942
  }
@@ -4731,15 +4957,15 @@ function getSchemas(definition = {}, responseCode = "") {
4731
4957
  schemas.response = definition.responses[responseCode].content[Object.keys(definition.responses[responseCode].content)[0]].schema;
4732
4958
  return schemas;
4733
4959
  }
4734
- async function compileExample(operation = {}, path31 = "", responseCode = "", exampleKey = "") {
4960
+ async function compileExample(operation = {}, path32 = "", responseCode = "", exampleKey = "") {
4735
4961
  if (!operation) {
4736
4962
  throw new Error("Operation is required.");
4737
4963
  }
4738
- if (!path31) {
4964
+ if (!path32) {
4739
4965
  throw new Error("Path is required.");
4740
4966
  }
4741
4967
  let example = {
4742
- url: path31,
4968
+ url: path32,
4743
4969
  request: { parameters: {}, headers: {}, body: {} },
4744
4970
  response: { headers: {}, body: {} }
4745
4971
  };
@@ -5104,6 +5330,9 @@ function getEnvironment() {
5104
5330
  const environment = {};
5105
5331
  environment.arch = import_node_os4.default.arch();
5106
5332
  environment.platform = platformMap[process.platform];
5333
+ environment.docDetectiveVersion = ownVersion();
5334
+ environment.nodeVersion = process.versions.node;
5335
+ environment.osRelease = import_node_os4.default.release();
5107
5336
  return environment;
5108
5337
  }
5109
5338
  function cacheKeyFor(config) {
@@ -5177,7 +5406,7 @@ function detectInstalledBrowserDrivers(config, isInstalled = (name) => Boolean(r
5177
5406
  async function probeBrowserEnvironment({ config, browsersDir }) {
5178
5407
  setAppiumHome({ cacheDir: config?.cacheDir });
5179
5408
  const cwd = process.cwd();
5180
- process.chdir(import_node_path9.default.join(__dirname3, "../.."));
5409
+ process.chdir(packageRoot() ?? cwd);
5181
5410
  let installedBrowsers = [];
5182
5411
  let browserDetectionFailed = false;
5183
5412
  let installedDrivers = { chromium: false, gecko: false, safari: false };
@@ -5393,7 +5622,7 @@ async function getBrowserDiagnostics({ config }) {
5393
5622
  });
5394
5623
  return { browsers, detectionFailed };
5395
5624
  }
5396
- var import_node_os4, import_node_fs11, import_node_path9, import_node_child_process6, import_node_url4, __dirname3, platformMap, defaultFileTypes, cachedAppsByDir;
5625
+ var import_node_os4, import_node_fs11, import_node_child_process6, platformMap, defaultFileTypes, cachedAppsByDir;
5397
5626
  var init_config = __esm({
5398
5627
  "dist/core/config.js"() {
5399
5628
  "use strict";
@@ -5401,15 +5630,13 @@ var init_config = __esm({
5401
5630
  import_node_fs11 = __toESM(require("node:fs"), 1);
5402
5631
  init_validate();
5403
5632
  init_utils();
5404
- import_node_path9 = __toESM(require("node:path"), 1);
5405
5633
  import_node_child_process6 = require("node:child_process");
5406
5634
  init_loader();
5407
5635
  init_cacheDir();
5636
+ init_heavyDeps();
5408
5637
  init_browsers();
5409
5638
  init_appium();
5410
5639
  init_openapi();
5411
- import_node_url4 = require("node:url");
5412
- __dirname3 = import_node_path9.default.dirname((0, import_node_url4.fileURLToPath)(importMetaUrl));
5413
5640
  platformMap = {
5414
5641
  darwin: "mac",
5415
5642
  linux: "linux",
@@ -6684,10 +6911,9 @@ function sendTelemetry(config, command, results) {
6684
6911
  if (config?.telemetry?.send === false)
6685
6912
  return;
6686
6913
  const telemetryData = process.env["DOC_DETECTIVE_META"] !== void 0 ? JSON.parse(process.env["DOC_DETECTIVE_META"]) : {};
6687
- const pkg = require2("../../package.json");
6688
6914
  telemetryData.distribution = telemetryData.distribution || "doc-detective";
6689
6915
  telemetryData.dist_interface = telemetryData.dist_interface || "package";
6690
- telemetryData.core_version = pkg.version;
6916
+ telemetryData.core_version = ownVersion() ?? "unknown";
6691
6917
  telemetryData.dist_version = telemetryData.dist_version || telemetryData.core_version;
6692
6918
  telemetryData.core_platform = platformMap2[import_node_os14.default.platform()] || import_node_os14.default.platform();
6693
6919
  telemetryData.dist_platform = telemetryData.dist_platform || telemetryData.core_platform;
@@ -6706,14 +6932,14 @@ function sendTelemetry(config, command, results) {
6706
6932
  Object.entries(value).forEach(([key, value2]) => {
6707
6933
  if (typeof value2 === "object") {
6708
6934
  Object.entries(value2).forEach(([key2, value22]) => {
6709
- telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}_${key2.replace(" ", "_")}`] = value22;
6935
+ telemetryData[`${parentKey.replaceAll(" ", "_")}_${key.replaceAll(" ", "_")}_${key2.replaceAll(" ", "_")}`] = value22;
6710
6936
  });
6711
6937
  } else {
6712
- telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}`] = value2;
6938
+ telemetryData[`${parentKey.replaceAll(" ", "_")}_${key.replaceAll(" ", "_")}`] = value2;
6713
6939
  }
6714
6940
  });
6715
6941
  } else {
6716
- telemetryData[parentKey.replace(" ", "_")] = value;
6942
+ telemetryData[parentKey.replaceAll(" ", "_")] = value;
6717
6943
  }
6718
6944
  });
6719
6945
  }
@@ -6728,15 +6954,14 @@ async function awaitTelemetryFlush() {
6728
6954
  if (pending)
6729
6955
  await pending;
6730
6956
  }
6731
- var import_node_os14, import_posthog_node, import_node_module5, require2, platformMap2, TELEMETRY_FLUSH_TIMEOUT_MS, pendingTelemetryFlush;
6957
+ var import_node_os14, import_posthog_node, platformMap2, TELEMETRY_FLUSH_TIMEOUT_MS, pendingTelemetryFlush;
6732
6958
  var init_telem = __esm({
6733
6959
  "dist/core/telem.js"() {
6734
6960
  "use strict";
6735
6961
  import_node_os14 = __toESM(require("node:os"), 1);
6736
6962
  import_posthog_node = require("posthog-node");
6737
- import_node_module5 = require("node:module");
6738
6963
  init_telemLite();
6739
- require2 = (0, import_node_module5.createRequire)(importMetaUrl);
6964
+ init_heavyDeps();
6740
6965
  platformMap2 = {
6741
6966
  win32: "windows",
6742
6967
  darwin: "mac",
@@ -6992,7 +7217,7 @@ var import_node_fs12 = __toESM(require("node:fs"), 1);
6992
7217
  var import_node_path10 = __toESM(require("node:path"), 1);
6993
7218
  var import_node_https2 = __toESM(require("node:https"), 1);
6994
7219
  var import_node_http = __toESM(require("node:http"), 1);
6995
- var import_node_module4 = require("node:module");
7220
+ var import_node_module3 = require("node:module");
6996
7221
  var import_node_os5 = __toESM(require("node:os"), 1);
6997
7222
  var import_node_crypto4 = __toESM(require("node:crypto"), 1);
6998
7223
  var import_adm_zip = __toESM(require("adm-zip"), 1);
@@ -7773,7 +7998,7 @@ var HerettoUploader = class {
7773
7998
  return contentTypes[ext] || "application/octet-stream";
7774
7999
  }
7775
8000
  };
7776
- var requireFromHere2 = (0, import_node_module4.createRequire)(importMetaUrl);
8001
+ var requireFromHere2 = (0, import_node_module3.createRequire)(importMetaUrl);
7777
8002
  var requireAxios = () => requireFromHere2("axios");
7778
8003
  var POLLING_INTERVAL_MS = 5e3;
7779
8004
  var POLLING_TIMEOUT_MS = 3e5;
@@ -8745,7 +8970,8 @@ function resolveContexts({ contexts, test, config }) {
8745
8970
  if (platform !== void 0)
8746
8971
  staticContext.platform = platform;
8747
8972
  if (browser !== void 0) {
8748
- staticContext.browser = browser.name === "safari" && !isMobileTargetPlatform(platform) ? { ...browser, name: "webkit" } : { ...browser };
8973
+ const { version: _authored, ...authoredBrowser } = browser;
8974
+ staticContext.browser = browser.name === "safari" && !isMobileTargetPlatform(platform) ? { ...authoredBrowser, name: "webkit" } : authoredBrowser;
8749
8975
  }
8750
8976
  staticContexts.push(staticContext);
8751
8977
  });
@@ -14816,11 +15042,11 @@ function hasUnresolvedMetaReference(expression, context) {
14816
15042
  }
14817
15043
  return false;
14818
15044
  }
14819
- function getMetaValue(path31, context) {
15045
+ function getMetaValue(path32, context) {
14820
15046
  if (!context) {
14821
15047
  return void 0;
14822
15048
  }
14823
- const [basePath, jsonPointer] = path31.split("#");
15049
+ const [basePath, jsonPointer] = path32.split("#");
14824
15050
  const resolvedPath = resolvePathTemplateVariables(basePath, context);
14825
15051
  let value = getNestedProperty(context, resolvedPath);
14826
15052
  if (jsonPointer && value) {
@@ -14837,9 +15063,9 @@ function getMetaValue(path31, context) {
14837
15063
  }
14838
15064
  return value;
14839
15065
  }
14840
- function resolvePathTemplateVariables(path31, context) {
15066
+ function resolvePathTemplateVariables(path32, context) {
14841
15067
  const templateRegex = /\{\{(\w+)\}\}/g;
14842
- return path31.replace(templateRegex, (match, varName) => {
15068
+ return path32.replace(templateRegex, (match, varName) => {
14843
15069
  if (context && context.id && varName === "id") {
14844
15070
  return context.id;
14845
15071
  }
@@ -14876,10 +15102,10 @@ async function resolveEmbeddedExpressions(str, context) {
14876
15102
  parts.push(str.slice(lastIdx));
14877
15103
  return parts.join("");
14878
15104
  }
14879
- function getNestedProperty(obj, path31) {
14880
- if (!obj || !path31)
15105
+ function getNestedProperty(obj, path32) {
15106
+ if (!obj || !path32)
14881
15107
  return void 0;
14882
- const parts = path31.split(".");
15108
+ const parts = path32.split(".");
14883
15109
  let current = obj;
14884
15110
  for (const part of parts) {
14885
15111
  if (current === null || current === void 0)
@@ -16050,6 +16276,11 @@ async function wait({ config, step, driver }) {
16050
16276
  return result;
16051
16277
  }
16052
16278
 
16279
+ // dist/core/tests/timing.js
16280
+ var RECORDING_PACE_MS = 2e3;
16281
+ var RENDER_SETTLE_MS = 100;
16282
+ var RECORDER_SETTLE_MS = 500;
16283
+
16053
16284
  // dist/core/tests/findElement.js
16054
16285
  async function finalizeFound({ result }) {
16055
16286
  const specs = [
@@ -16222,7 +16453,7 @@ async function findElement({ config, step, driver, click, appSession, processReg
16222
16453
  }
16223
16454
  }
16224
16455
  if (isRecordingActive(driver)) {
16225
- await wait({ config, step: { wait: 2e3 }, driver });
16456
+ await wait({ config, step: { wait: RECORDING_PACE_MS }, driver });
16226
16457
  }
16227
16458
  return result;
16228
16459
  } else {
@@ -16325,7 +16556,7 @@ async function findElement({ config, step, driver, click, appSession, processReg
16325
16556
  }
16326
16557
  }
16327
16558
  if (isRecordingActive(driver)) {
16328
- await wait({ config, step: { wait: 2e3 }, driver });
16559
+ await wait({ config, step: { wait: RECORDING_PACE_MS }, driver });
16329
16560
  }
16330
16561
  return result;
16331
16562
  }
@@ -16960,7 +17191,7 @@ async function goTo({ config, step, driver }) {
16960
17191
  }, { timeout: waitTimeout });
16961
17192
  waitResults.documentReady.passed = true;
16962
17193
  waitResults.documentReady.message = "Document ready";
16963
- await driver.pause(100);
17194
+ await driver.pause(RENDER_SETTLE_MS);
16964
17195
  const elapsedTime = Date.now() - waitStartTime;
16965
17196
  const remainingTimeout = waitTimeout - elapsedTime;
16966
17197
  if (remainingTimeout <= 0) {
@@ -17185,10 +17416,13 @@ async function startBackgroundProcessSurface({ config, descriptor, processRegist
17185
17416
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
17186
17417
  let bg;
17187
17418
  if (descriptor.tty) {
17419
+ const size = typeof descriptor.tty === "object" ? descriptor.tty : {};
17188
17420
  try {
17189
17421
  bg = await d.spawnPtyBackgroundCommand(descriptor.command, args, {
17190
17422
  ...bgOptions,
17191
- cacheDir: config?.cacheDir
17423
+ cacheDir: config?.cacheDir,
17424
+ cols: size.cols,
17425
+ rows: size.rows
17192
17426
  });
17193
17427
  } catch (error) {
17194
17428
  if (error?.code !== "NODE_PTY_UNAVAILABLE") {
@@ -17478,13 +17712,13 @@ function shouldRetry(statusCode) {
17478
17712
  return false;
17479
17713
  return statusCode === 429 || statusCode >= 500;
17480
17714
  }
17481
- async function attemptRequest(method, url, headers) {
17715
+ async function attemptRequest(method, url, headers, timeout) {
17482
17716
  try {
17483
17717
  const res = await (await loadAxios()).request({
17484
17718
  method,
17485
17719
  url,
17486
17720
  headers,
17487
- timeout: 1e4,
17721
+ timeout,
17488
17722
  maxRedirects: 5,
17489
17723
  validateStatus: () => true
17490
17724
  });
@@ -17497,7 +17731,7 @@ async function attemptRequest(method, url, headers) {
17497
17731
  retryAfter: parseRetryAfter(error.response.headers?.["retry-after"] ?? error.response.headers?.["Retry-After"])
17498
17732
  };
17499
17733
  }
17500
- return { statusCode: null, retryAfter: null };
17734
+ return { statusCode: null, retryAfter: null, errorCode: error?.code };
17501
17735
  }
17502
17736
  }
17503
17737
  async function checkLink({ config, step }) {
@@ -17538,11 +17772,12 @@ async function checkLink({ config, step }) {
17538
17772
  }
17539
17773
  const headers = mergeHeaders(step.checkLink.headers);
17540
17774
  const url = step.checkLink.url;
17775
+ const timeout = step.checkLink.timeout ?? 1e4;
17541
17776
  const acceptedCodes = step.checkLink.statusCodes;
17542
17777
  const isAccepted = (code) => code !== null && acceptedCodes.indexOf(code) >= 0;
17543
17778
  let last = { statusCode: null, retryAfter: null };
17544
17779
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
17545
- last = await attemptRequest("get", url, headers);
17780
+ last = await attemptRequest("get", url, headers, timeout);
17546
17781
  if (isAccepted(last.statusCode))
17547
17782
  break;
17548
17783
  if (!shouldRetry(last.statusCode))
@@ -17553,14 +17788,14 @@ async function checkLink({ config, step }) {
17553
17788
  }
17554
17789
  }
17555
17790
  if (!isAccepted(last.statusCode) && (last.statusCode === 429 || last.statusCode === 403)) {
17556
- const headResult = await attemptRequest("head", url, headers);
17791
+ const headResult = await attemptRequest("head", url, headers, timeout);
17557
17792
  if (isAccepted(headResult.statusCode)) {
17558
17793
  last = headResult;
17559
17794
  }
17560
17795
  }
17561
17796
  if (last.statusCode === null) {
17562
17797
  result.status = "FAIL";
17563
- result.description = `Invalid or unresolvable URL: ${url}`;
17798
+ result.description = last.errorCode === "ECONNABORTED" ? `Couldn't reach ${redactUrlForOutput(url)}: no response within ${timeout} ms (ECONNABORTED).` : `Invalid or unresolvable URL: ${redactUrlForOutput(url)}`;
17564
17799
  return result;
17565
17800
  }
17566
17801
  result.outputs = { statusCode: last.statusCode };
@@ -18048,7 +18283,8 @@ async function captureScreenshot({ config, step, driver, appSession, processRegi
18048
18283
  const fetched = await fetchFile(originalUrlPath, { binary: true });
18049
18284
  if (fetched.result !== "success") {
18050
18285
  result.status = "FAIL";
18051
- result.description = `Couldn't fetch remote reference image (${redactedUrl}): ${fetched.message}`;
18286
+ const msg = fetched.message?.message ?? String(fetched.message);
18287
+ result.description = fetched.message instanceof UrlGuardError ? msg : `Couldn't fetch remote reference image (${redactedUrl}): ${msg}`;
18052
18288
  return result;
18053
18289
  }
18054
18290
  existFilePath = fetched.path;
@@ -18171,7 +18407,7 @@ async function captureScreenshot({ config, step, driver, appSession, processRegi
18171
18407
  });
18172
18408
  window.scrollBy(-pad.left, -pad.top);
18173
18409
  }, element, padding);
18174
- await driver.pause(100);
18410
+ await driver.pause(RENDER_SETTLE_MS);
18175
18411
  }
18176
18412
  const recordingActive = !isAppCapture && isRecordingActive(driver);
18177
18413
  let captureBuffer;
@@ -19103,7 +19339,7 @@ async function startRecording({ config, context, step, driver, recordingHost, ap
19103
19339
  proc.on("error", (err) => {
19104
19340
  spawnError = err;
19105
19341
  });
19106
- await new Promise((r) => setTimeout(r, 500));
19342
+ await new Promise((r) => setTimeout(r, RECORDER_SETTLE_MS));
19107
19343
  if (spawnError || proc.exitCode !== null) {
19108
19344
  result.status = "FAIL";
19109
19345
  result.description = `Couldn't start ffmpeg recording.${spawnError ? ` ${spawnError}` : ` ffmpeg exited with code ${proc.exitCode}.`} On macOS, grant screen recording permission; on Linux, ensure a display (or Xvfb) is available.${stderrTail ? ` ffmpeg: ${stderrTail.trim().slice(-500)}` : ""}`;
@@ -19665,7 +19901,7 @@ async function waitForStableFile(filePath, maxSeconds) {
19665
19901
  stableReads = 0;
19666
19902
  }
19667
19903
  lastSize = size;
19668
- await new Promise((r) => setTimeout(r, 500));
19904
+ await new Promise((r) => setTimeout(r, RECORDER_SETTLE_MS));
19669
19905
  }
19670
19906
  return false;
19671
19907
  }
@@ -20066,9 +20302,9 @@ async function httpRequest({ config, step, openApiDefinitions = [] }) {
20066
20302
  delete step.httpRequest.openApi.definition;
20067
20303
  } else if (openApiDefinitions.length > 0) {
20068
20304
  findOperation: for (const openApiConfig of openApiDefinitions) {
20069
- for (const path31 in openApiConfig.definition.paths) {
20070
- for (const method in openApiConfig.definition.paths[path31]) {
20071
- if (openApiConfig.definition.paths[path31][method].operationId === step.httpRequest.openApi.operationId) {
20305
+ for (const path32 in openApiConfig.definition.paths) {
20306
+ for (const method in openApiConfig.definition.paths[path32]) {
20307
+ if (openApiConfig.definition.paths[path32][method].operationId === step.httpRequest.openApi.operationId) {
20072
20308
  openApiDefinition = openApiConfig.definition;
20073
20309
  step.httpRequest.openApi = {
20074
20310
  ...openApiConfig,
@@ -20138,10 +20374,7 @@ async function httpRequest({ config, step, openApiDefinitions = [] }) {
20138
20374
  };
20139
20375
  }
20140
20376
  if (step.httpRequest.openApi.statusCode) {
20141
- step.httpRequest.statusCodes = [
20142
- step.httpRequest.openApi.statusCode,
20143
- ...step.httpRequest.statusCodes || []
20144
- ];
20377
+ step.httpRequest.statusCodes = [step.httpRequest.openApi.statusCode].concat(step.httpRequest.statusCodes || []);
20145
20378
  } else if (!step.httpRequest.statusCodes) {
20146
20379
  step.httpRequest.statusCodes = Object.keys(operation.definition.responses).filter((code) => code.startsWith("2")).map((code) => Number(code));
20147
20380
  }
@@ -20162,7 +20395,8 @@ async function httpRequest({ config, step, openApiDefinitions = [] }) {
20162
20395
  step.httpRequest = {
20163
20396
  ...step.httpRequest,
20164
20397
  method: step.httpRequest.method || "get",
20165
- statusCodes: step.httpRequest.statusCodes || [200, 201],
20398
+ // A scalar statusCodes is one accepted code (as in checkLink).
20399
+ statusCodes: [].concat(step.httpRequest.statusCodes || [200, 201]),
20166
20400
  request: step.httpRequest.request || {
20167
20401
  params: {},
20168
20402
  headers: {},
@@ -20426,8 +20660,8 @@ async function httpRequest({ config, step, openApiDefinitions = [] }) {
20426
20660
  result.description = descriptions.join(" ").trim();
20427
20661
  return result;
20428
20662
  }
20429
- function fieldExistsAtPath(obj, path31) {
20430
- const segments = path31.match(/[^.[\]]+/g);
20663
+ function fieldExistsAtPath(obj, path32) {
20664
+ const segments = path32.match(/[^.[\]]+/g);
20431
20665
  if (!segments) {
20432
20666
  return false;
20433
20667
  }
@@ -20585,6 +20819,11 @@ async function clickElement({ config, step, driver, appSession, processRegistry,
20585
20819
  return result;
20586
20820
  }
20587
20821
  step = isValidStep.object;
20822
+ if (typeof step.click === "boolean") {
20823
+ result.status = "FAIL";
20824
+ result.description = `\`click\` needs an element, not ${step.click}. To click an element whose text is "${step.click}", quote it: "click": "${step.click}".`;
20825
+ return result;
20826
+ }
20588
20827
  let findStep;
20589
20828
  if (typeof step.click === "string") {
20590
20829
  findStep = { find: step.click };
@@ -21619,6 +21858,7 @@ function raceBootInitiation({ startAcquire, onError }) {
21619
21858
 
21620
21859
  // dist/core/tests.js
21621
21860
  init_cacheDir();
21861
+ init_heavyDeps();
21622
21862
 
21623
21863
  // dist/core/tests/runBrowserScript.js
21624
21864
  init_validate();
@@ -22141,26 +22381,35 @@ async function uploadChangedFiles({ config, report, log: log3 }) {
22141
22381
  for (const file of changedFiles) {
22142
22382
  const uploader = getUploader(file.sourceIntegration);
22143
22383
  if (!uploader) {
22144
- log3(config, "warning", `No uploader found for integration type: ${file.sourceIntegration.type}`);
22384
+ const why = `no uploader handles integration type \`${file.sourceIntegration.type}\`.`;
22385
+ log3(config, "warning", `Couldn't upload ${file.localPath}: ${why}`);
22145
22386
  results.skipped++;
22146
22387
  results.details.push({
22147
22388
  localPath: file.localPath,
22148
22389
  status: "SKIPPED",
22149
- reason: `No uploader for type: ${file.sourceIntegration.type}`
22390
+ reason: `Skipped: ${why}`
22150
22391
  });
22151
22392
  continue;
22152
22393
  }
22153
22394
  const integrationConfig = getIntegrationConfig(config, file.sourceIntegration);
22154
22395
  if (!integrationConfig) {
22155
- log3(config, "warning", `No integration config found for: ${file.sourceIntegration.integrationName}`);
22396
+ const why = `no \`${file.sourceIntegration.integrationName}\` integration is configured.`;
22397
+ log3(config, "warning", `Couldn't upload ${file.localPath}: ${why}`);
22156
22398
  results.skipped++;
22157
22399
  results.details.push({
22158
22400
  localPath: file.localPath,
22159
22401
  status: "SKIPPED",
22160
- reason: `No integration config found for: ${file.sourceIntegration.integrationName}`
22402
+ reason: `Skipped: ${why}`
22161
22403
  });
22162
22404
  continue;
22163
22405
  }
22406
+ if (integrationConfig.uploadOnChange !== true) {
22407
+ const reason2 = `Skipped: \`uploadOnChange\` isn't enabled for integration ${file.sourceIntegration.integrationName}.`;
22408
+ log3(config, "debug", `${file.localPath}: ${reason2}`);
22409
+ results.skipped++;
22410
+ results.details.push({ localPath: file.localPath, status: "SKIPPED", reason: reason2 });
22411
+ continue;
22412
+ }
22164
22413
  uploadTasks.push({
22165
22414
  file,
22166
22415
  uploader,
@@ -22232,8 +22481,6 @@ function getIntegrationConfig(config, sourceIntegration) {
22232
22481
  // dist/core/tests.js
22233
22482
  var import_node_http2 = __toESM(require("node:http"), 1);
22234
22483
  var import_node_https3 = __toESM(require("node:https"), 1);
22235
- var import_node_url5 = require("node:url");
22236
- var __dirname4 = import_node_path30.default.dirname((0, import_node_url5.fileURLToPath)(importMetaUrl));
22237
22484
  var lastReuseConfig;
22238
22485
  var appiumServerCache = createServerCache({
22239
22486
  kill: (server) => killTree(server.process?.pid),
@@ -22794,6 +23041,8 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
22794
23041
  steps: { pass: 0, fail: 0, warning: 0, skipped: 0 }
22795
23042
  },
22796
23043
  // Nothing ran, but the shape stays parity with runSpecs' short-circuit.
23044
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
23045
+ environment: getEnvironment(),
22797
23046
  durationMs: 0,
22798
23047
  specs: []
22799
23048
  };
@@ -22891,6 +23140,8 @@ async function runSpecs({ resolvedTests }) {
22891
23140
  contexts: { pass: 0, fail: 0, warning: 0, skipped: 0 },
22892
23141
  steps: { pass: 0, fail: 0, warning: 0, skipped: 0 }
22893
23142
  },
23143
+ startedAt: new Date(runStart).toISOString(),
23144
+ environment: getEnvironment(),
22894
23145
  durationMs: Date.now() - runStart,
22895
23146
  specs: []
22896
23147
  };
@@ -22917,6 +23168,10 @@ async function runSpecs({ resolvedTests }) {
22917
23168
  const report = {
22918
23169
  runId,
22919
23170
  runDir,
23171
+ // Run metadata (ADR 01163): when execution started and the host that ran
23172
+ // it, so a consumer can tell product drift from environment drift.
23173
+ startedAt: new Date(runStart).toISOString(),
23174
+ environment: runnerDetails.environment,
22920
23175
  summary: {
22921
23176
  specs: {
22922
23177
  pass: 0,
@@ -23391,30 +23646,31 @@ async function runSpecs({ resolvedTests }) {
23391
23646
  process.off("SIGINT", onSignal);
23392
23647
  process.off("SIGTERM", onSignal);
23393
23648
  }
23394
- const herettoConfigs = config?.integrations?.heretto || [];
23395
- const hasUploadEnabledIntegration = herettoConfigs.some(
23396
- (h) => h.uploadOnChange !== false
23397
- // Default to true if not explicitly set to false
23398
- );
23399
- const globalUploadOnChange = config?.uploadOnChange ?? true;
23400
- if (globalUploadOnChange && hasUploadEnabledIntegration && herettoConfigs.length > 0) {
23401
- try {
23402
- const uploadResults = await uploadChangedFiles({ config, report, log });
23403
- report.uploadResults = uploadResults;
23404
- } catch (error) {
23405
- log(config, "warning", `Failed to upload changed files: ${error.message}`);
23406
- report.uploadResults = {
23407
- total: 0,
23408
- successful: 0,
23409
- failed: 0,
23410
- skipped: 0,
23411
- error: error.message
23412
- };
23413
- }
23414
- }
23649
+ const uploadResults = await uploadChangedFilesIfEnabled({ config, report, log });
23650
+ if (uploadResults)
23651
+ report.uploadResults = uploadResults;
23415
23652
  report.durationMs = Date.now() - runStart;
23416
23653
  return report;
23417
23654
  }
23655
+ async function uploadChangedFilesIfEnabled({ config, report, log: logFn, upload = uploadChangedFiles }) {
23656
+ const integrationEntries = Object.values(config?.integrations ?? {}).flatMap((entries) => Array.isArray(entries) ? entries : []);
23657
+ if (!integrationEntries.some((entry) => entry?.uploadOnChange === true)) {
23658
+ return void 0;
23659
+ }
23660
+ try {
23661
+ return await upload({ config, report, log: logFn });
23662
+ } catch (error) {
23663
+ logFn(config, "warning", `Failed to upload changed files: ${error.message}`);
23664
+ return {
23665
+ total: 0,
23666
+ successful: 0,
23667
+ failed: 0,
23668
+ skipped: 0,
23669
+ details: [],
23670
+ error: error.message
23671
+ };
23672
+ }
23673
+ }
23418
23674
  async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, appServerPool, platform, markAutoRecord, limit, resourceRegistry, exclusivityCtx }) {
23419
23675
  let stopRest = false;
23420
23676
  let warnedRunStop = false;
@@ -24437,6 +24693,14 @@ ${JSON.stringify(context, null, 2)}`);
24437
24693
  engine: String(mobileWebBrowserName).toLowerCase(),
24438
24694
  driver
24439
24695
  });
24696
+ const versions = sessionVersions({ capabilities: driver?.capabilities });
24697
+ if (versions.browserVersion)
24698
+ contextReport.browser = {
24699
+ ...contextReport.browser,
24700
+ version: versions.browserVersion
24701
+ };
24702
+ if (versions.driver)
24703
+ contextReport.driver = versions.driver;
24440
24704
  } else if (driverRequired) {
24441
24705
  appiumPort = await appiumPool.acquire();
24442
24706
  if (portToDisplay) {
@@ -24566,6 +24830,17 @@ ${JSON.stringify(context, null, 2)}`);
24566
24830
  };
24567
24831
  clog("warning", fellBackNote);
24568
24832
  }
24833
+ const versions = sessionVersions({
24834
+ capabilities: driver?.capabilities,
24835
+ app: availableApps.find((app) => app.name === normalizeBrowserName(startedName))
24836
+ });
24837
+ if (versions.browserVersion)
24838
+ contextReport.browser = {
24839
+ ...contextReport.browser,
24840
+ version: versions.browserVersion
24841
+ };
24842
+ if (versions.driver)
24843
+ contextReport.driver = versions.driver;
24569
24844
  if (driver) {
24570
24845
  browserSessions = createSessionRegistry({
24571
24846
  open: async (engine, overrides) => {
@@ -24748,7 +25023,12 @@ ${JSON.stringify(r, null, 2)}`);
24748
25023
  r.resultDescription = r.description;
24749
25024
  delete r.status;
24750
25025
  delete r.description;
24751
- return { ...step, ...r, durationMs: Date.now() - stepStart };
25026
+ return {
25027
+ ...step,
25028
+ ...r,
25029
+ startedAt: new Date(stepStart).toISOString(),
25030
+ durationMs: Date.now() - stepStart
25031
+ };
24752
25032
  };
24753
25033
  let stepReport = await runStepOnce();
24754
25034
  let attempts = 1;
@@ -24952,6 +25232,7 @@ async function runContextWithRetries(args, runContextFn = runContext, delayMs =
24952
25232
  const start = Date.now();
24953
25233
  const report2 = await runContextFn(a);
24954
25234
  if (report2 && typeof report2 === "object") {
25235
+ report2.startedAt = new Date(start).toISOString();
24955
25236
  report2.durationMs = Date.now() - start;
24956
25237
  }
24957
25238
  return report2;
@@ -25024,6 +25305,7 @@ async function stopAllRecordings({ config, context, driver, contextReport }) {
25024
25305
  contextReport.steps.push({
25025
25306
  ...stopRecordStep,
25026
25307
  ...stepResult,
25308
+ startedAt: new Date(stopStart).toISOString(),
25027
25309
  durationMs: Date.now() - stopStart
25028
25310
  });
25029
25311
  } catch (error) {
@@ -25033,6 +25315,7 @@ async function stopAllRecordings({ config, context, driver, contextReport }) {
25033
25315
  ...stopRecordStep,
25034
25316
  result: "FAIL",
25035
25317
  resultDescription: `Couldn't stop recording. ${error?.message ?? error}`,
25318
+ startedAt: new Date(stopStart).toISOString(),
25036
25319
  durationMs: Date.now() - stopStart
25037
25320
  });
25038
25321
  }
@@ -25314,7 +25597,7 @@ async function spawnAppiumServer(appiumEntry, config, display, extraEnv, extraAr
25314
25597
  ...extraArgs ?? []
25315
25598
  ], {
25316
25599
  windowsHide: true,
25317
- cwd: import_node_path30.default.join(__dirname4, "../.."),
25600
+ cwd: packageRoot(),
25318
25601
  env
25319
25602
  });
25320
25603
  proc.on("error", (err) => {
@@ -25399,8 +25682,8 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
25399
25682
  if (!isRetryableSessionError(String(err && err.message), startupCeiling))
25400
25683
  throw err;
25401
25684
  if (attempt < maxAttempts) {
25402
- const reason = String(err?.message ?? err).split("\n")[0];
25403
- ctx.log?.(`Session start attempt ${attempt} of ${maxAttempts} failed (${reason}); retrying.`);
25685
+ const reason2 = String(err?.message ?? err).split("\n")[0];
25686
+ ctx.log?.(`Session start attempt ${attempt} of ${maxAttempts} failed (${reason2}); retrying.`);
25404
25687
  await new Promise((r) => setTimeout(r, 500 * attempt));
25405
25688
  }
25406
25689
  }
@@ -25495,7 +25778,7 @@ async function getRunner(options = {}) {
25495
25778
  ...useDriversArgs(process.env.APPIUM_HOME, browserPoolDriverPackages(process.platform))
25496
25779
  ], {
25497
25780
  windowsHide: true,
25498
- cwd: import_node_path30.default.join(__dirname4, "../..")
25781
+ cwd: packageRoot()
25499
25782
  });
25500
25783
  appium.on("error", (err) => {
25501
25784
  log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
@@ -25560,6 +25843,165 @@ async function getRunner(options = {}) {
25560
25843
  // dist/core/index.js
25561
25844
  init_telemLite();
25562
25845
  init_files();
25846
+
25847
+ // dist/upgradeNotice.js
25848
+ var import_node_path31 = __toESM(require("node:path"), 1);
25849
+ var UPGRADE_GUIDE_URL = "https://docs.doc-detective.com/docs/ci/upgrade-to-v5";
25850
+ var warned = /* @__PURE__ */ new Set();
25851
+ function isAction() {
25852
+ try {
25853
+ return JSON.parse(process.env.DOC_DETECTIVE_META || "{}").dist_interface === "github-actions";
25854
+ } catch {
25855
+ return false;
25856
+ }
25857
+ }
25858
+ function warnUpgrade(messages, logLevel) {
25859
+ if (logLevel === "silent" || isAction())
25860
+ return;
25861
+ for (const message of messages) {
25862
+ if (warned.has(message))
25863
+ continue;
25864
+ warned.add(message);
25865
+ console.error(`Warning: ${message} See ${UPGRADE_GUIDE_URL}`);
25866
+ }
25867
+ }
25868
+ function legacyEnv(env) {
25869
+ try {
25870
+ const parsed = JSON.parse(env.DOC_DETECTIVE || "{}");
25871
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
25872
+ } catch {
25873
+ return {};
25874
+ }
25875
+ }
25876
+ var ROUTING = ["onPass", "onFail", "onWarning", "onSkip"];
25877
+ var LOCATORS = ["selector", "elementText", "elementId", "elementTestId", "elementClass", "elementAttribute", "elementAria"];
25878
+ var REJECTS = "Doc Detective 5.0 rejects with exit 2";
25879
+ var EXIT_2 = `which ${REJECTS}`;
25880
+ function routes(owner) {
25881
+ return ROUTING.flatMap((key) => Array.isArray(owner[key]) ? owner[key] : []);
25882
+ }
25883
+ function testWarnings(test, where) {
25884
+ return routes(test).flatMap((route) => [
25885
+ ...route.stop === "run" ? [`A test in ${where} has \`stop: "run"\` in its routing, ${EXIT_2}. Use \`stop: "spec"\`, which is what 4.x does.`] : [],
25886
+ ...["retry", "goToStep"].filter((key) => key in route).map((key) => `A test in ${where} has \`${key}\` in its routing, which 4.x ignores and ${REJECTS}. Remove it, or move it to a step.`)
25887
+ ]);
25888
+ }
25889
+ var FREE_FORM = /* @__PURE__ */ new Set([
25890
+ "request",
25891
+ "response",
25892
+ "headers",
25893
+ "body",
25894
+ "params",
25895
+ "parameters",
25896
+ "elementAttribute",
25897
+ "variables",
25898
+ "outputs",
25899
+ "env",
25900
+ "driverOptions",
25901
+ "openApi"
25902
+ ]);
25903
+ function negatives(value, found = /* @__PURE__ */ new Set()) {
25904
+ if (value && typeof value === "object") {
25905
+ for (const [key, child] of Object.entries(value)) {
25906
+ if (FREE_FORM.has(key))
25907
+ continue;
25908
+ if (typeof child === "number" && child < 0 && (key === "wait" || /(timeout|duration|delay)$/i.test(key)))
25909
+ found.add(key);
25910
+ negatives(child, found);
25911
+ }
25912
+ }
25913
+ return found;
25914
+ }
25915
+ function offend(offenders, row, value, message) {
25916
+ if (!offenders.has(row))
25917
+ offenders.set(row, { values: /* @__PURE__ */ new Set(), message });
25918
+ offenders.get(row).values.add(value);
25919
+ }
25920
+ function stepWarnings(step, where, offenders) {
25921
+ const messages = [];
25922
+ for (const route of routes(step)) {
25923
+ if (route.stop === "spec" || route.stop === "run") {
25924
+ messages.push(`A step in ${where} has \`stop: "${route.stop}"\` in its routing, ${EXIT_2}. Use \`stop: "test"\` on the step and \`stop: "spec"\` on the test, which works today.`);
25925
+ }
25926
+ if ("goToTest" in route) {
25927
+ messages.push(`A step in ${where} has \`goToTest\` in its routing, which 4.x ignores and ${REJECTS}. Move it to the test's routing, which works today.`);
25928
+ }
25929
+ }
25930
+ if (typeof step.click === "boolean") {
25931
+ messages.push(`A \`click\` step in ${where} is \`${step.click}\`, which 4.x reads as text to click and ${REJECTS}. To click an element whose text is '${step.click}', quote it as \`"click": "${step.click}"\`, which works today. If you don't want a click, remove the step.`);
25932
+ }
25933
+ const objects = (value) => [value].flat().filter((s) => s && typeof s === "object");
25934
+ if (objects(step.startSurface).concat(objects(step.closeSurface)).some((s) => s.browser === "edge")) {
25935
+ messages.push(`A surface in ${where} uses \`browser: "edge"\`, which Doc Detective 5.0 removes. Use another \`browser\`, such as \`chrome\`, which works today.`);
25936
+ }
25937
+ const starts = objects(step.startSurface);
25938
+ for (const surface of starts) {
25939
+ const device = surface.device && typeof surface.device === "object" ? surface.device : {};
25940
+ const removed = ["orientation", "udid", "provider"].filter((key) => key in device);
25941
+ if (removed.length) {
25942
+ messages.push(`A \`startSurface\` device in ${where} sets ${removed.map((key) => `\`${key}\``).join(", ")}, which Doc Detective 5.0 removes. Remove ${removed.length > 1 ? "them" : "it"}, which works today.`);
25943
+ }
25944
+ if (surface.app != null && surface.workingDirectory === ".") {
25945
+ messages.push(`An app in ${where} sets \`workingDirectory: "."\`, which 4.x tolerates and Doc Detective 5.0 fails. Remove it, which works today.`);
25946
+ }
25947
+ }
25948
+ const zeroTimeouts = [
25949
+ ...["runShell", "runCode", "goTo", "httpRequest"].filter((action) => step[action]?.timeout === 0),
25950
+ ...starts.some((s) => s.timeout === 0) ? ["startSurface"] : []
25951
+ ];
25952
+ for (const action of zeroTimeouts) {
25953
+ messages.push(`A \`${action}\` step in ${where} sets \`timeout: 0\`, ${EXIT_2} on \`runShell\`, \`runCode\`, \`goTo\`, \`httpRequest\`, and \`startSurface\` processes and apps. Remove \`timeout\` for the default, or set it to 1 or more, which works today. Locator and wait timeouts can still be 0.`);
25954
+ }
25955
+ for (const key of negatives(step)) {
25956
+ messages.push(`A step in ${where} has a negative \`${key}\`, ${EXIT_2}. Use a positive value, which works today.`);
25957
+ }
25958
+ if (step.screenshot === false) {
25959
+ messages.push(`A \`screenshot: false\` step in ${where} takes a screenshot today, and Doc Detective 5.0 skips it. To keep the screenshot, use \`screenshot: true\`, which works today.`);
25960
+ }
25961
+ const find = step.find && typeof step.find === "object" ? step.find : {};
25962
+ const click = find.click;
25963
+ if (typeof click === "string" && !["left", "right", "middle"].includes(click) || click && typeof click === "object" && Object.keys(click).some((key) => key !== "button" && key !== "duration")) {
25964
+ messages.push(`A \`find.click\` in ${where} isn't \`true\`, \`false\`, a button, or \`{button, duration}\`, ${EXIT_2}. Use \`click: true\` to click the found element, or a separate \`click\` step, which works today.`);
25965
+ }
25966
+ if (find.type && typeof find.type === "object" && LOCATORS.some((key) => key in find.type)) {
25967
+ messages.push(`A \`find.type\` in ${where} sets its own locator, ${EXIT_2}. Move it to a separate \`type\` step, which works today.`);
25968
+ }
25969
+ for (const action of ["goTo", "httpRequest"]) {
25970
+ const url = typeof step[action] === "string" ? step[action] : step[action]?.url;
25971
+ if (typeof url === "string" && !/^(https?:\/\/|\/)/.test(url) && !/^\$[A-Za-z0-9_]+$/.test(url)) {
25972
+ offend(offenders, `${action} URL`, url, (first) => `A \`${action}\` URL in ${where} (${first}) doesn't start with \`http://\`, \`https://\`, or \`/\`, and isn't a whole \`$VAR\`, ${EXIT_2}. Put the whole URL in one variable, which works today.`);
25973
+ }
25974
+ }
25975
+ for (const action of ["saveCookie", "loadCookie"]) {
25976
+ const value = step[action];
25977
+ if (typeof value === "string" && value.includes("/") && !value.endsWith(".txt")) {
25978
+ offend(offenders, action, value, (first) => `A \`${action}\` in ${where} (${first}) has a \`/\` but doesn't end in \`.txt\`, ${EXIT_2}. End a file path in \`.txt\`, which works today.`);
25979
+ }
25980
+ }
25981
+ return messages;
25982
+ }
25983
+ function specWarnings(resolvedTests) {
25984
+ const messages = [];
25985
+ for (const spec of resolvedTests?.specs ?? []) {
25986
+ const file = spec.contentPath;
25987
+ const where = typeof file === "string" ? `'${import_node_path31.default.isAbsolute(file) ? import_node_path31.default.relative(process.cwd(), file) : file}'` : "a spec";
25988
+ const offenders = /* @__PURE__ */ new Map();
25989
+ for (const test of spec.tests ?? []) {
25990
+ messages.push(...testWarnings(test, where));
25991
+ for (const context of test.contexts ?? []) {
25992
+ for (const step of context.steps ?? [])
25993
+ messages.push(...stepWarnings(step, where, offenders));
25994
+ }
25995
+ }
25996
+ for (const { values, message } of offenders.values()) {
25997
+ const [first] = values;
25998
+ messages.push(message(values.size > 1 ? `\`${first}\` and ${values.size - 1} more` : `\`${first}\``));
25999
+ }
26000
+ }
26001
+ return [...new Set(messages)];
26002
+ }
26003
+
26004
+ // dist/core/index.js
25563
26005
  var supportMessage = `
25564
26006
  ##########################################################################
25565
26007
  # Thanks for using Doc Detective! If this project was helpful to you, #
@@ -25604,6 +26046,7 @@ async function runTestsAndReport(config, options) {
25604
26046
  config = { ...resolvedTests.config || {}, ...config || {} };
25605
26047
  resolvedTests.config = config;
25606
26048
  }
26049
+ const callerLogLevel = config?.logLevel;
25607
26050
  if (config.dryRun) {
25608
26051
  config.logLevel = "silent";
25609
26052
  if (resolvedTests)
@@ -25635,6 +26078,12 @@ async function runTestsAndReport(config, options) {
25635
26078
  log(config, "warning", "Couldn't resolve any tests.");
25636
26079
  return null;
25637
26080
  }
26081
+ const upgradeLogLevel = config.dryRun ? legacyEnv(process.env).config?.logLevel ?? callerLogLevel : effectiveConfig.logLevel;
26082
+ try {
26083
+ warnUpgrade(specWarnings(resolvedTests), upgradeLogLevel);
26084
+ } catch (error) {
26085
+ log(effectiveConfig, "debug", `Couldn't check specs for Doc Detective 5.0 changes: ${error?.message || error}`);
26086
+ }
25638
26087
  const resolvedConfig = resolvedTests.config;
25639
26088
  if (!resolvedConfig.dryRun && (willRunViaApi || runNeedsAvailableApps(resolvedTests.specs))) {
25640
26089
  resolvedConfig.environment.apps = await getAvailableApps({ config: resolvedConfig });