doc-detective 4.7.0 → 4.8.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 (48) hide show
  1. package/dist/core/appium.d.ts +2 -1
  2. package/dist/core/appium.d.ts.map +1 -1
  3. package/dist/core/appium.js +37 -5
  4. package/dist/core/appium.js.map +1 -1
  5. package/dist/core/config.d.ts +8 -3
  6. package/dist/core/config.d.ts.map +1 -1
  7. package/dist/core/config.js +14 -6
  8. package/dist/core/config.js.map +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +7 -10
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/tests/findElement.js +1 -1
  13. package/dist/core/tests/findElement.js.map +1 -1
  14. package/dist/core/tests/saveScreenshot.js +2 -2
  15. package/dist/core/tests/saveScreenshot.js.map +1 -1
  16. package/dist/core/tests/startRecording.d.ts.map +1 -1
  17. package/dist/core/tests/startRecording.js +0 -3
  18. package/dist/core/tests/startRecording.js.map +1 -1
  19. package/dist/core/tests/stopRecording.d.ts.map +1 -1
  20. package/dist/core/tests/stopRecording.js +23 -14
  21. package/dist/core/tests/stopRecording.js.map +1 -1
  22. package/dist/core/tests/typeKeys.js +2 -2
  23. package/dist/core/tests/typeKeys.js.map +1 -1
  24. package/dist/core/tests.d.ts +60 -4
  25. package/dist/core/tests.d.ts.map +1 -1
  26. package/dist/core/tests.js +771 -379
  27. package/dist/core/tests.js.map +1 -1
  28. package/dist/core/utils.d.ts +9 -1
  29. package/dist/core/utils.d.ts.map +1 -1
  30. package/dist/core/utils.js +57 -1
  31. package/dist/core/utils.js.map +1 -1
  32. package/dist/hints/context.d.ts.map +1 -1
  33. package/dist/hints/context.js +9 -2
  34. package/dist/hints/context.js.map +1 -1
  35. package/dist/hints/hints.d.ts.map +1 -1
  36. package/dist/hints/hints.js +20 -0
  37. package/dist/hints/hints.js.map +1 -1
  38. package/dist/hints/types.d.ts +5 -0
  39. package/dist/hints/types.d.ts.map +1 -1
  40. package/dist/index.cjs +591 -317
  41. package/dist/runtime/browsers.d.ts +14 -0
  42. package/dist/runtime/browsers.d.ts.map +1 -1
  43. package/dist/runtime/browsers.js +23 -0
  44. package/dist/runtime/browsers.js.map +1 -1
  45. package/dist/utils.d.ts.map +1 -1
  46. package/dist/utils.js +25 -0
  47. package/dist/utils.js.map +1 -1
  48. 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, } 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";
@@ -31,13 +32,13 @@ import { spawn } from "node:child_process";
31
32
  import { randomUUID } from "node:crypto";
32
33
  import { setAppiumHome } from "./appium.js";
33
34
  import { resolveExpression } from "./expressions.js";
34
- import { getEnvironment, getAvailableApps, clearAppCache } from "./config.js";
35
+ import { getEnvironment, getAvailableApps, clearAppCache, resolveConcurrentRunners, } from "./config.js";
35
36
  import { uploadChangedFiles } from "./integrations/index.js";
36
37
  import http from "node:http";
37
38
  import https from "node:https";
38
39
  import { fileURLToPath } from "node:url";
39
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, getDriverCapabilities, getDefaultBrowser, isSupportedContext, };
41
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, isSupportedContext, };
41
42
  // exports.appiumStart = appiumStart;
42
43
  // exports.appiumIsReady = appiumIsReady;
43
44
  // exports.driverStart = driverStart;
