doc-detective 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/common/src/detectTests.d.ts +16 -1
  2. package/dist/common/src/detectTests.d.ts.map +1 -1
  3. package/dist/common/src/detectTests.js +93 -14
  4. package/dist/common/src/detectTests.js.map +1 -1
  5. package/dist/common/src/schemas/schemas.json +1700 -4
  6. package/dist/common/src/types/generated/config_v3.d.ts +5 -1
  7. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  8. package/dist/common/src/types/generated/report_v3.d.ts +12 -0
  9. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  10. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +9 -1
  11. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  12. package/dist/common/src/types/generated/spec_v3.d.ts +4 -0
  13. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  14. package/dist/common/src/types/generated/step_v3.d.ts +64 -0
  15. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  16. package/dist/common/src/types/generated/test_v3.d.ts +132 -0
  17. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  18. package/dist/core/appium.d.ts +2 -1
  19. package/dist/core/appium.d.ts.map +1 -1
  20. package/dist/core/appium.js +37 -5
  21. package/dist/core/appium.js.map +1 -1
  22. package/dist/core/config.d.ts +8 -3
  23. package/dist/core/config.d.ts.map +1 -1
  24. package/dist/core/config.js +14 -6
  25. package/dist/core/config.js.map +1 -1
  26. package/dist/core/detectTests.d.ts +7 -1
  27. package/dist/core/detectTests.d.ts.map +1 -1
  28. package/dist/core/detectTests.js +53 -10
  29. package/dist/core/detectTests.js.map +1 -1
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +7 -10
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/resolveTests.d.ts.map +1 -1
  34. package/dist/core/resolveTests.js +54 -6
  35. package/dist/core/resolveTests.js.map +1 -1
  36. package/dist/core/tests/findElement.js +1 -1
  37. package/dist/core/tests/findElement.js.map +1 -1
  38. package/dist/core/tests/saveScreenshot.js +2 -2
  39. package/dist/core/tests/saveScreenshot.js.map +1 -1
  40. package/dist/core/tests/startRecording.d.ts.map +1 -1
  41. package/dist/core/tests/startRecording.js +0 -3
  42. package/dist/core/tests/startRecording.js.map +1 -1
  43. package/dist/core/tests/stopRecording.d.ts.map +1 -1
  44. package/dist/core/tests/stopRecording.js +23 -14
  45. package/dist/core/tests/stopRecording.js.map +1 -1
  46. package/dist/core/tests/typeKeys.js +2 -2
  47. package/dist/core/tests/typeKeys.js.map +1 -1
  48. package/dist/core/tests.d.ts +65 -4
  49. package/dist/core/tests.d.ts.map +1 -1
  50. package/dist/core/tests.js +905 -379
  51. package/dist/core/tests.js.map +1 -1
  52. package/dist/core/utils.d.ts +10 -1
  53. package/dist/core/utils.d.ts.map +1 -1
  54. package/dist/core/utils.js +107 -3
  55. package/dist/core/utils.js.map +1 -1
  56. package/dist/hints/context.d.ts +1 -0
  57. package/dist/hints/context.d.ts.map +1 -1
  58. package/dist/hints/context.js +17 -2
  59. package/dist/hints/context.js.map +1 -1
  60. package/dist/hints/hints.d.ts.map +1 -1
  61. package/dist/hints/hints.js +40 -0
  62. package/dist/hints/hints.js.map +1 -1
  63. package/dist/hints/types.d.ts +11 -0
  64. package/dist/hints/types.d.ts.map +1 -1
  65. package/dist/index.cjs +2502 -348
  66. package/dist/runtime/browsers.d.ts +14 -0
  67. package/dist/runtime/browsers.d.ts.map +1 -1
  68. package/dist/runtime/browsers.js +23 -0
  69. package/dist/runtime/browsers.js.map +1 -1
  70. package/dist/utils.d.ts.map +1 -1
  71. package/dist/utils.js +112 -2
  72. package/dist/utils.js.map +1 -1
  73. package/package.json +1 -1
@@ -6,8 +6,9 @@ import kill from "tree-kill";
6
6
  // so we don't carry a top-level `import type` whose `typeof` would refer
7
7
  // to a non-runtime identifier.
8
8
  import { loadHeavyDep, resolveHeavyDepPath } from "../runtime/loader.js";
9
+ import { requiredBrowserAssets, ensureBrowserInstalled, } from "../runtime/browsers.js";
9
10
  import os from "node:os";
10
- import { log, replaceEnvs, selectSpecsForRun, findFreePort } from "./utils.js";
11
+ import { log, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, rollUpResults, createAppiumPool, getRunOutputDir, sanitizeFilesystemName, } from "./utils.js";
11
12
  import axios from "axios";
12
13
  import { instantiateCursor } from "./tests/moveTo.js";
13
14
  import { goTo } from "./tests/goTo.js";
@@ -30,14 +31,15 @@ import path from "node:path";
30
31
  import { spawn } from "node:child_process";
31
32
  import { randomUUID } from "node:crypto";
32
33
  import { setAppiumHome } from "./appium.js";
34
+ import { contentHash } from "../common/src/detectTests.js";
33
35
  import { resolveExpression } from "./expressions.js";
34
- import { getEnvironment, getAvailableApps, clearAppCache } from "./config.js";
36
+ import { getEnvironment, getAvailableApps, clearAppCache, resolveConcurrentRunners, } from "./config.js";
35
37
  import { uploadChangedFiles } from "./integrations/index.js";
36
38
  import http from "node:http";
37
39
  import https from "node:https";
38
40
  import { fileURLToPath } from "node:url";
39
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, getDriverCapabilities, getDefaultBrowser, isSupportedContext, };
42
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, isSupportedContext, resolveAutoScreenshot, };
41
43
  // exports.appiumStart = appiumStart;
42
44
  // exports.appiumIsReady = appiumIsReady;
43
45
  // exports.driverStart = driverStart;
