@validate.qa/runner 1.0.102 → 1.0.104

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 (32) hide show
  1. package/README.md +7 -2
  2. package/dist/{chunk-E2VEMBQQ.mjs → chunk-6N4QCCEH.mjs} +2 -2
  3. package/dist/{chunk-ZZYBRIBT.mjs → chunk-EBWZ7NGU.mjs} +11 -8
  4. package/dist/{chunk-3JM6XTSZ.mjs → chunk-EKY6CN4Y.mjs} +660 -123
  5. package/dist/{chunk-DN3O6EYT.mjs → chunk-MEE4DXRX.mjs} +19834 -3064
  6. package/dist/{chunk-CBVHPYVJ.mjs → chunk-OXDU6ARE.mjs} +86 -7
  7. package/dist/{chunk-AAUHXGDS.mjs → chunk-PUMRAFJS.mjs} +37 -13
  8. package/dist/{chunk-4WLP3RWN.mjs → chunk-TGVKABFN.mjs} +45 -3
  9. package/dist/{chunk-5DFBEL72.mjs → chunk-TS4OUJVT.mjs} +1 -1
  10. package/dist/{chunk-BXLEPT4M.mjs → chunk-UR7KDOTP.mjs} +32 -8
  11. package/dist/{chunk-E2RRAE27.mjs → chunk-UZ67UBXH.mjs} +14 -3
  12. package/dist/{chunk-HJ6FKHMM.mjs → chunk-VWE3SQ2Z.mjs} +1 -1
  13. package/dist/{chunk-PWFWMVZU.mjs → chunk-XXZZMG6Q.mjs} +15 -3
  14. package/dist/cli.js +35519 -17101
  15. package/dist/cli.mjs +1171 -251
  16. package/dist/credential-mcp-server.js +750 -133
  17. package/dist/credential-mcp-server.mjs +3 -3
  18. package/dist/discovery-mcp-server.js +714 -132
  19. package/dist/discovery-mcp-server.mjs +4 -4
  20. package/dist/dist-BXHLIG5H.mjs +17 -0
  21. package/dist/inbox-mcp-server.js +743 -129
  22. package/dist/inbox-mcp-server.mjs +3 -3
  23. package/dist/route-seed-worker.js +672 -125
  24. package/dist/route-seed-worker.mjs +3 -3
  25. package/dist/run-write-guard-preload.js +714 -126
  26. package/dist/run-write-guard-preload.mjs +4 -4
  27. package/dist/run-write-guard.js +714 -126
  28. package/dist/run-write-guard.mjs +3 -3
  29. package/dist/tar-extract-child.js +38 -14
  30. package/dist/tar-extract-child.mjs +2 -2
  31. package/package.json +3 -2
  32. package/dist/dist-52M5RDZ6.mjs +0 -17
package/dist/cli.mjs CHANGED
@@ -33,23 +33,23 @@ import {
33
33
  require_resource_governor,
34
34
  require_result_api_call_bodies,
35
35
  require_result_shed
36
- } from "./chunk-DN3O6EYT.mjs";
36
+ } from "./chunk-MEE4DXRX.mjs";
37
37
  import {
38
38
  require_tar_isolated
39
- } from "./chunk-AAUHXGDS.mjs";
39
+ } from "./chunk-PUMRAFJS.mjs";
40
40
  import "./chunk-EXRFHATN.mjs";
41
41
  import {
42
42
  require_browser_config,
43
43
  require_cloud_runner
44
- } from "./chunk-ZZYBRIBT.mjs";
45
- import "./chunk-BXLEPT4M.mjs";
46
- import "./chunk-5DFBEL72.mjs";
47
- import "./chunk-CBVHPYVJ.mjs";
48
- import "./chunk-HJ6FKHMM.mjs";
49
- import "./chunk-PWFWMVZU.mjs";
50
- import "./chunk-E2VEMBQQ.mjs";
51
- import "./chunk-4WLP3RWN.mjs";
52
- import "./chunk-E2RRAE27.mjs";
44
+ } from "./chunk-EBWZ7NGU.mjs";
45
+ import "./chunk-UR7KDOTP.mjs";
46
+ import "./chunk-TS4OUJVT.mjs";
47
+ import "./chunk-OXDU6ARE.mjs";
48
+ import "./chunk-VWE3SQ2Z.mjs";
49
+ import "./chunk-XXZZMG6Q.mjs";
50
+ import "./chunk-6N4QCCEH.mjs";
51
+ import "./chunk-TGVKABFN.mjs";
52
+ import "./chunk-UZ67UBXH.mjs";
53
53
  import {
54
54
  LIVE_FRAME_IDLE_PUSH_INTERVAL_MS,
55
55
  MOBILE_COLD_BOOT_SETTLE_MS,
@@ -59,6 +59,7 @@ import {
59
59
  MOBILE_MJPEG_SETTINGS,
60
60
  MOBILE_POST_ACTION_SETTLE_MS,
61
61
  MOBILE_SCREENSHOT_INTERVAL_MS,
62
+ blockedRunLogLine,
62
63
  buildAppiumCode,
63
64
  clampMobileSwipeDistance,
64
65
  classifyErrorMessage,
@@ -77,6 +78,7 @@ import {
77
78
  isValidMobileAppId,
78
79
  mobilePlatformFromMedium,
79
80
  parseBoolFlag,
81
+ registrableDomain,
80
82
  repositoryCliLabel,
81
83
  resolveCrossOriginConfigOverride,
82
84
  resolveMobileStepValuePlaceholders,
@@ -84,7 +86,7 @@ import {
84
86
  stepAppliesToPlatform,
85
87
  stepsMissingPlatformLocators,
86
88
  stepsNeedingReviewForPlatform
87
- } from "./chunk-3JM6XTSZ.mjs";
89
+ } from "./chunk-EKY6CN4Y.mjs";
88
90
  import {
89
91
  __commonJS,
90
92
  __require,
@@ -215,13 +217,41 @@ var require_tls_cert_probe = __commonJS({
215
217
  });
216
218
  });
217
219
  }