@@ -57,6 +58,30 @@ const driverActions = [
57
58
  // Browser names getDriverCapabilities knows how to build caps for. `safari` is
58
59
  // rewritten to `webkit` during context resolution, so both appear here.
59
60
  const KNOWN_BROWSERS = ["firefox", "chrome", "safari", "webkit"];
61
+ /**
62
+ * Stable identity for a "context combination" — the platform + browser pairing
63
+ * that determines whether a driver session can be created. The runner memoizes
64
+ * warm-up outcomes by this key so a combination that fails to start once isn't
65
+ * re-attempted (with its slow driverStart backoff) for every later context.
66
+ * headless is intentionally excluded: headed/headless are two attempts at the
67
+ * same combination (the loop retries headless on failure), not distinct ones.
68
+ * `webkit` is normalized to `safari` so the key matches getAvailableApps naming.
69
+ */
70
+ function combinationKey(context) {
71
+ const rawName = context?.browser?.name;
72
+ const name = rawName === "webkit" ? "safari" : rawName || "<none>";
73
+ return `${context?.platform}::${name}`;
74
+ }
75
+ /**
76
+ * Decide whether a context combination should be attempted or skipped, given
77
+ * its prior warm-up outcome in this run. Pure so the memoization branching is
78
+ * unit-testable without spinning up Appium. A previously-failed combination is
79
+ * skipped outright; everything else is attempted (and its outcome recorded by
80
+ * the caller).
81
+ */
82
+ function warmUpDecision(prev) {
83
+ return prev === "failed" ? "skip" : "attempt";
84
+ }
60
85
  // Get Appium driver capabilities and apply options.
61
86
  function getDriverCapabilities({ runnerDetails, name, options }) {
62
87
  let capabilities = {};
@@ -129,8 +154,15 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
129
154
  args.push(`--auto-select-desktop-capture-source=RECORD_ME`);
130
155
  if (options.headless)
131
156
  args.push("--headless", "--disable-gpu");
132
- if (process.platform === "linux")
157
+ if (process.platform === "linux") {
133
158
  args.push("--no-sandbox");
159
+ // Chrome writes shared memory to /dev/shm, which is only ~64MB on
160
+ // many Linux/CI hosts. A single browser fits, but several launched
161
+ // at once under concurrentRunners exhaust it and ChromeDriver
162
+ // "crashed during startup". Redirect that allocation to /tmp so
163
+ // parallel browser contexts start reliably.
164
+ args.push("--disable-dev-shm-usage");
165
+ }
134
166
  // Set capabilities
135
167
  capabilities = {
136
168
  platformName: runnerDetails.environment.platform,
@@ -157,24 +189,11 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
157
189
  }
158
190
  return capabilities;
159
191
  }
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
192
  function isDriverRequired({ test }) {
176
193
  let driverRequired = false;
177
- test.steps.forEach((step) => {
194
+ // The resolved shape doesn't guarantee `steps` treat a stepless test or
195
+ // context as needing no driver instead of throwing.
196
+ (test.steps || []).forEach((step) => {
178
197
  // Check if test includes actions that require a driver.
179
198
  driverActions.forEach((action) => {
180
199
  if (typeof step[action] !== "undefined")
@@ -376,9 +395,11 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
376
395
  /**
377
396
  * Orchestrates execution of resolved test specifications and returns a hierarchical run report.
378
397
  *
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.
398
+ * Flattens every context across all specs and tests into one job list and runs it through a
399
+ * worker pool sized by config.concurrentRunners (default 1 = sequential). Conditionally starts
400
+ * Appium and browser drivers, applies viewport/window sizing, handles unsafe-step policies and
401
+ * recording, then rolls per-step, per-context, per-test, and per-spec results up in a
402
+ * deterministic post-pass. Report order always matches input order.
382
403
  *
383
404
  * @param {Object} resolvedTests - Resolved test bundle containing configuration and specs to run.
384
405
  * @param {Object} resolvedTests.config - Runner configuration used during execution.
@@ -422,9 +443,18 @@ async function runSpecs({ resolvedTests }) {
422
443
  };
423
444
  // Set initial shorthand values
424
445
  const platform = runnerDetails.environment.platform;
425
- const availableApps = runnerDetails.availableApps;
446
+ // `let`, not `const`: an on-demand browser install during the context loop
447
+ // re-detects available apps and reassigns this snapshot (see the support
448
+ // gate below).
449
+ let availableApps = runnerDetails.availableApps;
426
450
  const metaValues = { specs: {} };
427
- let appium;
451
+ // Per-run memoization, shared across the concurrent context pool below.
452
+ // installAttempts keeps a browser's on-demand install from being retried for
453
+ // every context that uses it; warmUpResults keeps a context combination that
454
+ // can't start a driver from being re-attempted (with its slow driverStart
455
+ // backoff) for the rest of the run.
456
+ const installAttempts = new Map();
457
+ const warmUpResults = new Map();
428
458
  const report = {
429
459
  summary: {
430
460
  specs: {
@@ -454,377 +484,191 @@ async function runSpecs({ resolvedTests }) {
454
484
  },
455
485
  specs: [],
456
486
  };
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
487
+ // Resolve concurrency up front (defensive re-resolve: API callers can hand
488
+ // runSpecs a config that never went through core setConfig, leaving
489
+ // concurrentRunners as `true`). Drives both the worker pool and how many
490
+ // Appium servers to start.
491
+ const limit = resolveConcurrentRunners(config);
492
+ // Phase 1: pre-build the report skeleton and a flat list of context jobs
493
+ // across all specs and tests. Slots are pre-assigned so report order always
494
+ // matches input order, no matter what order concurrent contexts finish in.
509
495
  log(config, "info", "Running test specs.");
496
+ const jobs = [];
510
497
  for (const spec of specs) {
511
498
  log(config, "debug", `SPEC: ${spec.specId}`);
512
- // Set spec report
513
- let specReport = {
499
+ // Create-if-missing: specIds (and testIds) aren't guaranteed unique
500
+ // across the run, and all registration now happens up front — an
501
+ // overwrite here would wipe an earlier spec's registered tests.
502
+ metaValues.specs[spec.specId] ??= { tests: {} };
503
+ const specReport = {
514
504
  specId: spec.specId,
515
505
  description: spec.description,
516
506
  contentPath: spec.contentPath,
517
507
  tests: [],
518
508
  };
519
- // Set meta values
520
- metaValues.specs[spec.specId] = { tests: {} };
521
- // Iterates tests
509
+ report.specs.push(specReport);
522
510
  for (const test of spec.tests) {
523
511
  log(config, "debug", `TEST: ${test.testId}`);
524
- // Set test report
525
- let testReport = {
512
+ metaValues.specs[spec.specId].tests[test.testId] ??= { contexts: {} };
513
+ const testReport = {
526
514
  testId: test.testId,
527
515
  description: test.description,
528
516
  contentPath: test.contentPath,
529
517
  detectSteps: test.detectSteps,
530
- contexts: [],
518
+ contexts: new Array(test.contexts.length),
531
519
  };
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
- ];
520
+ specReport.tests.push(testReport);
521
+ test.contexts.forEach((context, slot) => {
522
+ context.contextId = context.contextId || randomUUID();
523
+ jobs.push({ spec, test, context, contexts: testReport.contexts, slot });
524
+ });
525
+ }
526
+ }
527
+ if (limit > 1 &&
528
+ jobs.some((job) => job.context.steps?.some((step) => step.record !== undefined))) {
529
+ // Concurrent headed recordings capture via getDisplayMedia and can grab
530
+ // the wrong window. Recording filenames in the temp dir can also collide.
531
+ 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.");
532
+ }
533
+ // Start one Appium server per concurrent runner that will actually use a
534
+ // driver (capped at the number of driver contexts). Each server owns a
535
+ // distinct port, so parallel contexts never create sessions on the same
536
+ // server — that contention crashed ChromeDriver when every context shared
537
+ // one server. Non-driver runs start none.
538
+ const driverJobCount = jobs.filter((job) => isDriverRequired({ test: job.context })).length;
539
+ let appiumServers = [];
540
+ let appiumPool;
541
+ if (driverJobCount > 0) {
542
+ setAppiumHome({ cacheDir: config?.cacheDir });
543
+ // Resolve appium's actual JS entrypoint via `require.resolve` (shim
544
+ // node_modules first, runtime cache second) and invoke it with
545
+ // `node <entry>`. This sidesteps every shell-injection trap at once: no
546
+ // `.cmd` shim, so no Windows-requires-shell:true; no `npx`, so no PATH
547
+ // lookup; no user-controlled paths in a shell-interpreted string. Works
548
+ // for both `--omit=optional` users (appium in cache only) and default
549
+ // installs (appium in shim).
550
+ const appiumEntry = resolveHeavyDepPath("appium", {
551
+ cacheDir: config?.cacheDir,
552
+ });
553
+ if (!appiumEntry) {
554
+ 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`.");
555
+ }
556
+ const serverCount = Math.min(limit, driverJobCount);
557
+ log(config, "debug", `Starting ${serverCount} Appium server(s).`);
558
+ // Start servers one at a time rather than all at once: concurrent
559
+ // findFreePort() calls share a close-to-rebind window (two could hand out
560
+ // the same port), and spawning every Appium at once spikes CPU during
561
+ // startup. Sequential startup is a one-time per-run cost (serverCount <= 4)
562
+ // that removes the port race and fails fast on the first server that can't
563
+ // come up, tearing down any already started so they don't leak.
564
+ try {
565
+ for (let i = 0; i < serverCount; i++) {
566
+ appiumServers.push(await startAppiumServer(appiumEntry, config));
567
+ }
568
+ }
569
+ catch (error) {
570
+ for (const server of appiumServers) {
571
+ try {
572
+ kill(server.process.pid);
546
573
  }
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 });
574
+ catch {
575
+ // best-effort
550
576
  }
551
- // Set context report
552
- let contextReport = {
553
- contextId: context.contextId || randomUUID(),
554
- platform: context.platform,
555
- browser: context.browser,
577
+ }
578
+ throw error;
579
+ }
580
+ appiumPool = createAppiumPool(appiumServers.map((s) => s.port));
581
+ }
582
+ // Everything that uses the Appium servers runs inside this try so the
583
+ // shutdown in `finally` always reaches them — otherwise a throw in
584
+ // warmUpContexts (e.g. getAvailableApps failing during the re-detect) would
585
+ // leak the started servers, leaving orphaned processes bound to their ports.
586
+ try {
587
+ // For concurrent runs, resolve missing browser dependencies and warm up
588
+ // each unique driver combination serially *before* the pool. Two contexts
589
+ // can't then race on an on-demand install (which mutates the shared app
590
+ // cache), and a combination that can't start a driver is recorded once here
591
+ // so every parallel context sharing it skips instantly instead of re-paying
592
+ // driverStart's backoff. This pre-populates installAttempts /
593
+ // warmUpResults / runnerDetails.availableApps, so runContext's own gates
594
+ // below collapse to fast cache hits. Sequential runs (limit 1) keep #338's
595
+ // natural first-context-warms-up behavior in runContext — no pre-pass, no
596
+ // extra driver start, byte-identical to before.
597
+ if (limit > 1 && appiumPool) {
598
+ await warmUpContexts({
599
+ jobs,
600
+ config,
601
+ runnerDetails,
602
+ appiumPool,
603
+ installAttempts,
604
+ warmUpResults,
605
+ });
606
+ }
607
+ // Phase 2: run every context job through one flat worker pool. A limit of 1
608
+ // (the default) is strictly sequential in input order.
609
+ await runConcurrent(jobs, limit, async (job) => {
610
+ try {
611
+ job.contexts[job.slot] = await runContext({
612
+ config,
613
+ spec: job.spec,
614
+ test: job.test,
615
+ context: job.context,
616
+ runnerDetails,
617
+ appiumPool,
618
+ metaValues,
619
+ installAttempts,
620
+ warmUpResults,
621
+ logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
622
+ });
623
+ }
624
+ catch (error) {
625
+ // Error isolation: one crashing context must not abort sibling jobs.
626
+ // Guard against non-Error throws (a thrown string/object has no
627
+ // .message) so the real failure detail survives in logs and report.
628
+ const detail = error?.message ?? String(error);
629
+ log(config, "error", `Context '${job.context.contextId}' crashed: ${detail}`);
630
+ job.contexts[job.slot] = {
631
+ contextId: job.context.contextId,
632
+ platform: job.context.platform,
633
+ browser: job.context.browser,
634
+ result: "FAIL",
635
+ resultDescription: `Unexpected error: ${detail}`,
556
636
  steps: [],
557
637
  };
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;
575
- }
576
- // Check if current environment supports given contexts
577
- const supportedContext = isSupportedContext({
578
- context: context,
579
- apps: availableApps,
580
- platform: platform,
581
- });
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++;
638
+ }
639
+ });
640
+ // Phase 3: roll results up the tree and count the summary in one
641
+ // deterministic pass after all contexts have finished.
642
+ for (const specReport of report.specs) {
643
+ for (const testReport of specReport.tests) {
644
+ for (const contextReport of testReport.contexts) {
645
+ // Every slot is assigned by the pool callback (even on crash), so
646
+ // this guard should never fire — it documents the invariant and
647
+ // keeps a future gap from surfacing as a cryptic undefined read.
648
+ if (!contextReport)
693
649
  continue;
650
+ for (const stepReport of contextReport.steps) {
651
+ report.summary.steps[stepReport.result.toLowerCase()]++;
694
652
  }
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
- }
653
+ report.summary.contexts[contextReport.result.toLowerCase()]++;
779
654
  }
655
+ testReport.result = rollUpResults(testReport.contexts.filter(Boolean));
656
+ report.summary.tests[testReport.result.toLowerCase()]++;
780
657
  }
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()]++;
658
+ specReport.result = rollUpResults(specReport.tests);
659
+ report.summary.specs[specReport.result.toLowerCase()]++;
800
660
  }
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
661
  }
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
662
+ finally {
663
+ // Close every Appium server we started.
664
+ for (const server of appiumServers) {
665
+ log(config, "debug", `Closing Appium server on port ${server.port}`);
666
+ try {
667
+ kill(server.process.pid);
668
+ }
669
+ catch {
670
+ // Process may already be terminated
671
+ }
828
672
  }
829
673
  }
830
674
  // Upload changed files back to source integrations (best-effort)
@@ -853,6 +697,468 @@ async function runSpecs({ resolvedTests }) {
853
697
  }
854
698
  return report;
855
699
  }
700
+ /**
701
+ * Pick which contexts warmUpContexts should warm up: one representative per
702
+ * unique platform::browser combination among the driver-required jobs. Applies
703
+ * the same platform default and default-browser resolution runContext uses, so
704
+ * the combination keys it produces match the ones runContext looks up in the
705
+ * pool. Non-driver and browserless contexts are excluded. Mutates
706
+ * context.platform / context.browser in place — idempotent, since runContext
707
+ * applies the identical defaults. Pure (no I/O) so the selection + de-dup +
708
+ * normalization logic is unit-testable without Appium.
709
+ */
710
+ function selectWarmUpTargets(jobs, runnerDetails) {
711
+ const platform = runnerDetails.environment.platform;
712
+ const seen = new Set();
713
+ const targets = [];
714
+ for (const job of jobs) {
715
+ const context = job.context;
716
+ if (!context.steps)
717
+ context.steps = [];
718
+ // Default platform to the runner's, matching runContext. Without this a
719
+ // resolved context of `{}` (no runOn — the common case) keys as
720
+ // `undefined::<browser>`, fails the support check, and is skipped — which
721
+ // would defeat the warm-up/install de-racing the pre-pass exists for.
722
+ if (!context.platform)
723
+ context.platform = platform;
724
+ if (!context.browser && isDriverRequired({ test: context })) {
725
+ context.browser = getDefaultBrowser({ runnerDetails });
726
+ }
727
+ if (!isDriverRequired({ test: context }))
728
+ continue;
729
+ // No resolvable browser — runContext skips these per-context with its own
730
+ // message; nothing to warm up.
731
+ if (!context.browser?.name)
732
+ continue;
733
+ const combo = combinationKey(context);
734
+ if (seen.has(combo))
735
+ continue;
736
+ seen.add(combo);
737
+ targets.push({ context, combo });
738
+ }
739
+ return targets;
740
+ }
741
+ /**
742
+ * Serial pre-pass for concurrent runs. For each unique driver combination
743
+ * (platform::browser) among the jobs, resolves a missing browser dependency on
744
+ * demand and then warms up a driver once, recording the outcome. Runs before
745
+ * the worker pool so:
746
+ * - on-demand installs never race (they mutate the shared app cache), and
747
+ * - a combination that can't start a driver is recorded once, so every
748
+ * parallel context sharing it is skipped instantly by runContext's warm-up
749
+ * gate instead of each re-paying driverStart's retry/backoff.
750
+ * Mirrors the install + driver-start logic in runContext so the memoization
751
+ * state (installAttempts / warmUpResults / runnerDetails.availableApps) is
752
+ * identical to what the first same-combo context would have produced serially.
753
+ */
754
+ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, installAttempts, warmUpResults, }) {
755
+ const platform = runnerDetails.environment.platform;
756
+ // Which unique combinations to warm up (with the same platform/browser
757
+ // normalization runContext applies) is extracted into selectWarmUpTargets so
758
+ // it can be unit-tested without spinning up Appium.
759
+ for (const { context } of selectWarmUpTargets(jobs, runnerDetails)) {
760
+ const combo = combinationKey(context);
761
+ // On-demand install + re-detect (serial), mirroring runContext's gate.
762
+ let supported = isSupportedContext({
763
+ context,
764
+ apps: runnerDetails.availableApps,
765
+ platform,
766
+ });
767
+ if (!supported &&
768
+ context.platform === platform &&
769
+ Array.isArray(context?.steps) &&
770
+ requiredBrowserAssets(context.browser?.name).length > 0) {
771
+ const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
772
+ const outcome = await ensureContextBrowserInstalled({
773
+ browserName: context.browser?.name,
774
+ config,
775
+ installAttempts,
776
+ deps: {
777
+ ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
778
+ log,
779
+ },
780
+ });
781
+ if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
782
+ clearAppCache(config);
783
+ runnerDetails.availableApps = await getAvailableApps({ config });
784
+ supported = isSupportedContext({
785
+ context,
786
+ apps: runnerDetails.availableApps,
787
+ platform,
788
+ });
789
+ }
790
+ }
791
+ // Unsupported combinations are left unmarked; runContext skips each with the
792
+ // appropriate per-context reason (install-but-undetected vs unsupported).
793
+ if (!supported)
794
+ continue;
795
+ // Warm-up probe: start a driver once to prove the combination works.
796
+ // driverStart's own transient retry absorbs concurrent-launch flakiness;
797
+ // a headless fallback (on a throwaway caps object, so the real contexts
798
+ // keep their configured headedness) matches runContext so a headed-only
799
+ // failure on a headless-capable box doesn't poison the combination.
800
+ const port = await appiumPool.acquire();
801
+ let warmDriver;
802
+ try {
803
+ const options = {
804
+ width: context.browser?.window?.width || 1200,
805
+ height: context.browser?.window?.height || 800,
806
+ headless: context.browser?.headless !== false,
807
+ };
808
+ try {
809
+ warmDriver = await driverStart(getDriverCapabilities({
810
+ runnerDetails,
811
+ name: context.browser.name,
812
+ options,
813
+ }), port, 4, { cacheDir: config?.cacheDir });
814
+ }
815
+ catch {
816
+ log(config, "warning", `Warm-up for ${combo} failed headed; retrying headless.`);
817
+ warmDriver = await driverStart(getDriverCapabilities({
818
+ runnerDetails,
819
+ name: context.browser.name,
820
+ options: { ...options, headless: true },
821
+ }), port, 4, { cacheDir: config?.cacheDir });
822
+ }
823
+ warmUpResults.set(combo, "ok");
824
+ log(config, "debug", `Warm-up succeeded for ${combo}.`);
825
+ }
826
+ catch (error) {
827
+ warmUpResults.set(combo, "failed");
828
+ log(config, "warning", `Warm-up failed for ${combo}; contexts using it will be skipped: ${error?.message ?? String(error)}`);
829
+ }
830
+ finally {
831
+ if (warmDriver) {
832
+ try {
833
+ await warmDriver.deleteSession();
834
+ }
835
+ catch {
836
+ // best-effort teardown of the warm-up session
837
+ }
838
+ }
839
+ appiumPool.release(port);
840
+ }
841
+ }
842
+ }
843
+ /**
844
+ * Runs a single resolved context to completion and returns its finished
845
+ * contextReport (steps array + rolled-up result). Never touches the shared
846
+ * report or summary counters — the caller owns aggregation, which keeps this
847
+ * function safe to run concurrently with sibling contexts.
848
+ */
849
+ async function runContext({ config, spec, test, context, runnerDetails, appiumPool, metaValues, installAttempts, warmUpResults, logPrefix = "", }) {
850
+ const platform = runnerDetails.environment.platform;
851
+ // `let`, not `const`: an on-demand browser install below re-detects available
852
+ // apps and reassigns this snapshot.
853
+ let availableApps = runnerDetails.availableApps;
854
+ // Context-scoped log: prefixed only when contexts run concurrently, so
855
+ // sequential output stays unchanged.
856
+ const clog = (level, message) => log(config, level, logPrefix && typeof message === "string"
857
+ ? `${logPrefix} ${message}`
858
+ : message);
859
+ // Ensure context contains a 'steps' property before anything walks it —
860
+ // isDriverRequired iterates context.steps and the resolved shape doesn't
861
+ // guarantee the field.
862
+ if (!context.steps) {
863
+ context.steps = [];
864
+ }
865
+ // If "platform" is not defined, set it to the current platform
866
+ if (!context.platform)
867
+ context.platform = runnerDetails.environment.platform;
868
+ // Attach OpenAPI definitions to context
869
+ if (config.integrations?.openApi) {
870
+ context.openApi = [
871
+ ...(context.openApi || []),
872
+ ...config.integrations.openApi,
873
+ ];
874
+ }
875
+ // 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
876
+ if (!context.browser && isDriverRequired({ test: context })) {
877
+ context.browser = getDefaultBrowser({ runnerDetails });
878
+ }
879
+ // Set context report
880
+ const contextReport = {
881
+ contextId: context.contextId,
882
+ platform: context.platform,
883
+ browser: context.browser,
884
+ steps: [],
885
+ };
886
+ // Set meta values (create-if-missing — ids aren't guaranteed unique)
887
+ metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId] ??= { steps: {} };
888
+ // If a driver is required but no browser could be resolved (e.g.
889
+ // getDefaultBrowser found nothing installed, or the context supplied a
890
+ // browser object with no name), skip with an explicit reason instead of
891
+ // letting it fail later as "Failed to start context 'undefined'".
892
+ if (isDriverRequired({ test: context }) && !context.browser?.name) {
893
+ const errorMessage = `Skipping context on '${context.platform}': no supported browser is available in the current environment.`;
894
+ clog("warning", errorMessage);
895
+ contextReport.result = "SKIPPED";
896
+ contextReport.resultDescription = errorMessage;
897
+ return contextReport;
898
+ }
899
+ // Check if current environment supports given contexts
900
+ let supportedContext = isSupportedContext({
901
+ context: context,
902
+ apps: availableApps,
903
+ platform: platform,
904
+ });
905
+ // If the context needs a browser that isn't available yet, try to resolve
906
+ // the missing dependency on demand before giving up — e.g. Firefox declared
907
+ // but geckodriver absent because the pre-flight was skipped or its install
908
+ // failed. Memoized per browser (installAttempts) so a failed/no-op install
909
+ // isn't retried for every later context. The install + re-detect mutate the
910
+ // shared runnerDetails.availableApps; under concurrency that's racy, but it
911
+ // only fires for a genuinely-missing browser (rare) and the app list only
912
+ // grows, so a sibling reading a slightly stale snapshot still re-detects.
913
+ let freshInstallRedetected = false;
914
+ if (!supportedContext &&
915
+ context.platform === platform &&
916
+ // Mirror isSupportedContext's own guard: isDriverRequired iterates
917
+ // context.steps, so a malformed context without a steps array would
918
+ // otherwise crash here instead of skipping cleanly.
919
+ Array.isArray(context?.steps) &&
920
+ isDriverRequired({ test: context }) &&
921
+ requiredBrowserAssets(context.browser?.name).length > 0) {
922
+ // Whether this browser was already attempted earlier this run; a cached
923
+ // outcome installed nothing new, so there's no point paying for a re-detect.
924
+ const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
925
+ const outcome = await ensureContextBrowserInstalled({
926
+ browserName: context.browser?.name,
927
+ config,
928
+ installAttempts,
929
+ deps: {
930
+ ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
931
+ log,
932
+ },
933
+ });
934
+ // Re-detect after a real attempt regardless of outcome: a "failed" install
935
+ // can still have materialized assets before it threw, so a stale snapshot
936
+ // could wrongly skip a now-usable browser.
937
+ if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
938
+ freshInstallRedetected = true;
939
+ clearAppCache(config);
940
+ availableApps = await getAvailableApps({ config });
941
+ runnerDetails.availableApps = availableApps;
942
+ supportedContext = isSupportedContext({
943
+ context: context,
944
+ apps: availableApps,
945
+ platform: platform,
946
+ });
947
+ }
948
+ }
949
+ // If context isn't supported, skip it
950
+ if (!supportedContext) {
951
+ // Distinguish "we installed the dependency but still can't see it" from a
952
+ // plain unsupported context, so the skip reason points at the real problem.
953
+ const errorMessage = freshInstallRedetected
954
+ ? `Skipping context '${context.browser?.name}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
955
+ : `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`;
956
+ clog(freshInstallRedetected ? "warning" : "info", errorMessage);
957
+ contextReport.result = "SKIPPED";
958
+ contextReport.resultDescription = errorMessage;
959
+ return contextReport;
960
+ }
961
+ clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
962
+ let driver;
963
+ let appiumPort;
964
+ const driverRequired = isDriverRequired({ test: context });
965
+ if (driverRequired && !appiumPool) {
966
+ throw new Error("Driver requested but no Appium server pool was created; " +
967
+ "driverJobCount and isDriverRequired(context) disagreed; this is a bug.");
968
+ }
969
+ // Warm-up memoization. The first context of each combination acts as the
970
+ // warm-up; if that combination already failed to start a driver earlier in
971
+ // this run, skip it outright instead of paying driverStart's retry/backoff
972
+ // again. Under concurrency this is a best-effort speedup, not correctness —
973
+ // same-combo contexts may start before one records a result.
974
+ const combo = combinationKey(context);
975
+ try {
976
+ if (driverRequired) {
977
+ if (warmUpDecision(warmUpResults.get(combo)) === "skip") {
978
+ const errorMessage = `Skipping context '${context.browser?.name}' on '${context.platform}': this context combination could not start a driver earlier in this run.`;
979
+ clog("warning", errorMessage);
980
+ contextReport.result = "SKIPPED";
981
+ contextReport.resultDescription = errorMessage;
982
+ return contextReport;
983
+ }
984
+ // Check out a server for this context's lifetime — released in the
985
+ // finally so the next queued context can reuse it.
986
+ appiumPort = await appiumPool.acquire();
987
+ // Define driver capabilities
988
+ // TODO: Support custom apps
989
+ let caps = getDriverCapabilities({
990
+ runnerDetails: runnerDetails,
991
+ name: context.browser.name,
992
+ options: {
993
+ width: context.browser?.window?.width || 1200,
994
+ height: context.browser?.window?.height || 800,
995
+ headless: context.browser?.headless !== false,
996
+ },
997
+ });
998
+ clog("debug", "CAPABILITIES:");
999
+ clog("debug", caps);
1000
+ // Instantiate driver
1001
+ try {
1002
+ driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
1003
+ }
1004
+ catch (error) {
1005
+ try {
1006
+ // If driver fails to start, try again as headless
1007
+ clog("warning", `Failed to start context '${context.browser?.name}' on '${platform}'. Retrying as headless.`);
1008
+ context.browser.headless = true;
1009
+ caps = getDriverCapabilities({
1010
+ runnerDetails: runnerDetails,
1011
+ name: context.browser.name,
1012
+ options: {
1013
+ width: context.browser?.window?.width || 1200,
1014
+ height: context.browser?.window?.height || 800,
1015
+ headless: context.browser?.headless !== false,
1016
+ },
1017
+ });
1018
+ driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
1019
+ }
1020
+ catch (error) {
1021
+ let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`;
1022
+ // `safari` is normalized to `webkit` during context resolution, so
1023
+ // match both or this Safari-specific hint never fires on real runs.
1024
+ if (context.browser?.name === "safari" ||
1025
+ context.browser?.name === "webkit")
1026
+ errorMessage =
1027
+ errorMessage +
1028
+ " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
1029
+ clog("error", errorMessage);
1030
+ // Record the combination as failed so every later context that shares
1031
+ // it is skipped instantly (see the warm-up check above).
1032
+ if (!warmUpResults.has(combo))
1033
+ warmUpResults.set(combo, "failed");
1034
+ contextReport.result = "SKIPPED";
1035
+ contextReport.resultDescription = errorMessage;
1036
+ return contextReport;
1037
+ }
1038
+ }
1039
+ // Driver started (first attempt or headless retry) — mark this
1040
+ // combination as known-good for the rest of the run.
1041
+ if (!warmUpResults.has(combo))
1042
+ warmUpResults.set(combo, "ok");
1043
+ if (context.browser?.viewport?.width ||
1044
+ context.browser?.viewport?.height) {
1045
+ // Set driver viewport size
1046
+ await setViewportSize(context, driver);
1047
+ }
1048
+ else if (context.browser?.window?.width ||
1049
+ context.browser?.window?.height) {
1050
+ // Get driver window size
1051
+ const windowSize = await driver.getWindowSize();
1052
+ // Resize window if necessary
1053
+ await driver.setWindowSize(context.browser?.window?.width || windowSize.width, context.browser?.window?.height || windowSize.height);
1054
+ }
1055
+ }
1056
+ // Iterates steps
1057
+ let stepExecutionFailed = false;
1058
+ for (let step of context.steps) {
1059
+ // Set step id if not defined
1060
+ if (!step.stepId)
1061
+ step.stepId = randomUUID();
1062
+ clog("debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
1063
+ if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
1064
+ clog("warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
1065
+ // Mark as skipped
1066
+ const stepReport = {
1067
+ ...step,
1068
+ result: "SKIPPED",
1069
+ resultDescription: "Skipped because unsafe steps aren't allowed.",
1070
+ };
1071
+ contextReport.steps.push(stepReport);
1072
+ continue;
1073
+ }
1074
+ if (stepExecutionFailed) {
1075
+ // Mark as skipped
1076
+ const stepReport = {
1077
+ ...step,
1078
+ result: "SKIPPED",
1079
+ resultDescription: "Skipped due to previous failure in context.",
1080
+ };
1081
+ contextReport.steps.push(stepReport);
1082
+ continue;
1083
+ }
1084
+ // Set meta values
1085
+ metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId].steps[step.stepId] = {};
1086
+ // Run step
1087
+ const stepResult = await runStep({
1088
+ config: config,
1089
+ context: context,
1090
+ step: step,
1091
+ driver: driver,
1092
+ metaValues: metaValues,
1093
+ options: {
1094
+ openApiDefinitions: context.openApi || [],
1095
+ },
1096
+ });
1097
+ clog("debug", `RESULT: ${stepResult.status}\n${JSON.stringify(stepResult, null, 2)}`);
1098
+ stepResult.result = stepResult.status;
1099
+ stepResult.resultDescription = stepResult.description;
1100
+ delete stepResult.status;
1101
+ delete stepResult.description;
1102
+ // Add step result to report
1103
+ const stepReport = {
1104
+ ...step,
1105
+ ...stepResult,
1106
+ };
1107
+ contextReport.steps.push(stepReport);
1108
+ // If this step failed, set flag to skip remaining steps
1109
+ if (stepReport.result === "FAIL") {
1110
+ stepExecutionFailed = true;
1111
+ }
1112
+ }
1113
+ // If recording, stop recording
1114
+ if (driver?.state?.recording) {
1115
+ const stopRecordStep = {
1116
+ stopRecord: true,
1117
+ description: "Stopping recording",
1118
+ stepId: randomUUID(),
1119
+ };
1120
+ const stepResult = await runStep({
1121
+ config: config,
1122
+ context: context,
1123
+ step: stopRecordStep,
1124
+ driver: driver,
1125
+ options: {
1126
+ openApiDefinitions: context.openApi || [],
1127
+ },
1128
+ });
1129
+ stepResult.result = stepResult.status;
1130
+ stepResult.resultDescription = stepResult.description;
1131
+ delete stepResult.status;
1132
+ delete stepResult.description;
1133
+ // Add step result to report
1134
+ const stepReport = {
1135
+ ...stopRecordStep,
1136
+ ...stepResult,
1137
+ };
1138
+ contextReport.steps.push(stepReport);
1139
+ }
1140
+ }
1141
+ finally {
1142
+ // Close driver. In a finally so an unexpected throw can't leak a session
1143
+ // while sibling contexts keep running.
1144
+ if (driver) {
1145
+ try {
1146
+ await driver.deleteSession();
1147
+ }
1148
+ catch (error) {
1149
+ clog("error", `Failed to delete driver session: ${error.message}`);
1150
+ }
1151
+ }
1152
+ // Return the Appium server to the pool for the next queued context. Always
1153
+ // runs (even on the driver-start-failure early return) so a port can't
1154
+ // leak out of the pool and starve later contexts.
1155
+ if (appiumPort !== undefined && appiumPool) {
1156
+ appiumPool.release(appiumPort);
1157
+ }
1158
+ }
1159
+ contextReport.result = rollUpResults(contextReport.steps);
1160
+ return contextReport;
1161
+ }
856
1162
  // Run a specific step