@@ -57,6 +59,30 @@ const driverActions = [
57
59
  // Browser names getDriverCapabilities knows how to build caps for. `safari` is
58
60
  // rewritten to `webkit` during context resolution, so both appear here.
59
61
  const KNOWN_BROWSERS = ["firefox", "chrome", "safari", "webkit"];
62
+ /**
63
+ * Stable identity for a "context combination" — the platform + browser pairing
64
+ * that determines whether a driver session can be created. The runner memoizes
65
+ * warm-up outcomes by this key so a combination that fails to start once isn't
66
+ * re-attempted (with its slow driverStart backoff) for every later context.
67
+ * headless is intentionally excluded: headed/headless are two attempts at the
68
+ * same combination (the loop retries headless on failure), not distinct ones.
69
+ * `webkit` is normalized to `safari` so the key matches getAvailableApps naming.
70
+ */
71
+ function combinationKey(context) {
72
+ const rawName = context?.browser?.name;
73
+ const name = rawName === "webkit" ? "safari" : rawName || "<none>";
74
+ return `${context?.platform}::${name}`;
75
+ }
76
+ /**
77
+ * Decide whether a context combination should be attempted or skipped, given
78
+ * its prior warm-up outcome in this run. Pure so the memoization branching is
79
+ * unit-testable without spinning up Appium. A previously-failed combination is
80
+ * skipped outright; everything else is attempted (and its outcome recorded by
81
+ * the caller).
82
+ */
83
+ function warmUpDecision(prev) {
84
+ return prev === "failed" ? "skip" : "attempt";
85
+ }
60
86
  // Get Appium driver capabilities and apply options.
61
87
  function getDriverCapabilities({ runnerDetails, name, options }) {
62
88
  let capabilities = {};
@@ -129,8 +155,15 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
129
155
  args.push(`--auto-select-desktop-capture-source=RECORD_ME`);
130
156
  if (options.headless)
131
157
  args.push("--headless", "--disable-gpu");
132
- if (process.platform === "linux")
158
+ if (process.platform === "linux") {
133
159
  args.push("--no-sandbox");
160
+ // Chrome writes shared memory to /dev/shm, which is only ~64MB on
161
+ // many Linux/CI hosts. A single browser fits, but several launched
162
+ // at once under concurrentRunners exhaust it and ChromeDriver
163
+ // "crashed during startup". Redirect that allocation to /tmp so
164
+ // parallel browser contexts start reliably.
165
+ args.push("--disable-dev-shm-usage");
166
+ }
134
167
  // Set capabilities
135
168
  capabilities = {
136
169
  platformName: runnerDetails.environment.platform,
@@ -157,24 +190,11 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
157
190
  }
158
191
  return capabilities;
159
192
  }
160
- // Check if any steps require an Appium driver.
161
- function isAppiumRequired(specs) {
162
- let appiumRequired = false;
163
- specs.forEach((spec) => {
164
- spec.tests.forEach((test) => {
165
- test.contexts.forEach((context) => {
166
- // Check if test includes actions that require a driver.
167
- if (isDriverRequired({ test: context })) {
168
- appiumRequired = true;
169
- }
170
- });
171
- });
172
- });
173
- return appiumRequired;
174
- }
175
193
  function isDriverRequired({ test }) {
176
194
  let driverRequired = false;
177
- test.steps.forEach((step) => {
195
+ // The resolved shape doesn't guarantee `steps` treat a stepless test or
196
+ // context as needing no driver instead of throwing.
197
+ (test.steps || []).forEach((step) => {
178
198
  // Check if test includes actions that require a driver.
179
199
  driverActions.forEach((action) => {
180
200
  if (typeof step[action] !== "undefined")
@@ -376,9 +396,11 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
376
396
  /**
377
397
  * Orchestrates execution of resolved test specifications and returns a hierarchical run report.
378
398
  *
379
- * Executes each spec -> test -> context -> step, conditionally starts Appium and browser drivers,
380
- * applies viewport/window sizing, handles unsafe-step policies and recording, aggregates per-step,
381
- * per-context, per-test, and per-spec results, and performs resource cleanup.
399
+ * Flattens every context across all specs and tests into one job list and runs it through a
400
+ * worker pool sized by config.concurrentRunners (default 1 = sequential). Conditionally starts
401
+ * Appium and browser drivers, applies viewport/window sizing, handles unsafe-step policies and
402
+ * recording, then rolls per-step, per-context, per-test, and per-spec results up in a
403
+ * deterministic post-pass. Report order always matches input order.
382
404
  *
383
405
  * @param {Object} resolvedTests - Resolved test bundle containing configuration and specs to run.
384
406
  * @param {Object} resolvedTests.config - Runner configuration used during execution.
@@ -422,10 +444,27 @@ async function runSpecs({ resolvedTests }) {
422
444
  };
423
445
  // Set initial shorthand values
424
446
  const platform = runnerDetails.environment.platform;
425
- const availableApps = runnerDetails.availableApps;
447
+ // `let`, not `const`: an on-demand browser install during the context loop
448
+ // re-detects available apps and reassigns this snapshot (see the support
449
+ // gate below).
450
+ let availableApps = runnerDetails.availableApps;
426
451
  const metaValues = { specs: {} };
427
- let appium;
452
+ // Per-run memoization, shared across the concurrent context pool below.
453
+ // installAttempts keeps a browser's on-demand install from being retried for
454
+ // every context that uses it; warmUpResults keeps a context combination that
455
+ // can't start a driver from being re-attempted (with its slow driverStart
456
+ // backoff) for the rest of the run.
457
+ const installAttempts = new Map();
458
+ const warmUpResults = new Map();
459
+ // Per-run artifact folder and ID, stamped on the report so the runFolder
460
+ // reporter archives results beside any auto screenshots from the same run,
461
+ // and so consumers can correlate results over time. Created after the
462
+ // filter short-circuit above so a run that matched nothing leaves no folder.
463
+ const runDir = getRunOutputDir(config);
464
+ const runId = path.basename(runDir).replace(/^run-/, "");
428
465
  const report = {
466
+ runId,
467
+ runDir,
429
468
  summary: {
430
469
  specs: {
431
470
  pass: 0,
@@ -454,377 +493,212 @@ async function runSpecs({ resolvedTests }) {
454
493
  },
455
494
  specs: [],
456
495
  };
457
- // Determine which apps are required
458
- const appiumRequired = isAppiumRequired(specs);
459
- // Warm up Appium
460
- let appiumPort;
461
- if (appiumRequired) {
462
- setAppiumHome({ cacheDir: config?.cacheDir });
463
- appiumPort = await findFreePort();
464
- log(config, "debug", `Starting Appium on port ${appiumPort}`);
465
- // Resolve appium's actual JS entrypoint via `require.resolve`
466
- // (shim node_modules first, runtime cache second) and invoke it
467
- // with `node <entry>`. This sidesteps every shell-injection trap
468
- // at once: no `.cmd` shim, so no Windows-requires-shell:true; no
469
- // `npx`, so no PATH lookup; no user-controlled paths in a shell-
470
- // interpreted string. Works for both `--omit=optional` users
471
- // (appium in cache only) and default installs (appium in shim).
472
- const appiumEntry = resolveHeavyDepPath("appium", { cacheDir: config?.cacheDir });
473
- if (!appiumEntry) {
474
- throw new Error("appium is not installed. The runtime pre-flight should have installed it; check DOC_DETECTIVE_CACHE_DIR / config.cacheDir or run `doc-detective install runtime appium`.");
475
- }
476
- appium = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(appiumPort)], {
477
- windowsHide: true,
478
- cwd: path.join(__dirname, "../.."),
479
- });
480
- appium.on("error", (err) => {
481
- log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
482
- });
483
- appium.stdout.on("data", (data) => {
484
- // console.log(`stdout: ${data}`);
485
- });
486
- appium.stderr.on("data", (data) => {
487
- // console.error(`stderr: ${data}`);
488
- });
489
- try {
490
- await appiumIsReady(appiumPort);
491
- }
492
- catch (error) {
493
- // appiumIsReady threw or timed out — the spawned child is still
494
- // alive and would leak (orphan process, port still bound). Tear
495
- // it down before propagating so subsequent runs don't trip on
496
- // the stale state.
497
- try {
498
- if (appium && appium.pid)
499
- kill(appium.pid);
500
- }
501
- catch {
502
- // best-effort cleanup; the parent error is what matters
503
- }
504
- throw error;
505
- }
506
- log(config, "debug", "Appium is ready.");
507
- }
508
- // Iterate specs
496
+ // Resolve concurrency up front (defensive re-resolve: API callers can hand
497
+ // runSpecs a config that never went through core setConfig, leaving
498
+ // concurrentRunners as `true`). Drives both the worker pool and how many
499
+ // Appium servers to start.
500
+ const limit = resolveConcurrentRunners(config);
501
+ // Phase 1: pre-build the report skeleton and a flat list of context jobs
502
+ // across all specs and tests. Slots are pre-assigned so report order always
503
+ // matches input order, no matter what order concurrent contexts finish in.
509
504
  log(config, "info", "Running test specs.");
505
+ const jobs = [];
510
506
  for (const spec of specs) {
511
507
  log(config, "debug", `SPEC: ${spec.specId}`);
512
- // Set spec report
513
- let specReport = {
508
+ // Create-if-missing: specIds (and testIds) aren't guaranteed unique
509
+ // across the run, and all registration now happens up front — an
510
+ // overwrite here would wipe an earlier spec's registered tests.
511
+ metaValues.specs[spec.specId] ??= { tests: {} };
512
+ const specReport = {
514
513
  specId: spec.specId,
515
514
  description: spec.description,
516
515
  contentPath: spec.contentPath,
517
516
  tests: [],
518
517
  };
519
- // Set meta values
520
- metaValues.specs[spec.specId] = { tests: {} };
521
- // Iterates tests
518
+ report.specs.push(specReport);
522
519
  for (const test of spec.tests) {
523
520
  log(config, "debug", `TEST: ${test.testId}`);
524
- // Set test report
525
- let testReport = {
521
+ metaValues.specs[spec.specId].tests[test.testId] ??= { contexts: {} };
522
+ const testReport = {
526
523
  testId: test.testId,
527
524
  description: test.description,
528
525
  contentPath: test.contentPath,
529
526
  detectSteps: test.detectSteps,
530
- contexts: [],
527
+ contexts: new Array(test.contexts.length),
531
528
  };
532
- // Set meta values
533
- metaValues.specs[spec.specId].tests[test.testId] = { contexts: {} };
534
- // Iterate contexts
535
- // TODO: Support both serial and parallel execution
536
- for (const context of test.contexts) {
537
- // If "platform" is not defined, set it to the current platform
538
- if (!context.platform)
539
- context.platform = runnerDetails.environment.platform;
540
- // Attach OpenAPI definitions to context
541
- if (config.integrations?.openApi) {
542
- context.openApi = [
543
- ...(context.openApi || []),
544
- ...config.integrations.openApi,
545
- ];
529
+ specReport.tests.push(testReport);
530
+ // Track contextIds within this test so the deterministic fallback below
531
+ // can suffix collisions, mirroring resolveTests' deriveContextId.
532
+ const usedContextIds = new Set(test.contexts.map((c) => c.contextId).filter(Boolean));
533
+ test.contexts.forEach((context, slot) => {
534
+ // Derive a stable contextId from platform/browser when unset (the
535
+ // resolver normally assigns one) so the same context keeps the same
536
+ // ID across runs for comparison — `default` when neither is known,
537
+ // with an ordinal suffix on collision. No randomness, so two
538
+ // otherwise-identical runs produce identical reports. Normalized onto
539
+ // the context so runContext's metaValues keys and the report all read
540
+ // the same value.
541
+ if (!context.contextId) {
542
+ const base = [context.platform, context.browser?.name]
543
+ .filter(Boolean)
544
+ .join("-") || "default";
545
+ let id = base;
546
+ let suffix = 2;
547
+ while (usedContextIds.has(id)) {
548
+ id = `${base}-${suffix++}`;
549
+ }
550
+ usedContextIds.add(id);
551
+ context.contextId = id;
546
552
  }
547
- // If "browser" isn't defined but is required by the test, set it to the first available browser in the sequence of Firefox, Chrome, Safari
548
- if (!context.browser && isDriverRequired({ test: context })) {
549
- context.browser = getDefaultBrowser({ runnerDetails });
553
+ jobs.push({ spec, test, context, contexts: testReport.contexts, slot });
554
+ });
555
+ }
556
+ }
557
+ if (limit > 1 &&
558
+ jobs.some((job) => job.context.steps?.some((step) => step.record !== undefined))) {
559
+ // Concurrent headed recordings capture via getDisplayMedia and can grab
560
+ // the wrong window. Recording filenames in the temp dir can also collide.
561
+ log(config, "warning", "Tests include record steps while concurrentRunners is greater than 1. Concurrent recordings can capture the wrong window; set concurrentRunners to 1 for recording runs.");
562
+ }
563
+ // Start one Appium server per concurrent runner that will actually use a
564
+ // driver (capped at the number of driver contexts). Each server owns a
565
+ // distinct port, so parallel contexts never create sessions on the same
566
+ // server — that contention crashed ChromeDriver when every context shared
567
+ // one server. Non-driver runs start none.
568
+ const driverJobCount = jobs.filter((job) => isDriverRequired({ test: job.context })).length;
569
+ let appiumServers = [];
570
+ let appiumPool;
571
+ if (driverJobCount > 0) {
572
+ setAppiumHome({ cacheDir: config?.cacheDir });
573
+ // Resolve appium's actual JS entrypoint via `require.resolve` (shim
574
+ // node_modules first, runtime cache second) and invoke it with
575
+ // `node <entry>`. This sidesteps every shell-injection trap at once: no
576
+ // `.cmd` shim, so no Windows-requires-shell:true; no `npx`, so no PATH
577
+ // lookup; no user-controlled paths in a shell-interpreted string. Works
578
+ // for both `--omit=optional` users (appium in cache only) and default
579
+ // installs (appium in shim).
580
+ const appiumEntry = resolveHeavyDepPath("appium", {
581
+ cacheDir: config?.cacheDir,
582
+ });
583
+ if (!appiumEntry) {
584
+ throw new Error("appium is not installed. The runtime pre-flight should have installed it; check DOC_DETECTIVE_CACHE_DIR / config.cacheDir or run `doc-detective install runtime appium`.");
585
+ }
586
+ const serverCount = Math.min(limit, driverJobCount);
587
+ log(config, "debug", `Starting ${serverCount} Appium server(s).`);
588
+ // Start servers one at a time rather than all at once: concurrent
589
+ // findFreePort() calls share a close-to-rebind window (two could hand out
590
+ // the same port), and spawning every Appium at once spikes CPU during
591
+ // startup. Sequential startup is a one-time per-run cost (serverCount <= 4)
592
+ // that removes the port race and fails fast on the first server that can't
593
+ // come up, tearing down any already started so they don't leak.
594
+ try {
595
+ for (let i = 0; i < serverCount; i++) {
596
+ appiumServers.push(await startAppiumServer(appiumEntry, config));
597
+ }
598
+ }
599
+ catch (error) {
600
+ for (const server of appiumServers) {
601
+ try {
602
+ kill(server.process.pid);
550
603
  }
551
- // Set context report
552
- let contextReport = {
553
- contextId: context.contextId || randomUUID(),
554
- platform: context.platform,
555
- browser: context.browser,
556
- steps: [],
557
- };
558
- // Set meta values
559
- metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId] = { steps: {} };
560
- // If a driver is required but no browser could be resolved (e.g.
561
- // getDefaultBrowser found nothing installed, or the context supplied a
562
- // browser object with no name), skip with an explicit reason instead of
563
- // letting it fail later as "Failed to start context 'undefined'".
564
- if (isDriverRequired({ test: context }) && !context.browser?.name) {
565
- const errorMessage = `Skipping context on '${context.platform}': no supported browser is available in the current environment.`;
566
- log(config, "warning", errorMessage);
567
- contextReport = {
568
- ...contextReport,
569
- result: "SKIPPED",
570
- resultDescription: errorMessage,
571
- };
572
- report.summary.contexts.skipped++;
573
- testReport.contexts.push(contextReport);
574
- continue;
604
+ catch {
605
+ // best-effort
575
606
  }
576
- // Check if current environment supports given contexts
577
- const supportedContext = isSupportedContext({
578
- context: context,
579
- apps: availableApps,
580
- platform: platform,
607
+ }
608
+ throw error;
609
+ }
610
+ appiumPool = createAppiumPool(appiumServers.map((s) => s.port));
611
+ }
612
+ // Everything that uses the Appium servers runs inside this try so the
613
+ // shutdown in `finally` always reaches them — otherwise a throw in
614
+ // warmUpContexts (e.g. getAvailableApps failing during the re-detect) would
615
+ // leak the started servers, leaving orphaned processes bound to their ports.
616
+ try {
617
+ // For concurrent runs, resolve missing browser dependencies and warm up
618
+ // each unique driver combination serially *before* the pool. Two contexts
619
+ // can't then race on an on-demand install (which mutates the shared app
620
+ // cache), and a combination that can't start a driver is recorded once here
621
+ // so every parallel context sharing it skips instantly instead of re-paying
622
+ // driverStart's backoff. This pre-populates installAttempts /
623
+ // warmUpResults / runnerDetails.availableApps, so runContext's own gates
624
+ // below collapse to fast cache hits. Sequential runs (limit 1) keep #338's
625
+ // natural first-context-warms-up behavior in runContext — no pre-pass, no
626
+ // extra driver start, byte-identical to before.
627
+ if (limit > 1 && appiumPool) {
628
+ await warmUpContexts({
629
+ jobs,
630
+ config,
631
+ runnerDetails,
632
+ appiumPool,
633
+ installAttempts,
634
+ warmUpResults,
635
+ });
636
+ }
637
+ // Phase 2: run every context job through one flat worker pool. A limit of 1
638
+ // (the default) is strictly sequential in input order.
639
+ await runConcurrent(jobs, limit, async (job) => {
640
+ try {
641
+ job.contexts[job.slot] = await runContext({
642
+ config,
643
+ spec: job.spec,
644
+ test: job.test,
645
+ context: job.context,
646
+ runnerDetails,
647
+ appiumPool,
648
+ metaValues,
649
+ installAttempts,
650
+ warmUpResults,
651
+ logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
581
652
  });
582
- // If context isn't supported, skip it
583
- if (!supportedContext) {
584
- log(config, "info", `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`);
585
- contextReport = { result: "SKIPPED", ...contextReport };
586
- report.summary.contexts.skipped++;
587
- testReport.contexts.push(contextReport);
588
- continue;
589
- }
590
- log(config, "debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
591
- let driver;
592
- // Ensure context contains a 'steps' property
593
- if (!context.steps) {
594
- context.steps = [];
595
- }
596
- const driverRequired = isDriverRequired({ test: context });
597
- if (driverRequired) {
598
- // Define driver capabilities
599
- // TODO: Support custom apps
600
- let caps = getDriverCapabilities({
601
- runnerDetails: runnerDetails,
602
- name: context.browser.name,
603
- options: {
604
- width: context.browser?.window?.width || 1200,
605
- height: context.browser?.window?.height || 800,
606
- headless: context.browser?.headless !== false,
607
- },
608
- });
609
- log(config, "debug", "CAPABILITIES:");
610
- log(config, "debug", caps);
611
- if (appiumPort === undefined) {
612
- throw new Error("Driver requested but Appium was not started. " +
613
- "isAppiumRequired(specs) and isDriverRequired(context) disagreed; this is a bug.");
614
- }
615
- // Instantiate driver
616
- try {
617
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
618
- }
619
- catch (error) {
620
- try {
621
- // If driver fails to start, try again as headless
622
- log(config, "warning", `Failed to start context '${context.browser?.name}' on '${platform}'. Retrying as headless.`);
623
- context.browser.headless = true;
624
- caps = getDriverCapabilities({
625
- runnerDetails: runnerDetails,
626
- name: context.browser.name,
627
- options: {
628
- width: context.browser?.window?.width || 1200,
629
- height: context.browser?.window?.height || 800,
630
- headless: context.browser?.headless !== false,
631
- },
632
- });
633
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
634
- }
635
- catch (error) {
636
- let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`;
637
- if (context.browser?.name === "safari")
638
- errorMessage =
639
- errorMessage +
640
- " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
641
- log(config, "error", errorMessage);
642
- contextReport = {
643
- result: "SKIPPED",
644
- resultDescription: errorMessage,
645
- ...contextReport,
646
- };
647
- report.summary.contexts.skipped++;
648
- testReport.contexts.push(contextReport);
649
- continue;
650
- }
651
- }
652
- if (context.browser?.viewport?.width ||
653
- context.browser?.viewport?.height) {
654
- // Set driver viewport size
655
- await setViewportSize(context, driver);
656
- }
657
- else if (context.browser?.window?.width ||
658
- context.browser?.window?.height) {
659
- // Get driver window size
660
- const windowSize = await driver.getWindowSize();
661
- // Resize window if necessary
662
- await driver.setWindowSize(context.browser?.window?.width || windowSize.width, context.browser?.window?.height || windowSize.height);
663
- }
664
- }
665
- // Iterates steps
666
- let stepExecutionFailed = false;
667
- for (let step of context.steps) {
668
- // Set step id if not defined
669
- if (!step.stepId)
670
- step.stepId = randomUUID();
671
- log(config, "debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
672
- if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
673
- log(config, "warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
674
- // Mark as skipped
675
- const stepReport = {
676
- ...step,
677
- result: "SKIPPED",
678
- resultDescription: "Skipped because unsafe steps aren't allowed.",
679
- };
680
- contextReport.steps.push(stepReport);
681
- report.summary.steps.skipped++;
682
- continue;
683
- }
684
- if (stepExecutionFailed) {
685
- // Mark as skipped
686
- const stepReport = {
687
- ...step,
688
- result: "SKIPPED",
689
- resultDescription: "Skipped due to previous failure in context.",
690
- };
691
- contextReport.steps.push(stepReport);
692
- report.summary.steps.skipped++;
653
+ }
654
+ catch (error) {
655
+ // Error isolation: one crashing context must not abort sibling jobs.
656
+ // Guard against non-Error throws (a thrown string/object has no
657
+ // .message) so the real failure detail survives in logs and report.
658
+ const detail = error?.message ?? String(error);
659
+ log(config, "error", `Context '${job.context.contextId}' crashed: ${detail}`);
660
+ job.contexts[job.slot] = {
661
+ contextId: job.context.contextId,
662
+ platform: job.context.platform,
663
+ browser: job.context.browser,
664
+ result: "FAIL",
665
+ resultDescription: `Unexpected error: ${detail}`,
666
+ steps: [],
667
+ };
668
+ }
669
+ });
670
+ // Phase 3: roll results up the tree and count the summary in one
671
+ // deterministic pass after all contexts have finished.
672
+ for (const specReport of report.specs) {
673
+ for (const testReport of specReport.tests) {
674
+ for (const contextReport of testReport.contexts) {
675
+ // Every slot is assigned by the pool callback (even on crash), so
676
+ // this guard should never fire — it documents the invariant and
677
+ // keeps a future gap from surfacing as a cryptic undefined read.
678
+ if (!contextReport)
693
679
  continue;
680
+ for (const stepReport of contextReport.steps) {
681
+ report.summary.steps[stepReport.result.toLowerCase()]++;
694
682
  }
695
- // Set meta values
696
- metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId].steps[step.stepId] = {};
697
- // Run step
698
- const stepResult = await runStep({
699
- config: config,
700
- context: context,
701
- step: step,
702
- driver: driver,
703
- metaValues: metaValues,
704
- options: {
705
- openApiDefinitions: context.openApi || [],
706
- },
707
- });
708
- log(config, "debug", `RESULT: ${stepResult.status}\n${JSON.stringify(stepResult, null, 2)}`);
709
- stepResult.result = stepResult.status;
710
- stepResult.resultDescription = stepResult.description;
711
- delete stepResult.status;
712
- delete stepResult.description;
713
- // Add step result to report
714
- const stepReport = {
715
- ...step,
716
- ...stepResult,
717
- };
718
- contextReport.steps.push(stepReport);
719
- report.summary.steps[stepReport.result.toLowerCase()]++;
720
- // If this step failed, set flag to skip remaining steps
721
- if (stepReport.result === "FAIL") {
722
- stepExecutionFailed = true;
723
- }
724
- }
725
- // If recording, stop recording
726
- if (config.recording) {
727
- const stopRecordStep = {
728
- stopRecord: true,
729
- description: "Stopping recording",
730
- stepId: randomUUID(),
731
- };
732
- const stepResult = await runStep({
733
- config: config,
734
- context: context,
735
- step: stopRecordStep,
736
- driver: driver,
737
- options: {
738
- openApiDefinitions: context.openApi || [],
739
- },
740
- });
741
- stepResult.result = stepResult.status;
742
- stepResult.resultDescription = stepResult.description;
743
- delete stepResult.status;
744
- delete stepResult.description;
745
- // Add step result to report
746
- const stepReport = {
747
- ...stopRecordStep,
748
- ...stepResult,
749
- };
750
- contextReport.steps.push(stepReport);
751
- report.summary.steps[stepReport.result.toLowerCase()]++;
752
- }
753
- // Parse step results to calc context result
754
- // If any step fails, context fails
755
- let contextResult;
756
- if (contextReport.steps.find((step) => step.result === "FAIL"))
757
- contextResult = "FAIL";
758
- // If any step warns, context warns
759
- else if (contextReport.steps.find((step) => step.result === "WARNING"))
760
- contextResult = "WARNING";
761
- // If all steps skipped, context skipped
762
- else if (contextReport.steps.length ===
763
- contextReport.steps.filter((step) => step.result === "SKIPPED").length)
764
- contextResult = "SKIPPED";
765
- // If all steps pass, context passes
766
- else
767
- contextResult = "PASS";
768
- contextReport = { result: contextResult, ...contextReport };
769
- testReport.contexts.push(contextReport);
770
- report.summary.contexts[contextResult.toLowerCase()]++;
771
- if (driverRequired) {
772
- // Close driver
773
- try {
774
- await driver.deleteSession();
775
- }
776
- catch (error) {
777
- log(config, "error", `Failed to delete driver session: ${error.message}`);
778
- }
683
+ report.summary.contexts[contextReport.result.toLowerCase()]++;
779
684
  }
685
+ testReport.result = rollUpResults(testReport.contexts.filter(Boolean));
686
+ report.summary.tests[testReport.result.toLowerCase()]++;
780
687
  }
781
- // Parse context results to calc test result
782
- // If any context fails, test fails
783
- let testResult;
784
- if (testReport.contexts.find((context) => context.result === "FAIL"))
785
- testResult = "FAIL";
786
- // If any context warns, test warns
787
- else if (testReport.contexts.find((context) => context.result === "WARNING"))
788
- testResult = "WARNING";
789
- // If all contexts skipped, test skipped
790
- else if (testReport.contexts.length ===
791
- testReport.contexts.filter((context) => context.result === "SKIPPED")
792
- .length)
793
- testResult = "SKIPPED";
794
- // If all contexts pass, test passes
795
- else
796
- testResult = "PASS";
797
- testReport = { result: testResult, ...testReport };
798
- specReport.tests.push(testReport);
799
- report.summary.tests[testResult.toLowerCase()]++;
688
+ specReport.result = rollUpResults(specReport.tests);
689
+ report.summary.specs[specReport.result.toLowerCase()]++;
800
690
  }
801
- // Parse test results to calc spec result
802
- // If any context fails, test fails
803
- let specResult;
804
- if (specReport.tests.find((test) => test.result === "FAIL"))
805
- specResult = "FAIL";
806
- // If any test warns, spec warns
807
- else if (specReport.tests.find((test) => test.result === "WARNING"))
808
- specResult = "WARNING";
809
- // If all tests skipped, spec skipped
810
- else if (specReport.tests.length ===
811
- specReport.tests.filter((test) => test.result === "SKIPPED").length)
812
- specResult = "SKIPPED";
813
- // If all contexts pass, test passes
814
- else
815
- specResult = "PASS";
816
- specReport = { result: specResult, ...specReport };
817
- report.specs.push(specReport);
818
- report.summary.specs[specResult.toLowerCase()]++;
819
691
  }
820
- // Close appium server
821
- if (appium) {
822
- log(config, "debug", "Closing Appium server");
823
- try {
824
- kill(appium.pid);
825
- }
826
- catch {
827
- // Process may already be terminated
692
+ finally {
693
+ // Close every Appium server we started.
694
+ for (const server of appiumServers) {
695
+ log(config, "debug", `Closing Appium server on port ${server.port}`);
696
+ try {
697
+ kill(server.process.pid);
698
+ }
699
+ catch {
700
+ // Process may already be terminated
701
+ }
828
702
  }
829
703
  }
830
704
  // Upload changed files back to source integrations (best-effort)
@@ -853,6 +727,572 @@ async function runSpecs({ resolvedTests }) {
853
727
  }
854
728
  return report;
855
729
  }
730
+ /**
731
+ * Pick which contexts warmUpContexts should warm up: one representative per
732
+ * unique platform::browser combination among the driver-required jobs. Applies
733
+ * the same platform default and default-browser resolution runContext uses, so
734
+ * the combination keys it produces match the ones runContext looks up in the
735
+ * pool. Non-driver and browserless contexts are excluded. Mutates
736
+ * context.platform / context.browser in place — idempotent, since runContext
737
+ * applies the identical defaults. Pure (no I/O) so the selection + de-dup +
738
+ * normalization logic is unit-testable without Appium.
739
+ */
740
+ function selectWarmUpTargets(jobs, runnerDetails) {
741
+ const platform = runnerDetails.environment.platform;
742
+ const seen = new Set();
743
+ const targets = [];
744
+ for (const job of jobs) {
745
+ const context = job.context;
746
+ if (!context.steps)
747
+ context.steps = [];
748
+ // Default platform to the runner's, matching runContext. Without this a
749
+ // resolved context of `{}` (no runOn — the common case) keys as
750
+ // `undefined::<browser>`, fails the support check, and is skipped — which
751
+ // would defeat the warm-up/install de-racing the pre-pass exists for.
752
+ if (!context.platform)
753
+ context.platform = platform;
754
+ if (!context.browser && isDriverRequired({ test: context })) {
755
+ context.browser = getDefaultBrowser({ runnerDetails });
756
+ }
757
+ if (!isDriverRequired({ test: context }))
758
+ continue;
759
+ // No resolvable browser — runContext skips these per-context with its own
760
+ // message; nothing to warm up.
761
+ if (!context.browser?.name)
762
+ continue;
763
+ const combo = combinationKey(context);
764
+ if (seen.has(combo))
765
+ continue;
766
+ seen.add(combo);
767
+ targets.push({ context, combo });
768
+ }
769
+ return targets;
770
+ }
771
+ /**
772
+ * Serial pre-pass for concurrent runs. For each unique driver combination
773
+ * (platform::browser) among the jobs, resolves a missing browser dependency on
774
+ * demand and then warms up a driver once, recording the outcome. Runs before
775
+ * the worker pool so:
776
+ * - on-demand installs never race (they mutate the shared app cache), and
777
+ * - a combination that can't start a driver is recorded once, so every
778
+ * parallel context sharing it is skipped instantly by runContext's warm-up
779
+ * gate instead of each re-paying driverStart's retry/backoff.
780
+ * Mirrors the install + driver-start logic in runContext so the memoization
781
+ * state (installAttempts / warmUpResults / runnerDetails.availableApps) is
782
+ * identical to what the first same-combo context would have produced serially.
783
+ */
784
+ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, installAttempts, warmUpResults, }) {
785
+ const platform = runnerDetails.environment.platform;
786
+ // Which unique combinations to warm up (with the same platform/browser
787
+ // normalization runContext applies) is extracted into selectWarmUpTargets so
788
+ // it can be unit-tested without spinning up Appium.
789
+ for (const { context } of selectWarmUpTargets(jobs, runnerDetails)) {
790
+ const combo = combinationKey(context);
791
+ // On-demand install + re-detect (serial), mirroring runContext's gate.
792
+ let supported = isSupportedContext({
793
+ context,
794
+ apps: runnerDetails.availableApps,
795
+ platform,
796
+ });
797
+ if (!supported &&
798
+ context.platform === platform &&
799
+ Array.isArray(context?.steps) &&
800
+ requiredBrowserAssets(context.browser?.name).length > 0) {
801
+ const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
802
+ const outcome = await ensureContextBrowserInstalled({
803
+ browserName: context.browser?.name,
804
+ config,
805
+ installAttempts,
806
+ deps: {
807
+ ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
808
+ log,
809
+ },
810
+ });
811
+ if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
812
+ clearAppCache(config);
813
+ runnerDetails.availableApps = await getAvailableApps({ config });
814
+ supported = isSupportedContext({
815
+ context,
816
+ apps: runnerDetails.availableApps,
817
+ platform,
818
+ });
819
+ }
820
+ }
821
+ // Unsupported combinations are left unmarked; runContext skips each with the
822
+ // appropriate per-context reason (install-but-undetected vs unsupported).
823
+ if (!supported)
824
+ continue;
825
+ // Warm-up probe: start a driver once to prove the combination works.
826
+ // driverStart's own transient retry absorbs concurrent-launch flakiness;
827
+ // a headless fallback (on a throwaway caps object, so the real contexts
828
+ // keep their configured headedness) matches runContext so a headed-only
829
+ // failure on a headless-capable box doesn't poison the combination.
830
+ const port = await appiumPool.acquire();
831
+ let warmDriver;
832
+ try {
833
+ const options = {
834
+ width: context.browser?.window?.width || 1200,
835
+ height: context.browser?.window?.height || 800,
836
+ headless: context.browser?.headless !== false,
837
+ };
838
+ try {
839
+ warmDriver = await driverStart(getDriverCapabilities({
840
+ runnerDetails,
841
+ name: context.browser.name,
842
+ options,
843
+ }), port, 4, { cacheDir: config?.cacheDir });
844
+ }
845
+ catch {
846
+ log(config, "warning", `Warm-up for ${combo} failed headed; retrying headless.`);
847
+ warmDriver = await driverStart(getDriverCapabilities({
848
+ runnerDetails,
849
+ name: context.browser.name,
850
+ options: { ...options, headless: true },
851
+ }), port, 4, { cacheDir: config?.cacheDir });
852
+ }
853
+ warmUpResults.set(combo, "ok");
854
+ log(config, "debug", `Warm-up succeeded for ${combo}.`);
855
+ }
856
+ catch (error) {
857
+ warmUpResults.set(combo, "failed");
858
+ log(config, "warning", `Warm-up failed for ${combo}; contexts using it will be skipped: ${error?.message ?? String(error)}`);
859
+ }
860
+ finally {
861
+ if (warmDriver) {
862
+ try {
863
+ await warmDriver.deleteSession();
864
+ }
865
+ catch {
866
+ // best-effort teardown of the warm-up session
867
+ }
868
+ }
869
+ appiumPool.release(port);
870
+ }
871
+ }
872
+ }
873
+ // Effective autoScreenshot setting for a test: the test level wins over the
874
+ // spec level, which wins over the global config. Levels left unset defer
875
+ // down the chain.
876
+ function resolveAutoScreenshot({ config, spec, test, }) {
877
+ return Boolean(test?.autoScreenshot ?? spec?.autoScreenshot ?? config?.autoScreenshot);
878
+ }
879
+ // Directory/file segments built from IDs are capped so deeply nested doc
880
+ // trees can't push the full screenshot path past Windows' MAX_PATH. Keep the
881
+ // tail — content hashes live at the end of generated IDs.
882
+ function capPathSegment(segment, max = 64) {
883
+ return segment.length <= max ? segment : segment.slice(segment.length - max);
884
+ }
885
+ // Capture a post-step screenshot for `autoScreenshot` runs. The relative
886
+ // path is derived from stable IDs (spec/test/context) plus the step's
887
+ // order, action, and ID (e.g. screenshots/docs_guide.md/
888
+ // docs_guide.md~3f9a2c1b/windows-chrome/01-goTo-s4f2a91c.png), so the same
889
+ // step lands on the same relative path inside every run's folder — that's
890
+ // what makes run-over-run image comparison possible. Failures are logged as
891
+ // warnings, never thrown: a missed capture must not fail the step it
892
+ // documents.
893
+ async function captureAutoScreenshot({ config, driver, spec, test, context, step, stepIndex, stepCount, }) {
894
+ try {
895
+ const action = driverActions.find((key) => typeof step[key] !== "undefined") || "step";
896
+ const sanitizedTestId = sanitizeFilesystemName(String(test.testId ?? ""), "test");
897
+ const runDir = getRunOutputDir(config);
898
+ const dir = path.join(runDir, "screenshots", capPathSegment(sanitizeFilesystemName(String(spec.specId ?? ""), "spec")), capPathSegment(sanitizedTestId), capPathSegment(sanitizeFilesystemName(String(context.contextId ?? ""), "context")));
899
+ // The stepId usually embeds the testId (its parent folder) — strip that
900
+ // prefix so filenames stay short while still carrying the step's ID.
901
+ const stepIdString = sanitizeFilesystemName(String(step.stepId ?? ""), "step");
902
+ const stepRef = capPathSegment(stepIdString.startsWith(`${sanitizedTestId}~`)
903
+ ? stepIdString.slice(sanitizedTestId.length + 1)
904
+ : stepIdString);
905
+ // Zero-pad the step ordinal to the width of the context's step count
906
+ // (min 2), so file listings sort naturally even past 99 steps (100 would
907
+ // otherwise sort before 11).
908
+ const pad = Math.max(2, String(stepCount).length);
909
+ const fileName = `${String(stepIndex + 1).padStart(pad, "0")}-${action}-${stepRef}.png`;
910
+ const screenshotStep = {
911
+ stepId: `${step.stepId}_auto`,
912
+ description: "Automatic post-step screenshot",
913
+ screenshot: {
914
+ path: path.join(dir, fileName),
915
+ overwrite: "true",
916
+ },
917
+ };
918
+ const captureResult = await saveScreenshot({
919
+ config,
920
+ step: screenshotStep,
921
+ driver,
922
+ });
923
+ if (captureResult.status !== "PASS") {
924
+ log(config, "warning", `Auto screenshot failed after step ${step.stepId}: ${captureResult.description}`);
925
+ return null;
926
+ }
927
+ // Report the path relative to the run folder (normalized to forward
928
+ // slashes) so the same step produces an identical report value in every
929
+ // run — absolute, timestamped paths would defeat run-over-run diffing.
930
+ // Consumers resolve it against the report's `runDir`.
931
+ return path
932
+ .relative(runDir, screenshotStep.screenshot.path)
933
+ .split(path.sep)
934
+ .join("/");
935
+ }
936
+ catch (error) {
937
+ log(config, "warning", `Auto screenshot failed after step ${step.stepId}: ${error?.message ?? error}`);
938
+ return null;
939
+ }
940
+ }
941
+ /**
942
+ * Runs a single resolved context to completion and returns its finished
943
+ * contextReport (steps array + rolled-up result). Never touches the shared
944
+ * report or summary counters — the caller owns aggregation, which keeps this
945
+ * function safe to run concurrently with sibling contexts.
946
+ */
947
+ async function runContext({ config, spec, test, context, runnerDetails, appiumPool, metaValues, installAttempts, warmUpResults, logPrefix = "", }) {
948
+ const platform = runnerDetails.environment.platform;
949
+ // `let`, not `const`: an on-demand browser install below re-detects available
950
+ // apps and reassigns this snapshot.
951
+ let availableApps = runnerDetails.availableApps;
952
+ // Context-scoped log: prefixed only when contexts run concurrently, so
953
+ // sequential output stays unchanged.
954
+ const clog = (level, message) => log(config, level, logPrefix && typeof message === "string"
955
+ ? `${logPrefix} ${message}`
956
+ : message);
957
+ // Ensure context contains a 'steps' property before anything walks it —
958
+ // isDriverRequired iterates context.steps and the resolved shape doesn't
959
+ // guarantee the field.
960
+ if (!context.steps) {
961
+ context.steps = [];
962
+ }
963
+ // If "platform" is not defined, set it to the current platform
964
+ if (!context.platform)
965
+ context.platform = runnerDetails.environment.platform;
966
+ // Attach OpenAPI definitions to context
967
+ if (config.integrations?.openApi) {
968
+ context.openApi = [
969
+ ...(context.openApi || []),
970
+ ...config.integrations.openApi,
971
+ ];
972
+ }
973
+ // If "browser" isn't defined but is required by the test, set it to the first available browser in the sequence of Firefox, Chrome, Safari
974
+ if (!context.browser && isDriverRequired({ test: context })) {
975
+ context.browser = getDefaultBrowser({ runnerDetails });
976
+ }
977
+ // Set context report
978
+ const contextReport = {
979
+ contextId: context.contextId,
980
+ platform: context.platform,
981
+ browser: context.browser,
982
+ steps: [],
983
+ };
984
+ // Set meta values (create-if-missing — ids aren't guaranteed unique)
985
+ metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId] ??= { steps: {} };
986
+ // If a driver is required but no browser could be resolved (e.g.
987
+ // getDefaultBrowser found nothing installed, or the context supplied a
988
+ // browser object with no name), skip with an explicit reason instead of
989
+ // letting it fail later as "Failed to start context 'undefined'".
990
+ if (isDriverRequired({ test: context }) && !context.browser?.name) {
991
+ const errorMessage = `Skipping context on '${context.platform}': no supported browser is available in the current environment.`;
992
+ clog("warning", errorMessage);
993
+ contextReport.result = "SKIPPED";
994
+ contextReport.resultDescription = errorMessage;
995
+ return contextReport;
996
+ }
997
+ // Check if current environment supports given contexts
998
+ let supportedContext = isSupportedContext({
999
+ context: context,
1000
+ apps: availableApps,
1001
+ platform: platform,
1002
+ });
1003
+ // If the context needs a browser that isn't available yet, try to resolve
1004
+ // the missing dependency on demand before giving up — e.g. Firefox declared
1005
+ // but geckodriver absent because the pre-flight was skipped or its install
1006
+ // failed. Memoized per browser (installAttempts) so a failed/no-op install
1007
+ // isn't retried for every later context. The install + re-detect mutate the
1008
+ // shared runnerDetails.availableApps; under concurrency that's racy, but it
1009
+ // only fires for a genuinely-missing browser (rare) and the app list only
1010
+ // grows, so a sibling reading a slightly stale snapshot still re-detects.
1011
+ let freshInstallRedetected = false;
1012
+ if (!supportedContext &&
1013
+ context.platform === platform &&
1014
+ // Mirror isSupportedContext's own guard: isDriverRequired iterates
1015
+ // context.steps, so a malformed context without a steps array would
1016
+ // otherwise crash here instead of skipping cleanly.
1017
+ Array.isArray(context?.steps) &&
1018
+ isDriverRequired({ test: context }) &&
1019
+ requiredBrowserAssets(context.browser?.name).length > 0) {
1020
+ // Whether this browser was already attempted earlier this run; a cached
1021
+ // outcome installed nothing new, so there's no point paying for a re-detect.
1022
+ const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
1023
+ const outcome = await ensureContextBrowserInstalled({
1024
+ browserName: context.browser?.name,
1025
+ config,
1026
+ installAttempts,
1027
+ deps: {
1028
+ ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
1029
+ log,
1030
+ },
1031
+ });
1032
+ // Re-detect after a real attempt regardless of outcome: a "failed" install
1033
+ // can still have materialized assets before it threw, so a stale snapshot
1034
+ // could wrongly skip a now-usable browser.
1035
+ if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
1036
+ freshInstallRedetected = true;
1037
+ clearAppCache(config);
1038
+ availableApps = await getAvailableApps({ config });
1039
+ runnerDetails.availableApps = availableApps;
1040
+ supportedContext = isSupportedContext({
1041
+ context: context,
1042
+ apps: availableApps,
1043
+ platform: platform,
1044
+ });
1045
+ }
1046
+ }
1047
+ // If context isn't supported, skip it
1048
+ if (!supportedContext) {
1049
+ // Distinguish "we installed the dependency but still can't see it" from a
1050
+ // plain unsupported context, so the skip reason points at the real problem.
1051
+ const errorMessage = freshInstallRedetected
1052
+ ? `Skipping context '${context.browser?.name}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
1053
+ : `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`;
1054
+ clog(freshInstallRedetected ? "warning" : "info", errorMessage);
1055
+ contextReport.result = "SKIPPED";
1056
+ contextReport.resultDescription = errorMessage;
1057
+ return contextReport;
1058
+ }
1059
+ clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
1060
+ let driver;
1061
+ let appiumPort;
1062
+ const driverRequired = isDriverRequired({ test: context });
1063
+ if (driverRequired && !appiumPool) {
1064
+ throw new Error("Driver requested but no Appium server pool was created; " +
1065
+ "driverJobCount and isDriverRequired(context) disagreed; this is a bug.");
1066
+ }
1067
+ // Warm-up memoization. The first context of each combination acts as the
1068
+ // warm-up; if that combination already failed to start a driver earlier in
1069
+ // this run, skip it outright instead of paying driverStart's retry/backoff
1070
+ // again. Under concurrency this is a best-effort speedup, not correctness —
1071
+ // same-combo contexts may start before one records a result.
1072
+ const combo = combinationKey(context);
1073
+ try {
1074
+ if (driverRequired) {
1075
+ if (warmUpDecision(warmUpResults.get(combo)) === "skip") {
1076
+ const errorMessage = `Skipping context '${context.browser?.name}' on '${context.platform}': this context combination could not start a driver earlier in this run.`;
1077
+ clog("warning", errorMessage);
1078
+ contextReport.result = "SKIPPED";
1079
+ contextReport.resultDescription = errorMessage;
1080
+ return contextReport;
1081
+ }
1082
+ // Check out a server for this context's lifetime — released in the
1083
+ // finally so the next queued context can reuse it.
1084
+ appiumPort = await appiumPool.acquire();
1085
+ // Define driver capabilities
1086
+ // TODO: Support custom apps
1087
+ let caps = getDriverCapabilities({
1088
+ runnerDetails: runnerDetails,
1089
+ name: context.browser.name,
1090
+ options: {
1091
+ width: context.browser?.window?.width || 1200,
1092
+ height: context.browser?.window?.height || 800,
1093
+ headless: context.browser?.headless !== false,
1094
+ },
1095
+ });
1096
+ clog("debug", "CAPABILITIES:");
1097
+ clog("debug", caps);
1098
+ // Instantiate driver
1099
+ try {
1100
+ driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
1101
+ }
1102
+ catch (error) {
1103
+ try {
1104
+ // If driver fails to start, try again as headless
1105
+ clog("warning", `Failed to start context '${context.browser?.name}' on '${platform}'. Retrying as headless.`);
1106
+ context.browser.headless = true;
1107
+ caps = getDriverCapabilities({
1108
+ runnerDetails: runnerDetails,
1109
+ name: context.browser.name,
1110
+ options: {
1111
+ width: context.browser?.window?.width || 1200,
1112
+ height: context.browser?.window?.height || 800,
1113
+ headless: context.browser?.headless !== false,
1114
+ },
1115
+ });
1116
+ driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
1117
+ }
1118
+ catch (error) {
1119
+ let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`;
1120
+ // `safari` is normalized to `webkit` during context resolution, so
1121
+ // match both or this Safari-specific hint never fires on real runs.
1122
+ if (context.browser?.name === "safari" ||
1123
+ context.browser?.name === "webkit")
1124
+ errorMessage =
1125
+ errorMessage +
1126
+ " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
1127
+ clog("error", errorMessage);
1128
+ // Record the combination as failed so every later context that shares
1129
+ // it is skipped instantly (see the warm-up check above).
1130
+ if (!warmUpResults.has(combo))
1131
+ warmUpResults.set(combo, "failed");
1132
+ contextReport.result = "SKIPPED";
1133
+ contextReport.resultDescription = errorMessage;
1134
+ return contextReport;
1135
+ }
1136
+ }
1137
+ // Driver started (first attempt or headless retry) — mark this
1138
+ // combination as known-good for the rest of the run.
1139
+ if (!warmUpResults.has(combo))
1140
+ warmUpResults.set(combo, "ok");
1141
+ if (context.browser?.viewport?.width ||
1142
+ context.browser?.viewport?.height) {
1143
+ // Set driver viewport size
1144
+ await setViewportSize(context, driver);
1145
+ }
1146
+ else if (context.browser?.window?.width ||
1147
+ context.browser?.window?.height) {
1148
+ // Get driver window size
1149
+ const windowSize = await driver.getWindowSize();
1150
+ // Resize window if necessary
1151
+ await driver.setWindowSize(context.browser?.window?.width || windowSize.width, context.browser?.window?.height || windowSize.height);
1152
+ }
1153
+ }
1154
+ // Effective autoScreenshot for this context (test > spec > config).
1155
+ const autoScreenshotEnabled = resolveAutoScreenshot({ config, spec, test });
1156
+ // Iterates steps
1157
+ let stepExecutionFailed = false;
1158
+ const usedStepIds = new Set(context.steps.map((s) => s.stepId).filter(Boolean));
1159
+ for (const [stepIndex, step] of context.steps.entries()) {
1160
+ // Set step id if not defined. Derived from the test ID and a hash of
1161
+ // the step's authored definition so the same step keeps the same ID
1162
+ // (and any `screenshot: true` default filename) across runs.
1163
+ // Sanitized because the ID doubles as a screenshot filename; identical
1164
+ // steps in one test get an ordinal suffix.
1165
+ if (!step.stepId) {
1166
+ const baseId = sanitizeFilesystemName(`${test.testId}~s${contentHash(step)}`, `step-${randomUUID()}`);
1167
+ let stepId = baseId;
1168
+ let suffix = 2;
1169
+ while (usedStepIds.has(stepId)) {
1170
+ stepId = `${baseId}-${suffix++}`;
1171
+ }
1172
+ step.stepId = stepId;
1173
+ }
1174
+ usedStepIds.add(step.stepId);
1175
+ clog("debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
1176
+ if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
1177
+ clog("warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
1178
+ // Mark as skipped
1179
+ const stepReport = {
1180
+ ...step,
1181
+ result: "SKIPPED",
1182
+ resultDescription: "Skipped because unsafe steps aren't allowed.",
1183
+ };
1184
+ contextReport.steps.push(stepReport);
1185
+ continue;
1186
+ }
1187
+ if (stepExecutionFailed) {
1188
+ // Mark as skipped
1189
+ const stepReport = {
1190
+ ...step,
1191
+ result: "SKIPPED",
1192
+ resultDescription: "Skipped due to previous failure in context.",
1193
+ };
1194
+ contextReport.steps.push(stepReport);
1195
+ continue;
1196
+ }
1197
+ // Set meta values
1198
+ metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId].steps[step.stepId] = {};
1199
+ // Run step
1200
+ const stepResult = await runStep({
1201
+ config: config,
1202
+ context: context,
1203
+ step: step,
1204
+ driver: driver,
1205
+ metaValues: metaValues,
1206
+ options: {
1207
+ openApiDefinitions: context.openApi || [],
1208
+ },
1209
+ });
1210
+ clog("debug", `RESULT: ${stepResult.status}\n${JSON.stringify(stepResult, null, 2)}`);
1211
+ stepResult.result = stepResult.status;
1212
+ stepResult.resultDescription = stepResult.description;
1213
+ delete stepResult.status;
1214
+ delete stepResult.description;
1215
+ // Add step result to report
1216
+ const stepReport = {
1217
+ ...step,
1218
+ ...stepResult,
1219
+ };
1220
+ // Capture a post-step screenshot for autoScreenshot runs. Applies to
1221
+ // browser steps (explicit `screenshot` steps already produce an image);
1222
+ // failed steps are captured too — the failure frame is often the most
1223
+ // useful. A capture failure logs a warning and never fails the step.
1224
+ if (autoScreenshotEnabled &&
1225
+ driver &&
1226
+ typeof step.screenshot === "undefined" &&
1227
+ isDriverRequired({ test: { steps: [step] } })) {
1228
+ const capturedPath = await captureAutoScreenshot({
1229
+ config,
1230
+ driver,
1231
+ spec,
1232
+ test,
1233
+ context,
1234
+ step,
1235
+ stepIndex,
1236
+ stepCount: context.steps.length,
1237
+ });
1238
+ if (capturedPath)
1239
+ stepReport.autoScreenshot = capturedPath;
1240
+ }
1241
+ contextReport.steps.push(stepReport);
1242
+ // If this step failed, set flag to skip remaining steps
1243
+ if (stepReport.result === "FAIL") {
1244
+ stepExecutionFailed = true;
1245
+ }
1246
+ }
1247
+ // If recording, stop recording
1248
+ if (driver?.state?.recording) {
1249
+ const stopRecordStep = {
1250
+ stopRecord: true,
1251
+ description: "Stopping recording",
1252
+ stepId: randomUUID(),
1253
+ };
1254
+ const stepResult = await runStep({
1255
+ config: config,
1256
+ context: context,
1257
+ step: stopRecordStep,
1258
+ driver: driver,
1259
+ options: {
1260
+ openApiDefinitions: context.openApi || [],
1261
+ },
1262
+ });
1263
+ stepResult.result = stepResult.status;
1264
+ stepResult.resultDescription = stepResult.description;
1265
+ delete stepResult.status;
1266
+ delete stepResult.description;
1267
+ // Add step result to report
1268
+ const stepReport = {
1269
+ ...stopRecordStep,
1270
+ ...stepResult,
1271
+ };
1272
+ contextReport.steps.push(stepReport);
1273
+ }
1274
+ }
1275
+ finally {
1276
+ // Close driver. In a finally so an unexpected throw can't leak a session
1277
+ // while sibling contexts keep running.
1278
+ if (driver) {
1279
+ try {
1280
+ await driver.deleteSession();
1281
+ }
1282
+ catch (error) {
1283
+ clog("error", `Failed to delete driver session: ${error.message}`);
1284
+ }
1285
+ }
1286
+ // Return the Appium server to the pool for the next queued context. Always
1287
+ // runs (even on the driver-start-failure early return) so a port can't
1288
+ // leak out of the pool and starve later contexts.
1289
+ if (appiumPort !== undefined && appiumPool) {
1290
+ appiumPool.release(appiumPort);
1291
+ }
1292
+ }
1293
+ contextReport.result = rollUpResults(contextReport.steps);
1294
+ return contextReport;
1295
+ }
856
1296
  // Run a specific step
