@validate.qa/runner 1.0.103 → 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-YI3ZC2XI.mjs → chunk-6N4QCCEH.mjs} +2 -2
  3. package/dist/{chunk-CMM4DOXC.mjs → chunk-EBWZ7NGU.mjs} +11 -8
  4. package/dist/{chunk-OTKJZIOD.mjs → chunk-EKY6CN4Y.mjs} +659 -122
  5. package/dist/{chunk-CG75I3ZZ.mjs → chunk-MEE4DXRX.mjs} +13437 -1386
  6. package/dist/{chunk-WVLBQDFP.mjs → chunk-OXDU6ARE.mjs} +8 -2
  7. package/dist/{chunk-AAUHXGDS.mjs → chunk-PUMRAFJS.mjs} +37 -13
  8. package/dist/{chunk-BECCGMU4.mjs → chunk-TGVKABFN.mjs} +45 -3
  9. package/dist/{chunk-YIM746WO.mjs → chunk-TS4OUJVT.mjs} +1 -1
  10. package/dist/{chunk-NNJYKR3O.mjs → chunk-UR7KDOTP.mjs} +29 -6
  11. package/dist/{chunk-36AWDKPL.mjs → chunk-UZ67UBXH.mjs} +14 -3
  12. package/dist/{chunk-7SH3COMQ.mjs → chunk-VWE3SQ2Z.mjs} +1 -1
  13. package/dist/{chunk-TK5KEGNW.mjs → chunk-XXZZMG6Q.mjs} +15 -3
  14. package/dist/cli.js +20962 -7343
  15. package/dist/cli.mjs +1159 -245
  16. package/dist/credential-mcp-server.js +671 -127
  17. package/dist/credential-mcp-server.mjs +3 -3
  18. package/dist/discovery-mcp-server.js +710 -129
  19. package/dist/discovery-mcp-server.mjs +4 -4
  20. package/dist/dist-BXHLIG5H.mjs +17 -0
  21. package/dist/inbox-mcp-server.js +664 -123
  22. package/dist/inbox-mcp-server.mjs +3 -3
  23. package/dist/route-seed-worker.js +671 -124
  24. package/dist/route-seed-worker.mjs +3 -3
  25. package/dist/run-write-guard-preload.js +713 -125
  26. package/dist/run-write-guard-preload.mjs +4 -4
  27. package/dist/run-write-guard.js +713 -125
  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-2G7YTNH5.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-CG75I3ZZ.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-CMM4DOXC.mjs";
45
- import "./chunk-NNJYKR3O.mjs";
46
- import "./chunk-YIM746WO.mjs";
47
- import "./chunk-WVLBQDFP.mjs";
48
- import "./chunk-7SH3COMQ.mjs";
49
- import "./chunk-TK5KEGNW.mjs";
50
- import "./chunk-YI3ZC2XI.mjs";
51
- import "./chunk-BECCGMU4.mjs";
52
- import "./chunk-36AWDKPL.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-OTKJZIOD.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.103",
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/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/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,
@@ -10589,7 +10721,7 @@ ${finalVerifyResult.logs ?? ""}`;
10589
10721
  const hasFixFailed = enrichedSteps.some((s) => s.verdict === "fix_failed");
10590
10722
  const hasPass = enrichedSteps.some((s) => s.verdict === "pass");
10591
10723
  const verifyPassed = stepCount > 0 ? finalVerifyResult?.passed === true && hasPass && !hasFixFailed && !allAppBug : finalVerifyResult?.passed === true;
10592
- const partialSkipError = verifyPassed ? (0, import_runner_core7.apiHealPartialSkipError)(enrichedSteps) : void 0;
10724
+ const partialSkipError = verifyPassed ? (0, import_runner_core8.apiHealPartialSkipError)(enrichedSteps) : void 0;
10593
10725
  const passed = verifyPassed && partialSkipError === void 0;
10594
10726
  let composedError;
10595
10727
  if (!passed) {
@@ -10607,11 +10739,21 @@ ${finalVerifyResult.logs ?? ""}`;
10607
10739
  let apiHealStatus = passed ? "PASSED" : "FAILED";
10608
10740
  let apiHealError = composedError;
10609
10741
  if (!passed) {
10610
- 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)) {
10611
10743
  apiHealStatus = "ERROR";
10612
10744
  apiHealError = finalUnfixableReason;
10613
10745
  } else if (isConfigLikeReason(classifyErrorMessage(composedError ?? null))) {
10614
- 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
+ }
10615
10757
  } else {
10616
10758
  const rlDetection = (0, import_rate_limit_detector.detectRateLimitBlock)({
10617
10759
  testName: run2.testName ?? run2.testCaseId ?? void 0,
@@ -10645,10 +10787,10 @@ ${finalVerifyResult.logs ?? ""}`;
10645
10787
  }
10646
10788
  }
10647
10789
  if (apiHealStatus === "FAILED") {
10648
- const cold = (0, import_runner_core8.apiHealColdSessionRelabel)("verified", {
10790
+ const cold = (0, import_runner_core9.apiHealColdSessionRelabel)("verified", {
10649
10791
  tags: run2.tags ?? [],
10650
10792
  authStateExpected: apiHealAuthPlan.needsCookieState,
10651
- authStateInjected: (0, import_runner_core8.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10793
+ authStateInjected: (0, import_runner_core9.storageStateDelivered)(apiHealStorageStatePath, finalVerifyResult),
10652
10794
  needsStandingHeaders: apiHealAuthPlan.needsStandingHeaders,
10653
10795
  standingHeadersInjected: !!finalVerifyResult?.authDelivery?.standingHeadersInjected,
10654
10796
  error: apiHealError ?? finalVerifyResult?.error,
@@ -10711,11 +10853,11 @@ ${finalVerifyResult.logs ?? ""}`;
10711
10853
  completionTokens: info.completionTokens,
10712
10854
  totalTokens: info.totalTokens,
10713
10855
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
10714
- 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) }
10715
10857
  });
10716
10858
  };
10717
10859
  const envOverride = (process.env.USE_GROK_PER_TEST_HEAL ?? "").trim().toLowerCase();