857
1163
  async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, }) {
858
1164
  let actionResult;
@@ -915,7 +1221,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
915
1221
  step: step,
916
1222
  driver: driver,
917
1223
  });
918
- config.recording = actionResult.recording;
1224
+ driver.state.recording = actionResult.recording ?? null;
919
1225
  }
920
1226
  else if (typeof step.runCode !== "undefined") {
921
1227
  actionResult = await runCode({ config: config, step: step });
@@ -947,7 +1253,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
947
1253
  };
948
1254
  }
949
1255
  // If recording, wait until browser is loaded, then instantiate cursor
950
- if (config?.recording) {
1256
+ if (driver?.state?.recording) {
951
1257
  const currentUrl = await driver.getUrl();
952
1258
  if (currentUrl !== driver.state.url) {
953
1259
  driver.state.url = currentUrl;
@@ -971,6 +1277,40 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
971
1277
  }
972
1278
  return actionResult;
973
1279
  }
1280
+ // Start one Appium server on a free port and resolve once it answers /status.
1281
+ // Each concurrent runner gets its own server (own port) so parallel contexts
1282
+ // never create sessions on the same Appium instance.
1283
+ async function startAppiumServer(appiumEntry, config) {
1284
+ const port = await findFreePort();
1285
+ log(config, "debug", `Starting Appium on port ${port}`);
1286
+ const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port)], {
1287
+ windowsHide: true,
1288
+ cwd: path.join(__dirname, "../.."),
1289
+ });
1290
+ proc.on("error", (err) => {
1291
+ log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
1292
+ });
1293
+ proc.stdout.on("data", () => { });
1294
+ proc.stderr.on("data", () => { });
1295
+ try {
1296
+ await appiumIsReady(port);
1297
+ }
1298
+ catch (error) {
1299
+ // appiumIsReady threw or timed out — the spawned child is still alive and
1300
+ // would leak (orphan process, port still bound). Tear it down before
1301
+ // propagating so subsequent runs don't trip on the stale state.
1302
+ try {
1303
+ if (proc && proc.pid)
1304
+ kill(proc.pid);
1305
+ }
1306
+ catch {
1307
+ // best-effort cleanup; the parent error is what matters
1308
+ }
1309
+ throw error;
1310
+ }
1311
+ log(config, "debug", `Appium is ready on port ${port}.`);
1312
+ return { port, process: proc };
1313
+ }
974
1314
  // Delay execution until Appium server is available.