857
1297
  async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, }) {
858
1298
  let actionResult;
@@ -915,7 +1355,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
915
1355
  step: step,
916
1356
  driver: driver,
917
1357
  });
918
- config.recording = actionResult.recording;
1358
+ driver.state.recording = actionResult.recording ?? null;
919
1359
  }
920
1360
  else if (typeof step.runCode !== "undefined") {
921
1361
  actionResult = await runCode({ config: config, step: step });
@@ -947,7 +1387,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
947
1387
  };
948
1388
  }
949
1389
  // If recording, wait until browser is loaded, then instantiate cursor
950
- if (config?.recording) {
1390
+ if (driver?.state?.recording) {
951
1391
  const currentUrl = await driver.getUrl();
952
1392
  if (currentUrl !== driver.state.url) {
953
1393
  driver.state.url = currentUrl;
@@ -971,6 +1411,40 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
971
1411
  }
972
1412
  return actionResult;
973
1413
  }
1414
+ // Start one Appium server on a free port and resolve once it answers /status.
1415
+ // Each concurrent runner gets its own server (own port) so parallel contexts
1416
+ // never create sessions on the same Appium instance.
1417
+ async function startAppiumServer(appiumEntry, config) {
1418
+ const port = await findFreePort();
1419
+ log(config, "debug", `Starting Appium on port ${port}`);
1420
+ const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port)], {
1421
+ windowsHide: true,
1422
+ cwd: path.join(__dirname, "../.."),
1423
+ });
1424
+ proc.on("error", (err) => {
1425
+ log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
1426
+ });
1427
+ proc.stdout.on("data", () => { });
1428
+ proc.stderr.on("data", () => { });
1429
+ try {
1430
+ await appiumIsReady(port);
1431
+ }
1432
+ catch (error) {
1433
+ // appiumIsReady threw or timed out — the spawned child is still alive and
1434
+ // would leak (orphan process, port still bound). Tear it down before
1435
+ // propagating so subsequent runs don't trip on the stale state.
1436
+ try {
1437
+ if (proc && proc.pid)
1438
+ kill(proc.pid);
1439
+ }
1440
+ catch {
1441
+ // best-effort cleanup; the parent error is what matters
1442
+ }
1443
+ throw error;
1444
+ }
1445
+ log(config, "debug", `Appium is ready on port ${port}.`);
1446
+ return { port, process: proc };
1447
+ }
974
1448
  // Delay execution until Appium server is available.