10718
- 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";
10719
10861
  const healOpts = {
10720
10862
  testName: run2.testName ?? run2.testCaseId ?? void 0,
10721
10863
  tags: run2.tags ?? [],
@@ -10738,19 +10880,19 @@ ${finalVerifyResult.logs ?? ""}`;
10738
10880
  // Whole-heal deadline: the runner's heal deadline minus a margin for the result
10739
10881
  // post (heal-deadline.ts). The healer stops both phases inside it and returns a
10740
10882
  // HEAL_TIMEOUT: result instead of dying as a reclaimed task.
10741
- deadlineAt: (0, import_runner_core7.healDeadlineAt)(startTime)
10883
+ deadlineAt: (0, import_runner_core8.healDeadlineAt)(startTime)
10742
10884
  };
10743
10885
  if (useGrokHeal) {
10744
10886
  const source = envOverride === "true" ? "env USE_GROK_PER_TEST_HEAL=true" : `healerModel=${run2.healerModel ?? "null"}`;
10745
10887
  log.info(`Heal engine: GROK (${source})`);
10746
10888
  }
10747
10889
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
10748
- 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;
10749
10891
  let result2;
10750
10892
  if (useGrokHeal) {
10751
10893
  try {
10752
10894
  const grokLogin = await (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: "grok-build", onLog: grokHealLogStream?.onLog });
10753
- 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, {
10754
10896
  ...healOpts,
10755
10897
  subscriptionSecret: grokLogin?.value,
10756
10898
  saveRefreshedLogin: grokLogin?.saveRefreshed,
@@ -10795,10 +10937,12 @@ ${finalVerifyResult.logs ?? ""}`;
10795
10937
  console.warn(`[runner]${auditDrainError}`);
10796
10938
  }
10797
10939
  const relabelSteps = result2.relabelEvidenceSteps ?? result2.stepResults;
10798
- const isConfigIssue = !result2.passed && isConfigLikeReason(classifyErrorMessage(result2.error ?? null));
10799
- let healStatus = (0, import_runner_core7.healRunStatus)({ passed: result2.passed, error: result2.error, isConfigIssue });
10800
- let healError = result2.error;
10801
- const rateLimitRelabel = (0, import_runner_core8.uiHealRateLimitRelabel)({
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)({
10802
10946
  passed: result2.passed,
10803
10947
  isConfigIssue,
10804
10948
  testName: run2.testName ?? run2.testCaseId ?? void 0,
@@ -10813,7 +10957,7 @@ ${finalVerifyResult.logs ?? ""}`;
10813
10957
  healError = rateLimitRelabel.error;
10814
10958
  log.warn(rateLimitRelabel.logLine);
10815
10959
  }
10816
- const loginRelabel = (0, import_runner_core8.uiHealLoginRelabel)({
10960
+ const loginRelabel = (0, import_runner_core9.uiHealLoginRelabel)({
10817
10961
  status: healStatus,
10818
10962
  testName: run2.testName ?? run2.testCaseId ?? void 0,
10819
10963
  tags: run2.tags ?? [],
@@ -10888,7 +11032,7 @@ ${finalVerifyResult.logs ?? ""}`;
10888
11032
  signal
10889
11033
  });
10890
11034
  const overall = (0, import_regression_runner.regressionSuiteOverall)(result2);
10891
- (0, import_runner_core9.throwIfRunAborted)();
11035
+ (0, import_runner_core10.throwIfRunAborted)();
10892
11036
  await postRegressionResult(opts.serverUrl, headers, run2.runId, {
10893
11037
  overall,
10894
11038
  logs: result2.logs,
@@ -10918,9 +11062,9 @@ ${finalVerifyResult.logs ?? ""}`;
10918
11062
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
10919
11063
  headers
10920
11064
  );
10921
- const suiteQuotaFallback = (0, import_runner_core7.createQuotaFallbackState)(run2.quotaFallbackModel);
11065
+ const suiteQuotaFallback = (0, import_runner_core8.createQuotaFallbackState)(run2.quotaFallbackModel);
10922
11066
  for (const failedTest of failedTests) {
10923
- (0, import_runner_core9.throwIfRunAborted)();
11067
+ (0, import_runner_core10.throwIfRunAborted)();
10924
11068
  const testStart = Date.now();
10925
11069
  log.info(` Healing: ${failedTest.name ?? failedTest.testCaseId}`);
10926
11070
  const isApiTest = Array.isArray(failedTest.tags) && failedTest.tags.includes("@api");
@@ -10933,7 +11077,7 @@ ${finalVerifyResult.logs ?? ""}`;
10933
11077
  signal: AbortSignal.timeout(6e4)
10934
11078
  });
10935
11079
  const healBody = healRes.ok ? await healRes.json() : null;
10936
- if (healBody?.verdict === "unfixable" && (0, import_runner_core7.isHealAiQuotaError)(healBody.reason)) {
11080
+ if (healBody?.verdict === "unfixable" && (0, import_runner_core8.isHealAiQuotaError)(healBody.reason)) {
10937
11081
  results.push({
10938
11082
  testCaseId: failedTest.testCaseId,
10939
11083
  status: "unfixable",
@@ -10953,7 +11097,7 @@ ${finalVerifyResult.logs ?? ""}`;
10953
11097
  authStandingMode: failedTestPlan.standingHeadersMode,
10954
11098
  validateContext: callbackContext
10955
11099
  });
10956
- const verdict = (0, import_runner_core7.apiHealVerifyVerdict)(verifyResult);
11100
+ const verdict = (0, import_runner_core8.apiHealVerifyVerdict)(verifyResult);
10957
11101
  results.push({
10958
11102
  testCaseId: failedTest.testCaseId,
10959
11103
  status: verdict.fixed ? "fixed" : "unfixable",
@@ -10999,12 +11143,12 @@ ${finalVerifyResult.logs ?? ""}`;
10999
11143
  completionTokens: info.completionTokens,
11000
11144
  totalTokens: info.totalTokens,
11001
11145
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
11002
- 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) }
11003
11147
  });
11004
11148
  };
11005
11149
  try {
11006
11150
  const envOverrideSuite = (process.env.USE_GROK_PER_TEST_HEAL ?? "").trim().toLowerCase();
11007
- 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";
11008
11152
  const suiteHealOpts = {
11009
11153
  testName: failedTest.name ?? failedTest.testCaseId,
11010
11154
  tags: failedTest.tags ?? [],
@@ -11026,12 +11170,12 @@ ${finalVerifyResult.logs ?? ""}`;
11026
11170
  liveBrowserHandle,
11027
11171
  runNativeTest: runNativeWithLiveBrowser
11028
11172
  };
11029
- 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;
11030
11174
  let healResult;
11031
11175
  if (useGrokHealSuite) {
11032
11176
  try {
11033
11177
  const grokLogin = await (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: "grok-build", onLog: log.warn });
11034
- healResult = await (0, import_runner_core9.executeGrokPerTestHeal)(
11178
+ healResult = await (0, import_runner_core10.executeGrokPerTestHeal)(
11035
11179
  failedTest.playwrightCode,
11036
11180
  failedTest.steps ?? [],
11037
11181
  run2.baseUrl,
@@ -11071,20 +11215,21 @@ ${finalVerifyResult.logs ?? ""}`;
11071
11215
  });
