doc-detective 4.7.0-runtime-dependency-detection.1 → 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 (41) 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/tests/findElement.js +1 -1
  10. package/dist/core/tests/findElement.js.map +1 -1
  11. package/dist/core/tests/saveScreenshot.js +2 -2
  12. package/dist/core/tests/saveScreenshot.js.map +1 -1
  13. package/dist/core/tests/startRecording.d.ts.map +1 -1
  14. package/dist/core/tests/startRecording.js +0 -3
  15. package/dist/core/tests/startRecording.js.map +1 -1
  16. package/dist/core/tests/stopRecording.d.ts.map +1 -1
  17. package/dist/core/tests/stopRecording.js +23 -14
  18. package/dist/core/tests/stopRecording.js.map +1 -1
  19. package/dist/core/tests/typeKeys.js +2 -2
  20. package/dist/core/tests/typeKeys.js.map +1 -1
  21. package/dist/core/tests.d.ts +20 -4
  22. package/dist/core/tests.d.ts.map +1 -1
  23. package/dist/core/tests.js +700 -467
  24. package/dist/core/tests.js.map +1 -1
  25. package/dist/core/utils.d.ts +9 -1
  26. package/dist/core/utils.d.ts.map +1 -1
  27. package/dist/core/utils.js +57 -1
  28. package/dist/core/utils.js.map +1 -1
  29. package/dist/hints/context.d.ts.map +1 -1
  30. package/dist/hints/context.js +9 -2
  31. package/dist/hints/context.js.map +1 -1
  32. package/dist/hints/hints.d.ts.map +1 -1
  33. package/dist/hints/hints.js +20 -0
  34. package/dist/hints/hints.js.map +1 -1
  35. package/dist/hints/types.d.ts +5 -0
  36. package/dist/hints/types.d.ts.map +1 -1
  37. package/dist/index.cjs +536 -355
  38. package/dist/utils.d.ts.map +1 -1
  39. package/dist/utils.js +25 -0
  40. package/dist/utils.js.map +1 -1
  41. package/package.json +1 -1
@@ -8,7 +8,7 @@ import kill from "tree-kill";
8
8
  import { loadHeavyDep, resolveHeavyDepPath } from "../runtime/loader.js";
9
9
  import { requiredBrowserAssets, ensureBrowserInstalled, } from "../runtime/browsers.js";
10
10
  import os from "node:os";
11
- import { log, replaceEnvs, selectSpecsForRun, findFreePort } from "./utils.js";
11
+ import { log, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, rollUpResults, createAppiumPool, } from "./utils.js";
12
12
  import axios from "axios";
13
13
  import { instantiateCursor } from "./tests/moveTo.js";
14
14
  import { goTo } from "./tests/goTo.js";
@@ -32,13 +32,13 @@ import { spawn } from "node:child_process";
32
32
  import { randomUUID } from "node:crypto";
33
33
  import { setAppiumHome } from "./appium.js";
34
34
  import { resolveExpression } from "./expressions.js";
35
- import { getEnvironment, getAvailableApps, clearAppCache } from "./config.js";
35
+ import { getEnvironment, getAvailableApps, clearAppCache, resolveConcurrentRunners, } from "./config.js";
36
36
  import { uploadChangedFiles } from "./integrations/index.js";
37
37
  import http from "node:http";
38
38
  import https from "node:https";
39
39
  import { fileURLToPath } from "node:url";
40
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, getDriverCapabilities, getDefaultBrowser, isSupportedContext, };
41
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, isSupportedContext, };
42
42
  // exports.appiumStart = appiumStart;
43
43
  // exports.appiumIsReady = appiumIsReady;
44
44
  // exports.driverStart = driverStart;
@@ -154,8 +154,15 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
154
154
  args.push(`--auto-select-desktop-capture-source=RECORD_ME`);
155
155
  if (options.headless)
156
156
  args.push("--headless", "--disable-gpu");