975
1315
  async function appiumIsReady(port, timeoutMs = 120000) {
976
1316
  let isReady = false;
@@ -991,10 +1331,20 @@ async function appiumIsReady(port, timeoutMs = 120000) {
991
1331
  }
992
1332
  // Start the Appium driver specified in `capabilities`.
993
1333
  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.
1334
+ // Two families of transient, retryable session-creation failures, both worse
1335
+ // under concurrency (the TRANSIENT regex below enumerates the specific
1336
+ // patterns):
1337
+ // 1. POST /session races a just-spawned-or-still-dying Appium (Windows):
1338
+ // /status returns 200 from the outgoing process while /session no longer
1339
+ // accepts, or Appium's proxy to chromedriver drops the socket ->
1340
+ // ECONNREFUSED / ECONNRESET / "socket hang up" / "could not proxy command".
1341
+ // 2. Several Chromes launching at once briefly starve resources and
1342
+ // ChromeDriver "crashed during startup" / "cannot connect to" /
1343
+ // "DevToolsActivePort" / "session not created". A staggered retry lets
1344
+ // the contention clear; it recovers on the next attempt in practice.
1345
+ // Retry these with linear backoff; any other error is a real session-
1346
+ // creation failure and propagates immediately.
1347
+ const TRANSIENT = /ECONNREFUSED|ECONNRESET|socket hang up|could not proxy command|crashed during startup|cannot connect to|DevToolsActivePort|session not created/i;
998
1348
  const wdio = await loadHeavyDep("webdriverio", { ctx });
999
1349
  let lastError;
1000
1350
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -1009,12 +1359,14 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
1009
1359
  connectionRetryTimeout: 120000, // 2 minutes
1010
1360
  waitforTimeout: 120000, // 2 minutes
1011
1361
  });