11072
11216
  log.ok(` Fixed: ${failedTest.name}`);
11073
11217
  } else {
11074
- const quotaStopped = (0, import_runner_core7.isHealAiQuotaError)(healResult.error);
11075
- const timeoutStopped = (0, import_runner_core7.isHealTimeoutError)(healResult.error);
11076
- const isAppBug = !quotaStopped && !timeoutStopped && (0, import_runner_core7.isAppBugHealError)(healResult.error);
11077
- 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));
11078
11223
  const status = isAppBug ? "app-bug" : isConfigIssue ? "config-issue" : "unfixable";
11079
- 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";
11080
11225
  results.push({
11081
11226
  testCaseId: failedTest.testCaseId,
11082
11227
  status,
11083
- error: healResult.error,
11228
+ error: configIssueRefusal?.error ?? healResult.error,
11084
11229
  diagnosis: diagnosisMsg,
11085
11230
  duration: Date.now() - testStart
11086
11231
  });
11087
- 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}`);
11088
11233
  }
11089
11234
  } catch (err) {
11090
11235
  results.push({
@@ -11097,7 +11242,7 @@ ${finalVerifyResult.logs ?? ""}`;
11097
11242
  log.fail(` Heal crashed: ${failedTest.name}`);
11098
11243
  }
11099
11244
  }
11100
- (0, import_runner_core9.throwIfRunAborted)();
11245
+ (0, import_runner_core10.throwIfRunAborted)();
11101
11246
  const lastResult = results[results.length - 1];
11102
11247
  if (lastResult) {
11103
11248
  fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/live-suite-progress`, {
@@ -11126,7 +11271,7 @@ ${finalVerifyResult.logs ?? ""}`;
11126
11271
  auditDrainWarning = drainErr instanceof Error ? drainErr.message : String(drainErr);
11127
11272
  console.warn(`[runner] ${auditDrainWarning}`);
11128
11273
  }
11129
- (0, import_runner_core9.throwIfRunAborted)();
11274
+ (0, import_runner_core10.throwIfRunAborted)();
11130
11275
  const liveRes = await postLiveSuiteResult(opts.serverUrl, headers, run2.runId, {
11131
11276
  overall,
11132
11277
  duration: totalDuration,
@@ -11301,7 +11446,7 @@ ${finalVerifyResult.logs ?? ""}`;
11301
11446
  }
11302
11447
  return await res.json();
11303
11448
  };
11304
- 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 });
11305
11450
  }
11306
11451
  const canonicalBase = await (0, import_base_url_check.canonicalDiscoveryBaseUrl)(
11307
11452
  { runMode, baseUrl: ctx.baseUrl, native: ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE" || run2.framework === "APPIUM" }
@@ -11363,7 +11508,7 @@ ${finalVerifyResult.logs ?? ""}`;
11363
11508
  ...info.exitReason ? { exitReason: info.exitReason } : {},
11364
11509
  // Engine cost, engine id, step, cache + reasoning tokens: same helper as the server's
11365
11510
  // Fargate path (execute-run.ts), so the two rows agree.
11366
- ...(0, import_runner_core9.cliAuditMetadata)(info),
11511
+ ...(0, import_runner_core10.cliAuditMetadata)(info),
11367
11512
  ...ledgerAudit ? {
11368
11513
  ledger: ledgerAudit.entries,
11369
11514
  ledgerEntryCount: ledgerAudit.originalLength,
@@ -11398,12 +11543,12 @@ ${finalVerifyResult.logs ?? ""}`;
11398
11543
  }).catch(() => {
11399
11544
  });
11400
11545
  };
11401
- const onScreenshot = async (route2, base64, visual) => {
11546
+ const onScreenshot = async (route2, base64, visual, capture) => {
11402
11547
  try {
11403
11548
  const resp = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/page-screenshot`, {
11404
11549
  method: "POST",
11405
11550
  headers: { ...headers, "Content-Type": "application/json" },
11406
- 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 } : {} }),
11407
11552
  signal: AbortSignal.timeout(15e3)
11408
11553
  });
11409
11554
  if (!resp.ok) return null;
@@ -11672,7 +11817,7 @@ ${finalVerifyResult.logs ?? ""}`;
11672
11817
  requestLiveBrowserHeartbeat(true);
11673
11818
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
11674
11819
  setExecutorBusy(true);
11675
- let session = null;
11820
+ let session2 = null;
11676
11821
  let capture = null;
11677
11822
  let discoveryTeardownStarted = false;
11678
11823
  try {
@@ -11693,7 +11838,7 @@ ${finalVerifyResult.logs ?? ""}`;
11693
11838
  discoverySessionTarget,
11694
11839
  onDeviceSelected ? { onDeviceSelected } : void 0
11695
11840
  );