157
- if (process.platform === "linux")
157
+ if (process.platform === "linux") {
158
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
+ }
159
166
  // Set capabilities
160
167
  capabilities = {
161
168
  platformName: runnerDetails.environment.platform,
@@ -182,24 +189,11 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
182
189
  }
183
190
  return capabilities;
184
191
  }
185
- // Check if any steps require an Appium driver.
186
- function isAppiumRequired(specs) {
187
- let appiumRequired = false;
188
- specs.forEach((spec) => {
189
- spec.tests.forEach((test) => {
190
- test.contexts.forEach((context) => {
191
- // Check if test includes actions that require a driver.
192
- if (isDriverRequired({ test: context })) {
193
- appiumRequired = true;
194
- }
195
- });
196
- });
197
- });
198
- return appiumRequired;
199
- }
200
192
  function isDriverRequired({ test }) {
201
193
  let driverRequired = false;
202
- 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) => {
203
197
  // Check if test includes actions that require a driver.
204
198
  driverActions.forEach((action) => {
205
199
  if (typeof step[action] !== "undefined")
@@ -401,9 +395,11 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
401
395
  /**
402
396
  * Orchestrates execution of resolved test specifications and returns a hierarchical run report.
403
397
  *
404
- * Executes each spec -> test -> context -> step, conditionally starts Appium and browser drivers,
405
- * applies viewport/window sizing, handles unsafe-step policies and recording, aggregates per-step,
406
- * 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.
407
403
  *
408
404
  * @param {Object} resolvedTests - Resolved test bundle containing configuration and specs to run.
409
405
  * @param {Object} resolvedTests.config - Runner configuration used during execution.
@@ -452,13 +448,13 @@ async function runSpecs({ resolvedTests }) {
452
448
  // gate below).
453
449
  let availableApps = runnerDetails.availableApps;
454
450
  const metaValues = { specs: {} };
455
- // Per-run memoization. installAttempts keeps a browser's on-demand install
456
- // from being retried for every context that uses it; warmUpResults keeps a
457
- // context combination that can't start a driver from being re-attempted
458
- // (with its slow driverStart backoff) for the rest of the run.
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.
459
456
  const installAttempts = new Map();
460
457
  const warmUpResults = new Map();
461
- let appium;
462
458
  const report = {
463
459
  summary: {
464
460
  specs: {
@@ -488,462 +484,191 @@ async function runSpecs({ resolvedTests }) {
488
484
  },
489
485
  specs: [],
490
486
  };
491
- // Determine which apps are required
492
- const appiumRequired = isAppiumRequired(specs);
493
- // Warm up Appium
494
- let appiumPort;
495
- if (appiumRequired) {
496
- setAppiumHome({ cacheDir: config?.cacheDir });
497
- appiumPort = await findFreePort();
498
- log(config, "debug", `Starting Appium on port ${appiumPort}`);
499
- // Resolve appium's actual JS entrypoint via `require.resolve`
500
- // (shim node_modules first, runtime cache second) and invoke it
501
- // with `node <entry>`. This sidesteps every shell-injection trap
502
- // at once: no `.cmd` shim, so no Windows-requires-shell:true; no
503
- // `npx`, so no PATH lookup; no user-controlled paths in a shell-
504
- // interpreted string. Works for both `--omit=optional` users
505
- // (appium in cache only) and default installs (appium in shim).
506
- const appiumEntry = resolveHeavyDepPath("appium", { cacheDir: config?.cacheDir });
507
- if (!appiumEntry) {
508
- 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`.");
509
- }
510
- appium = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(appiumPort)], {
511
- windowsHide: true,
512
- cwd: path.join(__dirname, "../.."),
513
- });
514
- appium.on("error", (err) => {
515
- log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
516
- });
517
- appium.stdout.on("data", (data) => {
518
- // console.log(`stdout: ${data}`);
519
- });
520
- appium.stderr.on("data", (data) => {
521
- // console.error(`stderr: ${data}`);
522
- });
523
- try {
524
- await appiumIsReady(appiumPort);
525
- }
526
- catch (error) {
527
- // appiumIsReady threw or timed out — the spawned child is still
528
- // alive and would leak (orphan process, port still bound). Tear
529
- // it down before propagating so subsequent runs don't trip on
530
- // the stale state.
531
- try {
532
- if (appium && appium.pid)
533
- kill(appium.pid);
534
- }
535
- catch {
536
- // best-effort cleanup; the parent error is what matters
537
- }
538
- throw error;
539
- }
540
- log(config, "debug", "Appium is ready.");
541
- }
542
- // 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.
543
495
  log(config, "info", "Running test specs.");
496
+ const jobs = [];
544
497
  for (const spec of specs) {
545
498
  log(config, "debug", `SPEC: ${spec.specId}`);
546
- // Set spec report
547
- 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 = {
548
504
  specId: spec.specId,
549
505
  description: spec.description,
550
506
  contentPath: spec.contentPath,
551
507
  tests: [],
552
508
  };
553
- // Set meta values
554
- metaValues.specs[spec.specId] = { tests: {} };
555
- // Iterates tests
509
+ report.specs.push(specReport);
556
510
  for (const test of spec.tests) {
557
511
  log(config, "debug", `TEST: ${test.testId}`);
558
- // Set test report
559
- let testReport = {
512
+ metaValues.specs[spec.specId].tests[test.testId] ??= { contexts: {} };
513
+ const testReport = {
560
514
  testId: test.testId,
561
515
  description: test.description,
562
516
  contentPath: test.contentPath,
563
517
  detectSteps: test.detectSteps,
564
- contexts: [],
518
+ contexts: new Array(test.contexts.length),
565
519
  };
566
- // Set meta values
567
- metaValues.specs[spec.specId].tests[test.testId] = { contexts: {} };
568
- // Iterate contexts
569
- // TODO: Support both serial and parallel execution
570
- for (const context of test.contexts) {
571
- // If "platform" is not defined, set it to the current platform
572
- if (!context.platform)
573
- context.platform = runnerDetails.environment.platform;
574
- // Attach OpenAPI definitions to context
575
- if (config.integrations?.openApi) {
576
- context.openApi = [
577
- ...(context.openApi || []),
578
- ...config.integrations.openApi,
579
- ];
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);
580
573
  }
581
- // 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
582
- if (!context.browser && isDriverRequired({ test: context })) {
583
- context.browser = getDefaultBrowser({ runnerDetails });
574
+ catch {
575
+ // best-effort
584
576
  }
585
- // Set context report
586
- let contextReport = {
587
- contextId: context.contextId || randomUUID(),
588
- platform: context.platform,
589
- 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}`,
590
636
  steps: [],
591
637
  };
592
- // Set meta values
593
- metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId] = { steps: {} };
594
- // If a driver is required but no browser could be resolved (e.g.
595
- // getDefaultBrowser found nothing installed, or the context supplied a
596
- // browser object with no name), skip with an explicit reason instead of
597
- // letting it fail later as "Failed to start context 'undefined'".
598
- if (isDriverRequired({ test: context }) && !context.browser?.name) {
599
- const errorMessage = `Skipping context on '${context.platform}': no supported browser is available in the current environment.`;
600
- log(config, "warning", errorMessage);
601
- contextReport = {
602
- ...contextReport,
603
- result: "SKIPPED",
604
- resultDescription: errorMessage,
605
- };
606
- report.summary.contexts.skipped++;
607
- testReport.contexts.push(contextReport);
608
- continue;
609
- }
610
- // Check if current environment supports given contexts
611
- let supportedContext = isSupportedContext({
612
- context: context,
613
- apps: availableApps,
614
- platform: platform,
615
- });
616
- // If the context needs a browser that isn't available yet, try to
617
- // resolve the missing dependency on demand before giving up — e.g.
618
- // Firefox declared but geckodriver absent because the pre-flight was
619
- // skipped or its install failed. Memoized per browser (installAttempts)
620
- // so a failed/no-op install isn't retried for every later context.
621
- let freshInstallRedetected = false;
622
- if (!supportedContext &&
623
- context.platform === platform &&
624
- // Mirror isSupportedContext's own guard: isDriverRequired iterates
625
- // context.steps, so a malformed context without a steps array would
626
- // otherwise crash the loop here instead of skipping cleanly.
627
- Array.isArray(context?.steps) &&
628
- isDriverRequired({ test: context }) &&
629
- requiredBrowserAssets(context.browser?.name).length > 0) {
630
- // Whether this browser was already attempted earlier this run; a
631
- // cached outcome installed nothing new, so there's no point paying
632
- // for a re-detect.
633
- const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
634
- const outcome = await ensureContextBrowserInstalled({
635
- browserName: context.browser?.name,
636
- config,
637
- installAttempts,
638
- deps: {
639
- ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
640
- log,
641
- },
642
- });
643
- // Re-detect after a real attempt regardless of outcome: a "failed"
644
- // install can still have materialized assets before it threw (the
645
- // installs run sequentially), so a stale "not installed" snapshot
646
- // could wrongly skip a now-usable browser. Drop the cached apps and
647
- // re-scan so isSupportedContext (and getDriverCapabilities, which
648
- // reads runnerDetails.availableApps live) see the new state.
649
- if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
650
- freshInstallRedetected = true;
651
- clearAppCache(config);
652
- availableApps = await getAvailableApps({ config });
653
- runnerDetails.availableApps = availableApps;
654
- supportedContext = isSupportedContext({
655
- context: context,
656
- apps: availableApps,
657
- platform: platform,
658
- });
659
- }
660
- }
661
- // If context isn't supported, skip it
662
- if (!supportedContext) {
663
- // Distinguish "we installed the dependency but still can't see it"
664
- // from a plain unsupported context, so the skip reason points at the
665
- // real problem (detection after install) rather than implying a
666
- // platform mismatch.
667
- const errorMessage = freshInstallRedetected
668
- ? `Skipping context '${context.browser?.name}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
669
- : `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`;
670
- log(config, freshInstallRedetected ? "warning" : "info", errorMessage);
671
- contextReport = {
672
- ...contextReport,
673
- result: "SKIPPED",
674
- resultDescription: errorMessage,
675
- };
676
- report.summary.contexts.skipped++;
677
- testReport.contexts.push(contextReport);
678
- continue;
679
- }
680
- log(config, "debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
681
- let driver;
682
- // Ensure context contains a 'steps' property
683
- if (!context.steps) {
684
- context.steps = [];
685
- }
686
- const driverRequired = isDriverRequired({ test: context });
687
- if (driverRequired) {
688
- // Warm-up memoization: the first context of each combination acts as
689
- // the warm-up. If that combination already failed to start a driver
690
- // earlier in this run, skip it outright instead of paying
691
- // driverStart's retry/backoff again.
692
- const combo = combinationKey(context);
693
- if (warmUpDecision(warmUpResults.get(combo)) === "skip") {
694
- const errorMessage = `Skipping context '${context.browser?.name}' on '${context.platform}': this context combination could not start a driver earlier in this run.`;
695
- log(config, "warning", errorMessage);
696
- contextReport = {
697
- ...contextReport,
698
- result: "SKIPPED",
699
- resultDescription: errorMessage,
700
- };
701
- report.summary.contexts.skipped++;
702
- testReport.contexts.push(contextReport);
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)
703
649
  continue;
650
+ for (const stepReport of contextReport.steps) {
651
+ report.summary.steps[stepReport.result.toLowerCase()]++;
704
652
  }
705
- // Define driver capabilities
706
- // TODO: Support custom apps
707
- let caps = getDriverCapabilities({
708
- runnerDetails: runnerDetails,
709
- name: context.browser.name,
710
- options: {
711
- width: context.browser?.window?.width || 1200,
712
- height: context.browser?.window?.height || 800,
713
- headless: context.browser?.headless !== false,
714
- },
715
- });
716
- log(config, "debug", "CAPABILITIES:");
717
- log(config, "debug", caps);
718
- if (appiumPort === undefined) {
719
- throw new Error("Driver requested but Appium was not started. " +
720
- "isAppiumRequired(specs) and isDriverRequired(context) disagreed; this is a bug.");
721
- }
722
- // Instantiate driver
723
- try {
724
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
725
- }
726
- catch (error) {
727
- try {
728
- // If driver fails to start, try again as headless
729
- log(config, "warning", `Failed to start context '${context.browser?.name}' on '${platform}'. Retrying as headless.`);
730
- context.browser.headless = true;
731
- caps = getDriverCapabilities({
732
- runnerDetails: runnerDetails,
733
- name: context.browser.name,
734
- options: {
735
- width: context.browser?.window?.width || 1200,
736
- height: context.browser?.window?.height || 800,
737
- headless: context.browser?.headless !== false,
738
- },
739
- });
740
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
741
- }
742
- catch (error) {
743
- let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`;
744
- // `safari` is normalized to `webkit` during context resolution, so
745
- // match both or this Safari-specific hint never fires on real runs.
746
- if (context.browser?.name === "safari" ||
747
- context.browser?.name === "webkit")
748
- errorMessage =
749
- errorMessage +
750
- " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
751
- log(config, "error", errorMessage);
752
- // Record the combination as failed so every later context that
753
- // shares it is skipped instantly (see the warm-up check above)
754
- // rather than re-running this same doomed start.
755
- if (!warmUpResults.has(combo))
756
- warmUpResults.set(combo, "failed");
757
- contextReport = {
758
- ...contextReport,
759
- result: "SKIPPED",
760
- resultDescription: errorMessage,
761
- };
762
- report.summary.contexts.skipped++;
763
- testReport.contexts.push(contextReport);
764
- continue;
765
- }
766
- }
767
- // Driver started (on the first attempt or the headless retry) — mark
768
- // this combination as known-good for the rest of the run.
769
- if (!warmUpResults.has(combo))
770
- warmUpResults.set(combo, "ok");
771
- if (context.browser?.viewport?.width ||
772
- context.browser?.viewport?.height) {
773
- // Set driver viewport size
774
- await setViewportSize(context, driver);
775
- }
776
- else if (context.browser?.window?.width ||
777
- context.browser?.window?.height) {
778
- // Get driver window size
779
- const windowSize = await driver.getWindowSize();
780
- // Resize window if necessary
781
- await driver.setWindowSize(context.browser?.window?.width || windowSize.width, context.browser?.window?.height || windowSize.height);
782
- }
783
- }
784
- // Iterates steps
785
- let stepExecutionFailed = false;
786
- for (let step of context.steps) {
787
- // Set step id if not defined
788
- if (!step.stepId)
789
- step.stepId = randomUUID();
790
- log(config, "debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
791
- if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
792
- log(config, "warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
793
- // Mark as skipped
794
- const stepReport = {
795
- ...step,
796
- result: "SKIPPED",
797
- resultDescription: "Skipped because unsafe steps aren't allowed.",
798
- };
799
- contextReport.steps.push(stepReport);
800
- report.summary.steps.skipped++;
801
- continue;
802
- }
803
- if (stepExecutionFailed) {
804
- // Mark as skipped
805
- const stepReport = {
806
- ...step,
807
- result: "SKIPPED",
808
- resultDescription: "Skipped due to previous failure in context.",
809
- };
810
- contextReport.steps.push(stepReport);
811
- report.summary.steps.skipped++;
812
- continue;
813
- }
814
- // Set meta values
815
- metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId].steps[step.stepId] = {};
816
- // Run step
817
- const stepResult = await runStep({
818
- config: config,
819
- context: context,
820
- step: step,
821
- driver: driver,
822
- metaValues: metaValues,
823
- options: {
824
- openApiDefinitions: context.openApi || [],
825
- },
826
- });
827
- log(config, "debug", `RESULT: ${stepResult.status}\n${JSON.stringify(stepResult, null, 2)}`);
828
- stepResult.result = stepResult.status;
829
- stepResult.resultDescription = stepResult.description;
830
- delete stepResult.status;
831
- delete stepResult.description;
832
- // Add step result to report
833
- const stepReport = {
834
- ...step,
835
- ...stepResult,
836
- };
837
- contextReport.steps.push(stepReport);
838
- report.summary.steps[stepReport.result.toLowerCase()]++;
839
- // If this step failed, set flag to skip remaining steps
840
- if (stepReport.result === "FAIL") {
841
- stepExecutionFailed = true;
842
- }
843
- }
844
- // If recording, stop recording
845
- if (config.recording) {
846
- const stopRecordStep = {
847
- stopRecord: true,
848
- description: "Stopping recording",
849
- stepId: randomUUID(),
850
- };
851
- const stepResult = await runStep({
852
- config: config,
853
- context: context,
854
- step: stopRecordStep,
855
- driver: driver,
856
- options: {
857
- openApiDefinitions: context.openApi || [],
858
- },
859
- });
860
- stepResult.result = stepResult.status;
861
- stepResult.resultDescription = stepResult.description;
862
- delete stepResult.status;
863
- delete stepResult.description;
864
- // Add step result to report
865
- const stepReport = {
866
- ...stopRecordStep,
867
- ...stepResult,
868
- };
869
- contextReport.steps.push(stepReport);
870
- report.summary.steps[stepReport.result.toLowerCase()]++;
871
- }
872
- // Parse step results to calc context result
873
- // If any step fails, context fails
874
- let contextResult;
875
- if (contextReport.steps.find((step) => step.result === "FAIL"))
876
- contextResult = "FAIL";
877
- // If any step warns, context warns
878
- else if (contextReport.steps.find((step) => step.result === "WARNING"))
879
- contextResult = "WARNING";
880
- // If all steps skipped, context skipped
881
- else if (contextReport.steps.length ===
882
- contextReport.steps.filter((step) => step.result === "SKIPPED").length)
883
- contextResult = "SKIPPED";
884
- // If all steps pass, context passes
885
- else
886
- contextResult = "PASS";
887
- contextReport = { result: contextResult, ...contextReport };
888
- testReport.contexts.push(contextReport);
889
- report.summary.contexts[contextResult.toLowerCase()]++;
890
- if (driverRequired) {
891
- // Close driver
892
- try {
893
- await driver.deleteSession();
894
- }
895
- catch (error) {
896
- log(config, "error", `Failed to delete driver session: ${error.message}`);
897
- }
653
+ report.summary.contexts[contextReport.result.toLowerCase()]++;
898
654
  }
655
+ testReport.result = rollUpResults(testReport.contexts.filter(Boolean));
656
+ report.summary.tests[testReport.result.toLowerCase()]++;
899
657
  }
900
- // Parse context results to calc test result
901
- // If any context fails, test fails
902
- let testResult;
903
- if (testReport.contexts.find((context) => context.result === "FAIL"))
904
- testResult = "FAIL";
905
- // If any context warns, test warns
906
- else if (testReport.contexts.find((context) => context.result === "WARNING"))
907
- testResult = "WARNING";
908
- // If all contexts skipped, test skipped
909
- else if (testReport.contexts.length ===
910
- testReport.contexts.filter((context) => context.result === "SKIPPED")
911
- .length)
912
- testResult = "SKIPPED";
913
- // If all contexts pass, test passes
914
- else
915
- testResult = "PASS";
916
- testReport = { result: testResult, ...testReport };
917
- specReport.tests.push(testReport);
918
- report.summary.tests[testResult.toLowerCase()]++;
658
+ specReport.result = rollUpResults(specReport.tests);
659
+ report.summary.specs[specReport.result.toLowerCase()]++;
919
660
  }
920
- // Parse test results to calc spec result
921
- // If any context fails, test fails
922
- let specResult;
923
- if (specReport.tests.find((test) => test.result === "FAIL"))
924
- specResult = "FAIL";
925
- // If any test warns, spec warns
926
- else if (specReport.tests.find((test) => test.result === "WARNING"))
927
- specResult = "WARNING";
928
- // If all tests skipped, spec skipped
929
- else if (specReport.tests.length ===
930
- specReport.tests.filter((test) => test.result === "SKIPPED").length)
931
- specResult = "SKIPPED";
932
- // If all contexts pass, test passes
933
- else
934
- specResult = "PASS";
935
- specReport = { result: specResult, ...specReport };
936
- report.specs.push(specReport);
937
- report.summary.specs[specResult.toLowerCase()]++;
938
661
  }
939
- // Close appium server
940
- if (appium) {
941
- log(config, "debug", "Closing Appium server");
942
- try {
943
- kill(appium.pid);
944
- }
945
- catch {
946
- // 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
+ }
947
672
  }
948
673
  }
949
674
  // Upload changed files back to source integrations (best-effort)
@@ -972,6 +697,468 @@ async function runSpecs({ resolvedTests }) {
972
697
  }
973
698
  return report;
974
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
+ }
975
1162
  // Run a specific step
976
1163
  async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, }) {
977
1164
  let actionResult;
@@ -1034,7 +1221,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
1034
1221
  step: step,
1035
1222
  driver: driver,
1036
1223
  });
1037
- config.recording = actionResult.recording;
1224
+ driver.state.recording = actionResult.recording ?? null;
1038
1225
  }
1039
1226
  else if (typeof step.runCode !== "undefined") {
1040
1227
  actionResult = await runCode({ config: config, step: step });
@@ -1066,7 +1253,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
1066
1253
  };
1067
1254
  }
1068
1255
  // If recording, wait until browser is loaded, then instantiate cursor
1069
- if (config?.recording) {
1256
+ if (driver?.state?.recording) {
1070
1257
  const currentUrl = await driver.getUrl();
1071
1258
  if (currentUrl !== driver.state.url) {
1072
1259
  driver.state.url = currentUrl;
@@ -1090,6 +1277,40 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
1090
1277
  }
1091
1278
  return actionResult;
1092
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
+ }
1093
1314
  // Delay execution until Appium server is available.
1094
1315
  async function appiumIsReady(port, timeoutMs = 120000) {
1095
1316
  let isReady = false;
@@ -1110,10 +1331,20 @@ async function appiumIsReady(port, timeoutMs = 120000) {
1110
1331
  }
1111
1332
  // Start the Appium driver specified in `capabilities`.
1112
1333
  async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
1113
- // POST /session can race a just-spawned-or-still-dying Appium on Windows:
1114
- // /status may already return 200 from the outgoing process while /session
1115
- // is no longer accepting. Retry with linear backoff ONLY on ECONNREFUSED --
1116
- // 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;
1117
1348
  const wdio = await loadHeavyDep("webdriverio", { ctx });
1118
1349
  let lastError;
1119
1350
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -1128,12 +1359,14 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
1128
1359
  connectionRetryTimeout: 120000, // 2 minutes
1129
1360
  waitforTimeout: 120000, // 2 minutes
1130
1361
  });
1131
- 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 };
1132
1365
  return driver;
1133
1366
  }
1134
1367
  catch (err) {
1135
1368
  lastError = err;
1136
- if (!/ECONNREFUSED/.test(String(err && err.message)))
1369
+ if (!TRANSIENT.test(String(err && err.message)))
1137
1370
  throw err;
1138
1371
  if (attempt < maxAttempts) {
1139
1372
  await new Promise((r) => setTimeout(r, 500 * attempt));