220
+ var MAX_SPEC_HOSTS = 3;
221
+ var SPEC_GOTO_HTTPS = /\.goto\(\s*['"`](https:\/\/[^\s/?#'"`\\$]{1,300})(?=[/?#'"`])/gi;
222
+ function httpsOrigin(raw) {
223
+ try {
224
+ const url = new URL(raw);
225
+ return url.protocol === "https:" && url.hostname ? `https://${url.host}/` : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+ function specNavigationOrigins(specCode, baseUrl) {
231
+ const origins = [];
232
+ if (!specCode)
233
+ return origins;
234
+ const base = httpsOrigin(baseUrl);
235
+ for (const match of specCode.matchAll(SPEC_GOTO_HTTPS)) {
236
+ const origin = httpsOrigin(`${match[1]}/`);
237
+ if (!origin || origin === base || origins.includes(origin))
238
+ continue;
239
+ origins.push(origin);
240
+ if (origins.length === MAX_SPEC_HOSTS)
241
+ break;
242
+ }
243
+ return origins;
244
+ }
218
245
  function tlsCertFindingLine(finding) {
219
246
  return `\u26A0\uFE0F TLS certificate: ${finding.host} presented a certificate that strict TLS rejects (${finding.code}: ${finding.detail}). The run went ahead because test browsers ignore HTTPS errors, so this is not a failure. A visitor's browser may show a certificate warning: this check does not fetch a missing intermediate certificate and does not use the operating system's trust store (a private CA the visitor's machine trusts), so a browser can accept a certificate it rejects.`;
220
247
  }
221
248
  async function targetCertificateNote2(baseUrl, opts = {}) {
222
249
  try {
223
- const finding = await probeTargetCertificate(baseUrl, opts);
224
- return finding ? tlsCertFindingLine(finding) : null;
250
+ const { specCode, ...probeOpts } = opts;
251
+ const targets = [baseUrl, ...specNavigationOrigins(specCode, baseUrl)];
252
+ const findings = await Promise.all(targets.map((target) => probeTargetCertificate(target, probeOpts)));
253
+ const lines = findings.flatMap((finding) => finding ? [tlsCertFindingLine(finding)] : []);
254
+ return lines.length > 0 ? lines.join("\n") : null;
225
255
  } catch {
226
256
  return null;
227
257
  }
@@ -252,7 +282,7 @@ var require_package = __commonJS({
252
282
  "package.json"(exports, module) {
253
283
  module.exports = {
254
284
  name: "@validate.qa/runner",
255
- version: "1.0.102",
285
+ version: "1.0.104",
256
286
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
257
287
  bin: {
258
288
  "validate-runner": "dist/cli.js"
@@ -261,8 +291,9 @@ var require_package = __commonJS({
261
291
  build: "tsup",
262
292
  dev: "tsx src/cli.ts",
263
293
  lint: "eslint .",
264
- test: "node --import tsx --test src/supervisor-child-argv.test.ts src/audit-scrub.test.ts src/retry-on-429.test.ts src/version-check.test.ts src/poll-interval.test.ts src/node-version-notice.test.ts src/services/auth-state.test.ts src/run-no-longer-active.test.ts src/mode-abort-no-verdict.test.ts src/discovery-render-type.test.ts src/batch-heal-no-start.test.ts src/heartbeat-active-runs.test.ts src/result-post-structure-413.test.ts src/result-shed-routes-413.test.ts src/result-post-retry.test.ts src/result-media-stale-epoch.test.ts src/run-write-guard-bundle.test.ts src/spec-driver-bundle.test.ts src/mcp-servers-bundle.test.ts src/codex-files-mcp-server-bundle.test.ts src/claim-nonce-not-logged.test.ts src/sign-in-capture.test.ts src/run-status-verdicts.test.ts src/sign-in-failure.test.ts src/mobile-ui-path-succeeded.test.ts src/supervisor-policy.test.ts src/login-check.test.ts src/heal-post-lost-ack.test.ts src/ui-heal-result-video.test.ts src/heal-log-stream-chunks.test.ts src/shutdown-claim-window.test.ts src/result-post-redeploy-budget.test.ts src/shutdown-signals.test.ts",
294
+ test: "node --import tsx --test src/supervisor-child-argv.test.ts src/audit-scrub.test.ts src/retry-on-429.test.ts src/version-check.test.ts src/runner-upgrade-exit.test.ts src/poll-interval.test.ts src/node-version-notice.test.ts src/services/auth-state.test.ts src/run-no-longer-active.test.ts src/mode-abort-no-verdict.test.ts src/discovery-render-type.test.ts src/batch-heal-no-start.test.ts src/heartbeat-active-runs.test.ts src/result-post-structure-413.test.ts src/result-shed-routes-413.test.ts src/result-post-retry.test.ts src/result-media-stale-epoch.test.ts src/run-write-guard-bundle.test.ts src/run-write-guard-bundle-skip.test.ts src/spec-driver-bundle.test.ts src/mcp-servers-bundle.test.ts src/codex-files-mcp-server-bundle.test.ts src/claim-nonce-not-logged.test.ts src/sign-in-capture.test.ts src/run-status-verdicts.test.ts src/sign-in-failure.test.ts src/mobile-ui-path-succeeded.test.ts src/supervisor-policy.test.ts src/login-check.test.ts src/config-permissions.test.ts src/heal-post-lost-ack.test.ts src/ui-heal-result-video.test.ts src/heal-log-stream-chunks.test.ts src/shutdown-claim-window.test.ts src/result-post-redeploy-budget.test.ts src/shutdown-signals.test.ts src/sign-in-detect.test.ts src/no-credentials-help.test.ts src/resolve-credentials.test.ts src/sign-in-url.test.ts src/status-check.test.ts src/status-exit-code.test.ts src/services/doctor-web.test.ts src/browser-pool-failure.test.ts src/login-check-wrong-server.test.ts src/login-wrong-server-cli.test.ts src/start-wrong-server-cli.test.ts src/plaintext-server-warning.test.ts",
265
295
  "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/runner.mobile-heal-audit-wiring.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/runner-watchdog.test.ts src/result-payload-budget.test.ts src/result-media-upload.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
296
+ "test:browser": "RUN_BROWSER_TESTS=1 PLAYWRIGHT_HEADLESS=true node --import tsx --test src/sign-in-prove.browser.ts",
266
297
  "smoke:installed": "node scripts/installed-run-smoke.mjs",
267
298
  "check:packed-mcp": "node scripts/packed-install-check.mjs",
268
299
  prepublishOnly: "npm run build"
@@ -426,22 +457,46 @@ var cwdEnvIgnored = loadCwdEnv({
426
457
  import { Command } from "commander";
427
458
 
428
459
  // src/config.ts
429
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync, unlinkSync } from "fs";
460
+ import { chmodSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
430
461
  import { join as join3 } from "path";
431
462
  import { homedir } from "os";
432
463
  var CONFIG_DIR = join3(homedir(), ".validate.qa");
433
464
  var CREDENTIALS_FILE = join3(CONFIG_DIR, "credentials.json");
434
- function ensureConfigDir() {
435
- if (!existsSync2(CONFIG_DIR)) {
436
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
465
+ function setMode(path, mode) {
466
+ try {
467
+ chmodSync(path, mode);
468
+ } catch {
437
469
  }
438
470
  }
471
+ function ensureConfigDir() {
472
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
473
+ setMode(CONFIG_DIR, 448);
474
+ }
439
475
  function saveCredentials(creds) {
440
476
  ensureConfigDir();
441
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
477
+ const tmp = `${CREDENTIALS_FILE}.${process.pid}.tmp`;
478
+ try {
479
+ writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 384 });
480
+ setMode(tmp, 384);
481
+ renameSync(tmp, CREDENTIALS_FILE);
482
+ } catch (err) {
483
+ try {
484
+ unlinkSync(tmp);
485
+ } catch {
486
+ }
487
+ throw err;
488
+ }
489
+ }
490
+ function tightenCredentialsMode() {
491
+ if (process.platform === "win32") return;
492
+ try {
493
+ if ((statSync(CREDENTIALS_FILE).mode & 63) !== 0) setMode(CREDENTIALS_FILE, 384);
494
+ } catch {
495
+ }
442
496
  }
443
497
  function loadCredentials() {
444
498
  if (!existsSync2(CREDENTIALS_FILE)) return null;
499
+ tightenCredentialsMode();
445
500
  try {
446
501
  const data = readFileSync2(CREDENTIALS_FILE, "utf-8");
447
502
  const parsed = JSON.parse(data);
@@ -473,9 +528,9 @@ var import_playwright_native = __toESM(require_playwright_native());
473
528
  var import_runner_core = __toESM(require_dist());
474
529
 
475
530
  // src/runner.ts
476
- var import_runner_core7 = __toESM(require_dist());
477
531
  var import_runner_core8 = __toESM(require_dist());
478
532
  var import_runner_core9 = __toESM(require_dist());
533
+ var import_runner_core10 = __toESM(require_dist());
479
534
  var import_framework_import_runner = __toESM(require_framework_import_runner());
480
535
  var import_grok_repo_enrichment_runner = __toESM(require_grok_repo_enrichment_runner());
481
536
  var import_repo_brief_execution = __toESM(require_repo_brief_execution());
@@ -485,7 +540,7 @@ var import_playwright_signal_handlers = __toESM(require_playwright_signal_handle
485
540
  var import_cli_limit = __toESM(require_cli_limit());
486
541
  var import_fetch_cli_subscription = __toESM(require_fetch_cli_subscription());
487
542
  var import_codex_cli = __toESM(require_codex_cli());
488
- var import_runner_core10 = __toESM(require_dist());
543
+ var import_runner_core11 = __toESM(require_dist());
489
544
 
490
545
  // src/services/regression-runner.ts
491
546
  var import_regression_runner = __toESM(require_regression_runner());
@@ -564,7 +619,7 @@ function authRefusalAction(input) {
564
619
 
565
620
  // src/version-check.ts
566
621
  var RUNNER_NPM_PACKAGE = "@validate.qa/runner";
567
- var NPM_LATEST_URL = `https://registry.npmjs.org/${encodeURIComponent(RUNNER_NPM_PACKAGE)}/latest`;
622
+ var DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
568
623
  var DEFAULT_TIMEOUT_MS = 8e3;
569
624
  function parseSemver(raw) {
570
625
  const cleaned = raw.trim().replace(/^v/i, "");
@@ -602,11 +657,41 @@ function shouldSkipVersionCheck() {
602
657
  }
603
658
  return null;
604
659
  }
660
+ function npmConfigFromEnv(key, env) {
661
+ const want = `npm_config_${key}`.toLowerCase();
662
+ for (const [name, value] of Object.entries(env)) {
663
+ if (name.toLowerCase() === want && typeof value === "string" && value.trim() !== "") return value.trim();
664
+ }
665
+ return null;
666
+ }
667
+ function runnerRegistryUrl(env = process.env) {
668
+ const scope = RUNNER_NPM_PACKAGE.split("/")[0];
669
+ for (const raw of [npmConfigFromEnv(`${scope}:registry`, env), npmConfigFromEnv("registry", env)]) {
670
+ if (raw === null) continue;
671
+ let url;
672
+ try {
673
+ url = new URL(raw);
674
+ } catch {
675
+ continue;
676
+ }
677
+ if (url.protocol !== "https:" && url.protocol !== "http:") continue;
678
+ url.username = "";
679
+ url.password = "";
680
+ url.search = "";
681
+ url.hash = "";
682
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
683
+ return url;
684
+ }
685
+ return new URL(DEFAULT_NPM_REGISTRY);
686
+ }
687
+ function runnerLatestUrl(env = process.env) {
688
+ return new URL(`${encodeURIComponent(RUNNER_NPM_PACKAGE)}/latest`, runnerRegistryUrl(env)).href;
689
+ }
605
690
  async function fetchLatestRunnerVersion(timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch) {
606
691
  const controller = new AbortController();
607
692
  const timer = setTimeout(() => controller.abort(), timeoutMs);
608
693
  try {
609
- const res = await fetchImpl(NPM_LATEST_URL, {
694
+ const res = await fetchImpl(runnerLatestUrl(), {
610
695
  headers: {
611
696
  Accept: "application/json",
612
697
  // Identify ourselves; helps npm operators + caching intermediaries.
@@ -634,7 +719,7 @@ async function checkRunnerVersion(currentVersion, opts = {}) {
634
719
  outdated: false,
635
720
  currentVersion,
636
721
  latestVersion: null,
637
- skippedReason: "could not reach npm registry (or response was invalid)"
722
+ skippedReason: `could not reach npm registry ${runnerRegistryUrl().host} (or response was invalid)`
638
723
  };
639
724
  }
640
725
  if (!parseSemver(currentVersion) || !parseSemver(latestVersion)) {
@@ -672,6 +757,15 @@ function formatUpgradeInstructions(latestVersion) {
672
757
  ];
673
758
  return lines.join("\n");
674
759
  }
760
+ function exitForUpgrade(latestVersion, installed, opts = {}) {
761
+ const log2 = opts.log ?? ((msg) => console.error(msg));
762
+ log2(formatUpgradeInstructions(latestVersion));
763
+ log2(` Installed: ${installed}`);
764
+ log2(` Required: ${latestVersion} (or newer)`);
765
+ log2("");
766
+ const exit = opts.exitFn ?? ((code) => process.exit(code));
767
+ return exit(EXIT_TERMINAL);
768
+ }
675
769
  async function enforceRunnerUpToDate(currentVersion, opts = {}) {
676
770
  const log2 = opts.log ?? ((msg) => console.error(msg));
677
771
  const result = await checkRunnerVersion(currentVersion, opts);
@@ -682,12 +776,7 @@ async function enforceRunnerUpToDate(currentVersion, opts = {}) {
682
776
  return result;
683
777
  }
684
778
  if (result.outdated && result.latestVersion) {
685
- log2(formatUpgradeInstructions(result.latestVersion));
686
- log2(` Installed: ${result.currentVersion}`);
687
- log2(` Required: ${result.latestVersion} (or newer)`);
688
- log2("");
689
- const exit = opts.exitFn ?? ((code) => process.exit(code));
690
- exit(EXIT_TERMINAL);
779
+ exitForUpgrade(result.latestVersion, result.currentVersion, { exitFn: opts.exitFn, log: log2 });
691
780
  }
692
781
  return result;
693
782
  }
@@ -695,6 +784,19 @@ function upgradeAction(activeRuns) {
695
784
  return activeRuns > 0 ? "drain" : "exit";
696
785
  }
697
786
 
787
+ // src/browser-pool-failure.ts
788
+ var MISSING_EXECUTABLE2 = /Executable doesn't exist at /i;
789
+ function browserPoolInitFailure(message, playwrightVersion) {
790
+ if (MISSING_EXECUTABLE2.test(message)) {
791
+ const pkg2 = playwrightVersion ? `playwright@${playwrightVersion}` : "playwright";
792
+ return {
793
+ exitCode: EXIT_TERMINAL,
794
+ advice: `Chromium is not installed for this runner. Install it, then run start again: npx ${pkg2} install chromium`
795
+ };
796
+ }
797
+ return { exitCode: 1, advice: "Discovery requires a browser pool. Fix the browser installation or set BROWSER_POOL_SIZE=0 to disable." };
798
+ }
799
+
698
800
  // src/shutdown-signals.ts
699
801
  var REPEAT_SIGNAL_WINDOW_MS = 1e3;
700
802
  function installShutdownSignals(target, onShutdown, opts) {
@@ -6257,7 +6359,7 @@ async function harvestPlatformLocators(options) {
6257
6359
  let reachedOrder = null;
6258
6360
  let environmentalError;
6259
6361
  log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
6260
- const session = await createMobileDiscoverySession({
6362
+ const session2 = await createMobileDiscoverySession({
6261
6363
  appId: options.target.appId,
6262
6364
  platform: platform3,
6263
6365
  platformVersion: options.target.platformVersion,
@@ -6266,13 +6368,13 @@ async function harvestPlatformLocators(options) {
6266
6368
  launchMode: "LAUNCH_APP"
6267
6369
  });
6268
6370
  try {
6269
- options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
6270
- await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
6271
- await dismissSystemUiAfterColdBoot(session.sessionId, platform3, log2);
6371
+ options.onSessionReady?.(session2.sessionId, session2.deviceId, platform3);
6372
+ await freshLaunchTargetApp(session2.sessionId, platform3, options.target.appId, log2);
6373
+ await dismissSystemUiAfterColdBoot(session2.sessionId, platform3, log2);
6272
6374
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
6273
- await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
6375
+ await openDeepLink(session2.sessionId, platform3, options.target.deeplink, options.target.appId);
6274
6376
  }
6275
- await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
6377
+ await restoreTargetAppIfExternal(session2.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
6276
6378
  for (const step of harvestSteps) {
6277
6379
  if (step.action === "launchApp") continue;
6278
6380
  if (!stepAppliesToPlatform(step, platform3)) continue;
@@ -6282,7 +6384,7 @@ async function harvestPlatformLocators(options) {
6282
6384
  if (!webViewCss && identityStrategies.length > 0 && step.target) {
6283
6385
  try {
6284
6386
  elementId = await findElement(
6285
- session.sessionId,
6387
+ session2.sessionId,
6286
6388
  step.target,
6287
6389
  Math.max(DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS),
6288
6390
  {
@@ -6296,7 +6398,7 @@ async function harvestPlatformLocators(options) {
6296
6398
  }
6297
6399
  }
6298
6400
  if (elementId) {
6299
- const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
6401
+ const bundle = await readNativeElementBundle(session2.sessionId, elementId, platform3);
6300
6402
  if (bundle) {
6301
6403
  bundles[step.order] = bundle;
6302
6404
  log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
@@ -6308,20 +6410,20 @@ async function harvestPlatformLocators(options) {
6308
6410
  divergentOrders.push(step.order);
6309
6411
  log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
6310
6412
  }
6311
- const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
6413
+ const advanced = await driveHarvestStep(session2.sessionId, step, elementId, platform3);
6312
6414
  if (!advanced && isStateAdvancingAction(step.action)) {
6313
6415
  log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
6314
6416
  break;
6315
6417
  }
6316
6418
  reachedOrder = step.order;
6317
- await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
6419
+ await restoreTargetAppIfExternal(session2.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
6318
6420
  }
6319
6421
  } catch (err) {
6320
6422
  environmentalError = err instanceof Error ? err.message : String(err);
6321
6423
  log2(` \u2717 Harvest aborted: ${environmentalError}`);
6322
6424
  } finally {
6323
- options.onSessionEnding?.(session.deviceId);
6324
- await session.stop().catch(() => {
6425
+ options.onSessionEnding?.(session2.deviceId);
6426
+ await session2.stop().catch(() => {
6325
6427
  });
6326
6428
  }
6327
6429
  return {
@@ -6329,8 +6431,8 @@ async function harvestPlatformLocators(options) {
6329
6431
  bundles,
6330
6432
  divergentOrders,
6331
6433
  reachedOrder,
6332
- deviceId: session.deviceId,
6333
- deviceName: session.deviceName,
6434
+ deviceId: session2.deviceId,
6435
+ deviceName: session2.deviceName,
6334
6436
  logs: logLines.join("\n"),
6335
6437
  ...environmentalError ? { environmentalError } : {}
6336
6438
  };
@@ -8593,6 +8695,7 @@ function readActiveBundleId2(result) {
8593
8695
  }
8594
8696
 
8595
8697
  // src/services/doctor.ts
8698
+ var import_runner_core7 = __toESM(require_dist());
8596
8699
  import { execSync } from "child_process";
8597
8700
  import { existsSync as existsSync3 } from "fs";
8598
8701
  import { platform as platform2 } from "os";
@@ -8623,7 +8726,10 @@ function computeDoctorReadiness(web, ios, android) {
8623
8726
  const webReady = web.every((check2) => check2.ok);
8624
8727
  const iosReady = ios.length > 0 && ios.every((check2) => check2.ok);
8625
8728
  const androidReady = android.length > 0 && android.every((check2) => check2.ok);
8626
- return { allPassed: webReady && (iosReady || androidReady), iosReady, androidReady };
8729
+ return { allPassed: webReady && (iosReady || androidReady), webReady, iosReady, androidReady };
8730
+ }
8731
+ function doctorExitCode(report, opts = {}) {
8732
+ return (opts.mobile ? report.allPassed : report.webReady) ? 0 : 1;
8627
8733
  }
8628
8734
  function run(cmd) {
8629
8735
  try {
@@ -8640,17 +8746,33 @@ function check(name, fn) {
8640
8746
  return { name, ok: false, detail: err instanceof Error ? err.message : "Unknown error" };
8641
8747
  }
8642
8748
  }
8643
- function checkWeb() {
8749
+ function bundledPlaywrightVersion() {
8750
+ try {
8751
+ const pkg2 = __require("playwright/package.json");
8752
+ return typeof pkg2.version === "string" && pkg2.version ? pkg2.version : null;
8753
+ } catch {
8754
+ return null;
8755
+ }
8756
+ }
8757
+ var DEFAULT_WEB_DEPS = {
8758
+ nodeVersion: process.version,
8759
+ playwrightVersion: bundledPlaywrightVersion,
8760
+ chromiumPath: import_runner_core7.getFullChromiumPath
8761
+ };
8762
+ function checkWeb(deps = DEFAULT_WEB_DEPS) {
8644
8763
  const results = [];
8645
8764
  results.push(check("Node.js", () => {
8646
- const version = process.version;
8765
+ const version = deps.nodeVersion;
8647
8766
  const major = parseInt(version.slice(1).split(".")[0], 10);
8648
8767
  return { ok: major >= REQUIRED_NODE_MAJOR, detail: `${version}${major < REQUIRED_NODE_MAJOR ? ` (>= ${REQUIRED_NODE_MAJOR} required)` : ""}` };
8649
8768
  }));
8650
- results.push(check("Playwright", () => {
8651
- const version = run("npx playwright --version");
8652
- if (!version) return { ok: false, detail: "Not installed. Run: npm i -D playwright" };
8653
- return { ok: true, detail: version };
8769
+ const playwrightVersion = deps.playwrightVersion();
8770
+ results.push(check("Playwright", () => playwrightVersion ? { ok: true, detail: `${playwrightVersion} (bundled with the runner)` } : { ok: false, detail: "The runner's Playwright could not be read. Reinstall the runner: npx @validate.qa/runner@latest" }));
8771
+ results.push(check("Chromium", () => {
8772
+ const path = deps.chromiumPath();
8773
+ if (path) return { ok: true, detail: path };
8774
+ const pkg2 = playwrightVersion ? `playwright@${playwrightVersion}` : "playwright";
8775
+ return { ok: false, detail: `Not installed for this runner. Run: npx ${pkg2} install chromium` };
8654
8776
  }));
8655
8777
  return results;
8656
8778
  }
@@ -8796,10 +8918,10 @@ function runDoctor() {
8796
8918
  const ios = checkIOS();
8797
8919
  const android = checkAndroid();
8798
8920
  const tunnel = checkTunnel();
8799
- const { allPassed } = computeDoctorReadiness(web, ios, android);
8800
- return { web, ios, android, tunnel, allPassed };
8921
+ const { allPassed, webReady } = computeDoctorReadiness(web, ios, android);
8922
+ return { web, ios, android, tunnel, allPassed, webReady };
8801
8923
  }
8802
- function printDoctorReport(report) {
8924
+ function printDoctorReport(report, opts = {}) {
8803
8925
  console.log("");
8804
8926
  console.log(`${C.bold}${C.cyan}\u{1F3E5} validate.qa Doctor${C.reset}`);
8805
8927
  console.log(`${C.gray}${"\u2500".repeat(40)}${C.reset}`);
@@ -8818,12 +8940,15 @@ function printDoctorReport(report) {
8818
8940
  console.log("");
8819
8941
  if (report.allPassed) {
8820
8942
  console.log(` ${C.green}\u2705 Web plus at least one mobile platform is ready${C.reset}`);
8943
+ } else if (report.webReady && !opts.mobile) {
8944
+ console.log(` ${C.green}\u2705 Web testing is ready${C.reset}`);
8945
+ console.log(` ${C.gray} No complete iOS or Android toolchain: needed only for mobile tests (check with: doctor --mobile)${C.reset}`);
8821
8946
  } else {
8822
8947
  const readiness = computeDoctorReadiness(report.web, report.ios, report.android);
8823
8948
  const webFailures = report.web.filter((check2) => !check2.ok).length;
8824
8949
  let reason;
8825
8950
  if (webFailures > 0) {
8826
- reason = `${webFailures} web prerequisite${webFailures === 1 ? "" : "s"} need attention`;
8951
+ reason = `${webFailures} web prerequisite${webFailures === 1 ? " needs" : "s need"} attention`;
8827
8952
  } else if (!readiness.iosReady && !readiness.androidReady) {
8828
8953
  reason = "No complete iOS or Android toolchain is ready";
8829
8954
  } else {
@@ -9167,6 +9292,13 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
9167
9292
  }));
9168
9293
  }
9169
9294
  var runnerPackage = require_package();
9295
+ function bundledPlaywrightVersion2() {
9296
+ try {
9297
+ return __require("playwright-core/package.json").version;
9298
+ } catch {
9299
+ return void 0;
9300
+ }
9301
+ }
9170
9302
  var MIRROR_RELAY_FRESH_MS = 3e3;
9171
9303
  var liveFrameWatchedByProject = /* @__PURE__ */ new Map();
9172
9304
  var liveFrameIdleProbeAt = /* @__PURE__ */ new Map();
@@ -9911,7 +10043,7 @@ function pruneCollectedPageForPost(page) {
9911
10043
  return out;
9912
10044
  }
9913
10045
  function discoveryRenderTypeFields(input) {
9914
- if (input.err instanceof import_runner_core7.UnsupportedRendererError) {
10046
+ if (input.err instanceof import_runner_core8.UnsupportedRendererError) {
9915
10047
  return { detectedAppRenderType: input.err.renderType, appRenderDetectionSignals: input.err.signals };
9916
10048
  }
9917
10049
  return input.appRenderType ? { detectedAppRenderType: input.appRenderType } : {};
@@ -10092,8 +10224,8 @@ function toLiveBrowserMode(mode) {
10092
10224
  async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = () => {
10093
10225
  }, onMobileHealPending = () => {
10094
10226
  }, assignedDeviceId, mobileLeaseError, signal) {
10095
- if (signal && (0, import_runner_core9.currentRunAbortSignal)() !== signal) {
10096
- return (0, import_runner_core9.runWithAbortScope)({ signal, runId: run2.runId }, () => executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, onMobileHealPending, assignedDeviceId, mobileLeaseError, signal));
10227
+ if (signal && (0, import_runner_core10.currentRunAbortSignal)() !== signal) {
10228
+ return (0, import_runner_core10.runWithAbortScope)({ signal, runId: run2.runId }, () => executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, onMobileHealPending, assignedDeviceId, mobileLeaseError, signal));
10097
10229
  }
10098
10230
  const runMode = run2.mode ?? "run";
10099
10231
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
@@ -10154,7 +10286,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10154
10286
  ...nativeOptions,
10155
10287
  liveBrowserHandle,
10156
10288
  onLiveBrowserSnapshot: requestLiveBrowserHeartbeat,
10157
- signal: (0, import_runner_core9.linkRunAbort)(nativeOptions.signal) ?? signal
10289
+ signal: (0, import_runner_core10.linkRunAbort)(nativeOptions.signal) ?? signal
10158
10290
  });
10159
10291
  const callbackContext = {
10160
10292
  runId: run2.runId,
@@ -10167,7 +10299,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10167
10299
  };
10168
10300
  const startTime = Date.now();
10169
10301
  try {
10170
- (0, import_runner_core9.throwIfRunAborted)();
10302
+ (0, import_runner_core10.throwIfRunAborted)();
10171
10303
  if (mobileLeaseError) {
10172
10304
  await postRunResult({
10173
10305
  status: "ERROR",
@@ -10203,7 +10335,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10203
10335
  const requireCreds = authPlan.needsLoginCreds && !legacyHeaderBypass && !isHallucinationShape && !selfProvisioningRegisterTest;
10204
10336
  if (requireCreds && !run2.testVariables?.["TEST_USER_EMAIL"]?.trim()) {
10205
10337
  preflightError = "Pre-flight failed: Missing TEST_USER_EMAIL. Tests that log in (tagged @auth, @needs-auth, @auth-cookie-state, @auth-login-cookie, or @auth-login-bearer) or read TEST_USER_* in their code require login credentials.";
10206
- } else if (requireCreds && !run2.testVariables?.["TEST_USER_PASSWORD"]?.trim()) {
10338
+ } else if (requireCreds && !(codeProvided && (0, import_inbox_stamp.codeSignsInWithoutPassword)(code)) && !run2.testVariables?.["TEST_USER_PASSWORD"]?.trim()) {
10207
10339
  preflightError = "Pre-flight failed: Missing TEST_USER_PASSWORD. Tests that log in (tagged @auth, @needs-auth, @auth-cookie-state, @auth-login-cookie, or @auth-login-bearer) or read TEST_USER_* in their code require login credentials.";
10208
10340
  }
10209
10341
  }
@@ -10225,7 +10357,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10225
10357
  } else if (preflight.note) {
10226
10358
  log.info(preflight.note);
10227
10359
  }
10228
- tlsCertNote = (0, import_tls_cert_probe.targetCertificateNote)(run2.baseUrl);
10360
+ tlsCertNote = (0, import_tls_cert_probe.targetCertificateNote)(run2.baseUrl, { specCode: run2.playwrightCode });
10229
10361
  void tlsCertNote.then((note) => {
10230
10362
  if (note) log.warn(note);
10231
10363
  });
@@ -10287,7 +10419,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10287
10419
  releaseAuthLock2
10288
10420
  );
10289
10421
  const duration2 = Date.now() - startTime;
10290
- (0, import_runner_core9.throwIfRunAborted)();
10422
+ (0, import_runner_core10.throwIfRunAborted)();
10291
10423
  await postRunResult({
10292
10424
  status: result2.passed ? "PASSED" : "FAILED",
10293
10425
  apiCalls: result2.apiCalls,
@@ -10332,10 +10464,10 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10332
10464
  completionTokens: info.completionTokens,
10333
10465
  totalTokens: info.totalTokens,
10334
10466
  requestBody: { iteration: info.iteration, toolCalls: info.toolCalls, session: info.requestMessages?.[0] ?? null },
10335
- metadata: { phase: "heal", groupId: info.groupId, iteration: info.iteration, healMode: "batch", ...(0, import_runner_core9.cliAuditMetadata)(info) }
10467
+ metadata: { phase: "heal", groupId: info.groupId, iteration: info.iteration, healMode: "batch", ...(0, import_runner_core10.cliAuditMetadata)(info) }
10336
10468
  });
10337
10469
  };
10338
- const batchResult = await (0, import_runner_core9.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
10470
+ const batchResult = await (0, import_runner_core10.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
10339
10471
  onAICall: batchHealAudit,
10340
10472
  testVariables: run2.testVariables,
10341
10473
  runNativeTest: runNativeWithLiveBrowser,
@@ -10344,7 +10476,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10344
10476
  saveRefreshedLogin: grokLogin?.saveRefreshed,
10345
10477
  engineSpec: run2.healerModel,
10346
10478
  // Returns HEAL_TIMEOUT: for the tests not reached instead of dying at the runner's batch-heal deadline.
10347
- deadlineAt: (0, import_runner_core7.batchHealDeadlineAt)(startTime),
10479
+ deadlineAt: (0, import_runner_core8.batchHealDeadlineAt)(startTime),
10348
10480
  onLog: healLogStream.onLog,
10349
10481
  ...batchHealPartialHooks(opts.serverUrl, headers, run2.runId, run2.projectId || void 0, void 0, run2.claimEpoch)
10350
10482
  });
@@ -10354,7 +10486,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10354
10486
  } catch (drainErr) {
10355
10487
  console.warn(`[batch-heal] audit flush failed: ${drainErr instanceof Error ? drainErr.message : String(drainErr)}`);
10356
10488
  }
10357
- (0, import_runner_core9.throwIfRunAborted)();
10489
+ (0, import_runner_core10.throwIfRunAborted)();
10358
10490
  try {
10359
10491
  await postBatchHealResult(opts.serverUrl, headers, run2.runId, {
10360
10492
  perTest: batchResult.perTest,
@@ -10362,7 +10494,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10362
10494
  logs: batchResult.logs,
10363
10495
  durationMs: batchResult.totalDurationMs
10364
10496
  }, RESULT_POST_BACKOFF_BASE_MS, run2.claimEpoch);
10365
- log.ok((0, import_runner_core9.batchHealCompleteLine)(batchResult.perTest, tests.length, batchResult.totalDurationMs));
10497
+ log.ok((0, import_runner_core10.batchHealCompleteLine)(batchResult.perTest, tests.length, batchResult.totalDurationMs));
10366
10498
  } catch (err) {
10367
10499
  log.fail(`Failed to post batch heal result: ${err instanceof Error ? err.message : String(err)}`);
10368
10500
  }
@@ -10395,7 +10527,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
10395
10527
  }, run2.runId) : void 0;
10396
10528
  const apiHealStorageStatePath = apiHealAuthPaths ? (0, import_local_auth_state.getValidLocalAuthState)(apiHealAuthPaths.cachePath) : void 0;
10397
10529
  if (apiHealStorageStatePath) log.info("API heal verification is using saved auth state");
10398
- const apiHealAuthSession = (0, import_runner_core8.sharedAuthSessionStamp)(apiHealStorageStatePath, false, sharedAuthInjectEnabled());
10530
+ const apiHealAuthSession = (0, import_runner_core9.sharedAuthSessionStamp)(apiHealStorageStatePath, false, sharedAuthInjectEnabled());
10399
10531
  const apiHealAuthPlan = authPlanFor(run2.tags, run2.playwrightCode);
10400
10532
  const stickyAppBugs = /* @__PURE__ */ new Map();
10401
10533
  const lastChangeByTitle = /* @__PURE__ */ new Map();
@@ -10512,10 +10644,10 @@ ${finalVerifyResult.logs ?? ""}`;
10512
10644
  }
10513
10645
  if (result3.verdict === "app_bug") {
10514
10646
  const duration4 = Date.now() - startTime;
10515
- const legacyCold = (0, import_runner_core8.apiHealColdSessionRelabel)("legacy", {
10647
+ const legacyCold = (0, import_runner_core9.apiHealColdSessionRelabel)("legacy", {
10516
10648
  tags: run2.tags ?? [],
10517
10649
  authStateExpected: apiHealAuthPlan.needsCookieState,
10518
- authStateInjected: (0, import_runner_core8.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10650
+ authStateInjected: (0, import_runner_core9.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10519
10651
  needsStandingHeaders: apiHealAuthPlan.needsStandingHeaders,
10520
10652
  standingHeadersInjected: !!finalVerifyResult?.authDelivery?.standingHeadersInjected,
10521
10653
  error: result3.bugDescription ?? null,
@@ -10588,10 +10720,14 @@ ${finalVerifyResult.logs ?? ""}`;
10588
10720
  const allAppBug = stepCount > 0 && enrichedSteps.every((s) => s.verdict === "app_bug");
10589
10721
  const hasFixFailed = enrichedSteps.some((s) => s.verdict === "fix_failed");
10590
10722
  const hasPass = enrichedSteps.some((s) => s.verdict === "pass");
10591
- const passed = stepCount > 0 ? finalVerifyResult?.passed === true && hasPass && !hasFixFailed && !allAppBug : finalVerifyResult?.passed === true;
10723
+ const verifyPassed = stepCount > 0 ? finalVerifyResult?.passed === true && hasPass && !hasFixFailed && !allAppBug : finalVerifyResult?.passed === true;
10724
+ const partialSkipError = verifyPassed ? (0, import_runner_core8.apiHealPartialSkipError)(enrichedSteps) : void 0;
10725
+ const passed = verifyPassed && partialSkipError === void 0;
10592
10726
  let composedError;
10593
10727
  if (!passed) {
10594
- if (finalUnfixableReason) {
10728
+ if (partialSkipError !== void 0) {
10729
+ composedError = partialSkipError;
10730
+ } else if (finalUnfixableReason) {
10595
10731
  composedError = `Unfixable after ${attemptsUsed} attempt(s): ${finalUnfixableReason}`;
10596
10732
  } else if (allAppBug || enrichedSteps.some((s) => s.verdict === "app_bug") && !hasFixFailed) {
10597
10733
  const bugs = enrichedSteps.filter((s) => s.verdict === "app_bug");
@@ -10603,11 +10739,21 @@ ${finalVerifyResult.logs ?? ""}`;
10603
10739
  let apiHealStatus = passed ? "PASSED" : "FAILED";
10604
10740
  let apiHealError = composedError;
10605
10741
  if (!passed) {
10606
- if (finalUnfixableReason?.startsWith(import_runner_core7.HEAL_AI_NO_ANSWER_ERROR_PREFIX) || (0, import_runner_core7.isHealAiQuotaError)(finalUnfixableReason)) {
10742
+ if (finalUnfixableReason?.startsWith(import_runner_core8.HEAL_AI_NO_ANSWER_ERROR_PREFIX) || (0, import_runner_core8.isHealAiQuotaError)(finalUnfixableReason)) {
10607
10743
  apiHealStatus = "ERROR";
10608
10744
  apiHealError = finalUnfixableReason;
10609
10745
  } else if (isConfigLikeReason(classifyErrorMessage(composedError ?? null))) {
10610
- apiHealStatus = "ERROR";
10746
+ const configIssueRefusal2 = (0, import_runner_core8.refuseConfigIssueOnAppServerError)({
10747
+ error: composedError,
10748
+ baseUrl: run2.apiBaseUrl ?? run2.baseUrl,
10749
+ calls: (0, import_runner_core8.healResultCalls)({ stepResults: rawSteps })
10750
+ });
10751
+ if (configIssueRefusal2) {
10752
+ apiHealError = configIssueRefusal2.error;
10753
+ log.warn(`\u26A0 API-heal config-issue verdict refused: ${configIssueRefusal2.evidence}`);
10754
+ } else {
10755
+ apiHealStatus = "ERROR";
10756
+ }
10611
10757
  } else {
10612
10758
  const rlDetection = (0, import_rate_limit_detector.detectRateLimitBlock)({
10613
10759
  testName: run2.testName ?? run2.testCaseId ?? void 0,
@@ -10641,10 +10787,10 @@ ${finalVerifyResult.logs ?? ""}`;
10641
10787
  }
10642
10788
  }
10643
10789
  if (apiHealStatus === "FAILED") {
10644
- const cold = (0, import_runner_core8.apiHealColdSessionRelabel)("verified", {
10790
+ const cold = (0, import_runner_core9.apiHealColdSessionRelabel)("verified", {
10645
10791
  tags: run2.tags ?? [],
10646
10792
  authStateExpected: apiHealAuthPlan.needsCookieState,
10647
- authStateInjected: (0, import_runner_core8.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10793
+ authStateInjected: (0, import_runner_core9.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10648
10794
  needsStandingHeaders: apiHealAuthPlan.needsStandingHeaders,
10649
10795
  standingHeadersInjected: !!finalVerifyResult?.authDelivery?.standingHeadersInjected,
10650
10796
  error: apiHealError ?? finalVerifyResult?.error,
@@ -10707,11 +10853,11 @@ ${finalVerifyResult.logs ?? ""}`;
10707
10853
  completionTokens: info.completionTokens,
10708
10854
  totalTokens: info.totalTokens,
10709
10855
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
10710
- metadata: { groupId: info.groupId, iteration: info.iteration, testName: run2.testCaseId, ...(0, import_runner_core9.cliAuditMetadata)(info) }
10856
+ metadata: { groupId: info.groupId, iteration: info.iteration, testName: run2.testCaseId, ...(0, import_runner_core10.cliAuditMetadata)(info) }
10711
10857
  });
10712
10858
  };
10713
10859
  const envOverride = (process.env.USE_GROK_PER_TEST_HEAL ?? "").trim().toLowerCase();
10714
- const useGrokHeal = envOverride === "true" ? true : envOverride === "false" ? false : (0, import_runner_core9.getAgenticEngine)(run2.healerModel) === "grok-build";
10860
+ const useGrokHeal = envOverride === "true" ? true : envOverride === "false" ? false : (0, import_runner_core10.getAgenticEngine)(run2.healerModel) === "grok-build";
10715
10861
  const healOpts = {
10716
10862
  testName: run2.testName ?? run2.testCaseId ?? void 0,
10717
10863
  tags: run2.tags ?? [],
@@ -10734,19 +10880,19 @@ ${finalVerifyResult.logs ?? ""}`;
10734
10880
  // Whole-heal deadline: the runner's heal deadline minus a margin for the result
10735
10881
  // post (heal-deadline.ts). The healer stops both phases inside it and returns a
10736
10882
  // HEAL_TIMEOUT: result instead of dying as a reclaimed task.
10737
- deadlineAt: (0, import_runner_core7.healDeadlineAt)(startTime)
10883
+ deadlineAt: (0, import_runner_core8.healDeadlineAt)(startTime)
10738
10884
  };
10739
10885
  if (useGrokHeal) {
10740
10886
  const source = envOverride === "true" ? "env USE_GROK_PER_TEST_HEAL=true" : `healerModel=${run2.healerModel ?? "null"}`;
10741
10887
  log.info(`Heal engine: GROK (${source})`);
10742
10888
  }
10743
10889
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
10744
- const httpFallback = run2.healerFallbackModel && (0, import_runner_core9.getAgenticEngine)(run2.healerFallbackModel) === "native" ? run2.healerFallbackModel : null;
10890
+ const httpFallback = run2.healerFallbackModel && (0, import_runner_core10.getAgenticEngine)(run2.healerFallbackModel) === "native" ? run2.healerFallbackModel : null;
10745
10891
  let result2;
10746
10892
  if (useGrokHeal) {
10747
10893
  try {
10748
10894
  const grokLogin = await (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: "grok-build", onLog: grokHealLogStream?.onLog });
10749
- result2 = await (0, import_runner_core9.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
10895
+ result2 = await (0, import_runner_core10.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
10750
10896
  ...healOpts,
10751
10897
  subscriptionSecret: grokLogin?.value,
10752
10898
  saveRefreshedLogin: grokLogin?.saveRefreshed,
@@ -10790,17 +10936,20 @@ ${finalVerifyResult.logs ?? ""}`;
10790
10936
  [audit] ${drainErr instanceof Error ? drainErr.message : String(drainErr)}`;
10791
10937
  console.warn(`[runner]${auditDrainError}`);
10792
10938
  }
10793
- const isConfigIssue = !result2.passed && isConfigLikeReason(classifyErrorMessage(result2.error ?? null));
10794
- let healStatus = (0, import_runner_core7.healRunStatus)({ passed: result2.passed, error: result2.error, isConfigIssue });
10795
- let healError = result2.error;
10796
- const rateLimitRelabel = (0, import_runner_core8.uiHealRateLimitRelabel)({
10939
+ const relabelSteps = result2.relabelEvidenceSteps ?? result2.stepResults;
10940
+ const configIssueRefusal = result2.passed ? null : (0, import_runner_core8.refuseConfigIssueOnAppServerError)({ error: result2.error, baseUrl: run2.baseUrl, calls: (0, import_runner_core8.healResultCalls)(result2) });
10941
+ if (configIssueRefusal) log.warn(`\u26A0 Heal config-issue verdict refused: ${configIssueRefusal.evidence}`);
10942
+ const isConfigIssue = !result2.passed && !configIssueRefusal && isConfigLikeReason(classifyErrorMessage(result2.error ?? null));
10943
+ let healStatus = (0, import_runner_core8.healRunStatus)({ passed: result2.passed, error: configIssueRefusal?.error ?? result2.error, isConfigIssue });
10944
+ let healError = configIssueRefusal?.error ?? result2.error;
10945
+ const rateLimitRelabel = (0, import_runner_core9.uiHealRateLimitRelabel)({
10797
10946
  passed: result2.passed,
10798
10947
  isConfigIssue,
10799
10948
  testName: run2.testName ?? run2.testCaseId ?? void 0,
10800
10949
  tags: run2.tags ?? [],
10801
10950
  error: result2.error,
10802
10951
  logs: result2.logs,
10803
- stepResults: result2.stepResults,
10952
+ stepResults: relabelSteps,
10804
10953
  apiCalls: result2.apiCalls
10805
10954
  });
10806
10955
  if (rateLimitRelabel) {
@@ -10808,13 +10957,13 @@ ${finalVerifyResult.logs ?? ""}`;
10808
10957
  healError = rateLimitRelabel.error;
10809
10958
  log.warn(rateLimitRelabel.logLine);
10810
10959
  }
10811
- const loginRelabel = (0, import_runner_core8.uiHealLoginRelabel)({
10960
+ const loginRelabel = (0, import_runner_core9.uiHealLoginRelabel)({
10812
10961
  status: healStatus,
10813
10962
  testName: run2.testName ?? run2.testCaseId ?? void 0,
10814
10963
  tags: run2.tags ?? [],
10815
10964
  error: healError,
10816
10965
  logs: result2.logs,
10817
- stepResults: result2.stepResults
10966
+ stepResults: relabelSteps
10818
10967
  });
10819
10968
  if (loginRelabel) {
10820
10969
  healStatus = loginRelabel.status;
@@ -10883,7 +11032,7 @@ ${finalVerifyResult.logs ?? ""}`;
10883
11032
  signal
10884
11033
  });
10885
11034
  const overall = (0, import_regression_runner.regressionSuiteOverall)(result2);
10886
- (0, import_runner_core9.throwIfRunAborted)();
11035
+ (0, import_runner_core10.throwIfRunAborted)();
10887
11036
  await postRegressionResult(opts.serverUrl, headers, run2.runId, {
10888
11037
  overall,
10889
11038
  logs: result2.logs,
@@ -10913,9 +11062,9 @@ ${finalVerifyResult.logs ?? ""}`;
10913
11062
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
10914
11063
  headers
10915
11064
  );
10916
- const suiteQuotaFallback = (0, import_runner_core7.createQuotaFallbackState)(run2.quotaFallbackModel);
11065
+ const suiteQuotaFallback = (0, import_runner_core8.createQuotaFallbackState)(run2.quotaFallbackModel);
10917
11066
  for (const failedTest of failedTests) {
10918
- (0, import_runner_core9.throwIfRunAborted)();
11067
+ (0, import_runner_core10.throwIfRunAborted)();
10919
11068
  const testStart = Date.now();
10920
11069
  log.info(` Healing: ${failedTest.name ?? failedTest.testCaseId}`);
10921
11070
  const isApiTest = Array.isArray(failedTest.tags) && failedTest.tags.includes("@api");
@@ -10928,7 +11077,7 @@ ${finalVerifyResult.logs ?? ""}`;
10928
11077
  signal: AbortSignal.timeout(6e4)
10929
11078
  });
10930
11079
  const healBody = healRes.ok ? await healRes.json() : null;
10931
- if (healBody?.verdict === "unfixable" && (0, import_runner_core7.isHealAiQuotaError)(healBody.reason)) {
11080
+ if (healBody?.verdict === "unfixable" && (0, import_runner_core8.isHealAiQuotaError)(healBody.reason)) {
10932
11081
  results.push({
10933
11082
  testCaseId: failedTest.testCaseId,
10934
11083
  status: "unfixable",
@@ -10948,15 +11097,16 @@ ${finalVerifyResult.logs ?? ""}`;
10948
11097
  authStandingMode: failedTestPlan.standingHeadersMode,
10949
11098
  validateContext: callbackContext
10950
11099
  });
11100
+ const verdict = (0, import_runner_core8.apiHealVerifyVerdict)(verifyResult);
10951
11101
  results.push({
10952
11102
  testCaseId: failedTest.testCaseId,
10953
- status: verifyResult.passed ? "fixed" : "unfixable",
10954
- error: verifyResult.error,
11103
+ status: verdict.fixed ? "fixed" : "unfixable",
11104
+ error: verdict.error,
10955
11105
  diagnosis: "API test healed via server-side AI",
10956
- healedCode: verifyResult.passed ? healedCode : void 0,
11106
+ healedCode: verdict.fixed ? healedCode : void 0,
10957
11107
  duration: Date.now() - testStart
10958
11108
  });
10959
- if (verifyResult.passed) {
11109
+ if (verdict.fixed) {
10960
11110
  log.ok(` Fixed: ${failedTest.name}`);
10961
11111
  } else {
10962
11112
  log.fail(` Could not fix: ${failedTest.name}`);
@@ -10993,12 +11143,12 @@ ${finalVerifyResult.logs ?? ""}`;
10993
11143
  completionTokens: info.completionTokens,
10994
11144
  totalTokens: info.totalTokens,
10995
11145
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
10996
- metadata: { groupId: info.groupId, testCaseId: failedTest.testCaseId, ...(0, import_runner_core9.cliAuditMetadata)(info) }
11146
+ metadata: { groupId: info.groupId, testCaseId: failedTest.testCaseId, ...(0, import_runner_core10.cliAuditMetadata)(info) }
10997
11147
  });
10998
11148
  };
10999
11149
  try {
11000
11150
  const envOverrideSuite = (process.env.USE_GROK_PER_TEST_HEAL ?? "").trim().toLowerCase();
11001
- const useGrokHealSuite = envOverrideSuite === "true" ? true : envOverrideSuite === "false" ? false : (0, import_runner_core9.getAgenticEngine)(run2.healerModel) === "grok-build";
11151
+ const useGrokHealSuite = envOverrideSuite === "true" ? true : envOverrideSuite === "false" ? false : (0, import_runner_core10.getAgenticEngine)(run2.healerModel) === "grok-build";
11002
11152
  const suiteHealOpts = {
11003
11153
  testName: failedTest.name ?? failedTest.testCaseId,
11004
11154
  tags: failedTest.tags ?? [],
@@ -11020,12 +11170,12 @@ ${finalVerifyResult.logs ?? ""}`;
11020
11170
  liveBrowserHandle,
11021
11171
  runNativeTest: runNativeWithLiveBrowser
11022
11172
  };
11023
- const suiteHttpFallback = run2.healerFallbackModel && (0, import_runner_core9.getAgenticEngine)(run2.healerFallbackModel) === "native" ? run2.healerFallbackModel : null;
11173
+ const suiteHttpFallback = run2.healerFallbackModel && (0, import_runner_core10.getAgenticEngine)(run2.healerFallbackModel) === "native" ? run2.healerFallbackModel : null;
11024
11174
  let healResult;
11025
11175
  if (useGrokHealSuite) {
11026
11176
  try {
11027
11177
  const grokLogin = await (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: "grok-build", onLog: log.warn });
11028
- healResult = await (0, import_runner_core9.executeGrokPerTestHeal)(
11178
+ healResult = await (0, import_runner_core10.executeGrokPerTestHeal)(
11029
11179
  failedTest.playwrightCode,
11030
11180
  failedTest.steps ?? [],
11031
11181
  run2.baseUrl,
@@ -11065,20 +11215,21 @@ ${finalVerifyResult.logs ?? ""}`;
11065
11215
  });
11066
11216
  log.ok(` Fixed: ${failedTest.name}`);
11067
11217
  } else {
11068
- const quotaStopped = (0, import_runner_core7.isHealAiQuotaError)(healResult.error);
11069
- const timeoutStopped = (0, import_runner_core7.isHealTimeoutError)(healResult.error);
11070
- const isAppBug = !quotaStopped && !timeoutStopped && (0, import_runner_core7.isAppBugHealError)(healResult.error);
11071
- const isConfigIssue = isConfigLikeReason(classifyErrorMessage(healResult.error ?? null));
11218
+ const quotaStopped = (0, import_runner_core8.isHealAiQuotaError)(healResult.error);
11219
+ const infraStopped = (0, import_runner_core8.isHealInfraStopError)(healResult.error);
11220
+ const isAppBug = !infraStopped && (0, import_runner_core8.isAppBugHealError)(healResult.error);
11221
+ const configIssueRefusal = infraStopped ? null : (0, import_runner_core8.refuseConfigIssueOnAppServerError)({ error: healResult.error, baseUrl: run2.baseUrl, calls: (0, import_runner_core8.healResultCalls)(healResult) });
11222
+ const isConfigIssue = !configIssueRefusal && isConfigLikeReason(classifyErrorMessage(healResult.error ?? null));
11072
11223
  const status = isAppBug ? "app-bug" : isConfigIssue ? "config-issue" : "unfixable";
11073
- const diagnosisMsg = isAppBug ? "Application error detected" : isConfigIssue ? "Configuration issue detected" : (quotaStopped || timeoutStopped) && healResult.error ? healResult.error : "Could not heal after AI attempts";
11224
+ const diagnosisMsg = isAppBug ? "Application error detected" : isConfigIssue ? "Configuration issue detected" : configIssueRefusal ? configIssueRefusal.error : infraStopped && healResult.error ? healResult.error : "Could not heal after AI attempts";
11074
11225
  results.push({
11075
11226
  testCaseId: failedTest.testCaseId,
11076
11227
  status,
11077
- error: healResult.error,
11228
+ error: configIssueRefusal?.error ?? healResult.error,
11078
11229
  diagnosis: diagnosisMsg,
11079
11230
  duration: Date.now() - testStart
11080
11231
  });
11081
- log[isAppBug || isConfigIssue || quotaStopped || timeoutStopped ? "warn" : "fail"](` ${isAppBug ? "App bug" : isConfigIssue ? "Config issue" : quotaStopped ? "Not healed (AI provider quota)" : timeoutStopped ? "Not healed (heal time budget)" : "Could not fix"}: ${failedTest.name}`);
11232
+ log[isAppBug || isConfigIssue || infraStopped ? "warn" : "fail"](` ${isAppBug ? "App bug" : isConfigIssue ? "Config issue" : quotaStopped ? "Not healed (AI provider quota)" : infraStopped ? `Not healed (${healResult.error.slice(0, healResult.error.indexOf(":"))})` : configIssueRefusal ? `Could not fix (config-issue verdict refused: ${configIssueRefusal.evidence})` : "Could not fix"}: ${failedTest.name}`);
11082
11233
  }
11083
11234
  } catch (err) {
11084
11235
  results.push({
@@ -11091,7 +11242,7 @@ ${finalVerifyResult.logs ?? ""}`;
11091
11242
  log.fail(` Heal crashed: ${failedTest.name}`);
11092
11243
  }
11093
11244
  }
11094
- (0, import_runner_core9.throwIfRunAborted)();
11245
+ (0, import_runner_core10.throwIfRunAborted)();
11095
11246
  const lastResult = results[results.length - 1];
11096
11247
  if (lastResult) {
11097
11248
  fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/live-suite-progress`, {
@@ -11120,7 +11271,7 @@ ${finalVerifyResult.logs ?? ""}`;
11120
11271
  auditDrainWarning = drainErr instanceof Error ? drainErr.message : String(drainErr);
11121
11272
  console.warn(`[runner] ${auditDrainWarning}`);
11122
11273
  }
11123
- (0, import_runner_core9.throwIfRunAborted)();
11274
+ (0, import_runner_core10.throwIfRunAborted)();
11124
11275
  const liveRes = await postLiveSuiteResult(opts.serverUrl, headers, run2.runId, {
11125
11276
  overall,
11126
11277
  duration: totalDuration,
@@ -11295,7 +11446,7 @@ ${finalVerifyResult.logs ?? ""}`;
11295
11446
  }
11296
11447
  return await res.json();
11297
11448
  };
11298
- ctx.smsReserve = (0, import_runner_core9.createHttpSmsReserveTransport)({ serverUrl: opts.serverUrl, headers, runId: run2.runId });
11449
+ ctx.smsReserve = (0, import_runner_core10.createHttpSmsReserveTransport)({ serverUrl: opts.serverUrl, headers, runId: run2.runId });
11299
11450
  }
11300
11451
  const canonicalBase = await (0, import_base_url_check.canonicalDiscoveryBaseUrl)(
11301
11452
  { runMode, baseUrl: ctx.baseUrl, native: ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE" || run2.framework === "APPIUM" }
@@ -11357,7 +11508,7 @@ ${finalVerifyResult.logs ?? ""}`;
11357
11508
  ...info.exitReason ? { exitReason: info.exitReason } : {},
11358
11509
  // Engine cost, engine id, step, cache + reasoning tokens: same helper as the server's
11359
11510
  // Fargate path (execute-run.ts), so the two rows agree.
11360
- ...(0, import_runner_core9.cliAuditMetadata)(info),
11511
+ ...(0, import_runner_core10.cliAuditMetadata)(info),
11361
11512
  ...ledgerAudit ? {
11362
11513
  ledger: ledgerAudit.entries,
11363
11514
  ledgerEntryCount: ledgerAudit.originalLength,
@@ -11392,12 +11543,12 @@ ${finalVerifyResult.logs ?? ""}`;
11392
11543
  }).catch(() => {
11393
11544
  });
11394
11545
  };
11395
- const onScreenshot = async (route2, base64, visual) => {
11546
+ const onScreenshot = async (route2, base64, visual, capture) => {
11396
11547
  try {
11397
11548
  const resp = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/page-screenshot`, {
11398
11549
  method: "POST",
11399
11550
  headers: { ...headers, "Content-Type": "application/json" },
11400
- body: JSON.stringify({ route: route2, base64, ...visual ? { visualName: visual.name } : {} }),
11551
+ body: JSON.stringify({ route: route2, base64, ...visual ? { visualName: visual.name } : {}, ...capture?.stillLoading ? { stillLoading: true } : {} }),
11401
11552
  signal: AbortSignal.timeout(15e3)
11402
11553
  });
11403
11554
  if (!resp.ok) return null;
@@ -11666,7 +11817,7 @@ ${finalVerifyResult.logs ?? ""}`;
11666
11817
  requestLiveBrowserHeartbeat(true);
11667
11818
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
11668
11819
  setExecutorBusy(true);
11669
- let session = null;
11820
+ let session2 = null;
11670
11821
  let capture = null;
11671
11822
  let discoveryTeardownStarted = false;
11672
11823
  try {
@@ -11687,7 +11838,7 @@ ${finalVerifyResult.logs ?? ""}`;
11687
11838
  discoverySessionTarget,
11688
11839
  onDeviceSelected ? { onDeviceSelected } : void 0
11689
11840
  );
11690
- session = await openDiscoverySession(
11841
+ session2 = await openDiscoverySession(
11691
11842
  async (device) => {
11692
11843
  discoverySessionTarget.assignedDeviceId = device.deviceId;
11693
11844
  liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
@@ -11708,18 +11859,18 @@ ${finalVerifyResult.logs ?? ""}`;
11708
11859
  }
11709
11860
  }
11710
11861
  );
11711
- mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
11712
- attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
11862
+ mobileOnLog(`Appium session created: ${session2.sessionId} on device ${session2.deviceId}`);
11863
+ attachMirrorWithRelay(session2.sessionId, platform3, session2.deviceId);
11713
11864
  const driver = new MobileBridgeDriver({
11714
- sessionId: session.sessionId,
11865
+ sessionId: session2.sessionId,
11715
11866
  platform: platform3,
11716
- appId: session.appId,
11717
- deviceId: session.deviceId,
11867
+ appId: session2.appId,
11868
+ deviceId: session2.deviceId,
11718
11869
  // Deterministic-state authorization: Android emulators / iOS
11719
11870
  // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
11720
11871
  // Android default) — a personal physical device must never have
11721
11872
  // its app data wiped.
11722
- allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
11873
+ allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session2.deviceId),
11723
11874
  // Dual-device discovery + long AI turns can lose WDA/UIA2 more than
11724
11875
  // once per run. One recovery was too tight — the next snapshot
11725
11876
  // failed as "no UI tree" after a single WDA death.
@@ -11728,13 +11879,13 @@ ${finalVerifyResult.logs ?? ""}`;
11728
11879
  if (discoveryTeardownStarted) {
11729
11880
  throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
11730
11881
  }
11731
- const lost = session;
11882
+ const lost = session2;
11732
11883
  mobileOnLog(
11733
11884
  `[appium] discovery session lost (${cause.message}); recreating on the leased device`
11734
11885
  );
11735
11886
  if (lost) {
11736
11887
  detachMirrorSession(lost.deviceId);
11737
- session = null;
11888
+ session2 = null;
11738
11889
  try {
11739
11890
  await lost.stop();
11740
11891
  } catch {
@@ -11748,7 +11899,7 @@ ${finalVerifyResult.logs ?? ""}`;
11748
11899
  }
11749
11900
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
11750
11901
  }
11751
- session = replacement;
11902
+ session2 = replacement;
11752
11903
  attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
11753
11904
  liveBrowserHandle.patch({
11754
11905
  deviceId: replacement.deviceId,
@@ -11812,7 +11963,7 @@ ${finalVerifyResult.logs ?? ""}`;
11812
11963
  const apiCalls = stopped;
11813
11964
  const captureHealth = activeCapture.health();
11814
11965
  if (discoveryResult) {
11815
- discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
11966
+ discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session2?.appId ?? mobileTarget.appId) : void 0;
11816
11967
  const priorHealth = discoveryResult.healthObservations;
11817
11968
  const healthObject = {
11818
11969
  consoleErrors: priorHealth?.consoleErrors ?? [],
@@ -11827,9 +11978,9 @@ ${finalVerifyResult.logs ?? ""}`;
11827
11978
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
11828
11979
  }
11829
11980
  }
11830
- if (session) {
11831
- detachMirrorSession(session.deviceId);
11832
- const closing = session;
11981
+ if (session2) {
11982
+ detachMirrorSession(session2.deviceId);
11983
+ const closing = session2;
11833
11984
  try {
11834
11985
  const stopped = await timeboxed(closing.stop());
11835
11986
  if (stopped === TEARDOWN_STEP_TIMEOUT) {
@@ -11902,7 +12053,7 @@ ${line}`)
11902
12053
  };
11903
12054
  } else if (ctx.frameworkImport?.kind === "app-map") {
11904
12055
  log.info("Building the app map.");
11905
- discoveryResult = await (0, import_runner_core9.runAppMapOnRunner)(ctx, { onLog: (line) => log.info(line) });
12056
+ discoveryResult = await (0, import_runner_core10.runAppMapOnRunner)(ctx, { onLog: (line) => log.info(line) });
11906
12057
  } else if (ctx.frameworkImport?.kind === "repo-enrichment") {
11907
12058
  const frameworkImport = ctx.frameworkImport;
11908
12059
  const primaryCli = isRepositoryCli(frameworkImport.repoHuntCli) ? frameworkImport.repoHuntCli : "claude-code";
@@ -11927,7 +12078,7 @@ ${line}`)
11927
12078
  })
11928
12079
  });
11929
12080
  } else {
11930
- const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core9.runRepoAnalysisOnRunner : import_framework_import_runner.runFrameworkImportOnRunner;
12081
+ const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core10.runRepoAnalysisOnRunner : import_framework_import_runner.runFrameworkImportOnRunner;
11931
12082
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
11932
12083
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
11933
12084
  archiveAuthHeader: headers.Authorization,
@@ -11989,7 +12140,7 @@ ${line}`)
11989
12140
  }
11990
12141
  const resolveCliSubscription = (engine, logLine) => (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: engine, onLog: logLine });
11991
12142
  const claimNonce = claimNonceFor(run2.runId);
11992
- savedSignIn = ["survey", "deep", "full"].includes(ctx.mode) && claimNonce && typeof run2.claimEpoch === "number" ? await (0, import_runner_core9.openSavedSignInSession)((0, import_runner_core9.createSavedSignInClient)({
12143
+ savedSignIn = ["survey", "deep", "full"].includes(ctx.mode) && claimNonce && typeof run2.claimEpoch === "number" ? await (0, import_runner_core10.openSavedSignInSession)((0, import_runner_core10.createSavedSignInClient)({
11993
12144
  serverUrl: opts.serverUrl,
11994
12145
  runId: run2.runId,
11995
12146
  claimEpoch: run2.claimEpoch,
@@ -12009,7 +12160,7 @@ ${line}`)
12009
12160
  if (ctx.repoBrief) discoveryResult.repoBrief = ctx.repoBrief;
12010
12161
  if (ctx.repoRouteManifest) discoveryResult.repoRouteManifest = ctx.repoRouteManifest;
12011
12162
  }
12012
- discoveryResult = (0, import_runner_core9.redactDiscoveryCaptureNeedles)(discoveryResult, {
12163
+ discoveryResult = (0, import_runner_core10.redactDiscoveryCaptureNeedles)(discoveryResult, {
12013
12164
  ...run2.testVariables,
12014
12165
  // The saved sign-in's values, with every one it rotated to during the run.
12015
12166
  ...savedSignIn?.loaded ? savedSignIn.secrets() : {}
@@ -12167,7 +12318,7 @@ ${line}`)
12167
12318
  completionTokens: info.completionTokens,
12168
12319
  totalTokens: info.totalTokens,
12169
12320
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
12170
- metadata: { groupId: info.groupId, iteration: info.iteration, phase: "capture", featureName: ctx.featureName, ...(0, import_runner_core9.cliAuditMetadata)(info) }
12321
+ metadata: { groupId: info.groupId, iteration: info.iteration, phase: "capture", featureName: ctx.featureName, ...(0, import_runner_core10.cliAuditMetadata)(info) }
12171
12322
  });
12172
12323
  };
12173
12324
  const logBuffer = [];
@@ -12384,7 +12535,7 @@ ${line}`)
12384
12535
  completionTokens: info.completionTokens,
12385
12536
  totalTokens: info.totalTokens,
12386
12537
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
12387
- metadata: { groupId: info.groupId, iteration: info.iteration, testName: run2.testCaseId, platform: "mobile", ...(0, import_runner_core9.cliAuditMetadata)(info) }
12538
+ metadata: { groupId: info.groupId, iteration: info.iteration, testName: run2.testCaseId, platform: "mobile", ...(0, import_runner_core10.cliAuditMetadata)(info) }
12388
12539
  });
12389
12540
  },
12390
12541
  runLiveHeal: async (a) => {
@@ -12726,7 +12877,7 @@ ${result2.logs}`);
12726
12877
  if (storageStatePath) {
12727
12878
  log.info(`Using auth state for project ${projectId}`);
12728
12879
  }
12729
- const authSession = (0, import_runner_core8.sharedAuthSessionStamp)(storageStatePath, captureAuth, sharedAuthInjectEnabled());
12880
+ const authSession = (0, import_runner_core9.sharedAuthSessionStamp)(storageStatePath, captureAuth, sharedAuthInjectEnabled());
12730
12881
  const runAuthPath = captureAuth ? authPaths?.capturePath : void 0;
12731
12882
  const result = await (async () => {
12732
12883
  try {
@@ -12785,16 +12936,17 @@ ${result2.logs}`);
12785
12936
  runStatus = "ERROR";
12786
12937
  log.warn(`\u26A0 CONFIG_ISSUE from native execution - posting ERROR (not FAILED) to skip heal/quarantine`);
12787
12938
  }
12788
- const authRelabel = (0, import_runner_core8.authReadinessRelabel)({
12939
+ const authRelabel = (0, import_runner_core9.authReadinessRelabel)({
12789
12940
  status: runStatus,
12790
12941
  error: runError,
12791
12942
  testName: run2.testName ?? run2.testCaseId ?? void 0,
12792
12943
  tags: run2.tags ?? [],
12944
+ code: run2.playwrightCode,
12793
12945
  logs: result.logs,
12794
12946
  stepResults: result.stepResults,
12795
12947
  apiCalls: result.apiCalls,
12796
12948
  authStateExpected,
12797
- authStateInjected: (0, import_runner_core8.storageStateDelivered)(storageStatePath, result),
12949
+ authStateInjected: (0, import_runner_core9.storageStateDelivered)(storageStatePath, result),
12798
12950
  needsStandingHeaders: normalRunAuthPlan.needsStandingHeaders,
12799
12951
  standingHeadersInjected: !!result.authDelivery?.standingHeadersInjected
12800
12952
  });
@@ -12803,7 +12955,7 @@ ${result2.logs}`);
12803
12955
  runError = authRelabel.error;
12804
12956
  log.warn(authRelabel.logLine);
12805
12957
  }
12806
- (0, import_runner_core9.throwIfRunAborted)();
12958
+ (0, import_runner_core10.throwIfRunAborted)();
12807
12959
  await postRunResult({
12808
12960
  status: runStatus,
12809
12961
  stepResults: result.stepResults,
@@ -12826,7 +12978,7 @@ ${result2.logs}`);
12826
12978
  if (result.passed) {
12827
12979
  log.ok(`PASSED (${duration}ms)${result.suspiciousPass ? ` \u26A0 suspicious-pass (viewport ${Math.round(result.suspiciousPass.emptyRatio * 100)}% blank)` : ""}`);
12828
12980
  } else if (runStatus === "ERROR") {
12829
- log.warn(`BLOCKED (${duration}ms) - environmental block (rate-limit / config-issue), not counted as failure`);
12981
+ log.warn(blockedRunLogLine(runError, duration));
12830
12982
  } else {
12831
12983
  log.fail(`FAILED (${duration}ms)${result.error ? ` - ${result.error}` : ""}`);
12832
12984
  if (result.logs) {
@@ -12837,14 +12989,14 @@ ${result2.logs}`);
12837
12989
  }
12838
12990
  } catch (err) {
12839
12991
  if (err instanceof RunNoLongerActiveError) throw err;
12840
- const abortOutcome = (0, import_runner_core9.runAbortOutcome)(err, signal);
12992
+ const abortOutcome = (0, import_runner_core10.runAbortOutcome)(err, signal);
12841
12993
  if (abortOutcome === "discard") {
12842
- throw new RunNoLongerActiveError((0, import_runner_core9.isSupersededRunAbort)(signal) ? `Run ${run2.runId} was superseded by a newer claim` : `Run ${run2.runId} was cancelled on the server`);
12994
+ throw new RunNoLongerActiveError((0, import_runner_core10.isSupersededRunAbort)(signal) ? `Run ${run2.runId} was superseded by a newer claim` : `Run ${run2.runId} was cancelled on the server`);
12843
12995
  }
12844
12996
  if (abortOutcome === "interrupt") {
12845
12997
  await postRunResult({
12846
12998
  status: "ERROR",
12847
- error: import_runner_core9.RUNNER_SHUTDOWN_INTERRUPT_ERROR,
12999
+ error: import_runner_core10.RUNNER_SHUTDOWN_INTERRUPT_ERROR,
12848
13000
  duration: Date.now() - startTime
12849
13001
  });
12850
13002
  throw err;
@@ -12961,13 +13113,6 @@ async function startRunner(opts) {
12961
13113
  6e4,
12962
13114
  envInt("VALIDATE_RUNNER_VERSION_CHECK_MS", 15 * 6e4)
12963
13115
  );
12964
- const exitForUpgrade = (latestVersion, installed) => {
12965
- console.error(formatUpgradeInstructions(latestVersion));
12966
- console.error(` Installed: ${installed}`);
12967
- console.error(` Required: ${latestVersion} (or newer)`);
12968
- console.error("");
12969
- process.exit(1);
12970
- };
12971
13116
  let drainingForUpgrade = false;
12972
13117
  const deferUpgradeWhileBusy = () => {
12973
13118
  if (upgradeAction(activeRuns) === "exit") return false;
@@ -13048,9 +13193,11 @@ async function startRunner(opts) {
13048
13193
  await browserPool.init();
13049
13194
  log.ok(`Browser pool: ${poolSize} shared browsers ready`);
13050
13195
  } catch (err) {
13051
- log.fail(`Browser pool failed to init: ${err instanceof Error ? err.message : err}`);
13052
- log.fail("Discovery requires a browser pool. Fix the browser installation or set BROWSER_POOL_SIZE=0 to disable.");
13053
- process.exit(1);
13196
+ const message = err instanceof Error ? err.message : String(err);
13197
+ log.fail(`Browser pool failed to init: ${message}`);
13198
+ const failure = browserPoolInitFailure(message, bundledPlaywrightVersion2());
13199
+ log.fail(failure.advice);
13200
+ process.exit(failure.exitCode);
13054
13201
  }
13055
13202
  }
13056
13203
  const SHUTDOWN_FINISH_MS = Math.max(0, envInt("RUNNER_SHUTDOWN_FINISH_MS", 5e3));
@@ -13322,7 +13469,7 @@ async function startRunner(opts) {
13322
13469
  }
13323
13470
  });
13324
13471
  };
13325
- const removeSupersededRunFetchFence = (0, import_runner_core9.installSupersededRunFetchFence)(serverUrl);
13472
+ const removeSupersededRunFetchFence = (0, import_runner_core10.installSupersededRunFetchFence)(serverUrl);
13326
13473
  const heartbeat = setInterval(sendHeartbeat, 3e4);
13327
13474
  sendHeartbeat();
13328
13475
  let shuttingDown = false;
@@ -13334,7 +13481,7 @@ async function startRunner(opts) {
13334
13481
  headers: { ...headers, "Content-Type": "application/json" },
13335
13482
  body: JSON.stringify(withClaimEpoch({
13336
13483
  status: "ERROR",
13337
- error: import_runner_core9.RUNNER_SHUTDOWN_INTERRUPT_ERROR
13484
+ error: import_runner_core10.RUNNER_SHUTDOWN_INTERRUPT_ERROR
13338
13485
  }, claimEpoch)),
13339
13486
  signal: AbortSignal.timeout(5e3)
13340
13487
  }));
@@ -13350,7 +13497,7 @@ async function startRunner(opts) {
13350
13497
  setTimeout(() => {
13351
13498
  console.error("Shutdown grace elapsed \u2014 forcing exit");
13352
13499
  process.exit(0);
13353
- }, (0, import_runner_core9.shutdownForceExitMs)(SHUTDOWN_FINISH_MS, SHUTDOWN_GRACE_MS)).unref();
13500
+ }, (0, import_runner_core10.shutdownForceExitMs)(SHUTDOWN_FINISH_MS, SHUTDOWN_GRACE_MS)).unref();
13354
13501
  clearInterval(heartbeat);
13355
13502
  heartbeatKeepalive.stop();
13356
13503
  if (liveBrowserHeartbeatTimer) {
@@ -13368,7 +13515,7 @@ async function startRunner(opts) {
13368
13515
  if (claimPending) {
13369
13516
  await Promise.race([claimPending, new Promise((resolve3) => setTimeout(resolve3, SHUTDOWN_FINISH_MS))]);
13370
13517
  }
13371
- await (0, import_runner_core9.drainAbortedRuns)({
13518
+ await (0, import_runner_core10.drainAbortedRuns)({
13372
13519
  finishMs: SHUTDOWN_FINISH_MS,
13373
13520
  graceMs: SHUTDOWN_GRACE_MS,
13374
13521
  inFlight: activeWebRuns,
@@ -13385,7 +13532,7 @@ async function startRunner(opts) {
13385
13532
  await stopAppiumServer();
13386
13533
  }
13387
13534
  await browserPool.shutdown();
13388
- await (0, import_runner_core10.closeLocalGoogleBroker)().catch(() => {
13535
+ await (0, import_runner_core11.closeLocalGoogleBroker)().catch(() => {
13389
13536
  });
13390
13537
  removeSupersededRunFetchFence();
13391
13538
  log.info("Runner stopped.");
@@ -13449,7 +13596,7 @@ async function startRunner(opts) {
13449
13596
  const abortSupersededRun = (runId, status, serverEpoch) => {
13450
13597
  const controller = activeWebRuns.get(runId);
13451
13598
  const already = !controller || controller.signal.aborted;
13452
- if (controller) (0, import_runner_core9.abortRun)(controller, "superseded", runId);
13599
+ if (controller) (0, import_runner_core10.abortRun)(controller, "superseded", runId);
13453
13600
  activeMobileRuns.get(runId)?.triggerAbort(`was superseded by a newer claim (epoch ${serverEpoch ?? "?"}, status ${status})`);
13454
13601
  if (!already) {
13455
13602
  log.warn(`Run ${runId} was superseded by a newer claim (server epoch ${serverEpoch ?? "?"}, ours ${claimedRunEpochs.get(runId) ?? "?"}) \u2014 stopping it; this runner posts nothing more for it.`);
@@ -13465,7 +13612,7 @@ async function startRunner(opts) {
13465
13612
  (runId, status) => {
13466
13613
  const controller = activeWebRuns.get(runId);
13467
13614
  const already = !controller || controller.signal.aborted;
13468
- if (controller) (0, import_runner_core9.abortRun)(controller, "cancelled", runId);
13615
+ if (controller) (0, import_runner_core10.abortRun)(controller, "cancelled", runId);
13469
13616
  activeMobileRuns.get(runId)?.triggerAbort(`was cancelled server-side (status ${status})`);
13470
13617
  if (!already) log.warn(`Run ${runId} was cancelled server-side (status ${status}) \u2014 stopping it.`);
13471
13618
  }
@@ -13665,6 +13812,7 @@ async function startRunner(opts) {
13665
13812
  const applyRelease = () => {
13666
13813
  claimedRunEpochs.delete(run2.runId);
13667
13814
  claimNonces.delete(run2.runId);
13815
+ if (activeWebRuns.get(run2.runId) === runAbort) activeWebRuns.delete(run2.runId);
13668
13816
  activeRuns--;
13669
13817
  decrementType(runMode);
13670
13818
  decrementProject(runProject);
@@ -13723,9 +13871,9 @@ async function startRunner(opts) {
13723
13871
  runWatchdog.start();
13724
13872
  }
13725
13873
  activeWebRuns.set(run2.runId, runAbort);
13726
- const runWritePolicy = (0, import_runner_core9.parseRunWritePolicy)(run2.runWritePolicy);
13727
- const runSignInSecrets = runWritePolicy ? (0, import_runner_core9.signInSecretsOf)({ ...run2.discoveryContext?.variableContext?.allTestVariables ?? {}, ...run2.testVariables ?? {} }) : [];
13728
- (0, import_runner_core9.withRunWritePolicyScope)(runWritePolicy, () => (0, import_runner_core10.withLocalGoogleScope)({ baseUrl: run2.baseUrl, runId: run2.runId }, () => executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
13874
+ const runWritePolicy = (0, import_runner_core10.parseRunWritePolicy)(run2.runWritePolicy);
13875
+ const runSignInSecrets = runWritePolicy ? (0, import_runner_core10.signInSecretsOf)({ ...run2.discoveryContext?.variableContext?.allTestVariables ?? {}, ...run2.testVariables ?? {} }) : [];
13876
+ (0, import_runner_core10.withRunWritePolicyScope)(runWritePolicy, () => (0, import_runner_core11.withLocalGoogleScope)({ baseUrl: run2.baseUrl, runId: run2.runId }, () => executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
13729
13877
  if (mobilePlatform) {
13730
13878
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
13731
13879
  }
@@ -13733,11 +13881,11 @@ async function startRunner(opts) {
13733
13881
  runsCompleted++;
13734
13882
  }).catch((err) => {
13735
13883
  const abortReason = runAbort.signal.reason;
13736
- if ((0, import_runner_core9.isRunAbortError)(abortReason) && abortReason.reason === "shutdown") {
13884
+ if ((0, import_runner_core10.isRunAbortError)(abortReason) && abortReason.reason === "shutdown") {
13737
13885
  log.info(`Run ${run2.runId} was interrupted by runner shutdown.`);
13738
13886
  return;
13739
13887
  }
13740
- if ((0, import_runner_core9.isSupersededRunAbort)(runAbort.signal)) {
13888
+ if ((0, import_runner_core10.isSupersededRunAbort)(runAbort.signal)) {
13741
13889
  log.info(`Run ${run2.runId} was superseded by a newer claim; this runner posted nothing more for it.`);
13742
13890
  return;
13743
13891
  }
@@ -13749,7 +13897,7 @@ async function startRunner(opts) {
13749
13897
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
13750
13898
  }).finally(() => {
13751
13899
  runSettled = true;
13752
- activeWebRuns.delete(run2.runId);
13900
+ if (activeWebRuns.get(run2.runId) === runAbort) activeWebRuns.delete(run2.runId);
13753
13901
  if (runWatchdog) runWatchdog.settle();
13754
13902
  else applyRelease();
13755
13903
  });
@@ -13809,12 +13957,61 @@ async function startRunner(opts) {
13809
13957
  }
13810
13958
  }
13811
13959
 
13960
+ // src/resolve-credentials.ts
13961
+ function sameServer(a, b) {
13962
+ const norm = (u) => u.trim().replace(/\/+$/, "").toLowerCase();
13963
+ return norm(a) === norm(b);
13964
+ }
13965
+ function resolveCredentials(flags, env, saved) {
13966
+ const rawServer = flags.server || env.SERVER_URL || saved?.serverUrl;
13967
+ const serverUrl = rawServer?.replace(/\/+$/, "");
13968
+ const savedApplies = saved !== null && serverUrl !== void 0 && sameServer(serverUrl, saved.serverUrl);
13969
+ return {
13970
+ savedLoginSkipped: saved !== null && !savedApplies,
13971
+ authToken: flags.token || env.AUTH_TOKEN || (savedApplies ? saved.token : void 0),
13972
+ serverUrl,
13973
+ projectId: flags.project || env.PROJECT_ID || (savedApplies ? saved.projectId : void 0)
13974
+ };
13975
+ }
13976
+
13812
13977
  // src/login-check.ts
13813
13978
  var RUNNER_TOKEN_PREFIXES = ["urt_", "rt_"];
13814
13979
  function runnerTokenShapeProblem(token) {
13815
13980
  if (RUNNER_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) return null;
13816
13981
  return "This is not a runner token. Use the runner token (urt_...) from Project Settings \u2192 Local Runner; an API token (pat_...) or a login token cannot run tests.";
13817
13982
  }
13983
+ var SERVER_URL_HINT = "Use the server URL shown in Project Settings \u2192 Local Runner (the dashboard address is not always it).";
13984
+ function loginServerUrlProblem(serverUrl, label = "--server") {
13985
+ let parsed = null;
13986
+ try {
13987
+ parsed = new URL(serverUrl);
13988
+ } catch {
13989
+ parsed = null;
13990
+ }
13991
+ if (parsed && (parsed.protocol === "https:" || parsed.protocol === "http:")) return null;
13992
+ const guess = !parsed && serverUrl && !serverUrl.includes("://") ? ` (did you mean https://${serverUrl}?)` : "";
13993
+ return `${label} needs a full address starting with https:// or http://, got '${serverUrl}'${guess}.`;
13994
+ }
13995
+ function sendsTokenInPlaintext(serverUrl) {
13996
+ let url;
13997
+ try {
13998
+ url = new URL(serverUrl);
13999
+ } catch {
14000
+ return false;
14001
+ }
14002
+ if (url.protocol !== "http:") return false;
14003
+ const host = url.hostname.toLowerCase();
14004
+ const loopback = host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
14005
+ return !loopback;
14006
+ }
14007
+ async function isRunnerHealthAnswer(res) {
14008
+ try {
14009
+ const body = await res.json();
14010
+ return typeof body === "object" && body !== null && !Array.isArray(body) && typeof body.status === "string";
14011
+ } catch {
14012
+ return false;
14013
+ }
14014
+ }
13818
14015
  async function checkRunnerLogin(serverUrl, token, fetchImpl = fetch) {
13819
14016
  let res;
13820
14017
  try {
@@ -13822,17 +14019,388 @@ async function checkRunnerLogin(serverUrl, token, fetchImpl = fetch) {
13822
14019
  } catch {
13823
14020
  return { ok: false, message: `Could not reach ${serverUrl}` };
13824
14021
  }
13825
- if (res.ok) return { ok: true };
14022
+ if (res.ok) {
14023
+ if (await isRunnerHealthAnswer(res)) return { ok: true };
14024
+ return { ok: false, wrongServer: true, message: `${serverUrl} is not a validate.qa server: it answered, but not as the runner API - credentials not saved. Check --server. ${SERVER_URL_HINT}` };
14025
+ }
13826
14026
  if (res.status === 401 || res.status === 403) {
13827
14027
  return { ok: false, message: "Invalid token - check your runner token (it may be revoked, or belong to another project)" };
13828
14028
  }
14029
+ if (res.status === 404 || res.status === 405) {
14030
+ return { ok: false, wrongServer: true, message: `${serverUrl} is not a validate.qa server (HTTP ${res.status} for the runner API) - credentials not saved. Check --server. ${SERVER_URL_HINT}` };
14031
+ }
13829
14032
  return { ok: false, message: `Server returned ${res.status} - credentials not saved, try again later` };
13830
14033
  }
14034
+ function startServerProblem(check2, serverUrl) {
14035
+ if (check2.ok || !check2.wrongServer) return null;
14036
+ return `${serverUrl} is not a validate.qa server: it has no runner API, so the runner would poll it forever. Check --server, SERVER_URL or the saved login. ${SERVER_URL_HINT}`;
14037
+ }
14038
+ var MAX_TOKEN_INPUT = 4096;
14039
+ async function resolveLoginToken(input) {
14040
+ if (input.flag !== void 0 && input.flag !== "-") return { token: input.flag };
14041
+ const raw = input.stdinIsTTY ? await input.promptHidden("Paste the runner token (urt_..., not shown) and press Enter: ") : await input.readPiped();
14042
+ const token = raw.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
14043
+ if (!token) {
14044
+ return { error: "No runner token. Paste it at the prompt, pipe it on stdin, or pass --token <urt_...>." };
14045
+ }
14046
+ return { token };
14047
+ }
14048
+ function readPipedToken(stream, max = MAX_TOKEN_INPUT) {
14049
+ return new Promise((resolve3, reject) => {
14050
+ let text = "";
14051
+ const cleanup = () => {
14052
+ stream.removeListener("data", onData);
14053
+ stream.removeListener("end", onEnd);
14054
+ stream.removeListener("error", onError);
14055
+ };
14056
+ const onData = (chunk) => {
14057
+ text += String(chunk);
14058
+ if (text.length > max) {
14059
+ cleanup();
14060
+ stream.pause();
14061
+ reject(new Error(`more than ${max} characters on stdin: that is not a runner token`));
14062
+ }
14063
+ };
14064
+ const onEnd = () => {
14065
+ cleanup();
14066
+ resolve3(text);
14067
+ };
14068
+ const onError = (err) => {
14069
+ cleanup();
14070
+ reject(err);
14071
+ };
14072
+ stream.on("data", onData);
14073
+ stream.on("end", onEnd);
14074
+ stream.on("error", onError);
14075
+ stream.resume();
14076
+ });
14077
+ }
14078
+ function promptHidden(input, output, question) {
14079
+ return new Promise((resolve3, reject) => {
14080
+ const wasRaw = input.isRaw === true;
14081
+ let line = "";
14082
+ output.write(question);
14083
+ input.setRawMode?.(true);
14084
+ const finish = (err) => {
14085
+ input.removeListener("data", onData);
14086
+ input.setRawMode?.(wasRaw);
14087
+ input.pause();
14088
+ output.write("\n");
14089
+ if (err) reject(err);
14090
+ else resolve3(line);
14091
+ };
14092
+ const onData = (chunk) => {
14093
+ for (const ch of String(chunk)) {
14094
+ if (ch === "\r" || ch === "\n" || ch === "") {
14095
+ finish();
14096
+ return;
14097
+ }
14098
+ if (ch === "") {
14099
+ finish(new Error("cancelled"));
14100
+ return;
14101
+ }
14102
+ if (ch === "\x7F" || ch === "\b") {
14103
+ line = line.slice(0, -1);
14104
+ continue;
14105
+ }
14106
+ if (line.length >= MAX_TOKEN_INPUT) {
14107
+ finish(new Error(`more than ${MAX_TOKEN_INPUT} characters: that is not a runner token`));
14108
+ return;
14109
+ }
14110
+ line += ch;
14111
+ }
14112
+ };
14113
+ input.on("data", onData);
14114
+ input.resume();
14115
+ });
14116
+ }
14117
+
14118
+ // src/status-check.ts
14119
+ var SAVED = "saved credentials";
14120
+ function pick(flag, flagName, envValue, envName, savedValue) {
14121
+ if (flag) return { value: flag, from: flagName };
14122
+ if (envValue) return { value: envValue, from: envName };
14123
+ if (savedValue) return { value: savedValue, from: SAVED };
14124
+ return null;
14125
+ }
14126
+ function resolveStatusCredentials(opts, env, saved) {
14127
+ const server2 = pick(opts.server, "--server", env.SERVER_URL, "SERVER_URL", saved?.serverUrl);
14128
+ const norm = (u) => u.trim().replace(/\/+$/, "").toLowerCase();
14129
+ const savedApplies = !!saved && !!server2 && norm(server2.value) === norm(saved.serverUrl);
14130
+ const token = pick(opts.token, "--token", env.AUTH_TOKEN, "AUTH_TOKEN", savedApplies ? saved?.token : void 0);
14131
+ const project = pick(opts.project, "--project", env.PROJECT_ID, "PROJECT_ID", savedApplies ? saved?.projectId : void 0);
14132
+ if (!token && !server2) {
14133
+ return { missing: "No saved credentials. Run: validate-runner login, or set AUTH_TOKEN and SERVER_URL (or pass --token and --server)" };
14134
+ }
14135
+ if (!token && saved?.token && !savedApplies) {
14136
+ return { missing: `The saved login is for ${saved.serverUrl}, not ${server2?.value}. Run: validate-runner login --server <url>, or set AUTH_TOKEN (or pass --token)` };
14137
+ }
14138
+ if (!token) return { missing: "No runner token. Run: validate-runner login, or set AUTH_TOKEN (or pass --token)" };
14139
+ if (!server2) return { missing: "No server URL. Run: validate-runner login --server <url>, or set SERVER_URL (or pass --server)" };
14140
+ return {
14141
+ serverUrl: server2.value.replace(/\/+$/, ""),
14142
+ token: token.value,
14143
+ ...project ? { projectId: project.value } : {},
14144
+ from: { serverUrl: server2.from, token: token.from, ...project ? { projectId: project.from } : {} }
14145
+ };
14146
+ }
14147
+ var STATUS_OK = 0;
14148
+ var STATUS_FAILED = 1;
14149
+ async function runStatusCheck(resolved, fetchImpl = fetch, out = console) {
14150
+ if (!resolved || "missing" in resolved) {
14151
+ out.error(resolved ? resolved.missing : "No saved credentials. Run: validate-runner login");
14152
+ return STATUS_FAILED;
14153
+ }
14154
+ const saved = resolved;
14155
+ const from = (source) => source ? ` (from ${source})` : "";
14156
+ out.log(`Server: ${saved.serverUrl}${from(saved.from?.serverUrl)}`);
14157
+ out.log(`Project: ${saved.projectId || "(not set)"}${from(saved.projectId ? saved.from?.projectId : void 0)}`);
14158
+ out.log(`Token: ${saved.token.slice(0, 8)}...${saved.token.slice(-4)}${from(saved.from?.token)}`);
14159
+ out.log("");
14160
+ let res;
14161
+ try {
14162
+ res = await fetchImpl(`${saved.serverUrl}/api/runner/health`, {
14163
+ headers: {
14164
+ Authorization: `Bearer ${saved.token}`,
14165
+ ...saved.projectId ? { "X-Project-Id": saved.projectId } : {}
14166
+ }
14167
+ });
14168
+ } catch {
14169
+ out.error("\u2717 Could not reach server");
14170
+ return STATUS_FAILED;
14171
+ }
14172
+ if (res.status === 401 || res.status === 403) {
14173
+ out.error(`\u2717 Server returned ${res.status}: invalid token - check your runner token (it may be revoked, or belong to another project). Run: validate-runner login`);
14174
+ return STATUS_FAILED;
14175
+ }
14176
+ if (!res.ok) {
14177
+ out.error(`\u2717 Server returned ${res.status}`);
14178
+ return STATUS_FAILED;
14179
+ }
14180
+ let data = null;
14181
+ try {
14182
+ data = await res.json();
14183
+ } catch {
14184
+ }
14185
+ if (!data || typeof data.status !== "string") {
14186
+ out.error(`\u2717 Server returned ${res.status} but not a runner health answer - check the server URL`);
14187
+ return STATUS_FAILED;
14188
+ }
14189
+ const runners = typeof data.runners === "number" ? data.runners : 0;
14190
+ out.log(`\u2713 Server reachable - runner status: ${data.status} (${runners} runner(s) online)`);
14191
+ return STATUS_OK;
14192
+ }
13831
14193
 
13832
14194
  // src/services/sign-in-capture.ts
13833
14195
  var import_browser_config3 = __toESM(require_browser_config());
13834
14196
  var import_google_local_signin = __toESM(require_google_local_signin());
13835
14197
  import { chromium } from "playwright";
14198
+
14199
+ // src/services/sign-in-detect.ts
14200
+ init_dist();
14201
+ var SIGN_IN_PATH_SEGMENT = /^(log-?in|log_in|sign-?in|sign_in|sign-?on|sign_on|auth|authenticate|authorize|oauth2?|openid|sso|saml2?|cas|callback|sign-?up|sign_up|register|password|forgot(-password)?|reset(-password)?|verify|mfa|2fa|otp|challenge|consent|signin-oidc|wp-login)$/i;
14202
+ var SIGN_IN_PATH = /\/sessions?\/new(\/|$)/i;
14203
+ var SIGN_OUT_PATH_SEGMENT = /^(log-?out|log_out|sign-?out|sign_out|sign-?off|log-?off|end-?session)$/i;
14204
+ var SIGN_IN_QUERY = /(^|&)((action|mode|view|page|step)=(log-?in|sign-?in|sign_in|register|sign-?up|lostpassword|rp|resetpass)(&|$)|(login|signin)=(fail(ed|ure)?|error|invalid|denied|required|expired|true|1)?(&|$))/i;
14205
+ var SIGN_OUT_PATH = /\/sessions?\/destroy(\/|$)/i;
14206
+ var SIGN_OUT_QUERY = /(^|&)(action|mode)=(log-?out|sign-?out|log-?off)(&|$)/i;
14207
+ var SIGN_IN_HOST_LABEL = /^(auth|login|signin|sso|accounts?|id|identity|idp|oauth|secure-?login)$/i;
14208
+ var AUTH_NAME = /token|auth|session|sess|jwt|sid|refresh|credential|bearer|access|login|user|remember/i;
14209
+ var FLOW_COOKIE = /csrf|xsrf|callback-?url|redirect|return-?to|state|nonce|pkce|code[-_]?verifier/i;
14210
+ var NOT_A_SIGN_IN_COOKIE = /^(_ga.*|_gid|_gat.*|_gcl_.*|_fbp|_fbc|_hj.*|_clck|_clsk|ajs_.*|amplitude.*|mp_.*|_mkto_trk|hubspotutk|__hs.*|__stripe_.*|intercom-.*|optimizely.*|_dd_s|_uetsid|_uetvid|_tt_.*|_pin_unauth|_rdt_uuid|cookieconsent.*|cookie_?consent.*|OptanonConsent|OptanonAlertBoxClosed|euconsent.*|NEXT_LOCALE|locale|lang|theme|cf_clearance|__cf_bm|_cfuvid|__cflb|datadome|_abck|bm_sz|bm_sv|ak_bmsc|reese84|incap_ses_.*|visid_incap_.*|nlbi_.*|_px.*|akavpau_.*|csrftoken|_csrf|XSRF-TOKEN|csrf_token|__Host-csrf.*)$/i;
14211
+ function bareHost(domain) {
14212
+ return domain.trim().toLowerCase().replace(/^\./, "").replace(/\.$/, "");
14213
+ }
14214
+ function onAppSite(hostOrDomain, target) {
14215
+ const host = bareHost(hostOrDomain);
14216
+ const app = target.hostname.toLowerCase();
14217
+ if (!host) return false;
14218
+ if (host === app) return true;
14219
+ const site = registrableDomain(app);
14220
+ return host === site || host.endsWith(`.${site}`);
14221
+ }
14222
+ function isSignInStep(pageUrl, target) {
14223
+ let url;
14224
+ try {
14225
+ url = new URL(pageUrl);
14226
+ } catch {
14227
+ return false;
14228
+ }
14229
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
14230
+ if (!onAppSite(url.hostname, target)) return true;
14231
+ const host = url.hostname.toLowerCase();
14232
+ if (host !== target.hostname.toLowerCase() && SIGN_IN_HOST_LABEL.test(host.split(".")[0] ?? "")) return true;
14233
+ if (SIGN_IN_QUERY.test(url.search.slice(1)) && !SIGN_OUT_QUERY.test(url.search.slice(1))) return true;
14234
+ if (SIGN_IN_PATH.test(url.pathname)) return true;
14235
+ return pathSegments(url).some((segment) => SIGN_IN_PATH_SEGMENT.test(segment));
14236
+ }
14237
+ function pathSegments(url) {
14238
+ const hashPath = /^#!?\//.test(url.hash) ? url.hash.replace(/^#!?/, "").split(/[?#]/)[0] ?? "" : "";
14239
+ return `${url.pathname}/${hashPath}`.split("/").map((segment) => segment.replace(/\.(php|html?|aspx?|jsp|cgi)$/i, ""));
14240
+ }
14241
+ function isSignOutPage(pageUrl) {
14242
+ try {
14243
+ const url = new URL(pageUrl);
14244
+ if (SIGN_OUT_PATH.test(url.pathname) || SIGN_OUT_QUERY.test(url.search.slice(1))) return true;
14245
+ return pathSegments(url).some((segment) => SIGN_OUT_PATH_SEGMENT.test(segment));
14246
+ } catch {
14247
+ return false;
14248
+ }
14249
+ }
14250
+ function isSignOutRequest(request, target) {
14251
+ if (/^(GET|HEAD|OPTIONS)$/i.test(request.method)) return false;
14252
+ let url;
14253
+ try {
14254
+ url = new URL(request.url);
14255
+ } catch {
14256
+ return false;
14257
+ }
14258
+ if (!onAppSite(url.hostname, target)) return false;
14259
+ if (isSignOutPage(url.toString())) return true;
14260
+ return /^DELETE$/i.test(request.method) && /\/(session|sessions\/(current|me)|sign_?in|log_?in)\/?$/i.test(url.pathname);
14261
+ }
14262
+ function isHostedSignIn(url) {
14263
+ const host = url.hostname.toLowerCase();
14264
+ if (host === "identitytoolkit.googleapis.com") return /\/accounts:(signIn|signUp)/i.test(url.pathname);
14265
+ if (host === "securetoken.googleapis.com") return /\/token$/i.test(url.pathname);
14266
+ if (host.endsWith(".supabase.co")) return /^\/auth\/v1\/(token|signup|verify|otp|magiclink)(\/|$)/i.test(url.pathname);
14267
+ return /^cognito-idp\.[a-z0-9-]+\.amazonaws\.com$/.test(host);
14268
+ }
14269
+ function isSignInSubmission(request, target) {
14270
+ if (/^(GET|HEAD|OPTIONS)$/i.test(request.method)) return false;
14271
+ if (!["document", "xhr", "fetch"].includes(request.resourceType)) return false;
14272
+ let url;
14273
+ let page;
14274
+ try {
14275
+ url = new URL(request.url);
14276
+ page = new URL(request.pageUrl);
14277
+ } catch {
14278
+ return false;
14279
+ }
14280
+ if (isSignOutRequest(request, target)) return false;
14281
+ const signInPath = /(^|[/._-])(log-?in|sign-?in|signin|sign_in|auth|oauth2?|token|session|sessions|password|otp|verify|mfa|2fa|challenge|identifier)([/._?-]|$)/i;
14282
+ if (onAppSite(url.hostname, target) && signInPath.test(url.pathname)) return true;
14283
+ if (onAppSite(page.hostname, target) && isHostedSignIn(url)) return true;
14284
+ const firstParty = url.hostname === page.hostname || registrableDomain(url.hostname) === registrableDomain(page.hostname);
14285
+ return firstParty && isSignInStep(request.pageUrl, target);
14286
+ }
14287
+ var LOGIN_PATH_SEGMENT = /^(log-?in|log_in|sign-?in|sign_in|signin-oidc|wp-login|sso|saml2?|oauth2?|authorize|authenticate|auth|passwordless|magic-?link)$/i;
14288
+ function isLoginStep(pageUrl, target) {
14289
+ let url;
14290
+ try {
14291
+ url = new URL(pageUrl);
14292
+ } catch {
14293
+ return false;
14294
+ }
14295
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
14296
+ const host = url.hostname.toLowerCase();
14297
+ if (host !== target.hostname.toLowerCase() && SIGN_IN_HOST_LABEL.test(host.split(".")[0] ?? "")) return true;
14298
+ const query = url.search.slice(1);
14299
+ if (SIGN_IN_QUERY.test(query) && !SIGN_OUT_QUERY.test(query)) return true;
14300
+ if (SIGN_IN_PATH.test(url.pathname)) return true;
14301
+ return pathSegments(url).some((segment) => LOGIN_PATH_SEGMENT.test(segment));
14302
+ }
14303
+ function isOnAppPastSignIn(pageUrl, target) {
14304
+ let url;
14305
+ try {
14306
+ url = new URL(pageUrl);
14307
+ } catch {
14308
+ return false;
14309
+ }
14310
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
14311
+ return onAppSite(url.hostname, target) && !isSignInStep(pageUrl, target);
14312
+ }
14313
+ function cookieKey(cookie) {
14314
+ return `${bareHost(cookie.domain)}\0${cookie.path ?? "/"}\0${cookie.name}`;
14315
+ }
14316
+ function originOnApp(origin, target) {
14317
+ try {
14318
+ return onAppSite(new URL(origin).hostname, target);
14319
+ } catch {
14320
+ return false;
14321
+ }
14322
+ }
14323
+ function indexedDbEntries(databases) {
14324
+ const out = /* @__PURE__ */ new Map();
14325
+ for (const db of databases ?? []) {
14326
+ if (!db || typeof db !== "object") continue;
14327
+ const name = String(db.name ?? "");
14328
+ out.set(name, JSON.stringify(db));
14329
+ }
14330
+ return out;
14331
+ }
14332
+ function gainedAppCredential(baseline, current, target) {
14333
+ const before = new Map(baseline.cookies.map((cookie) => [cookieKey(cookie), cookie.value]));
14334
+ for (const cookie of current.cookies) {
14335
+ if (!cookie.value || !credentialCookie(cookie, target)) continue;
14336
+ if (before.get(cookieKey(cookie)) !== cookie.value) return true;
14337
+ }
14338
+ const storageBefore = /* @__PURE__ */ new Map();
14339
+ for (const origin of baseline.origins) {
14340
+ for (const [kind, entries] of [["l", origin.localStorage], ["s", origin.sessionStorage ?? []]]) {
14341
+ for (const entry of entries) storageBefore.set(`${origin.origin}\0${kind}\0${entry.name}`, entry.value);
14342
+ }
14343
+ for (const [name, value] of indexedDbEntries(origin.indexedDB)) storageBefore.set(`${origin.origin}\0i\0${name}`, value);
14344
+ }
14345
+ for (const origin of current.origins) {
14346
+ if (!originOnApp(origin.origin, target)) continue;
14347
+ for (const [kind, entries] of [["l", origin.localStorage], ["s", origin.sessionStorage ?? []]]) {
14348
+ for (const entry of entries) {
14349
+ if (!entry.value || !AUTH_NAME.test(entry.name) || FLOW_COOKIE.test(entry.name)) continue;
14350
+ if (storageBefore.get(`${origin.origin}\0${kind}\0${entry.name}`) !== entry.value) return true;
14351
+ }
14352
+ }
14353
+ for (const [name, value] of indexedDbEntries(origin.indexedDB)) {
14354
+ if (!AUTH_NAME.test(name) && name !== "firebaseLocalStorageDb") continue;
14355
+ if (storageBefore.get(`${origin.origin}\0i\0${name}`) !== value) return true;
14356
+ }
14357
+ }
14358
+ return false;
14359
+ }
14360
+ var SESSION_NAME = /sess|session|sid|token|jwt|auth|refresh|remember|credential|bearer|^\.aspnetcore\.(cookies|identity)|^_\w+_key$/i;
14361
+ function credentialCookie(cookie, target) {
14362
+ if (!onAppSite(cookie.domain, target) || NOT_A_SIGN_IN_COOKIE.test(cookie.name) || FLOW_COOKIE.test(cookie.name)) return false;
14363
+ return Boolean(cookie.httpOnly) || AUTH_NAME.test(cookie.name);
14364
+ }
14365
+ var session = (cookie) => SESSION_NAME.test(cookie.name) && !NOT_A_SIGN_IN_COOKIE.test(cookie.name) && !FLOW_COOKIE.test(cookie.name);
14366
+ var authStorage = (entry) => AUTH_NAME.test(entry.name) && !FLOW_COOKIE.test(entry.name);
14367
+ var authDatabase = (db) => {
14368
+ const name = String(db?.name ?? "");
14369
+ return AUTH_NAME.test(name) || name === "firebaseLocalStorageDb";
14370
+ };
14371
+ function withoutCredentials(state2, target) {
14372
+ return {
14373
+ cookies: state2.cookies.filter((cookie) => onAppSite(cookie.domain, target) && !session(cookie)),
14374
+ origins: state2.origins.filter((origin) => originOnApp(origin.origin, target)).map((origin) => ({
14375
+ origin: origin.origin,
14376
+ localStorage: origin.localStorage.filter((entry) => !authStorage(entry)),
14377
+ ...origin.indexedDB ? { indexedDB: origin.indexedDB.filter((db) => !authDatabase(db)) } : {}
14378
+ }))
14379
+ };
14380
+ }
14381
+ function droppedCredentials(state2, target) {
14382
+ const out = /* @__PURE__ */ new Set();
14383
+ for (const cookie of state2.cookies) if (onAppSite(cookie.domain, target) && session(cookie)) out.add(`c\0${cookieKey(cookie)}\0${cookie.value}`);
14384
+ for (const origin of state2.origins) {
14385
+ if (!originOnApp(origin.origin, target)) continue;
14386
+ for (const entry of origin.localStorage) if (authStorage(entry)) out.add(`l\0${origin.origin}\0${entry.name}\0${entry.value}`);
14387
+ for (const db of origin.indexedDB ?? []) if (authDatabase(db)) out.add(`i\0${origin.origin}\0${JSON.stringify(db)}`);
14388
+ }
14389
+ return out;
14390
+ }
14391
+ function credentialsKeptAcrossSignIn(before, after, target) {
14392
+ const kept = droppedCredentials(after, target);
14393
+ return [...droppedCredentials(before, target)].every((item) => kept.has(item));
14394
+ }
14395
+ function looksSignedIn(observation, target) {
14396
+ const urls = observation.openPageUrls.filter((url) => /^https?:/i.test(url));
14397
+ if (urls.length === 0 || !observation.submitted) return false;
14398
+ if (urls.some((url) => isSignInStep(url, target))) return false;
14399
+ if (!urls.some((url) => isOnAppPastSignIn(url, target))) return false;
14400
+ return gainedAppCredential(observation.baseline, observation.current, target);
14401
+ }
14402
+
14403
+ // src/services/sign-in-capture.ts
13836
14404
  async function captureSignInInteractively(url, ready, opts = {}) {
13837
14405
  const target = new URL(url);
13838
14406
  let browser;
@@ -13841,47 +14409,316 @@ async function captureSignInInteractively(url, ready, opts = {}) {
13841
14409
  } catch {
13842
14410
  browser = await chromium.launch({ headless: false, ignoreDefaultArgs: ["--enable-automation"], args: [...import_google_local_signin.SIGN_IN_WINDOW_ARGS], env: (0, import_browser_config3.browserLaunchEnv)() });
13843
14411
  }
13844
- return captureFromBrowser(browser, target, ready, opts.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
14412
+ return captureFromBrowser(browser, target, ready, opts.timeoutMs ?? SIGN_IN_TIMEOUT_MS, opts);
13845
14413
  }
13846
14414
  var SIGN_IN_TIMEOUT_MS = 15 * 60 * 1e3;
13847
- async function captureFromBrowser(browser, target, ready, timeoutMs) {
14415
+ var SIGN_IN_SNAPSHOT_INTERVAL_MS = 2e3;
14416
+ var PROVE_RETRY_MS = 1e4;
14417
+ var PROVE_DEADLINE_MS = 25e3;
14418
+ var CLOSE_PROVE_DEADLINE_MS = 3e4;
14419
+ async function readState(context, tabs = () => true, indexedDB = true) {
14420
+ const state2 = await context.storageState({ indexedDB });
14421
+ for (const open of context.pages()) {
14422
+ let origin;
14423
+ try {
14424
+ if (!tabs(open.url())) continue;
14425
+ origin = new URL(open.url()).origin;
14426
+ } catch {
14427
+ continue;
14428
+ }
14429
+ const entries = await open.evaluate(() => {
14430
+ const out = [];
14431
+ for (let i = 0; i < window.sessionStorage.length; i += 1) {
14432
+ const name = window.sessionStorage.key(i);
14433
+ if (name !== null) out.push({ name, value: window.sessionStorage.getItem(name) ?? "" });
14434
+ }
14435
+ return out;
14436
+ }).catch(() => []);
14437
+ if (entries.length === 0) continue;
14438
+ const entry = state2.origins.find((candidate) => candidate.origin === origin);
14439
+ if (entry) entry.sessionStorage = entries;
14440
+ else state2.origins.push({ origin, localStorage: [], sessionStorage: entries });
14441
+ }
14442
+ return state2;
14443
+ }
14444
+ async function readEnv(context) {
14445
+ const page0 = context.pages()[0];
14446
+ return page0 ? await page0.evaluate(() => ({ ua: navigator.userAgent, locale: navigator.language, tz: Intl.DateTimeFormat().resolvedOptions().timeZone })) : { ua: "", locale: "", tz: "" };
14447
+ }
14448
+ function toCapture(state2, env) {
14449
+ return { storageState: JSON.stringify(state2), userAgent: env?.ua ?? "", locale: env?.locale ?? "", timezoneId: env?.tz ?? "" };
14450
+ }
14451
+ function watchSignIn(context, target, opts = {}) {
14452
+ const now = opts.now ?? Date.now;
14453
+ let baseline;
14454
+ let submitted = false;
14455
+ let announced = false;
14456
+ let last;
14457
+ let beforeSubmit;
14458
+ let lastDurable = false;
14459
+ const durableCredential = (state2) => Boolean(baseline) && gainedAppCredential(
14460
+ { cookies: baseline.cookies, origins: baseline.origins.map(({ sessionStorage: _s, ...rest }) => rest) },
14461
+ { cookies: state2.cookies, origins: state2.origins.map(({ sessionStorage: _s, ...rest }) => rest) },
14462
+ target
14463
+ );
14464
+ let env;
14465
+ const indexedDbByOrigin = /* @__PURE__ */ new Map();
14466
+ let inFlight;
14467
+ let queued;
14468
+ let stopped = false;
14469
+ const prove = opts.proveSignedIn ?? proveSignedInHeadless;
14470
+ const aborts = new AbortController();
14471
+ let proving;
14472
+ let proven;
14473
+ let tried;
14474
+ const openUrls = () => context.pages().filter((open) => !open.isClosed()).map((open) => open.url());
14475
+ const signedOut = () => {
14476
+ submitted = false;
14477
+ last = void 0;
14478
+ lastDurable = false;
14479
+ proven = void 0;
14480
+ };
14481
+ const snapshot = async (full) => {
14482
+ if (baseline && openUrls().some((url) => isSignInStep(url, target))) {
14483
+ return;
14484
+ }
14485
+ const state2 = await readState(context, (url) => isOnAppPastSignIn(url, target), full);
14486
+ if (full) {
14487
+ for (const origin of state2.origins) if (origin.indexedDB) indexedDbByOrigin.set(origin.origin, origin.indexedDB);
14488
+ } else {
14489
+ for (const [origin, databases] of indexedDbByOrigin) {
14490
+ const entry = state2.origins.find((candidate) => candidate.origin === origin);
14491
+ if (entry) entry.indexedDB = databases;
14492
+ else state2.origins.push({ origin, localStorage: [], indexedDB: databases });
14493
+ }
14494
+ }
14495
+ if (!env) env = await readEnv(context).catch(() => void 0);
14496
+ if (!baseline) {
14497
+ baseline = state2;
14498
+ beforeSubmit = state2;
14499
+ return;
14500
+ }
14501
+ const openPageUrls = openUrls();
14502
+ if (openPageUrls.some((url) => isSignOutPage(url))) signedOut();
14503
+ if (last && lastDurable && !durableCredential(state2)) signedOut();
14504
+ if (!submitted) beforeSubmit = state2;
14505
+ if (looksSignedIn({ openPageUrls, submitted, baseline, current: state2 }, target)) {
14506
+ const candidate = {
14507
+ capture: toCapture(state2, env),
14508
+ pageUrl: openPageUrls.find((url) => isOnAppPastSignIn(url, target)) ?? target.toString(),
14509
+ ...beforeSubmit ? { signedOut: JSON.stringify(beforeSubmit) } : {}
14510
+ };
14511
+ last = candidate;
14512
+ lastDurable = durableCredential(state2);
14513
+ const key = `${candidate.pageUrl}\0${candidate.capture.storageState}`;
14514
+ if (!announced && !proving && !stopped && !aborts.signal.aborted && tried?.key !== key && (!tried || now() - tried.at >= PROVE_RETRY_MS)) {
14515
+ tried = { key, at: now() };
14516
+ proving = prove(candidate.capture, candidate.pageUrl, target, { signal: aborts.signal, signedOut: candidate.signedOut }).catch(() => false).then((ok) => {
14517
+ proving = void 0;
14518
+ if (!ok || stopped || last === void 0) return;
14519
+ proven = candidate;
14520
+ if (!announced) {
14521
+ announced = true;
14522
+ opts.onLooksSignedIn?.();
14523
+ }
14524
+ });
14525
+ }
14526
+ }
14527
+ };
14528
+ const take = (full = true) => {
14529
+ if (stopped) return;
14530
+ if (inFlight) {
14531
+ queued = queued === "full" || full ? "full" : "light";
14532
+ return;
14533
+ }
14534
+ inFlight = snapshot(full).catch(() => {
14535
+ }).finally(() => {
14536
+ inFlight = void 0;
14537
+ const next = queued;
14538
+ queued = void 0;
14539
+ if (next && !stopped) take(next === "full");
14540
+ });
14541
+ };
14542
+ const watch = (page) => {
14543
+ page.on("framenavigated", (frame) => {
14544
+ if (frame !== page.mainFrame()) return;
14545
+ if (isSignOutPage(page.url())) signedOut();
14546
+ take();
14547
+ });
14548
+ page.on("load", () => take());
14549
+ };
14550
+ context.pages().forEach(watch);
14551
+ context.on("page", watch);
14552
+ context.on("request", (request) => {
14553
+ try {
14554
+ const frame = request.frame();
14555
+ if (frame.parentFrame()) return;
14556
+ const sent = { method: request.method(), resourceType: request.resourceType(), url: request.url(), pageUrl: frame.url() };
14557
+ if (isSignOutRequest(sent, target)) signedOut();
14558
+ else if (baseline && isSignInSubmission(sent, target)) submitted = true;
14559
+ } catch {
14560
+ }
14561
+ });
14562
+ const timer = setInterval(() => take(false), opts.snapshotIntervalMs ?? SIGN_IN_SNAPSHOT_INTERVAL_MS);
14563
+ timer.unref?.();
14564
+ take();
14565
+ return {
14566
+ take,
14567
+ // Wait (briefly) for the read in flight and the one queued behind it, then for a proof in flight
14568
+ // (the prover has its own time limits; stop() aborts it).
14569
+ settle: async () => {
14570
+ const deadline = now() + 5e3;
14571
+ while (inFlight && now() < deadline) {
14572
+ await Promise.race([inFlight, new Promise((resolve3) => setTimeout(resolve3, 250))]);
14573
+ }
14574
+ if (proving) await Promise.race([proving, new Promise((resolve3) => aborts.signal.aborted ? resolve3() : aborts.signal.addEventListener("abort", () => resolve3(), { once: true }))]);
14575
+ },
14576
+ stop: () => {
14577
+ stopped = true;
14578
+ clearInterval(timer);
14579
+ aborts.abort();
14580
+ },
14581
+ // Ends a background proof (and starts none): the close path proves the last state itself.
14582
+ abortProof: () => aborts.abort(),
14583
+ lastSignedIn: () => last,
14584
+ proven: () => proven
14585
+ };
14586
+ }
14587
+ async function captureFromBrowser(browser, target, ready, timeoutMs, opts = {}) {
13848
14588
  let context;
14589
+ let watcher;
13849
14590
  try {
13850
14591
  context = await browser.newContext({ viewport: null });
13851
14592
  const page = await context.newPage();
13852
14593
  await page.goto(target.toString(), { waitUntil: "domcontentloaded" });
13853
- await readyUnlessClosed(ready, whenWindowClosed(browser, context), timeoutMs);
13854
- const state2 = await context.storageState({ indexedDB: true });
13855
- for (const open of context.pages()) {
13856
- let origin;
14594
+ watcher = watchSignIn(context, target, opts);
14595
+ try {
14596
+ await readyUnlessClosed(ready, whenWindowClosed(browser, context), timeoutMs);
14597
+ } catch (error) {
14598
+ const name = error instanceof Error ? error.name : "";
14599
+ if (name !== "TargetClosedError" && name !== "SignInTimeoutError") throw error;
14600
+ watcher.abortProof();
14601
+ watcher.take();
14602
+ await watcher.settle();
14603
+ watcher.stop();
14604
+ const saved = watcher.lastSignedIn();
14605
+ const earlier = watcher.proven();
14606
+ if (!saved) throw error;
14607
+ const prove = opts.proveSignedIn ?? proveSignedInHeadless;
14608
+ const pages = [.../* @__PURE__ */ new Set([saved.pageUrl, ...earlier ? [earlier.pageUrl] : []])];
14609
+ const bound = new AbortController();
14610
+ const timer = setTimeout(() => bound.abort(), opts.closeProveDeadlineMs ?? CLOSE_PROVE_DEADLINE_MS);
14611
+ timer.unref?.();
13857
14612
  try {
13858
- origin = new URL(open.url()).origin;
13859
- } catch {
13860
- continue;
13861
- }
13862
- const entries = await open.evaluate(() => {
13863
- const out = [];
13864
- for (let i = 0; i < window.sessionStorage.length; i += 1) {
13865
- const name = window.sessionStorage.key(i);
13866
- if (name !== null) out.push({ name, value: window.sessionStorage.getItem(name) ?? "" });
14613
+ for (const pageUrl of pages) {
14614
+ if (bound.signal.aborted) break;
14615
+ const ended = new Promise((resolve3) => bound.signal.addEventListener("abort", () => resolve3(false), { once: true }));
14616
+ if (await Promise.race([prove(saved.capture, pageUrl, target, { signal: bound.signal, signedOut: saved.signedOut }).catch(() => false), ended])) {
14617
+ return { ...saved.capture, savedWithoutEnter: true };
14618
+ }
13867
14619
  }
13868
- return out;
13869
- }).catch(() => []);
13870
- if (entries.length === 0) continue;
13871
- const entry = state2.origins.find((candidate) => candidate.origin === origin);
13872
- if (entry) entry.sessionStorage = entries;
13873
- else state2.origins.push({ origin, localStorage: [], sessionStorage: entries });
13874
- }
13875
- const page0 = context.pages()[0];
13876
- const env = page0 ? await page0.evaluate(() => ({ ua: navigator.userAgent, locale: navigator.language, tz: Intl.DateTimeFormat().resolvedOptions().timeZone })) : { ua: "", locale: "", tz: "" };
13877
- return { storageState: JSON.stringify(state2), userAgent: env.ua, locale: env.locale, timezoneId: env.tz };
14620
+ } finally {
14621
+ clearTimeout(timer);
14622
+ }
14623
+ throw error;
14624
+ }
14625
+ watcher.stop();
14626
+ await watcher.settle();
14627
+ try {
14628
+ return toCapture(await readState(context), await readEnv(context));
14629
+ } catch (error) {
14630
+ const saved = watcher.lastSignedIn();
14631
+ if (saved) return saved.capture;
14632
+ throw error;
14633
+ }
13878
14634
  } finally {
14635
+ watcher?.stop();
13879
14636
  await context?.close().catch(() => {
13880
14637
  });
13881
14638
  await browser.close().catch(() => {
13882
14639
  });
13883
14640
  }
13884
14641
  }
14642
+ function provenByProbe(signedIn, signedOut, target, anonymous) {
14643
+ if (isSignInStep(signedIn.url, target) || signedIn.offersSignIn) return false;
14644
+ const signedOutThere = (side) => isLoginStep(side.url, target) || side.offersSignIn;
14645
+ return signedOutThere(signedOut) && (anonymous === void 0 || signedOutThere(anonymous));
14646
+ }
14647
+ var OFFERS_SIGN_IN = `(() => {
14648
+ const shown = (el) => { const box = el.getBoundingClientRect(); return box.width > 0 && box.height > 0; };
14649
+ if ([...document.querySelectorAll('input[type="password"]')].some(shown)) return true;
14650
+ return [...document.querySelectorAll('a, button, [role="button"]')].some((el) => shown(el) && /^\\s*(log ?in|sign ?in|sign in with .+|continue with google)\\s*$/i.test(el.innerText || ''));
14651
+ })()`;
14652
+ var chromeChannelMissing = false;
14653
+ async function launchProver() {
14654
+ if (!chromeChannelMissing) {
14655
+ try {
14656
+ return await chromium.launch({ headless: true, channel: "chrome", env: (0, import_browser_config3.browserLaunchEnv)() });
14657
+ } catch {
14658
+ chromeChannelMissing = true;
14659
+ }
14660
+ }
14661
+ return chromium.launch({ headless: true, env: (0, import_browser_config3.browserLaunchEnv)() });
14662
+ }
14663
+ async function proveSignedInHeadless(capture, pageUrl, target, options = {}) {
14664
+ const ended = new AbortController();
14665
+ const end = () => ended.abort();
14666
+ const timer = setTimeout(end, options.deadlineMs ?? PROVE_DEADLINE_MS);
14667
+ timer.unref?.();
14668
+ options.signal?.addEventListener("abort", end);
14669
+ if (options.signal?.aborted) end();
14670
+ const gone = new Promise((_resolve, reject) => {
14671
+ if (ended.signal.aborted) reject(new Error("proof ended"));
14672
+ else ended.signal.addEventListener("abort", () => reject(new Error("proof ended")), { once: true });
14673
+ });
14674
+ gone.catch(() => {
14675
+ });
14676
+ const live = (work) => Promise.race([work, gone]);
14677
+ let launching;
14678
+ try {
14679
+ if (ended.signal.aborted) return false;
14680
+ launching = (options.launch ?? launchProver)();
14681
+ const browser = await live(launching);
14682
+ const probe = async (storageState) => {
14683
+ const context = await live(browser.newContext({ storageState, ...capture.userAgent ? { userAgent: capture.userAgent } : {} }));
14684
+ try {
14685
+ const page = await live(context.newPage());
14686
+ await live(page.goto(pageUrl, { waitUntil: "domcontentloaded", timeout: 3e4 }));
14687
+ await live(page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
14688
+ }));
14689
+ const offersSignIn = await live(page.evaluate(OFFERS_SIGN_IN)) === true;
14690
+ return { url: page.url(), offersSignIn };
14691
+ } finally {
14692
+ void context.close().catch(() => {
14693
+ });
14694
+ }
14695
+ };
14696
+ const forContext = (state2) => ({ cookies: state2.cookies, origins: state2.origins.map(({ origin, localStorage, indexedDB }) => ({ origin, localStorage, ...indexedDB ? { indexedDB } : {} })) });
14697
+ const after = JSON.parse(capture.storageState);
14698
+ const signedIn = await probe(forContext(after));
14699
+ if (isSignInStep(signedIn.url, target) || signedIn.offersSignIn) return false;
14700
+ const before = options.signedOut ? JSON.parse(options.signedOut) : { cookies: [], origins: [] };
14701
+ const asIs = options.signedOut && !credentialsKeptAcrossSignIn(before, after, target) ? before : void 0;
14702
+ const [signedOut, anonymous] = await Promise.all([
14703
+ probe(forContext(withoutCredentials(before, target))),
14704
+ asIs ? probe(forContext(asIs)) : Promise.resolve(void 0)
14705
+ ]);
14706
+ return provenByProbe(signedIn, signedOut, target, anonymous);
14707
+ } catch {
14708
+ return false;
14709
+ } finally {
14710
+ clearTimeout(timer);
14711
+ options.signal?.removeEventListener("abort", end);
14712
+ end();
14713
+ if (launching) {
14714
+ const browser = await Promise.race([launching.catch(() => void 0), new Promise((resolve3) => setTimeout(resolve3, 1e3))]);
14715
+ if (browser) await Promise.race([browser.close().catch(() => {
14716
+ }), new Promise((resolve3) => setTimeout(resolve3, 5e3))]);
14717
+ else void launching.then((late) => late.close()).catch(() => {
14718
+ });
14719
+ }
14720
+ }
14721
+ }
13885
14722
  function whenWindowClosed(browser, context) {
13886
14723
  return new Promise((resolve3) => {
13887
14724
  const watch = (page) => {
@@ -13940,7 +14777,22 @@ function closedTargetError() {
13940
14777
  error.name = "TargetClosedError";
13941
14778
  return error;
13942
14779
  }
14780
+ var LIVE_SIGN_IN_KEPT_NOTICE = "A sign-in is already saved for this project, and this one was not confirmed with Enter (the window was closed or timed out), so the saved one was kept and nothing was replaced. To replace it, run sign-in again and press Enter once you are signed in.";
13943
14781
  async function uploadSignInCapture(input) {
14782
+ if (!input.capture.savedWithoutEnter) {
14783
+ const sent = await postCapture(input, true);
14784
+ return sent === "live_sign_in_exists" ? { ok: false, message: LIVE_SIGN_IN_KEPT_NOTICE } : sent;
14785
+ }
14786
+ const first = await postCapture(input, false);
14787
+ if (first !== "live_sign_in_exists") return first;
14788
+ if (!input.confirmReplace) return { ok: false, message: LIVE_SIGN_IN_KEPT_NOTICE };
14789
+ if (!await input.confirmReplace().catch(() => false)) {
14790
+ return { ok: true, kept: true, message: "Kept the sign-in already saved. The one from this window was not uploaded." };
14791
+ }
14792
+ const replaced = await postCapture(input, true);
14793
+ return replaced === "live_sign_in_exists" ? { ok: false, message: LIVE_SIGN_IN_KEPT_NOTICE } : replaced;
14794
+ }
14795
+ async function postCapture(input, replace) {
13944
14796
  const doFetch = input.fetchImpl ?? fetch;
13945
14797
  try {
13946
14798
  const res = await doFetch(`${input.serverUrl.replace(/\/+$/, "")}/api/runner/saved-sign-in/capture`, {
@@ -13953,14 +14805,21 @@ async function uploadSignInCapture(input) {
13953
14805
  storageState: input.capture.storageState,
13954
14806
  userAgent: input.capture.userAgent,
13955
14807
  locale: input.capture.locale,
13956
- timezoneId: input.capture.timezoneId
14808
+ timezoneId: input.capture.timezoneId,
14809
+ // Only for a save without Enter: a save after Enter is sent exactly as before.
14810
+ ...replace ? {} : { replaceExisting: false }
13957
14811
  }),
13958
14812
  signal: AbortSignal.timeout(6e4)
13959
14813
  });
13960
14814
  const body = await res.json().catch(() => ({}));
14815
+ if (!replace && res.status === 409 && body.reason === "live_sign_in_exists") return "live_sign_in_exists";
13961
14816
  if (res.ok) return { ok: true, message: "Sign-in saved. Surveys and tests for this project will run signed in." };
13962
14817
  if (res.status === 403) return { ok: false, message: body.error ?? "Only project admins can save a sign-in." };
13963
- if (res.status === 422) return { ok: false, message: "The browser was not signed in to your app (nothing of the app was found). Sign in, then try again." };
14818
+ if (res.status === 422) {
14819
+ if (typeof body.error === "string" && body.error.trim()) return { ok: false, message: `The sign-in was not saved: ${body.error.trim()}.` };
14820
+ return { ok: false, message: "The browser was not signed in to your app (nothing of the app was found). Sign in, then try again." };
14821
+ }
14822
+ if (res.status === 413) return { ok: false, message: "The sign-in is too large to save (the server takes up to 4 MB). Sign in with fewer tabs open, or clear unrelated site data, then try again." };
13964
14823
  return { ok: false, message: `Could not save the sign-in (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}).` };
13965
14824
  } catch {
13966
14825
  return { ok: false, message: "Could not reach the validate.qa server to save the sign-in." };
@@ -14026,6 +14885,43 @@ function shownAddress(url) {
14026
14885
  return oneLine.replace(/^([a-z][a-z0-9+.-]*:\/\/)?\S*@/i, "$1");
14027
14886
  }
14028
14887
 
14888
+ // src/sign-in-url.ts
14889
+ var OPENABLE_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
14890
+ var SIGN_IN_URL_HINT = "--url must be a full address starting with http:// or https://, e.g. https://app.example.com/login or http://localhost:3000.";
14891
+ function signInUrlProblem(raw) {
14892
+ try {
14893
+ const url = new URL(raw);
14894
+ if (OPENABLE_PROTOCOLS.has(url.protocol) && url.hostname) return null;
14895
+ } catch {
14896
+ }
14897
+ return SIGN_IN_URL_HINT;
14898
+ }
14899
+
14900
+ // src/no-credentials-help.ts
14901
+ var RUNNER_TOKEN_SOURCE = "Project Settings \u2192 Local Runner \u2192 Generate Token (urt_...)";
14902
+ function noSignInCredentialsMessage() {
14903
+ return [
14904
+ "\u2717 No runner credentials on this machine yet.",
14905
+ ` Get a runner token (urt_...) in validate.qa: ${RUNNER_TOKEN_SOURCE}.`,
14906
+ " Then either:",
14907
+ " \u2022 sign in in one go: npx @validate.qa/runner sign-in --url <your-app> --token <urt_...> --server <url> --project <id>",
14908
+ " \u2022 or log in once (paste the token at its prompt, which keeps it out of shell history), then sign in:",
14909
+ " npx @validate.qa/runner login --server <url>"
14910
+ ].join("\n");
14911
+ }
14912
+ function noStartTokenMessage() {
14913
+ return [
14914
+ "\u2717 No runner token.",
14915
+ "",
14916
+ "Steps:",
14917
+ ` 1. In validate.qa: ${RUNNER_TOKEN_SOURCE}`,
14918
+ " 2. npx @validate.qa/runner login --server <url> (the server URL printed above or from the dashboard;",
14919
+ " paste the token at its prompt, which keeps it out of shell history)",
14920
+ "",
14921
+ " Or pass --token and --server flags to this start command."
14922
+ ].join("\n");
14923
+ }
14924
+
14029
14925
  // src/supervisor-child-argv.ts
14030
14926
  function stripRunnerTokenFromArgv(argv) {
14031
14927
  const out = [];
@@ -14064,23 +14960,48 @@ program.name("validate-runner").description("validate.qa local test runner - exe
14064
14960
  async function requireLatestRunner() {
14065
14961
  await enforceRunnerUpToDate(pkg.version);
14066
14962
  }
14067
- program.command("login").description("Save runner credentials for future use").requiredOption("--token <token>", "Runner token (urt_... from validate.qa dashboard)").requiredOption("--server <url>", "validate.qa server URL").option("--project <id>", "Project ID (from dashboard URL)").action(async (opts) => {
14963
+ program.command("login").description("Save runner credentials for future use").option("--token <token>", "Runner token (urt_... from validate.qa dashboard). Leave it out to paste it at a prompt that does not show it, or pipe it on stdin: on the command line it lands in shell history").requiredOption("--server <url>", "validate.qa server URL").option("--project <id>", "Project ID (from dashboard URL)").action(async (opts) => {
14068
14964
  await requireLatestRunner();
14069
14965
  const serverUrl = opts.server.replace(/\/+$/, "");
14070
- const shapeProblem = runnerTokenShapeProblem(opts.token);
14966
+ const urlProblem = loginServerUrlProblem(serverUrl);
14967
+ if (urlProblem) {
14968
+ console.error(`\u2717 ${urlProblem}`);
14969
+ process.exit(1);
14970
+ }
14971
+ let resolved;
14972
+ try {
14973
+ resolved = await resolveLoginToken({
14974
+ flag: opts.token,
14975
+ stdinIsTTY: process.stdin.isTTY === true,
14976
+ readPiped: () => readPipedToken(process.stdin),
14977
+ promptHidden: (question) => promptHidden(process.stdin, process.stdout, question)
14978
+ });
14979
+ } catch (err) {
14980
+ console.error(`\u2717 No runner token: ${err instanceof Error ? err.message : String(err)}`);
14981
+ process.exit(1);
14982
+ }
14983
+ if ("error" in resolved) {
14984
+ console.error(`\u2717 ${resolved.error}`);
14985
+ process.exit(1);
14986
+ }
14987
+ const token = resolved.token;
14988
+ const shapeProblem = runnerTokenShapeProblem(token);
14071
14989
  if (shapeProblem) {
14072
14990
  console.error(`\u2717 ${shapeProblem}`);
14073
14991
  process.exit(1);
14074
14992
  }
14993
+ if (sendsTokenInPlaintext(serverUrl)) {
14994
+ console.warn("\u26A0 WARNING: Server URL uses HTTP, not HTTPS. Runner token will be sent in plaintext.");
14995
+ }
14075
14996
  console.log(`Testing connection to ${serverUrl}...`);
14076
- const check2 = await checkRunnerLogin(serverUrl, opts.token);
14997
+ const check2 = await checkRunnerLogin(serverUrl, token);
14077
14998
  if (!check2.ok) {
14078
14999
  console.error(`\u2717 ${check2.message}`);
14079
15000
  process.exit(1);
14080
15001
  }
14081
15002
  console.log("\u2713 Connected successfully");
14082
15003
  saveCredentials({
14083
- token: opts.token,
15004
+ token,
14084
15005
  serverUrl,
14085
15006
  projectId: opts.project
14086
15007
  });
@@ -14165,17 +15086,10 @@ program.command("start").description("Start the local test runner").option("--to
14165
15086
  }
14166
15087
  await requireLatestRunner();
14167
15088
  const saved = loadCredentials();
14168
- const authToken = opts.token || process.env.AUTH_TOKEN || saved?.token;
14169
- const serverUrl = (opts.server || process.env.SERVER_URL || saved?.serverUrl)?.replace(/\/+$/, "");
14170
- const projectId = opts.project || process.env.PROJECT_ID || saved?.projectId;
15089
+ const { authToken, serverUrl, projectId, savedLoginSkipped } = resolveCredentials(opts, process.env, saved);
14171
15090
  if (!authToken) {
14172
- console.error("\u2717 No runner token.");
14173
- console.error("");
14174
- console.error("Steps:");
14175
- console.error(" 1. In validate.qa UI: Project Settings \u2192 Local Runner \u2192 Generate Token (urt_...)");
14176
- console.error(" 2. npx @validate.qa/runner login --token <urt_...> --server <the-server-url-printed-above-or-from-dashboard>");
14177
- console.error("");
14178
- console.error(" Or pass --token and --server flags to this start command.");
15091
+ if (savedLoginSkipped) console.error(`\u2717 The saved login is for ${saved?.serverUrl}, not ${serverUrl}: its token is not sent to another server.`);
15092
+ console.error(noStartTokenMessage());
14179
15093
  process.exit(EXIT_TERMINAL);
14180
15094
  }
14181
15095
  if (!serverUrl) {
@@ -14183,12 +15097,19 @@ program.command("start").description("Start the local test runner").option("--to
14183
15097
  console.error(" Or pass --server <url> to this command");
14184
15098
  process.exit(EXIT_TERMINAL);
14185
15099
  }
15100
+ {
15101
+ const urlProblem = loginServerUrlProblem(serverUrl, opts.server ? "--server" : "The server URL");
15102
+ if (urlProblem) {
15103
+ console.error(`\u2717 ${urlProblem}`);
15104
+ process.exit(EXIT_TERMINAL);
15105
+ }
15106
+ }
14186
15107
  const isPersonalToken = authToken.startsWith("urt_");
14187
15108
  if (!projectId && !isPersonalToken) {
14188
15109
  console.error("\u2717 No project ID. Pass --project <id> or set PROJECT_ID env var");
14189
15110
  process.exit(EXIT_TERMINAL);
14190
15111
  }
14191
- if (serverUrl.startsWith("http://") && !serverUrl.includes("localhost") && !serverUrl.includes("127.0.0.1")) {
15112
+ if (sendsTokenInPlaintext(serverUrl)) {
14192
15113
  console.warn("\u26A0 WARNING: Server URL uses HTTP, not HTTPS. Runner token will be sent in plaintext.");
14193
15114
  console.warn(" Consider using an HTTPS URL to protect credentials in transit.");
14194
15115
  }
@@ -14207,7 +15128,14 @@ program.command("start").description("Start the local test runner").option("--to
14207
15128
  console.warn("\u26A0 --tunnel has no effect without --mobile (the mobile bridge it exposes only starts with --mobile). Ignoring --tunnel.");
14208
15129
  }
14209
15130
  {
14210
- const { hasLocalGoogleLogin, GOOGLE_LOGIN_COMMAND } = await import("./dist-52M5RDZ6.mjs");
15131
+ const serverProblem = startServerProblem(await checkRunnerLogin(serverUrl, authToken), serverUrl);
15132
+ if (serverProblem) {
15133
+ console.error(`\u2717 ${serverProblem}`);
15134
+ process.exit(EXIT_TERMINAL);
15135
+ }
15136
+ }
15137
+ {
15138
+ const { hasLocalGoogleLogin, GOOGLE_LOGIN_COMMAND } = await import("./dist-BXHLIG5H.mjs");
14211
15139
  if (process.stdout.isTTY && !await hasLocalGoogleLogin()) {
14212
15140
  console.log(`Tip: for apps with "Continue with Google", sign in to Google once on this machine: ${GOOGLE_LOGIN_COMMAND}`);
14213
15141
  }
@@ -14237,7 +15165,7 @@ async function askLine(question) {
14237
15165
  }
14238
15166
  }
14239
15167
  program.command("google-login").description('Sign in to a TEST Google account once on this machine, for tests of apps with "Continue with Google"').option("--site <url...>", "The app address(es) this sign-in may be used for, e.g. https://app.example.com").option("--allow-file-storage", "Keep the sign-in in a plain file (only you can read it) when the system keychain is not available").option("--yes", "Confirm without asking that this is a dedicated test Google account").action(async (opts) => {
14240
- const { signInLocalGoogle, LOCAL_GOOGLE_WARNING, grantedSite, defaultStoreKind } = await import("./dist-52M5RDZ6.mjs");
15168
+ const { signInLocalGoogle, LOCAL_GOOGLE_WARNING, grantedSite, defaultStoreKind } = await import("./dist-BXHLIG5H.mjs");
14241
15169
  console.log(LOCAL_GOOGLE_WARNING);
14242
15170
  if (!opts.yes) {
14243
15171
  const answer = await askLine('Is this a dedicated test Google account? Type "yes" to continue: ');
@@ -14272,7 +15200,7 @@ program.command("google-login").description('Sign in to a TEST Google account on
14272
15200
  }
14273
15201
  });
14274
15202
  program.command("google-allow-site <url>").description("Let the Google sign-in on this machine be used for one more app address").action(async (url) => {
14275
- const { allowLocalGoogleSites, grantedSite } = await import("./dist-52M5RDZ6.mjs");
15203
+ const { allowLocalGoogleSites, grantedSite } = await import("./dist-BXHLIG5H.mjs");
14276
15204
  const site = grantedSite(url);
14277
15205
  if (!site) {
14278
15206
  console.error("\u2717 Not a web address: give it like https://app.example.com");
@@ -14286,17 +15214,26 @@ program.command("google-allow-site <url>").description("Let the Google sign-in o
14286
15214
  console.log(`\u2713 Tests on this machine may now use the Google sign-in for: ${sites.join(", ")}`);
14287
15215
  });
14288
15216
  program.command("google-logout").description("Forget the Google sign-in saved on this machine").action(async () => {
14289
- const { deleteLocalGoogleLogin } = await import("./dist-52M5RDZ6.mjs");
15217
+ const { deleteLocalGoogleLogin } = await import("./dist-BXHLIG5H.mjs");
14290
15218
  await deleteLocalGoogleLogin();
14291
15219
  console.log("\u2713 Google sign-in removed from this machine");
14292
15220
  });
14293
15221
  program.command("sign-in").description("Sign in to your app once in a browser window; surveys and tests then run signed in").requiredOption("--url <url>", "Your app's sign-in page or home page").option("--token <token>", "Runner token (overrides saved credentials)").option("--server <url>", "validate.qa server URL (overrides saved credentials)").option("--project <id>", "Project ID (overrides saved credentials)").action(async (opts) => {
14294
15222
  const saved = loadCredentials();
14295
- const token = opts.token || process.env.AUTH_TOKEN || saved?.token;
14296
- const serverUrl = (opts.server || process.env.SERVER_URL || saved?.serverUrl)?.replace(/\/+$/, "");
14297
- const projectId = opts.project || process.env.PROJECT_ID || saved?.projectId;
15223
+ const { authToken: token, serverUrl, projectId, savedLoginSkipped } = resolveCredentials(opts, process.env, saved);
14298
15224
  if (!token || !serverUrl) {
14299
- console.error("\u2717 No runner credentials. Run: npx @validate.qa/runner login --token <urt_...> --server <url>");
15225
+ if (savedLoginSkipped) console.error(`\u2717 The saved login is for ${saved?.serverUrl}, not ${serverUrl}: pass --token for that server.`);
15226
+ console.error(noSignInCredentialsMessage());
15227
+ process.exit(1);
15228
+ }
15229
+ const serverUrlProblem = loginServerUrlProblem(serverUrl, opts.server ? "--server" : "The server URL");
15230
+ if (serverUrlProblem) {
15231
+ console.error(`\u2717 ${serverUrlProblem}`);
15232
+ process.exit(1);
15233
+ }
15234
+ const urlProblem = signInUrlProblem(opts.url);
15235
+ if (urlProblem) {
15236
+ console.error(`\u2717 ${urlProblem}`);
14300
15237
  process.exit(1);
14301
15238
  }
14302
15239
  let appUrl;
@@ -14304,7 +15241,7 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14304
15241
  try {
14305
15242
  appUrl = new URL(opts.url);
14306
15243
  console.log(`Opening ${appUrl.origin} in a browser window.`);
14307
- console.log("Sign in to your app there with a test account. Then come back here and press Enter.");
15244
+ console.log("Sign in to your app there with a test account. Then press Enter here, or just close the window.");
14308
15245
  capture = await captureSignInInteractively(appUrl.toString(), async (signal) => {
14309
15246
  const rl = createInterface({ input: process.stdin, output: process.stdout });
14310
15247
  try {
@@ -14315,6 +15252,9 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14315
15252
  } finally {
14316
15253
  rl.close();
14317
15254
  }
15255
+ }, {
15256
+ // Once: the prompt's line is still open, so the notice gets its own line.
15257
+ onLooksSignedIn: () => process.stdout.write("\n\u2713 Looks like you're signed in. You can press Enter or just close the window.\n")
14318
15258
  });
14319
15259
  } catch (error) {
14320
15260
  const message = describeSignInFailure(error, opts.url);
@@ -14327,43 +15267,23 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14327
15267
  token,
14328
15268
  projectId,
14329
15269
  label: `Signed in on ${appUrl.hostname}`,
14330
- capture
15270
+ capture,
15271
+ // Asked only for a save without Enter that meets a saved sign-in; no terminal keeps the saved one.
15272
+ ...process.stdin.isTTY ? {
15273
+ confirmReplace: async () => /^y(es)?$/i.test(await askLine("A sign-in is already saved for this project. Replace it with the one from this window? [y/N] "))
15274
+ } : {}
14331
15275
  });
14332
- console.log(result.ok ? `\u2713 ${result.message}` : `\u2717 ${result.message}`);
15276
+ console.log(result.kept ? `\u2139 ${result.message}` : result.ok ? `\u2713 ${result.message}` : `\u2717 ${result.message}`);
14333
15277
  process.exit(result.ok ? 0 : 1);
14334
15278
  });
14335
- program.command("status").description("Check connection to validate.qa server").action(async () => {
15279
+ program.command("status").description("Check connection to validate.qa server, with the credentials start would use").option("--token <token>", "Runner token (overrides AUTH_TOKEN and saved credentials)").option("--server <url>", "Server URL (overrides SERVER_URL and saved credentials)").option("--project <id>", "Project ID (overrides PROJECT_ID and saved credentials)").action(async (opts) => {
14336
15280
  await requireLatestRunner();
14337
- const saved = loadCredentials();
14338
- if (!saved) {
14339
- console.log("No saved credentials. Run: validate-runner login");
14340
- return;
14341
- }
14342
- console.log(`Server: ${saved.serverUrl}`);
14343
- console.log(`Project: ${saved.projectId || "(not set)"}`);
14344
- console.log(`Token: ${saved.token.slice(0, 8)}...${saved.token.slice(-4)}`);
14345
- console.log("");
14346
- try {
14347
- const res = await fetch(`${saved.serverUrl}/api/runner/health`, {
14348
- headers: {
14349
- Authorization: `Bearer ${saved.token}`,
14350
- ...saved.projectId ? { "X-Project-Id": saved.projectId } : {}
14351
- }
14352
- });
14353
- if (res.ok) {
14354
- const data = await res.json();
14355
- console.log(`\u2713 Server reachable - runner status: ${data.status} (${data.runners} runner(s) online)`);
14356
- } else {
14357
- console.log(`\u26A0 Server returned ${res.status}`);
14358
- }
14359
- } catch {
14360
- console.error("\u2717 Could not reach server");
14361
- }
15281
+ process.exitCode = await runStatusCheck(resolveStatusCredentials(opts, process.env, loadCredentials()));
14362
15282
  });
14363
- program.command("doctor").description("Check prerequisites for web and mobile testing").action(() => {
15283
+ program.command("doctor").description("Check prerequisites for web testing (and mobile testing with --mobile)").option("--mobile", "Also require a complete iOS or Android toolchain (exit 1 without one)").action((opts) => {
14364
15284
  const report = runDoctor();
14365
- printDoctorReport(report);
14366
- process.exit(report.allPassed ? 0 : 1);
15285
+ printDoctorReport(report, { mobile: opts.mobile === true });
15286
+ process.exit(doctorExitCode(report, { mobile: opts.mobile === true }));
14367
15287
  });
14368
15288
  program.command("setup-mobile").description("Install Appium and the iOS (XCUITest) + Android (UiAutomator2) drivers").action(async () => {
14369
15289
  const { spawn: spawn2 } = await import("child_process");