11696
- session = await openDiscoverySession(
11841
+ session2 = await openDiscoverySession(
11697
11842
  async (device) => {
11698
11843
  discoverySessionTarget.assignedDeviceId = device.deviceId;
11699
11844
  liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
@@ -11714,18 +11859,18 @@ ${finalVerifyResult.logs ?? ""}`;
11714
11859
  }
11715
11860
  }
11716
11861
  );
11717
- mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
11718
- attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
11862
+ mobileOnLog(`Appium session created: ${session2.sessionId} on device ${session2.deviceId}`);
11863
+ attachMirrorWithRelay(session2.sessionId, platform3, session2.deviceId);
11719
11864
  const driver = new MobileBridgeDriver({
11720
- sessionId: session.sessionId,
11865
+ sessionId: session2.sessionId,
11721
11866
  platform: platform3,
11722
- appId: session.appId,
11723
- deviceId: session.deviceId,
11867
+ appId: session2.appId,
11868
+ deviceId: session2.deviceId,
11724
11869
  // Deterministic-state authorization: Android emulators / iOS
11725
11870
  // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
11726
11871
  // Android default) — a personal physical device must never have
11727
11872
  // its app data wiped.
11728
- allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
11873
+ allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session2.deviceId),
11729
11874
  // Dual-device discovery + long AI turns can lose WDA/UIA2 more than
11730
11875
  // once per run. One recovery was too tight — the next snapshot
11731
11876
  // failed as "no UI tree" after a single WDA death.
@@ -11734,13 +11879,13 @@ ${finalVerifyResult.logs ?? ""}`;
11734
11879
  if (discoveryTeardownStarted) {
11735
11880
  throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
11736
11881
  }
11737
- const lost = session;
11882
+ const lost = session2;
11738
11883
  mobileOnLog(
11739
11884
  `[appium] discovery session lost (${cause.message}); recreating on the leased device`
11740
11885
  );
11741
11886
  if (lost) {
11742
11887
  detachMirrorSession(lost.deviceId);
11743
- session = null;
11888
+ session2 = null;
11744
11889
  try {
11745
11890
  await lost.stop();
11746
11891
  } catch {
@@ -11754,7 +11899,7 @@ ${finalVerifyResult.logs ?? ""}`;
11754
11899
  }
11755
11900
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
11756
11901
  }
11757
- session = replacement;
11902
+ session2 = replacement;
11758
11903
  attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
11759
11904
  liveBrowserHandle.patch({
11760
11905
  deviceId: replacement.deviceId,
@@ -11818,7 +11963,7 @@ ${finalVerifyResult.logs ?? ""}`;
11818
11963
  const apiCalls = stopped;
11819
11964
  const captureHealth = activeCapture.health();
11820
11965
  if (discoveryResult) {
11821
- 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;
11822
11967
  const priorHealth = discoveryResult.healthObservations;
11823
11968
  const healthObject = {
11824
11969
  consoleErrors: priorHealth?.consoleErrors ?? [],
@@ -11833,9 +11978,9 @@ ${finalVerifyResult.logs ?? ""}`;
11833
11978
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
11834
11979
  }
11835
11980
  }
11836
- if (session) {
11837
- detachMirrorSession(session.deviceId);
11838
- const closing = session;
11981
+ if (session2) {
11982
+ detachMirrorSession(session2.deviceId);
11983
+ const closing = session2;
11839
11984
  try {
11840
11985
  const stopped = await timeboxed(closing.stop());
11841
11986
  if (stopped === TEARDOWN_STEP_TIMEOUT) {
@@ -11908,7 +12053,7 @@ ${line}`)
11908
12053
  };
11909
12054
  } else if (ctx.frameworkImport?.kind === "app-map") {
11910
12055
  log.info("Building the app map.");
11911
- 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) });
11912
12057
  } else if (ctx.frameworkImport?.kind === "repo-enrichment") {
11913
12058
  const frameworkImport = ctx.frameworkImport;
11914
12059
  const primaryCli = isRepositoryCli(frameworkImport.repoHuntCli) ? frameworkImport.repoHuntCli : "claude-code";
@@ -11933,7 +12078,7 @@ ${line}`)
11933
12078
  })
11934
12079
  });
11935
12080
  } else {
11936
- 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;
11937
12082
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
11938
12083
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
11939
12084
  archiveAuthHeader: headers.Authorization,
@@ -11995,7 +12140,7 @@ ${line}`)
11995
12140
  }
11996
12141
  const resolveCliSubscription = (engine, logLine) => (0, import_fetch_cli_subscription.fetchCliSubscription)({ serverUrl: opts.serverUrl, runId: run2.runId, headers, cli: engine, onLog: logLine });
11997
12142
  const claimNonce = claimNonceFor(run2.runId);
11998
- 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)({
11999
12144
  serverUrl: opts.serverUrl,
12000
12145
  runId: run2.runId,
12001
12146
  claimEpoch: run2.claimEpoch,
@@ -12015,7 +12160,7 @@ ${line}`)
12015
12160
  if (ctx.repoBrief) discoveryResult.repoBrief = ctx.repoBrief;
12016
12161
  if (ctx.repoRouteManifest) discoveryResult.repoRouteManifest = ctx.repoRouteManifest;
12017
12162
  }
12018
- discoveryResult = (0, import_runner_core9.redactDiscoveryCaptureNeedles)(discoveryResult, {
12163
+ discoveryResult = (0, import_runner_core10.redactDiscoveryCaptureNeedles)(discoveryResult, {
12019
12164
  ...run2.testVariables,
12020
12165
  // The saved sign-in's values, with every one it rotated to during the run.
12021
12166
  ...savedSignIn?.loaded ? savedSignIn.secrets() : {}
@@ -12173,7 +12318,7 @@ ${line}`)
12173
12318
  completionTokens: info.completionTokens,
12174
12319
  totalTokens: info.totalTokens,
12175
12320
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
12176
- 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) }
12177
12322
  });
12178
12323
  };
12179
12324
  const logBuffer = [];
@@ -12390,7 +12535,7 @@ ${line}`)
12390
12535
  completionTokens: info.completionTokens,
12391
12536
  totalTokens: info.totalTokens,
12392
12537
  ...hasTurnDetails ? buildAuditTurnPayload(info, run2.testVariables) : { requestBody: { iteration: info.iteration, toolCalls: info.toolCalls } },
12393
- 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) }
12394
12539
  });
12395
12540
  },
12396
12541
  runLiveHeal: async (a) => {
@@ -12732,7 +12877,7 @@ ${result2.logs}`);
12732
12877
  if (storageStatePath) {
12733
12878
  log.info(`Using auth state for project ${projectId}`);
12734
12879
  }
12735
- const authSession = (0, import_runner_core8.sharedAuthSessionStamp)(storageStatePath, captureAuth, sharedAuthInjectEnabled());
12880
+ const authSession = (0, import_runner_core9.sharedAuthSessionStamp)(storageStatePath, captureAuth, sharedAuthInjectEnabled());
12736
12881
  const runAuthPath = captureAuth ? authPaths?.capturePath : void 0;
