doc-detective 4.28.0 → 4.29.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/cli.js +13 -4
- package/dist/cli.js.map +1 -1
- package/dist/common/src/detectTests.d.ts +9 -0
- package/dist/common/src/detectTests.d.ts.map +1 -1
- package/dist/common/src/detectTests.js +22 -9
- package/dist/common/src/detectTests.js.map +1 -1
- package/dist/common/src/schemas/schemas.json +85 -1
- package/dist/common/src/types/generated/report_v3.d.ts +34 -0
- package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
- package/dist/core/detectTests.d.ts.map +1 -1
- package/dist/core/detectTests.js +37 -13
- package/dist/core/detectTests.js.map +1 -1
- package/dist/core/expressions.d.ts.map +1 -1
- package/dist/core/expressions.js +24 -1
- package/dist/core/expressions.js.map +1 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/resolveTests.js +23 -12
- package/dist/core/resolveTests.js.map +1 -1
- package/dist/core/telem.d.ts +2 -1
- package/dist/core/telem.d.ts.map +1 -1
- package/dist/core/telem.js +26 -2
- package/dist/core/telem.js.map +1 -1
- package/dist/core/tests/androidEmulator.d.ts.map +1 -1
- package/dist/core/tests/androidEmulator.js +13 -0
- package/dist/core/tests/androidEmulator.js.map +1 -1
- package/dist/core/tests/appSurface.d.ts +23 -0
- package/dist/core/tests/appSurface.d.ts.map +1 -1
- package/dist/core/tests/appSurface.js +3 -1
- package/dist/core/tests/appSurface.js.map +1 -1
- package/dist/core/tests/iosSimulator.d.ts.map +1 -1
- package/dist/core/tests/iosSimulator.js +13 -0
- package/dist/core/tests/iosSimulator.js.map +1 -1
- package/dist/core/tests/mobileBrowser.d.ts +1 -0
- package/dist/core/tests/mobileBrowser.d.ts.map +1 -1
- package/dist/core/tests/mobileBrowser.js +8 -0
- package/dist/core/tests/mobileBrowser.js.map +1 -1
- package/dist/core/tests.d.ts +83 -2
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +572 -70
- package/dist/core/tests.js.map +1 -1
- package/dist/core/utils.d.ts +3 -2
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +37 -29
- package/dist/core/utils.js.map +1 -1
- package/dist/core/warmPhase.d.ts +161 -0
- package/dist/core/warmPhase.d.ts.map +1 -0
- package/dist/core/warmPhase.js +341 -0
- package/dist/core/warmPhase.js.map +1 -0
- package/dist/index.cjs +776 -90
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +11 -3
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/core/tests.js
CHANGED
|
@@ -13,7 +13,7 @@ import { requiredBrowserAssets, ensureBrowserInstalled, } from "../runtime/brows
|
|
|
13
13
|
// Single source of truth for browser/driver-requiring step keys.
|
|
14
14
|
import { BROWSER_STEP_KEYS as driverActions, startSurfaceDescriptors, stepOpensBrowserSurface, } from "../runtime/browserStepKeys.js";
|
|
15
15
|
import os from "node:os";
|
|
16
|
-
import { log, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, } from "./utils.js";
|
|
16
|
+
import { log, logLevelEnabled, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, } from "./utils.js";
|
|
17
17
|
import axios from "axios";
|
|
18
18
|
import { instantiateCursor } from "./tests/moveTo.js";
|
|
19
19
|
import { goTo } from "./tests/goTo.js";
|
|
@@ -34,10 +34,12 @@ import { httpRequest } from "./tests/httpRequest.js";
|
|
|
34
34
|
import { clickElement } from "./tests/click.js";
|
|
35
35
|
import { runCode } from "./tests/runCode.js";
|
|
36
36
|
import { closeSurface } from "./tests/closeSurface.js";
|
|
37
|
-
import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, } from "./tests/appSurface.js";
|
|
37
|
+
import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, APP_DRIVER_PLATFORMS, probeIosToolchain, } from "./tests/appSurface.js";
|
|
38
38
|
import { startSurfaceStep } from "./tests/startSurface.js";
|
|
39
39
|
import { isMobileTargetPlatform } from "./tests/mobilePlatform.js";
|
|
40
|
-
import { mobileBrowserGate, buildMobileBrowserCapabilities, } from "./tests/mobileBrowser.js";
|
|
40
|
+
import { mobileBrowserGate, buildMobileBrowserCapabilities, CHROMEDRIVER_AUTODOWNLOAD_ARGS, } from "./tests/mobileBrowser.js";
|
|
41
|
+
import { planWarmTasks, executeWarmTasks, raceBootInitiation, wrapInitiationEffects, RUNTIME_INSTALL_RESOURCE, } from "./warmPhase.js";
|
|
42
|
+
import { locateManagedWda } from "../runtime/wdaProducts.js";
|
|
41
43
|
import { getCacheDir } from "../runtime/cacheDir.js";
|
|
42
44
|
import { detectAndroidSdk } from "../runtime/androidSdk.js";
|
|
43
45
|
import { hostAbi, listInstalledSystemImages, installAndroid, } from "../runtime/androidInstaller.js";
|
|
@@ -59,9 +61,7 @@ import http from "node:http";
|
|
|
59
61
|
import https from "node:https";
|
|
60
62
|
import { fileURLToPath } from "node:url";
|
|
61
63
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
62
|
-
export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withChromedriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, };
|
|
63
|
-
// exports.appiumStart = appiumStart;
|
|
64
|
-
// exports.appiumIsReady = appiumIsReady;
|
|
64
|
+
export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withChromedriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, buildWarmPlanDeps, warmBrowserInstall, prefetchMobileChromedriver, appiumIsReady, };
|
|
65
65
|
// exports.driverStart = driverStart;
|
|
66
66
|
// Browser names getDriverCapabilities knows how to build caps for. `safari` is
|
|
67
67
|
// rewritten to `webkit` during context resolution, so both appear here.
|
|
@@ -157,6 +157,28 @@ function combinationKey(context) {
|
|
|
157
157
|
function warmUpDecision(prev) {
|
|
158
158
|
return prev === "failed" ? "skip" : "attempt";
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Bind the real selection predicates for the warm-phase planner
|
|
162
|
+
* (planWarmTasks in warmPhase.ts). The planner takes these as an injected
|
|
163
|
+
* bag because most of them live in this module — importing them from
|
|
164
|
+
* warmPhase.ts would create a tests.ts ⇄ warmPhase.ts cycle — and because
|
|
165
|
+
* the bag keeps the planner hermetically unit-testable. Exported so planner
|
|
166
|
+
* tests exercise production selection logic, not stand-ins.
|
|
167
|
+
*/
|
|
168
|
+
function buildWarmPlanDeps() {
|
|
169
|
+
return {
|
|
170
|
+
isBrowserRequired,
|
|
171
|
+
isAppDriverRequired,
|
|
172
|
+
isMobileTargetPlatform,
|
|
173
|
+
getDefaultBrowser,
|
|
174
|
+
requiredBrowserAssets,
|
|
175
|
+
collectDeviceDescriptors,
|
|
176
|
+
normalizeDeviceDescriptor,
|
|
177
|
+
mobileBrowserGate,
|
|
178
|
+
contextRequirementsSkipMessage,
|
|
179
|
+
appDriverPlatforms: APP_DRIVER_PLATFORMS,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
160
182
|
// Get Appium driver capabilities and apply options.
|
|
161
183
|
function getDriverCapabilities({ runnerDetails, name, options }) {
|
|
162
184
|
let capabilities = {};
|
|
@@ -1129,6 +1151,11 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1129
1151
|
},
|
|
1130
1152
|
},
|
|
1131
1153
|
specs: [],
|
|
1154
|
+
// Inline warm phase results (docs/design/warm-phase.md). Present on the
|
|
1155
|
+
// skeleton so a run that plans nothing (or whose planning fails —
|
|
1156
|
+
// best-effort) still reports the structural empty block; the phase
|
|
1157
|
+
// overwrites it with real task results below.
|
|
1158
|
+
warm: { durationMs: 0, tasks: [] },
|
|
1132
1159
|
};
|
|
1133
1160
|
// Resolve concurrency up front (defensive re-resolve: API callers can hand
|
|
1134
1161
|
// runSpecs a config that never went through core setConfig, leaving
|
|
@@ -1392,6 +1419,11 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1392
1419
|
// that removes the port race and fails fast on the first server that can't
|
|
1393
1420
|
// come up, tearing down any already started so they don't leak.
|
|
1394
1421
|
try {
|
|
1422
|
+
// Spawn servers one at a time (serial spawn keeps the findFreePort race
|
|
1423
|
+
// protection + avoids a CPU spike), but collect their readiness polls and
|
|
1424
|
+
// await them together so the waits OVERLAP — total ≈ max(readiness)
|
|
1425
|
+
// instead of the sum of serial waits.
|
|
1426
|
+
const readinessWaits = [];
|
|
1395
1427
|
for (let i = 0; i < serverCount; i++) {
|
|
1396
1428
|
let display;
|
|
1397
1429
|
if (useXvfbDisplays) {
|
|
@@ -1399,7 +1431,20 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1399
1431
|
xvfbProcesses.push(await startXvfb(display));
|
|
1400
1432
|
log(config, "debug", `Started Xvfb on ${display} for recording.`);
|
|
1401
1433
|
}
|
|
1402
|
-
|
|
1434
|
+
const server = await spawnAppiumServer(appiumEntry, config, display);
|
|
1435
|
+
appiumServers.push(server);
|
|
1436
|
+
const wait = appiumIsReady(server.port);
|
|
1437
|
+
// Attach a no-op catch so that once Promise.all rejects on the FIRST
|
|
1438
|
+
// failing server, the other still-pending readiness rejections don't
|
|
1439
|
+
// surface as unhandled promise rejections. Promise.all still sees the
|
|
1440
|
+
// original `wait`, so it fails fast on the first error; the catch
|
|
1441
|
+
// block below tears down every spawned server (ready or not).
|
|
1442
|
+
wait.catch(() => { });
|
|
1443
|
+
readinessWaits.push(wait);
|
|
1444
|
+
}
|
|
1445
|
+
await Promise.all(readinessWaits);
|
|
1446
|
+
for (const server of appiumServers) {
|
|
1447
|
+
log(config, "debug", `Appium is ready on port ${server.port}.`);
|
|
1403
1448
|
}
|
|
1404
1449
|
}
|
|
1405
1450
|
catch (error) {
|
|
@@ -1439,6 +1484,15 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1439
1484
|
// Swept in the `finally` below — only simulators Doc Detective booted are
|
|
1440
1485
|
// shut down (launch-ownership).
|
|
1441
1486
|
const simulatorRegistry = createSimulatorRegistry();
|
|
1487
|
+
// One resource registry per run, shared by the warm phase, every phase's
|
|
1488
|
+
// flat pool, AND the routed sequencer — so warm's cache-mutating tasks,
|
|
1489
|
+
// flat-pool recordings, and routed-spec recordings all contend on the same
|
|
1490
|
+
// named mutexes. Warm is awaited before Phase 2 dispatch and
|
|
1491
|
+
// runResourceAware releases every tag in a `finally`, so the pools always
|
|
1492
|
+
// start with an empty registry. Only consulted where items carry tags
|
|
1493
|
+
// (warm tasks always do; jobs only at limit>1 — at limit===1 the pools
|
|
1494
|
+
// stay on the byte-identical runConcurrent path).
|
|
1495
|
+
const resourceRegistry = createResourceRegistry();
|
|
1442
1496
|
// Kill every still-registered background process (and its child tree) and
|
|
1443
1497
|
// remove any deferred temp scripts. Awaits the kills so the process tree is
|
|
1444
1498
|
// actually gone before the run returns. Idempotent: closeSurface already
|
|
@@ -1495,25 +1549,56 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1495
1549
|
// warmUpContexts (e.g. getAvailableApps failing during the re-detect) would
|
|
1496
1550
|
// leak the started servers, leaving orphaned processes bound to their ports.
|
|
1497
1551
|
try {
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1552
|
+
// Inline warm phase (docs/design/warm-phase.md): always-on, best-effort
|
|
1553
|
+
// provisioning between resolution and execution. The planner derives
|
|
1554
|
+
// every task the run's contexts would JIT-provision anyway (browser and
|
|
1555
|
+
// app-driver installs, device boots, the WDA availability check, the
|
|
1556
|
+
// mobile chromedriver prefetch, the folded-in session probe) and the
|
|
1557
|
+
// executor overlaps them under the run's resource registry — so boot ∥
|
|
1558
|
+
// npm install ∥ browser download overlap each other even for a serial
|
|
1559
|
+
// test run. A failed task is a warning; the per-context paths retry or
|
|
1560
|
+
// skip with exactly the semantics they have today. The historical
|
|
1561
|
+
// `limit > 1 && appiumPool` gate now guards only the session-probe TASK
|
|
1562
|
+
// (inside planWarmTasks), preserving #338's natural
|
|
1563
|
+
// first-context-warms-up behavior for serial runs, whose memo state is
|
|
1564
|
+
// byte-identical by the warmBrowserInstall mirror contract. Device
|
|
1565
|
+
// boots resolve at initiation; only the chromedriver prefetch awaits
|
|
1566
|
+
// readiness (and only runs with android mobile-web contexts pay it —
|
|
1567
|
+
// they'd pay the same boot + session at their first mobile context).
|
|
1568
|
+
// The never-gates contract covers the whole phase, planning included: a
|
|
1569
|
+
// throw from the planner or its bound predicates must degrade to a
|
|
1570
|
+
// warning + the skeleton's empty warm block, never abort the run
|
|
1571
|
+
// (executeWarmTasks already isolates per-task failures internally).
|
|
1572
|
+
try {
|
|
1573
|
+
const warmTasks = planWarmTasks({
|
|
1574
|
+
sizingJobs,
|
|
1512
1575
|
runnerDetails,
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1576
|
+
limit,
|
|
1577
|
+
hasAppiumPool: !!appiumPool,
|
|
1578
|
+
deps: buildWarmPlanDeps(),
|
|
1516
1579
|
});
|
|
1580
|
+
if (warmTasks.length > 0) {
|
|
1581
|
+
log(config, "debug", `Warm phase: ${warmTasks.length} task(s).`);
|
|
1582
|
+
report.warm = await executeWarmTasks({
|
|
1583
|
+
tasks: warmTasks,
|
|
1584
|
+
registry: resourceRegistry,
|
|
1585
|
+
runTask: buildWarmTaskRunner({
|
|
1586
|
+
config,
|
|
1587
|
+
runnerDetails,
|
|
1588
|
+
sizingJobs,
|
|
1589
|
+
appiumPool,
|
|
1590
|
+
installAttempts,
|
|
1591
|
+
warmUpResults,
|
|
1592
|
+
deviceRegistry,
|
|
1593
|
+
simulatorRegistry,
|
|
1594
|
+
resourceRegistry,
|
|
1595
|
+
}),
|
|
1596
|
+
log: (level, message) => log(config, level, message),
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
catch (error) {
|
|
1601
|
+
log(config, "warning", `Warm phase skipped (planning failed; the run proceeds with on-demand provisioning): ${error?.message ?? error}`);
|
|
1517
1602
|
}
|
|
1518
1603
|
// Phase 2: run context jobs through the worker pool, gated into three
|
|
1519
1604
|
// sequential phases. Config-level `beforeAny` specs all finish before any
|
|
@@ -1598,12 +1683,6 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1598
1683
|
: "main";
|
|
1599
1684
|
routedByPhase[phase].push(entry);
|
|
1600
1685
|
}
|
|
1601
|
-
// One resource registry per run, shared by every phase's flat pool AND the
|
|
1602
|
-
// routed sequencer, so a flat-pool recording and a routed-spec recording
|
|
1603
|
-
// never hold the shared "display" at the same time. Only consulted at
|
|
1604
|
-
// limit>1 (where jobs were tagged); at limit===1 the pools stay on the
|
|
1605
|
-
// byte-identical runConcurrent path.
|
|
1606
|
-
const resourceRegistry = createResourceRegistry();
|
|
1607
1686
|
for (const phase of PHASES) {
|
|
1608
1687
|
if (limit > 1) {
|
|
1609
1688
|
await runResourceAware(jobsByPhase[phase], limit, resourceRegistry, runJob);
|
|
@@ -2103,27 +2182,20 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
|
|
|
2103
2182
|
context.platform === platform &&
|
|
2104
2183
|
Array.isArray(context?.steps) &&
|
|
2105
2184
|
requiredBrowserAssets(context.browser?.name).length > 0) {
|
|
2106
|
-
|
|
2107
|
-
|
|
2185
|
+
// Extracted install + first-attempt re-detect (shared with the warm
|
|
2186
|
+
// phase's browser-install task) — the memo state it leaves is exactly
|
|
2187
|
+
// what this loop produced inline before.
|
|
2188
|
+
await warmBrowserInstall({
|
|
2108
2189
|
browserName: context.browser?.name,
|
|
2109
2190
|
config,
|
|
2191
|
+
runnerDetails,
|
|
2110
2192
|
installAttempts,
|
|
2111
|
-
deps: {
|
|
2112
|
-
ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
|
|
2113
|
-
log,
|
|
2114
|
-
},
|
|
2115
|
-
// Repair a present-but-broken driver, not just install-if-missing.
|
|
2116
|
-
repair: true,
|
|
2117
2193
|
});
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
runnerDetails.availableApps
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
apps: runnerDetails.availableApps,
|
|
2124
|
-
platform,
|
|
2125
|
-
});
|
|
2126
|
-
}
|
|
2194
|
+
supported = isSupportedContext({
|
|
2195
|
+
context,
|
|
2196
|
+
apps: runnerDetails.availableApps,
|
|
2197
|
+
platform,
|
|
2198
|
+
});
|
|
2127
2199
|
}
|
|
2128
2200
|
// Unsupported combinations are left unmarked; runContext skips each with the
|
|
2129
2201
|
// appropriate per-context reason (install-but-undetected vs unsupported).
|
|
@@ -2177,6 +2249,404 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
|
|
|
2177
2249
|
}
|
|
2178
2250
|
}
|
|
2179
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* On-demand browser install + first-attempt re-detect — the install half of
|
|
2254
|
+
* warmUpContexts, extracted so the warm phase's browser-install task and the
|
|
2255
|
+
* session-probe loop share ONE implementation of the mirror contract: the
|
|
2256
|
+
* `installAttempts` / `runnerDetails.availableApps` state left behind is
|
|
2257
|
+
* exactly what the first same-browser consuming context would have produced
|
|
2258
|
+
* serially (no more, no less), so every later gate collapses to a cache hit.
|
|
2259
|
+
* Deps are injected for hermetic tests; production callers use the defaults.
|
|
2260
|
+
*/
|
|
2261
|
+
async function warmBrowserInstall({ browserName, config, runnerDetails, installAttempts, deps = {}, }) {
|
|
2262
|
+
// Already-detected engines need no install — and, mirroring the serial
|
|
2263
|
+
// path (which only reaches the install when the support gate failed),
|
|
2264
|
+
// no memo entry either.
|
|
2265
|
+
const appName = normalizeBrowserName(browserName);
|
|
2266
|
+
if (runnerDetails.availableApps?.find((app) => app.name === appName)) {
|
|
2267
|
+
return {
|
|
2268
|
+
outcome: "skipped",
|
|
2269
|
+
note: `'${browserName}' is already available`,
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
const firstAttempt = !installAttempts.has((browserName ?? "<none>").toLowerCase());
|
|
2273
|
+
const outcome = await ensureContextBrowserInstalled({
|
|
2274
|
+
browserName,
|
|
2275
|
+
config,
|
|
2276
|
+
installAttempts,
|
|
2277
|
+
deps: {
|
|
2278
|
+
ensureBrowser: deps.ensureBrowser ??
|
|
2279
|
+
((asset, options) => ensureBrowserInstalled(asset, options)),
|
|
2280
|
+
log,
|
|
2281
|
+
},
|
|
2282
|
+
// Repair a present-but-broken driver, not just install-if-missing.
|
|
2283
|
+
repair: true,
|
|
2284
|
+
});
|
|
2285
|
+
// Re-detect only after a FIRST install attempt (installed or failed):
|
|
2286
|
+
// the app cache is stale either way, and later gates must read the
|
|
2287
|
+
// refreshed list or they'd misread the memo as installed-but-undetected.
|
|
2288
|
+
if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
|
|
2289
|
+
(deps.clearAppCache ?? clearAppCache)(config);
|
|
2290
|
+
runnerDetails.availableApps = await (deps.getAvailableApps ??
|
|
2291
|
+
getAvailableApps)({ config });
|
|
2292
|
+
}
|
|
2293
|
+
if (outcome === "installed")
|
|
2294
|
+
return { outcome: "warmed" };
|
|
2295
|
+
if (outcome === "failed") {
|
|
2296
|
+
return { outcome: "failed", note: `couldn't install '${browserName}'` };
|
|
2297
|
+
}
|
|
2298
|
+
return {
|
|
2299
|
+
outcome: "skipped",
|
|
2300
|
+
note: `'${browserName}' has no installable assets`,
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Light per-run Android environment probe for warm tasks: SDK + emulator
|
|
2305
|
+
* binary + acceleration (or a running emulator). Null means "not ready" —
|
|
2306
|
+
* the warm task reports skipped and the consuming context performs the full
|
|
2307
|
+
* androidContextPreflight (including the loud lazy toolchain install, which
|
|
2308
|
+
* warm deliberately never triggers — that decision and its warning belong on
|
|
2309
|
+
* the context report).
|
|
2310
|
+
*/
|
|
2311
|
+
/* c8 ignore start — real SDK/emulator probes; exercised on the CI emulator
|
|
2312
|
+
legs and dev boxes. Unit coverage targets the pure planner/executor. */
|
|
2313
|
+
async function resolveAndroidWarmEnv(config) {
|
|
2314
|
+
try {
|
|
2315
|
+
const abi = hostAbi();
|
|
2316
|
+
const sdk = detectAndroidSdk({ cacheDir: config?.cacheDir });
|
|
2317
|
+
if (!sdk?.emulator)
|
|
2318
|
+
return null;
|
|
2319
|
+
const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => log(config, "debug", m));
|
|
2320
|
+
const running = await deviceDeps.listRunning();
|
|
2321
|
+
const capable = running.length > 0 || (await checkEmulatorAcceleration(sdk.emulator));
|
|
2322
|
+
if (!capable)
|
|
2323
|
+
return null;
|
|
2324
|
+
return { sdkRoot: sdk.sdkRoot, deviceDeps };
|
|
2325
|
+
}
|
|
2326
|
+
catch {
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
/* c8 ignore stop */
|
|
2331
|
+
/**
|
|
2332
|
+
* Async twin of probeIosToolchain for the warm executor: the sync probe's
|
|
2333
|
+
* spawnSync (up to 120s on a cold CoreSimulator service) would block the
|
|
2334
|
+
* event loop and stall every "concurrent" warm task. Pre-run both probe
|
|
2335
|
+
* commands with async spawns (in parallel), then hand the collected results
|
|
2336
|
+
* to the real probe via its injected runner — the decision logic and every
|
|
2337
|
+
* skip message stay in ONE place.
|
|
2338
|
+
*/
|
|
2339
|
+
/* c8 ignore start — real xcode-select/xcrun spawns; the decision logic is
|
|
2340
|
+
probeIosToolchain's and is unit-tested there. */
|
|
2341
|
+
async function probeIosToolchainWarm() {
|
|
2342
|
+
if (process.platform !== "darwin")
|
|
2343
|
+
return probeIosToolchain();
|
|
2344
|
+
const runAsync = (command, args, timeout) => new Promise((resolve) => {
|
|
2345
|
+
let stdout = "";
|
|
2346
|
+
let stderr = "";
|
|
2347
|
+
try {
|
|
2348
|
+
const child = spawn(command, args, { windowsHide: true, timeout });
|
|
2349
|
+
child.stdout?.on("data", (d) => (stdout += String(d)));
|
|
2350
|
+
child.stderr?.on("data", (d) => (stderr += String(d)));
|
|
2351
|
+
child.on("error", () => resolve({ status: null, stdout, stderr }));
|
|
2352
|
+
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
2353
|
+
}
|
|
2354
|
+
catch {
|
|
2355
|
+
resolve({ status: null, stdout, stderr });
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
const [xcodeSelect, simctl] = await Promise.all([
|
|
2359
|
+
runAsync("xcode-select", ["-p"], 15000),
|
|
2360
|
+
// Same generous ceiling as the sync probe's xcrun spawn: the first cold
|
|
2361
|
+
// simctl call launches CoreSimulatorService.
|
|
2362
|
+
runAsync("xcrun", ["simctl", "list", "devices", "available"], 120000),
|
|
2363
|
+
]);
|
|
2364
|
+
return probeIosToolchain({
|
|
2365
|
+
run: (command) => (command === "xcrun" ? simctl : xcodeSelect),
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
/* c8 ignore stop */
|
|
2369
|
+
/**
|
|
2370
|
+
* Bind the effectful per-kind warm task bodies to the run's state. Every
|
|
2371
|
+
* body upholds the warm contract: best-effort (a throw is caught by the
|
|
2372
|
+
* executor and recorded as failed), and memo effects identical to what the
|
|
2373
|
+
* first consuming context would have produced serially. Device boots resolve
|
|
2374
|
+
* at boot initiation (raceBootInitiation); the chromedriver prefetch is the
|
|
2375
|
+
* one task that awaits device readiness (it needs a live session).
|
|
2376
|
+
*/
|
|
2377
|
+
/* c8 ignore start — thin dispatch over injected/imported effects; the pure
|
|
2378
|
+
pieces (planner, executor, raceBootInitiation, warmBrowserInstall) carry
|
|
2379
|
+
the unit coverage, and the wiring is exercised end-to-end by the fixture
|
|
2380
|
+
matrix + core-core.test.js. */
|
|
2381
|
+
function buildWarmTaskRunner({ config, runnerDetails, sizingJobs, appiumPool, installAttempts, warmUpResults, deviceRegistry, simulatorRegistry, resourceRegistry, }) {
|
|
2382
|
+
// One Android env probe per run, shared by device boots and the
|
|
2383
|
+
// chromedriver prefetch.
|
|
2384
|
+
let androidEnv;
|
|
2385
|
+
const getAndroidEnv = () => (androidEnv ??= resolveAndroidWarmEnv(config));
|
|
2386
|
+
// One iOS toolchain probe per run, async so the (potentially slow) xcrun
|
|
2387
|
+
// spawn never blocks the executor's event loop; device boots and the
|
|
2388
|
+
// driver install share the single result.
|
|
2389
|
+
let iosToolchain;
|
|
2390
|
+
const getIosToolchain = () => (iosToolchain ??= probeIosToolchainWarm());
|
|
2391
|
+
// Manual leases on the run's resource registry, for work that must
|
|
2392
|
+
// serialize on a named mutex but can't express its hold window as a task
|
|
2393
|
+
// tag (runResourceAware releases tags at task RESOLUTION, and warm tasks
|
|
2394
|
+
// deliberately resolve before their background work finishes). The
|
|
2395
|
+
// returned release is idempotent.
|
|
2396
|
+
const acquireLease = async (names) => {
|
|
2397
|
+
while (!resourceRegistry.tryAcquire(names)) {
|
|
2398
|
+
await resourceRegistry.waitForFree();
|
|
2399
|
+
}
|
|
2400
|
+
let released = false;
|
|
2401
|
+
return () => {
|
|
2402
|
+
if (released)
|
|
2403
|
+
return;
|
|
2404
|
+
released = true;
|
|
2405
|
+
resourceRegistry.release(names);
|
|
2406
|
+
};
|
|
2407
|
+
};
|
|
2408
|
+
const withRuntimeInstallLock = async (fn) => {
|
|
2409
|
+
const release = await acquireLease([RUNTIME_INSTALL_RESOURCE]);
|
|
2410
|
+
try {
|
|
2411
|
+
return await fn();
|
|
2412
|
+
}
|
|
2413
|
+
finally {
|
|
2414
|
+
release();
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
// One android app preflight (driver install + Appium co-homing) per run,
|
|
2418
|
+
// always performed under the install lease.
|
|
2419
|
+
let androidPreflight;
|
|
2420
|
+
const getAndroidPreflight = () => (androidPreflight ??= withRuntimeInstallLock(() => appSurfacePreflight({ config, platform: "android" })));
|
|
2421
|
+
return async (task) => {
|
|
2422
|
+
switch (task.kind) {
|
|
2423
|
+
case "browser-install":
|
|
2424
|
+
return warmBrowserInstall({
|
|
2425
|
+
browserName: task.payload.browserName,
|
|
2426
|
+
config,
|
|
2427
|
+
runnerDetails,
|
|
2428
|
+
installAttempts,
|
|
2429
|
+
});
|
|
2430
|
+
case "driver-install": {
|
|
2431
|
+
// Mirror the per-context preflights' host-capability gates BEFORE
|
|
2432
|
+
// installing: they probe the environment first and skip without
|
|
2433
|
+
// installing on hosts that can never run the platform, and warm
|
|
2434
|
+
// must not install what those gates would refuse.
|
|
2435
|
+
if (task.payload.platform === "android") {
|
|
2436
|
+
const env = await getAndroidEnv();
|
|
2437
|
+
if (!env) {
|
|
2438
|
+
return {
|
|
2439
|
+
outcome: "skipped",
|
|
2440
|
+
note: "Android toolchain not ready; the driver install stays with the consuming context",
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
if (task.payload.platform === "ios") {
|
|
2445
|
+
const toolchain = await getIosToolchain();
|
|
2446
|
+
if (!toolchain.ok) {
|
|
2447
|
+
return { outcome: "skipped", note: toolchain.reason };
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
// Same lazy-loaded install path appSurfacePreflight uses; it skips
|
|
2451
|
+
// packages already resolvable, so the later per-context preflight
|
|
2452
|
+
// finds the driver present and pays only Appium co-homing.
|
|
2453
|
+
const { ensureRuntimeInstalled } = await import("../runtime/loader.js");
|
|
2454
|
+
await ensureRuntimeInstalled([task.payload.driverPackage], {
|
|
2455
|
+
ctx: { cacheDir: config?.cacheDir },
|
|
2456
|
+
deps: { logger: (m) => log(config, "debug", m) },
|
|
2457
|
+
});
|
|
2458
|
+
return { outcome: "warmed" };
|
|
2459
|
+
}
|
|
2460
|
+
case "device-boot": {
|
|
2461
|
+
const desc = task.payload.desc;
|
|
2462
|
+
const onError = (error) => log(config, "warning", `Warm boot of '${task.name}' failed (a consuming context will retry): ${error?.message ?? String(error)}`);
|
|
2463
|
+
if (task.payload.platform === "android") {
|
|
2464
|
+
const env = await getAndroidEnv();
|
|
2465
|
+
if (!env) {
|
|
2466
|
+
return {
|
|
2467
|
+
outcome: "skipped",
|
|
2468
|
+
note: "Android toolchain not ready; device setup stays with the consuming context",
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
// Hold the run's "android-emulator" mutex from before initiation
|
|
2472
|
+
// until the boot settles — in the BACKGROUND, past this task's
|
|
2473
|
+
// resolution — so warm's boot and any Phase-2 job's boot (which
|
|
2474
|
+
// tag the same name) can never run two emulators at once: on a
|
|
2475
|
+
// small CI runner concurrent boots starve each other and the
|
|
2476
|
+
// sessions that follow. Released via the acquire promise's settle
|
|
2477
|
+
// chain (both branches handled — no unhandled rejection), not a
|
|
2478
|
+
// finally, precisely because the task resolves first.
|
|
2479
|
+
const releaseEmulatorLease = await acquireLease([
|
|
2480
|
+
"android-emulator",
|
|
2481
|
+
]);
|
|
2482
|
+
return raceBootInitiation({
|
|
2483
|
+
onError,
|
|
2484
|
+
startAcquire: (signalInitiated) => {
|
|
2485
|
+
const acquiring = acquireDevice({
|
|
2486
|
+
desc,
|
|
2487
|
+
registry: deviceRegistry,
|
|
2488
|
+
sdkRoot: env.sdkRoot,
|
|
2489
|
+
deps: wrapInitiationEffects(env.deviceDeps, ["createAvd", "boot"], signalInitiated),
|
|
2490
|
+
});
|
|
2491
|
+
acquiring.then(() => releaseEmulatorLease(), () => releaseEmulatorLease());
|
|
2492
|
+
return acquiring;
|
|
2493
|
+
},
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
const toolchain = await getIosToolchain();
|
|
2497
|
+
if (!toolchain.ok) {
|
|
2498
|
+
return { outcome: "skipped", note: toolchain.reason };
|
|
2499
|
+
}
|
|
2500
|
+
const simDeps = buildAcquireSimulatorDeps((m) => log(config, "debug", m));
|
|
2501
|
+
return raceBootInitiation({
|
|
2502
|
+
onError,
|
|
2503
|
+
startAcquire: (signalInitiated) => acquireSimulator({
|
|
2504
|
+
desc,
|
|
2505
|
+
registry: simulatorRegistry,
|
|
2506
|
+
deps: wrapInitiationEffects(simDeps, ["create", "boot"], signalInitiated),
|
|
2507
|
+
}),
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
case "wda-check": {
|
|
2511
|
+
const hit = locateManagedWda({ ctx: { cacheDir: config?.cacheDir } });
|
|
2512
|
+
if (hit) {
|
|
2513
|
+
return {
|
|
2514
|
+
outcome: "warmed",
|
|
2515
|
+
note: `prebuilt WebDriverAgent available (${hit.key})`,
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
return {
|
|
2519
|
+
outcome: "skipped",
|
|
2520
|
+
note: "no prebuilt WebDriverAgent for the current toolchain — `doc-detective install ios` prebuilds it",
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
case "session-probe": {
|
|
2524
|
+
if (!appiumPool) {
|
|
2525
|
+
return { outcome: "skipped", note: "no browser Appium pool" };
|
|
2526
|
+
}
|
|
2527
|
+
await warmUpContexts({
|
|
2528
|
+
jobs: sizingJobs,
|
|
2529
|
+
config,
|
|
2530
|
+
runnerDetails,
|
|
2531
|
+
appiumPool,
|
|
2532
|
+
installAttempts,
|
|
2533
|
+
warmUpResults,
|
|
2534
|
+
});
|
|
2535
|
+
const ok = [...warmUpResults.values()].filter((v) => v === "ok").length;
|
|
2536
|
+
const failed = warmUpResults.size - ok;
|
|
2537
|
+
// Failed combinations are a warm-level note, not a task failure:
|
|
2538
|
+
// they already have per-context recorded-skip semantics downstream.
|
|
2539
|
+
return {
|
|
2540
|
+
outcome: "warmed",
|
|
2541
|
+
note: `${ok} combination${ok === 1 ? "" : "s"} ok${failed ? `, ${failed} failed` : ""}`,
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
case "chromedriver-prefetch":
|
|
2545
|
+
return prefetchMobileChromedriver({
|
|
2546
|
+
config,
|
|
2547
|
+
desc: task.payload.desc,
|
|
2548
|
+
deviceRegistry,
|
|
2549
|
+
getAndroidEnv,
|
|
2550
|
+
deps: {
|
|
2551
|
+
// The cache-mutating half (driver install + Appium co-homing)
|
|
2552
|
+
// runs once per run under the manual runtime-install lease, so
|
|
2553
|
+
// the prefetch task itself only holds its device tag while it
|
|
2554
|
+
// awaits readiness and runs the throwaway session.
|
|
2555
|
+
appSurfacePreflight: () => getAndroidPreflight(),
|
|
2556
|
+
acquireEmulatorLease: () => acquireLease(["android-emulator"]),
|
|
2557
|
+
},
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
/* c8 ignore stop */
|
|
2563
|
+
/**
|
|
2564
|
+
* Pre-pay the on-device chromedriver download for android mobile-web: the
|
|
2565
|
+
* UiAutomator2 server only fetches a chromedriver matching the device's
|
|
2566
|
+
* Chrome at SESSION creation, so this task awaits the device (the one warm
|
|
2567
|
+
* task that blocks on readiness), opens a disposable mobile-web session on a
|
|
2568
|
+
* dedicated short-lived Appium server with the scoped autodownload feature —
|
|
2569
|
+
* the exact shape runContext's mobile-web branch uses — and tears both down.
|
|
2570
|
+
* The downloaded chromedriver lands in the shared cache, so the first real
|
|
2571
|
+
* session skips the download. Because the warm phase is awaited before
|
|
2572
|
+
* Phase 2 dispatch, this throwaway session can never overlap the first real
|
|
2573
|
+
* session on the same device. A throw is the executor's problem (recorded
|
|
2574
|
+
* as failed, run proceeds). Effects are injected for hermetic tests.
|
|
2575
|
+
*/
|
|
2576
|
+
async function prefetchMobileChromedriver({ config, desc, deviceRegistry, getAndroidEnv, deps = {}, }) {
|
|
2577
|
+
const preflight = deps.appSurfacePreflight ?? appSurfacePreflight;
|
|
2578
|
+
const acquire = deps.acquireDevice ?? acquireDevice;
|
|
2579
|
+
const startServer = deps.startAppiumServer ?? startAppiumServer;
|
|
2580
|
+
const startDriver = deps.driverStart ?? driverStart;
|
|
2581
|
+
const kill = deps.killTree ?? killTree;
|
|
2582
|
+
const env = await getAndroidEnv();
|
|
2583
|
+
if (!env) {
|
|
2584
|
+
return {
|
|
2585
|
+
outcome: "skipped",
|
|
2586
|
+
note: "Android toolchain not ready; the first mobile-web session downloads chromedriver as needed",
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
// Driver install + Appium co-homing, idempotent: usually a no-op after
|
|
2590
|
+
// the driver-install task (the shared runtime-install exclusivity tag
|
|
2591
|
+
// keeps the two from ever mutating the cache concurrently), and android
|
|
2592
|
+
// has no platform probes, so this is exactly the install half.
|
|
2593
|
+
const pre = await preflight({ config, platform: "android" });
|
|
2594
|
+
if (!pre.ok)
|
|
2595
|
+
return { outcome: "skipped", note: pre.reason };
|
|
2596
|
+
// Await the device. If the device-boot task already initiated this boot,
|
|
2597
|
+
// this acquire converges on the same registry entry and awaits its
|
|
2598
|
+
// in-flight `ready`; otherwise it performs the full acquire itself —
|
|
2599
|
+
// under the shared emulator lease, so a boot this task performs never
|
|
2600
|
+
// overlaps another emulator boot in the run.
|
|
2601
|
+
const releaseLease = deps.acquireEmulatorLease
|
|
2602
|
+
? await deps.acquireEmulatorLease()
|
|
2603
|
+
: undefined;
|
|
2604
|
+
let acquired;
|
|
2605
|
+
try {
|
|
2606
|
+
acquired = await acquire({
|
|
2607
|
+
desc,
|
|
2608
|
+
registry: deviceRegistry,
|
|
2609
|
+
sdkRoot: env.sdkRoot,
|
|
2610
|
+
deps: env.deviceDeps,
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
finally {
|
|
2614
|
+
releaseLease?.();
|
|
2615
|
+
}
|
|
2616
|
+
if ("skip" in acquired)
|
|
2617
|
+
return { outcome: "skipped", note: acquired.skip };
|
|
2618
|
+
let server;
|
|
2619
|
+
let driver;
|
|
2620
|
+
try {
|
|
2621
|
+
server = await startServer(pre.appiumEntry, config, undefined, {
|
|
2622
|
+
APPIUM_HOME: pre.appiumHome,
|
|
2623
|
+
ANDROID_HOME: env.sdkRoot,
|
|
2624
|
+
ANDROID_SDK_ROOT: env.sdkRoot,
|
|
2625
|
+
}, CHROMEDRIVER_AUTODOWNLOAD_ARGS);
|
|
2626
|
+
driver = await startDriver(buildMobileBrowserCapabilities({
|
|
2627
|
+
platform: "android",
|
|
2628
|
+
udid: acquired.entry.udid,
|
|
2629
|
+
cacheDir: getCacheDir({ cacheDir: config?.cacheDir }),
|
|
2630
|
+
}), server.port, 2, { cacheDir: config?.cacheDir });
|
|
2631
|
+
return {
|
|
2632
|
+
outcome: "warmed",
|
|
2633
|
+
note: `chromedriver ready for device '${acquired.entry.name}'`,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
finally {
|
|
2637
|
+
if (driver) {
|
|
2638
|
+
try {
|
|
2639
|
+
await driver.deleteSession();
|
|
2640
|
+
}
|
|
2641
|
+
catch {
|
|
2642
|
+
// best-effort teardown of the throwaway session
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
if (server) {
|
|
2646
|
+
await kill(server.process?.pid);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2180
2650
|
/**
|
|
2181
2651
|
* Pure predicate: does this spec carry TEST-level routing? True iff ANY of its
|
|
2182
2652
|
* tests has a non-empty `onPass`/`onFail`/`onWarning`/`onSkip` array. This is
|
|
@@ -2814,7 +3284,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2814
3284
|
contextReport.resultDescription = errorMessage;
|
|
2815
3285
|
return contextReport;
|
|
2816
3286
|
}
|
|
2817
|
-
|
|
3287
|
+
if (logLevelEnabled(config, "debug"))
|
|
3288
|
+
clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
|
|
2818
3289
|
let driver;
|
|
2819
3290
|
let appiumPort;
|
|
2820
3291
|
// Multi-surface Phase 4 (ADR 01019): the context's browser-session registry.
|
|
@@ -2854,9 +3325,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2854
3325
|
// Chromedriver autodownload is an Appium insecure feature; opt in
|
|
2855
3326
|
// scoped to the uiautomator2 driver on this run-owned server so it can
|
|
2856
3327
|
// fetch the chromedriver matching the device's Chrome.
|
|
2857
|
-
const extraArgs = mobileTarget === "android"
|
|
2858
|
-
? ["--allow-insecure", "uiautomator2:chromedriver_autodownload"]
|
|
2859
|
-
: [];
|
|
3328
|
+
const extraArgs = mobileTarget === "android" ? CHROMEDRIVER_AUTODOWNLOAD_ARGS : [];
|
|
2860
3329
|
// Server start + device boot are environment work: any failure there
|
|
2861
3330
|
// (port pressure, an emulator that can't finish booting on this host,
|
|
2862
3331
|
// simctl trouble) is a gating SKIP with the reason named — never a
|
|
@@ -3278,7 +3747,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
3278
3747
|
stepExecutionFailed = true;
|
|
3279
3748
|
break;
|
|
3280
3749
|
}
|
|
3281
|
-
|
|
3750
|
+
if (logLevelEnabled(config, "debug"))
|
|
3751
|
+
clog("debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
|
|
3282
3752
|
if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
|
|
3283
3753
|
clog("warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
|
|
3284
3754
|
// Mark as skipped
|
|
@@ -3380,7 +3850,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
3380
3850
|
processRegistry: processRegistry,
|
|
3381
3851
|
appSession: appSession,
|
|
3382
3852
|
});
|
|
3383
|
-
|
|
3853
|
+
if (logLevelEnabled(config, "debug"))
|
|
3854
|
+
clog("debug", `RESULT: ${r.status}\n${JSON.stringify(r, null, 2)}`);
|
|
3384
3855
|
r.result = r.status;
|
|
3385
3856
|
r.resultDescription = r.description;
|
|
3386
3857
|
delete r.status;
|
|
@@ -3968,7 +4439,12 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
|
|
|
3968
4439
|
// Start one Appium server on a free port and resolve once it answers /status.
|
|
3969
4440
|
// Each concurrent runner gets its own server (own port) so parallel contexts
|
|
3970
4441
|
// never create sessions on the same Appium instance.
|
|
3971
|
-
|
|
4442
|
+
// Spawn an Appium server process WITHOUT waiting for readiness. Split out from
|
|
4443
|
+
// startAppiumServer so the browser-pool startup (below) can spawn servers
|
|
4444
|
+
// SERIALLY — preserving the findFreePort close-to-rebind race protection and
|
|
4445
|
+
// avoiding a startup CPU spike — while OVERLAPPING their readiness polls. A
|
|
4446
|
+
// single-server caller uses startAppiumServer, which spawns then awaits.
|
|
4447
|
+
async function spawnAppiumServer(appiumEntry, config, display, extraEnv,
|
|
3972
4448
|
// Extra CLI args for the server, e.g. the scoped `--allow-insecure`
|
|
3973
4449
|
// chromedriver-autodownload opt-in for android mobile-web sessions.
|
|
3974
4450
|
extraArgs) {
|
|
@@ -3996,8 +4472,15 @@ extraArgs) {
|
|
|
3996
4472
|
});
|
|
3997
4473
|
proc.stdout.on("data", () => { });
|
|
3998
4474
|
proc.stderr.on("data", () => { });
|
|
4475
|
+
return { port, process: proc, display };
|
|
4476
|
+
}
|
|
4477
|
+
async function startAppiumServer(appiumEntry, config, display, extraEnv,
|
|
4478
|
+
// Extra CLI args for the server, e.g. the scoped `--allow-insecure`
|
|
4479
|
+
// chromedriver-autodownload opt-in for android mobile-web sessions.
|
|
4480
|
+
extraArgs) {
|
|
4481
|
+
const server = await spawnAppiumServer(appiumEntry, config, display, extraEnv, extraArgs);
|
|
3999
4482
|
try {
|
|
4000
|
-
await appiumIsReady(port);
|
|
4483
|
+
await appiumIsReady(server.port);
|
|
4001
4484
|
}
|
|
4002
4485
|
catch (error) {
|
|
4003
4486
|
// appiumIsReady threw or timed out — the spawned child is still alive and
|
|
@@ -4005,29 +4488,48 @@ extraArgs) {
|
|
|
4005
4488
|
// propagating so subsequent runs don't trip on the stale state. Awaited
|
|
4006
4489
|
// so the process is confirmed gone before this function returns control
|
|
4007
4490
|
// to the caller.
|
|
4008
|
-
await killTree(
|
|
4491
|
+
await killTree(server.process?.pid);
|
|
4009
4492
|
throw error;
|
|
4010
4493
|
}
|
|
4011
|
-
log(config, "debug", `Appium is ready on port ${port}.`);
|
|
4012
|
-
return
|
|
4494
|
+
log(config, "debug", `Appium is ready on port ${server.port}.`);
|
|
4495
|
+
return server;
|
|
4013
4496
|
}
|
|
4014
|
-
//
|
|
4015
|
-
|
|
4016
|
-
|
|
4497
|
+
// Per-probe HTTP timeout for the Appium `/status` check. Bounds a single
|
|
4498
|
+
// hung request so the overall readiness timeout can still fire; a healthy
|
|
4499
|
+
// server answers in milliseconds.
|
|
4500
|
+
const STATUS_PROBE_TIMEOUT_MS = 10000;
|
|
4501
|
+
// Delay execution until Appium server is available. Probe `/status`
|
|
4502
|
+
// IMMEDIATELY, then poll on a short 250ms interval until ready or the overall
|
|
4503
|
+
// timeout — a server that is already up returns in ~one round-trip instead of
|
|
4504
|
+
// paying a fixed leading 1s sleep (the old loop slept before its first probe).
|
|
4505
|
+
// `probe`/`sleep` are injectable for hermetic unit tests; the overall timeout
|
|
4506
|
+
// cap (default 120s) is unchanged.
|
|
4507
|
+
async function appiumIsReady(port, timeoutMs = 120000, deps = {}) {
|
|
4508
|
+
const probe = deps.probe ??
|
|
4509
|
+
(async (p) => {
|
|
4510
|
+
try {
|
|
4511
|
+
// Bound each probe: without a per-request timeout a hung /status
|
|
4512
|
+
// response would block this await indefinitely, and the overall
|
|
4513
|
+
// `timeoutMs` guard (checked only between probes) could never fire.
|
|
4514
|
+
const resp = await axios.get(`http://127.0.0.1:${p}/status`, {
|
|
4515
|
+
timeout: STATUS_PROBE_TIMEOUT_MS,
|
|
4516
|
+
});
|
|
4517
|
+
return resp.status === 200;
|
|
4518
|
+
}
|
|
4519
|
+
catch {
|
|
4520
|
+
return false;
|
|
4521
|
+
}
|
|
4522
|
+
});
|
|
4523
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
4017
4524
|
const start = Date.now();
|
|
4018
|
-
while (
|
|
4525
|
+
while (true) {
|
|
4526
|
+
if (await probe(port))
|
|
4527
|
+
return true;
|
|
4019
4528
|
if (Date.now() - start > timeoutMs) {
|
|
4020
4529
|
throw new Error(`Appium server on port ${port} failed to start within ${timeoutMs / 1000} seconds`);
|
|
4021
4530
|
}
|
|
4022
|
-
await
|
|
4023
|
-
try {
|
|
4024
|
-
let resp = await axios.get(`http://127.0.0.1:${port}/status`);
|
|
4025
|
-
if (resp.status === 200)
|
|
4026
|
-
isReady = true;
|
|
4027
|
-
}
|
|
4028
|
-
catch { }
|
|
4531
|
+
await sleep(250);
|
|
4029
4532
|
}
|
|
4030
|
-
return isReady;
|
|
4031
4533
|
}
|
|
4032
4534
|
// Start the Appium driver specified in `capabilities`.
|
|
4033
4535
|
async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
|