1012
- driver.state = { url: "", x: null, y: null };
1362
+ // Per-context mutable state. `recording` lives here (not on config)
1363
+ // so concurrent contexts can't clobber each other's recordings.
1364
+ driver.state = { url: "", x: null, y: null, recording: null };
1013
1365
  return driver;
1014
1366
  }
1015
1367
  catch (err) {
1016
1368
  lastError = err;
1017
- if (!/ECONNREFUSED/.test(String(err && err.message)))
1369
+ if (!TRANSIENT.test(String(err && err.message)))
1018
1370
  throw err;
1019
1371
  if (attempt < maxAttempts) {
1020
1372
  await new Promise((r) => setTimeout(r, 500 * attempt));
@@ -1106,6 +1458,46 @@ async function ensureChromeAvailable(config, deps) {
1106
1458
  }
1107
1459
  return availableApps;
1108
1460
  }
1461
+ /**
1462
+ * On-demand, per-context browser/driver install used by the runner when a
1463
+ * context's browser isn't yet available (e.g. Firefox declared but geckodriver
1464
+ * missing). Attempts to install every asset the browser needs, memoizing the
1465
+ * outcome in `installAttempts` so a failed (or no-op) attempt isn't repeated
1466
+ * for every later context that shares the browser. Like ensureChromeAvailable,
1467
+ * this self-provisions regardless of DOC_DETECTIVE_AUTOINSTALL (that env var
1468
+ * only governs the eager postinstall). Deps are injected for testing.
1469
+ *
1470
+ * @returns "installed" when all assets installed, "failed" when an install
1471
+ * threw, or "notInstallable" for browsers with no installable asset (safari).
1472
+ */
1473
+ async function ensureContextBrowserInstalled({ browserName, config, installAttempts, deps, }) {
1474
+ const key = (browserName ?? "<none>").toLowerCase();
1475
+ const cached = installAttempts.get(key);
1476
+ if (cached)
1477
+ return cached;
1478
+ const assets = requiredBrowserAssets(browserName);
1479
+ if (assets.length === 0) {
1480
+ installAttempts.set(key, "notInstallable");
1481
+ return "notInstallable";
1482
+ }
1483
+ const ctx = { cacheDir: config?.cacheDir };
1484
+ // Bridge runtime modules' (msg, level) logger to core/utils.ts#log, mapping
1485
+ // "warn" → "warning" the same way provisionChromeRuntime does.
1486
+ const logger = (msg, level = "info") => deps.log?.(config, level === "warn" ? "warning" : level, msg);
1487
+ try {
1488
+ deps.log?.(config, "info", `Browser '${browserName}' is not available; attempting on-demand install of: ${assets.join(", ")}.`);
1489
+ for (const asset of assets) {
1490
+ await deps.ensureBrowser(asset, { ctx, deps: { logger } });
1491
+ }
1492
+ installAttempts.set(key, "installed");
1493
+ return "installed";
1494
+ }
1495
+ catch (err) {
1496
+ deps.log?.(config, "warning", `On-demand install for '${browserName}' failed: ${err?.message ?? err}`);
1497
+ installAttempts.set(key, "failed");
1498
+ return "failed";
1499
+ }
1500
+ }
1109
1501
  async function getRunner(options = {}) {
1110
1502
  const environment = getEnvironment();
1111
1503
  const config = { ...options.config, environment };