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.
- 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/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 +20 -4
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +700 -467
- 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 +536 -355
- 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
|
@@ -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
|
-
|
|
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
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
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
|
|
456
|
-
//
|
|
457
|
-
// context
|
|
458
|
-
//
|
|
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
|
-
//
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
//
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
context.browser = getDefaultBrowser({ runnerDetails });
|
|
574
|
+
catch {
|
|
575
|
+
// best-effort
|
|
584
576
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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));
|