12737
12882
  const result = await (async () => {
12738
12883
  try {
@@ -12791,16 +12936,17 @@ ${result2.logs}`);
12791
12936
  runStatus = "ERROR";
12792
12937
  log.warn(`\u26A0 CONFIG_ISSUE from native execution - posting ERROR (not FAILED) to skip heal/quarantine`);
12793
12938
  }
12794
- const authRelabel = (0, import_runner_core8.authReadinessRelabel)({
12939
+ const authRelabel = (0, import_runner_core9.authReadinessRelabel)({
12795
12940
  status: runStatus,
12796
12941
  error: runError,
12797
12942
  testName: run2.testName ?? run2.testCaseId ?? void 0,
12798
12943
  tags: run2.tags ?? [],
12944
+ code: run2.playwrightCode,
12799
12945
  logs: result.logs,
12800
12946
  stepResults: result.stepResults,
12801
12947
  apiCalls: result.apiCalls,
12802
12948
  authStateExpected,
12803
- authStateInjected: (0, import_runner_core8.storageStateDelivered)(storageStatePath, result),
12949
+ authStateInjected: (0, import_runner_core9.storageStateDelivered)(storageStatePath, result),
12804
12950
  needsStandingHeaders: normalRunAuthPlan.needsStandingHeaders,
12805
12951
  standingHeadersInjected: !!result.authDelivery?.standingHeadersInjected
12806
12952
  });
@@ -12809,7 +12955,7 @@ ${result2.logs}`);
12809
12955
  runError = authRelabel.error;
12810
12956
  log.warn(authRelabel.logLine);
12811
12957
  }
12812
- (0, import_runner_core9.throwIfRunAborted)();
12958
+ (0, import_runner_core10.throwIfRunAborted)();
12813
12959
  await postRunResult({
12814
12960
  status: runStatus,
12815
12961
  stepResults: result.stepResults,
@@ -12832,7 +12978,7 @@ ${result2.logs}`);
12832
12978
  if (result.passed) {
12833
12979
  log.ok(`PASSED (${duration}ms)${result.suspiciousPass ? ` \u26A0 suspicious-pass (viewport ${Math.round(result.suspiciousPass.emptyRatio * 100)}% blank)` : ""}`);
12834
12980
  } else if (runStatus === "ERROR") {
12835
- log.warn(`BLOCKED (${duration}ms) - environmental block (rate-limit / config-issue), not counted as failure`);
12981
+ log.warn(blockedRunLogLine(runError, duration));
12836
12982
  } else {
12837
12983
  log.fail(`FAILED (${duration}ms)${result.error ? ` - ${result.error}` : ""}`);
12838
12984
  if (result.logs) {
@@ -12843,14 +12989,14 @@ ${result2.logs}`);
12843
12989
  }
12844
12990
  } catch (err) {
12845
12991
  if (err instanceof RunNoLongerActiveError) throw err;
12846
- const abortOutcome = (0, import_runner_core9.runAbortOutcome)(err, signal);
12992
+ const abortOutcome = (0, import_runner_core10.runAbortOutcome)(err, signal);
12847
12993
  if (abortOutcome === "discard") {
12848
- 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`);
12849
12995
  }
12850
12996
  if (abortOutcome === "interrupt") {
12851
12997
  await postRunResult({
12852
12998
  status: "ERROR",
12853
- error: import_runner_core9.RUNNER_SHUTDOWN_INTERRUPT_ERROR,
12999
+ error: import_runner_core10.RUNNER_SHUTDOWN_INTERRUPT_ERROR,
12854
13000
  duration: Date.now() - startTime
12855
13001
  });
12856
13002
  throw err;
@@ -12967,13 +13113,6 @@ async function startRunner(opts) {
12967
13113
  6e4,
12968
13114
  envInt("VALIDATE_RUNNER_VERSION_CHECK_MS", 15 * 6e4)
12969
13115
  );
12970
- const exitForUpgrade = (latestVersion, installed) => {
12971
- console.error(formatUpgradeInstructions(latestVersion));
12972
- console.error(` Installed: ${installed}`);
12973
- console.error(` Required: ${latestVersion} (or newer)`);
12974
- console.error("");
12975
- process.exit(1);
12976
- };
12977
13116
  let drainingForUpgrade = false;
12978
13117
  const deferUpgradeWhileBusy = () => {
12979
13118
  if (upgradeAction(activeRuns) === "exit") return false;
@@ -13054,9 +13193,11 @@ async function startRunner(opts) {
13054
13193
  await browserPool.init();
13055
13194
  log.ok(`Browser pool: ${poolSize} shared browsers ready`);
13056
13195
  } catch (err) {
13057
- log.fail(`Browser pool failed to init: ${err instanceof Error ? err.message : err}`);
13058
- log.fail("Discovery requires a browser pool. Fix the browser installation or set BROWSER_POOL_SIZE=0 to disable.");
13059
- 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);
13060
13201
  }
13061
13202
  }
13062
13203
  const SHUTDOWN_FINISH_MS = Math.max(0, envInt("RUNNER_SHUTDOWN_FINISH_MS", 5e3));
@@ -13328,7 +13469,7 @@ async function startRunner(opts) {
13328
13469
  }
13329
13470
  });
13330
13471
  };
13331
- const removeSupersededRunFetchFence = (0, import_runner_core9.installSupersededRunFetchFence)(serverUrl);
13472
+ const removeSupersededRunFetchFence = (0, import_runner_core10.installSupersededRunFetchFence)(serverUrl);
13332
13473
  const heartbeat = setInterval(sendHeartbeat, 3e4);
13333
13474
  sendHeartbeat();
13334
13475
  let shuttingDown = false;
@@ -13340,7 +13481,7 @@ async function startRunner(opts) {
13340
13481
  headers: { ...headers, "Content-Type": "application/json" },
13341
13482
  body: JSON.stringify(withClaimEpoch({
13342
13483
  status: "ERROR",
13343
- error: import_runner_core9.RUNNER_SHUTDOWN_INTERRUPT_ERROR
13484
+ error: import_runner_core10.RUNNER_SHUTDOWN_INTERRUPT_ERROR
13344
13485
  }, claimEpoch)),
13345
13486
  signal: AbortSignal.timeout(5e3)
13346
13487
  }));
@@ -13356,7 +13497,7 @@ async function startRunner(opts) {
13356
13497
  setTimeout(() => {
13357
13498
  console.error("Shutdown grace elapsed \u2014 forcing exit");
13358
13499
  process.exit(0);
13359
- }, (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();
13360
13501
  clearInterval(heartbeat);
13361
13502
  heartbeatKeepalive.stop();
13362
13503
  if (liveBrowserHeartbeatTimer) {
@@ -13374,7 +13515,7 @@ async function startRunner(opts) {
13374
13515
  if (claimPending) {
13375
13516
  await Promise.race([claimPending, new Promise((resolve3) => setTimeout(resolve3, SHUTDOWN_FINISH_MS))]);
13376
13517
  }
13377
- await (0, import_runner_core9.drainAbortedRuns)({
13518
+ await (0, import_runner_core10.drainAbortedRuns)({
13378
13519
  finishMs: SHUTDOWN_FINISH_MS,
13379
13520
  graceMs: SHUTDOWN_GRACE_MS,
13380
13521
  inFlight: activeWebRuns,
@@ -13391,7 +13532,7 @@ async function startRunner(opts) {
13391
13532
  await stopAppiumServer();
13392
13533
  }
13393
13534
  await browserPool.shutdown();
13394
- await (0, import_runner_core10.closeLocalGoogleBroker)().catch(() => {
13535
+ await (0, import_runner_core11.closeLocalGoogleBroker)().catch(() => {
13395
13536
  });
13396
13537
  removeSupersededRunFetchFence();
13397
13538
  log.info("Runner stopped.");
@@ -13455,7 +13596,7 @@ async function startRunner(opts) {
13455
13596
  const abortSupersededRun = (runId, status, serverEpoch) => {
13456
13597
  const controller = activeWebRuns.get(runId);
13457
13598
  const already = !controller || controller.signal.aborted;
13458
- if (controller) (0, import_runner_core9.abortRun)(controller, "superseded", runId);
13599
+ if (controller) (0, import_runner_core10.abortRun)(controller, "superseded", runId);
13459
13600
  activeMobileRuns.get(runId)?.triggerAbort(`was superseded by a newer claim (epoch ${serverEpoch ?? "?"}, status ${status})`);
13460
13601
  if (!already) {
13461
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.`);
@@ -13471,7 +13612,7 @@ async function startRunner(opts) {
13471
13612
  (runId, status) => {
13472
13613
  const controller = activeWebRuns.get(runId);
13473
13614
  const already = !controller || controller.signal.aborted;
13474
- if (controller) (0, import_runner_core9.abortRun)(controller, "cancelled", runId);
13615
+ if (controller) (0, import_runner_core10.abortRun)(controller, "cancelled", runId);
13475
13616
  activeMobileRuns.get(runId)?.triggerAbort(`was cancelled server-side (status ${status})`);
13476
13617
  if (!already) log.warn(`Run ${runId} was cancelled server-side (status ${status}) \u2014 stopping it.`);
13477
13618
  }
@@ -13671,6 +13812,7 @@ async function startRunner(opts) {
13671
13812
  const applyRelease = () => {
13672
13813
  claimedRunEpochs.delete(run2.runId);
13673
13814
  claimNonces.delete(run2.runId);
13815
+ if (activeWebRuns.get(run2.runId) === runAbort) activeWebRuns.delete(run2.runId);
13674
13816
  activeRuns--;
13675
13817
  decrementType(runMode);
13676
13818
  decrementProject(runProject);
@@ -13729,9 +13871,9 @@ async function startRunner(opts) {
13729
13871
  runWatchdog.start();
13730
13872
  }
13731
13873
  activeWebRuns.set(run2.runId, runAbort);
13732
- const runWritePolicy = (0, import_runner_core9.parseRunWritePolicy)(run2.runWritePolicy);
13733
- const runSignInSecrets = runWritePolicy ? (0, import_runner_core9.signInSecretsOf)({ ...run2.discoveryContext?.variableContext?.allTestVariables ?? {}, ...run2.testVariables ?? {} }) : [];
13734
- (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) => {
13735
13877
  if (mobilePlatform) {
13736
13878
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
13737
13879
  }
@@ -13739,11 +13881,11 @@ async function startRunner(opts) {
13739
13881
  runsCompleted++;
13740
13882
  }).catch((err) => {
13741
13883
  const abortReason = runAbort.signal.reason;
13742
- if ((0, import_runner_core9.isRunAbortError)(abortReason) && abortReason.reason === "shutdown") {
13884
+ if ((0, import_runner_core10.isRunAbortError)(abortReason) && abortReason.reason === "shutdown") {
13743
13885
  log.info(`Run ${run2.runId} was interrupted by runner shutdown.`);
13744
13886
  return;
13745
13887
  }
13746
- if ((0, import_runner_core9.isSupersededRunAbort)(runAbort.signal)) {
13888
+ if ((0, import_runner_core10.isSupersededRunAbort)(runAbort.signal)) {
13747
13889
  log.info(`Run ${run2.runId} was superseded by a newer claim; this runner posted nothing more for it.`);
13748
13890
  return;
13749
13891
  }
@@ -13755,7 +13897,7 @@ async function startRunner(opts) {
13755
13897
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
13756
13898
  }).finally(() => {
13757
13899
  runSettled = true;
13758
- activeWebRuns.delete(run2.runId);
13900
+ if (activeWebRuns.get(run2.runId) === runAbort) activeWebRuns.delete(run2.runId);
13759
13901
  if (runWatchdog) runWatchdog.settle();
13760
13902
  else applyRelease();
13761
13903
  });
@@ -13815,12 +13957,61 @@ async function startRunner(opts) {
13815
13957
  }
13816
13958
  }
13817
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
+
13818
13977
  // src/login-check.ts
13819
13978
  var RUNNER_TOKEN_PREFIXES = ["urt_", "rt_"];
13820
13979
  function runnerTokenShapeProblem(token) {
13821
13980
  if (RUNNER_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) return null;
13822
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.";
13823
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
+ }
13824
14015
  async function checkRunnerLogin(serverUrl, token, fetchImpl = fetch) {
13825
14016
  let res;
13826
14017
  try {
@@ -13828,17 +14019,388 @@ async function checkRunnerLogin(serverUrl, token, fetchImpl = fetch) {
13828
14019
  } catch {
13829
14020
  return { ok: false, message: `Could not reach ${serverUrl}` };
13830
14021
  }
13831
- 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
+ }
13832
14026
  if (res.status === 401 || res.status === 403) {
13833
14027
  return { ok: false, message: "Invalid token - check your runner token (it may be revoked, or belong to another project)" };
13834
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
+ }
13835
14032
  return { ok: false, message: `Server returned ${res.status} - credentials not saved, try again later` };
13836
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
+ }
13837
14193
 
13838
14194
  // src/services/sign-in-capture.ts
13839
14195
  var import_browser_config3 = __toESM(require_browser_config());
13840
14196
  var import_google_local_signin = __toESM(require_google_local_signin());
13841
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
13842
14404
  async function captureSignInInteractively(url, ready, opts = {}) {
13843
14405
  const target = new URL(url);
13844
14406
  let browser;
@@ -13847,47 +14409,316 @@ async function captureSignInInteractively(url, ready, opts = {}) {
13847
14409
  } catch {
13848
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)() });
13849
14411
  }
13850
- return captureFromBrowser(browser, target, ready, opts.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
14412
+ return captureFromBrowser(browser, target, ready, opts.timeoutMs ?? SIGN_IN_TIMEOUT_MS, opts);
13851
14413
  }
13852
14414
  var SIGN_IN_TIMEOUT_MS = 15 * 60 * 1e3;
13853
- 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 = {}) {
13854
14588
  let context;
14589
+ let watcher;
13855
14590
  try {
13856
14591
  context = await browser.newContext({ viewport: null });
13857
14592
  const page = await context.newPage();
13858
14593
  await page.goto(target.toString(), { waitUntil: "domcontentloaded" });
13859
- await readyUnlessClosed(ready, whenWindowClosed(browser, context), timeoutMs);
13860
- const state2 = await context.storageState({ indexedDB: true });
13861
- for (const open of context.pages()) {
13862
- 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?.();
13863
14612
  try {
13864
- origin = new URL(open.url()).origin;
13865
- } catch {
13866
- continue;
13867
- }
13868
- const entries = await open.evaluate(() => {
13869
- const out = [];
13870
- for (let i = 0; i < window.sessionStorage.length; i += 1) {
13871
- const name = window.sessionStorage.key(i);
13872
- 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
+ }
13873
14619
  }
13874
- return out;
13875
- }).catch(() => []);
13876
- if (entries.length === 0) continue;
13877
- const entry = state2.origins.find((candidate) => candidate.origin === origin);
13878
- if (entry) entry.sessionStorage = entries;
13879
- else state2.origins.push({ origin, localStorage: [], sessionStorage: entries });
13880
- }
13881
- const page0 = context.pages()[0];
13882
- const env = page0 ? await page0.evaluate(() => ({ ua: navigator.userAgent, locale: navigator.language, tz: Intl.DateTimeFormat().resolvedOptions().timeZone })) : { ua: "", locale: "", tz: "" };
13883
- 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
+ }
13884
14634
  } finally {
14635
+ watcher?.stop();
13885
14636
  await context?.close().catch(() => {
13886
14637
  });
13887
14638
  await browser.close().catch(() => {
13888
14639
  });
13889
14640
  }
13890
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
+ }
13891
14722
  function whenWindowClosed(browser, context) {
13892
14723
  return new Promise((resolve3) => {
13893
14724
  const watch = (page) => {
@@ -13946,7 +14777,22 @@ function closedTargetError() {
13946
14777
  error.name = "TargetClosedError";
13947
14778
  return error;
13948
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.";
13949
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) {
13950
14796
  const doFetch = input.fetchImpl ?? fetch;
13951
14797
  try {
13952
14798
  const res = await doFetch(`${input.serverUrl.replace(/\/+$/, "")}/api/runner/saved-sign-in/capture`, {
@@ -13959,14 +14805,21 @@ async function uploadSignInCapture(input) {
13959
14805
  storageState: input.capture.storageState,
13960
14806
  userAgent: input.capture.userAgent,
13961
14807
  locale: input.capture.locale,
13962
- 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 }
13963
14811
  }),
13964
14812
  signal: AbortSignal.timeout(6e4)
13965
14813
  });
13966
14814
  const body = await res.json().catch(() => ({}));
14815
+ if (!replace && res.status === 409 && body.reason === "live_sign_in_exists") return "live_sign_in_exists";
13967
14816
  if (res.ok) return { ok: true, message: "Sign-in saved. Surveys and tests for this project will run signed in." };
13968
14817
  if (res.status === 403) return { ok: false, message: body.error ?? "Only project admins can save a sign-in." };
13969
- 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." };
13970
14823
  return { ok: false, message: `Could not save the sign-in (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}).` };
13971
14824
  } catch {
13972
14825
  return { ok: false, message: "Could not reach the validate.qa server to save the sign-in." };
@@ -14032,6 +14885,43 @@ function shownAddress(url) {
14032
14885
  return oneLine.replace(/^([a-z][a-z0-9+.-]*:\/\/)?\S*@/i, "$1");
14033
14886
  }
14034
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
+
14035
14925
  // src/supervisor-child-argv.ts
14036
14926
  function stripRunnerTokenFromArgv(argv) {
14037
14927
  const out = [];
@@ -14070,23 +14960,48 @@ program.name("validate-runner").description("validate.qa local test runner - exe
14070
14960
  async function requireLatestRunner() {
14071
14961
  await enforceRunnerUpToDate(pkg.version);
14072
14962
  }
14073
- 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) => {
14074
14964
  await requireLatestRunner();
14075
14965
  const serverUrl = opts.server.replace(/\/+$/, "");
14076
- 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);
14077
14989
  if (shapeProblem) {
14078
14990
  console.error(`\u2717 ${shapeProblem}`);
14079
14991
  process.exit(1);
14080
14992
  }
14993
+ if (sendsTokenInPlaintext(serverUrl)) {
14994
+ console.warn("\u26A0 WARNING: Server URL uses HTTP, not HTTPS. Runner token will be sent in plaintext.");
14995
+ }
14081
14996
  console.log(`Testing connection to ${serverUrl}...`);
14082
- const check2 = await checkRunnerLogin(serverUrl, opts.token);
14997
+ const check2 = await checkRunnerLogin(serverUrl, token);
14083
14998
  if (!check2.ok) {
14084
14999
  console.error(`\u2717 ${check2.message}`);
14085
15000
  process.exit(1);
14086
15001
  }
14087
15002
  console.log("\u2713 Connected successfully");
14088
15003
  saveCredentials({
14089
- token: opts.token,
15004
+ token,
14090
15005
  serverUrl,
14091
15006
  projectId: opts.project
14092
15007
  });
@@ -14171,17 +15086,10 @@ program.command("start").description("Start the local test runner").option("--to
14171
15086
  }
14172
15087
  await requireLatestRunner();
14173
15088
  const saved = loadCredentials();
14174
- const authToken = opts.token || process.env.AUTH_TOKEN || saved?.token;
14175
- const serverUrl = (opts.server || process.env.SERVER_URL || saved?.serverUrl)?.replace(/\/+$/, "");
14176
- const projectId = opts.project || process.env.PROJECT_ID || saved?.projectId;
15089
+ const { authToken, serverUrl, projectId, savedLoginSkipped } = resolveCredentials(opts, process.env, saved);
14177
15090
  if (!authToken) {
14178
- console.error("\u2717 No runner token.");
14179
- console.error("");
14180
- console.error("Steps:");
14181
- console.error(" 1. In validate.qa UI: Project Settings \u2192 Local Runner \u2192 Generate Token (urt_...)");
14182
- console.error(" 2. npx @validate.qa/runner login --token <urt_...> --server <the-server-url-printed-above-or-from-dashboard>");
14183
- console.error("");
14184
- 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());
14185
15093
  process.exit(EXIT_TERMINAL);
14186
15094
  }
14187
15095
  if (!serverUrl) {
@@ -14189,12 +15097,19 @@ program.command("start").description("Start the local test runner").option("--to
14189
15097
  console.error(" Or pass --server <url> to this command");
14190
15098
  process.exit(EXIT_TERMINAL);
14191
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
+ }
14192
15107
  const isPersonalToken = authToken.startsWith("urt_");
14193
15108
  if (!projectId && !isPersonalToken) {
14194
15109
  console.error("\u2717 No project ID. Pass --project <id> or set PROJECT_ID env var");
14195
15110
  process.exit(EXIT_TERMINAL);
14196
15111
  }
14197
- if (serverUrl.startsWith("http://") && !serverUrl.includes("localhost") && !serverUrl.includes("127.0.0.1")) {
15112
+ if (sendsTokenInPlaintext(serverUrl)) {
14198
15113
  console.warn("\u26A0 WARNING: Server URL uses HTTP, not HTTPS. Runner token will be sent in plaintext.");
14199
15114
  console.warn(" Consider using an HTTPS URL to protect credentials in transit.");
14200
15115
  }
@@ -14213,7 +15128,14 @@ program.command("start").description("Start the local test runner").option("--to
14213
15128
  console.warn("\u26A0 --tunnel has no effect without --mobile (the mobile bridge it exposes only starts with --mobile). Ignoring --tunnel.");
14214
15129
  }
14215
15130
  {
14216
- const { hasLocalGoogleLogin, GOOGLE_LOGIN_COMMAND } = await import("./dist-2G7YTNH5.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");
14217
15139
  if (process.stdout.isTTY && !await hasLocalGoogleLogin()) {
14218
15140
  console.log(`Tip: for apps with "Continue with Google", sign in to Google once on this machine: ${GOOGLE_LOGIN_COMMAND}`);
14219
15141
  }
@@ -14243,7 +15165,7 @@ async function askLine(question) {
14243
15165
  }
14244
15166
  }
14245
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) => {
14246
- const { signInLocalGoogle, LOCAL_GOOGLE_WARNING, grantedSite, defaultStoreKind } = await import("./dist-2G7YTNH5.mjs");
15168
+ const { signInLocalGoogle, LOCAL_GOOGLE_WARNING, grantedSite, defaultStoreKind } = await import("./dist-BXHLIG5H.mjs");
14247
15169
  console.log(LOCAL_GOOGLE_WARNING);
14248
15170
  if (!opts.yes) {
14249
15171
  const answer = await askLine('Is this a dedicated test Google account? Type "yes" to continue: ');
@@ -14278,7 +15200,7 @@ program.command("google-login").description('Sign in to a TEST Google account on
14278
15200
  }
14279
15201
  });
14280
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) => {
14281
- const { allowLocalGoogleSites, grantedSite } = await import("./dist-2G7YTNH5.mjs");
15203
+ const { allowLocalGoogleSites, grantedSite } = await import("./dist-BXHLIG5H.mjs");
14282
15204
  const site = grantedSite(url);
14283
15205
  if (!site) {
14284
15206
  console.error("\u2717 Not a web address: give it like https://app.example.com");
@@ -14292,17 +15214,26 @@ program.command("google-allow-site <url>").description("Let the Google sign-in o
14292
15214
  console.log(`\u2713 Tests on this machine may now use the Google sign-in for: ${sites.join(", ")}`);
14293
15215
  });
14294
15216
  program.command("google-logout").description("Forget the Google sign-in saved on this machine").action(async () => {
14295
- const { deleteLocalGoogleLogin } = await import("./dist-2G7YTNH5.mjs");
15217
+ const { deleteLocalGoogleLogin } = await import("./dist-BXHLIG5H.mjs");
14296
15218
  await deleteLocalGoogleLogin();
14297
15219
  console.log("\u2713 Google sign-in removed from this machine");
14298
15220
  });
14299
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) => {
14300
15222
  const saved = loadCredentials();
14301
- const token = opts.token || process.env.AUTH_TOKEN || saved?.token;
14302
- const serverUrl = (opts.server || process.env.SERVER_URL || saved?.serverUrl)?.replace(/\/+$/, "");
14303
- const projectId = opts.project || process.env.PROJECT_ID || saved?.projectId;
15223
+ const { authToken: token, serverUrl, projectId, savedLoginSkipped } = resolveCredentials(opts, process.env, saved);
14304
15224
  if (!token || !serverUrl) {
14305
- 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}`);
14306
15237
  process.exit(1);
14307
15238
  }
14308
15239
  let appUrl;
@@ -14310,7 +15241,7 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14310
15241
  try {
14311
15242
  appUrl = new URL(opts.url);
14312
15243
  console.log(`Opening ${appUrl.origin} in a browser window.`);
14313
- 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.");
14314
15245
  capture = await captureSignInInteractively(appUrl.toString(), async (signal) => {
14315
15246
  const rl = createInterface({ input: process.stdin, output: process.stdout });
14316
15247
  try {
@@ -14321,6 +15252,9 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14321
15252
  } finally {
14322
15253
  rl.close();
14323
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")
14324
15258
  });
14325
15259
  } catch (error) {
14326
15260
  const message = describeSignInFailure(error, opts.url);
@@ -14333,43 +15267,23 @@ program.command("sign-in").description("Sign in to your app once in a browser wi
14333
15267
  token,
14334
15268
  projectId,
14335
15269
  label: `Signed in on ${appUrl.hostname}`,
14336
- 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
+ } : {}
14337
15275
  });
14338
- 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}`);
14339
15277
  process.exit(result.ok ? 0 : 1);
14340
15278
  });
14341
- 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) => {
14342
15280
  await requireLatestRunner();
14343
- const saved = loadCredentials();
14344
- if (!saved) {
14345
- console.log("No saved credentials. Run: validate-runner login");
14346
- return;
14347
- }
14348
- console.log(`Server: ${saved.serverUrl}`);
14349
- console.log(`Project: ${saved.projectId || "(not set)"}`);
14350
- console.log(`Token: ${saved.token.slice(0, 8)}...${saved.token.slice(-4)}`);
14351
- console.log("");
14352
- try {
14353
- const res = await fetch(`${saved.serverUrl}/api/runner/health`, {
14354
- headers: {
14355
- Authorization: `Bearer ${saved.token}`,
14356
- ...saved.projectId ? { "X-Project-Id": saved.projectId } : {}
14357
- }
14358
- });
14359
- if (res.ok) {
14360
- const data = await res.json();
14361
- console.log(`\u2713 Server reachable - runner status: ${data.status} (${data.runners} runner(s) online)`);
14362
- } else {
14363
- console.log(`\u26A0 Server returned ${res.status}`);
14364
- }
14365
- } catch {
14366
- console.error("\u2717 Could not reach server");
14367
- }
15281
+ process.exitCode = await runStatusCheck(resolveStatusCredentials(opts, process.env, loadCredentials()));
14368
15282
  });
14369
- 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) => {
14370
15284
  const report = runDoctor();
14371
- printDoctorReport(report);
14372
- process.exit(report.allPassed ? 0 : 1);
15285
+ printDoctorReport(report, { mobile: opts.mobile === true });
15286
+ process.exit(doctorExitCode(report, { mobile: opts.mobile === true }));
14373
15287
  });
14374
15288
  program.command("setup-mobile").description("Install Appium and the iOS (XCUITest) + Android (UiAutomator2) drivers").action(async () => {
14375
15289
  const { spawn: spawn2 } = await import("child_process");