975
1449
  async function appiumIsReady(port, timeoutMs = 120000) {
976
1450
  let isReady = false;
@@ -991,10 +1465,20 @@ async function appiumIsReady(port, timeoutMs = 120000) {
991
1465
  }
992
1466
  // Start the Appium driver specified in `capabilities`.
993
1467
  async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
994
- // POST /session can race a just-spawned-or-still-dying Appium on Windows:
995
- // /status may already return 200 from the outgoing process while /session
996
- // is no longer accepting. Retry with linear backoff ONLY on ECONNREFUSED --
997
- // any other error is a real session-creation failure and propagates.
1468
+ // Two families of transient, retryable session-creation failures, both worse
1469
+ // under concurrency (the TRANSIENT regex below enumerates the specific
1470
+ // patterns):
1471
+ // 1. POST /session races a just-spawned-or-still-dying Appium (Windows):
1472
+ // /status returns 200 from the outgoing process while /session no longer
1473
+ // accepts, or Appium's proxy to chromedriver drops the socket ->
1474
+ // ECONNREFUSED / ECONNRESET / "socket hang up" / "could not proxy command".
1475
+ // 2. Several Chromes launching at once briefly starve resources and
1476
+ // ChromeDriver "crashed during startup" / "cannot connect to" /
1477
+ // "DevToolsActivePort" / "session not created". A staggered retry lets
1478
+ // the contention clear; it recovers on the next attempt in practice.
1479
+ // Retry these with linear backoff; any other error is a real session-
1480
+ // creation failure and propagates immediately.
1481
+ const TRANSIENT = /ECONNREFUSED|ECONNRESET|socket hang up|could not proxy command|crashed during startup|cannot connect to|DevToolsActivePort|session not created/i;
998
1482
  const wdio = await loadHeavyDep("webdriverio", { ctx });
999
1483
  let lastError;
1000
1484
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -1009,12 +1493,14 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
1009
1493
  connectionRetryTimeout: 120000, // 2 minutes
1010
1494
  waitforTimeout: 120000, // 2 minutes
1011
1495
  });
