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.
- package/dist/core/appium.d.ts +2 -1
- package/dist/core/appium.d.ts.map +1 -1
- package/dist/core/appium.js +37 -5
- package/dist/core/appium.js.map +1 -1
- package/dist/core/config.d.ts +8 -3
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +14 -6
- package/dist/core/config.js.map +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -10
- package/dist/core/index.js.map +1 -1
- package/dist/core/tests/findElement.js +1 -1
- package/dist/core/tests/findElement.js.map +1 -1
- package/dist/core/tests/saveScreenshot.js +2 -2
- package/dist/core/tests/saveScreenshot.js.map +1 -1
- package/dist/core/tests/startRecording.d.ts.map +1 -1
- package/dist/core/tests/startRecording.js +0 -3
- package/dist/core/tests/startRecording.js.map +1 -1
- package/dist/core/tests/stopRecording.d.ts.map +1 -1
- package/dist/core/tests/stopRecording.js +23 -14
- package/dist/core/tests/stopRecording.js.map +1 -1
- package/dist/core/tests/typeKeys.js +2 -2
- package/dist/core/tests/typeKeys.js.map +1 -1
- package/dist/core/tests.d.ts +60 -4
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +771 -379
- package/dist/core/tests.js.map +1 -1
- package/dist/core/utils.d.ts +9 -1
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +57 -1
- package/dist/core/utils.js.map +1 -1
- package/dist/hints/context.d.ts.map +1 -1
- package/dist/hints/context.js +9 -2
- package/dist/hints/context.js.map +1 -1
- package/dist/hints/hints.d.ts.map +1 -1
- package/dist/hints/hints.js +20 -0
- package/dist/hints/hints.js.map +1 -1
- package/dist/hints/types.d.ts +5 -0
- package/dist/hints/types.d.ts.map +1 -1
- package/dist/index.cjs +591 -317
- package/dist/runtime/browsers.d.ts +14 -0
- package/dist/runtime/browsers.d.ts.map +1 -1
- package/dist/runtime/browsers.js +23 -0
- package/dist/runtime/browsers.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +25 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/core/tests.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
458
|
-
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
//
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
context.browser = getDefaultBrowser({ runnerDetails });
|
|
574
|
+
catch {
|
|
575
|
+
// best-effort
|
|
550
576
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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 };
|