1012
- driver.state = { url: "", x: null, y: null };
1496
+ // Per-context mutable state. `recording` lives here (not on config)
1497
+ // so concurrent contexts can't clobber each other's recordings.
1498
+ driver.state = { url: "", x: null, y: null, recording: null };
1013
1499
  return driver;
1014
1500
  }
1015
1501
  catch (err) {
1016
1502
  lastError = err;
1017
- if (!/ECONNREFUSED/.test(String(err && err.message)))
1503
+ if (!TRANSIENT.test(String(err && err.message)))
1018
1504
  throw err;
1019
1505
  if (attempt < maxAttempts) {
1020
1506
  await new Promise((r) => setTimeout(r, 500 * attempt));
@@ -1106,6 +1592,46 @@ async function ensureChromeAvailable(config, deps) {
1106
1592
  }
1107
1593
  return availableApps;
1108
1594
  }
1595
+ /**
1596
+ * On-demand, per-context browser/driver install used by the runner when a
1597
+ * context's browser isn't yet available (e.g. Firefox declared but geckodriver
1598
+ * missing). Attempts to install every asset the browser needs, memoizing the
1599
+ * outcome in `installAttempts` so a failed (or no-op) attempt isn't repeated
1600
+ * for every later context that shares the browser. Like ensureChromeAvailable,
1601
+ * this self-provisions regardless of DOC_DETECTIVE_AUTOINSTALL (that env var
1602
+ * only governs the eager postinstall). Deps are injected for testing.
1603
+ *
1604
+ * @returns "installed" when all assets installed, "failed" when an install
1605
+ * threw, or "notInstallable" for browsers with no installable asset (safari).
1606
+ */
1607
+ async function ensureContextBrowserInstalled({ browserName, config, installAttempts, deps, }) {
1608
+ const key = (browserName ?? "<none>").toLowerCase();
1609
+ const cached = installAttempts.get(key);
1610
+ if (cached)
1611
+ return cached;
1612
+ const assets = requiredBrowserAssets(browserName);
1613
+ if (assets.length === 0) {
1614
+ installAttempts.set(key, "notInstallable");
1615
+ return "notInstallable";
1616
+ }
1617
+ const ctx = { cacheDir: config?.cacheDir };
1618
+ // Bridge runtime modules' (msg, level) logger to core/utils.ts#log, mapping
1619
+ // "warn" → "warning" the same way provisionChromeRuntime does.
1620
+ const logger = (msg, level = "info") => deps.log?.(config, level === "warn" ? "warning" : level, msg);
1621
+ try {
1622
+ deps.log?.(config, "info", `Browser '${browserName}' is not available; attempting on-demand install of: ${assets.join(", ")}.`);
1623
+ for (const asset of assets) {
1624
+ await deps.ensureBrowser(asset, { ctx, deps: { logger } });
1625
+ }
1626
+ installAttempts.set(key, "installed");
1627
+ return "installed";
1628
+ }
1629
+ catch (err) {
1630
+ deps.log?.(config, "warning", `On-demand install for '${browserName}' failed: ${err?.message ?? err}`);
1631
+ installAttempts.set(key, "failed");
1632
+ return "failed";
1633
+ }
1634
+ }
1109
1635
  async function getRunner(options = {}) {
1110
1636
  const environment = getEnvironment();
1111
1637
  const config = { ...options.config, environment };