doc-detective 4.21.0 → 4.23.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/common/src/schemas/schemas.json +538 -338
- package/dist/common/src/types/generated/config_v3.d.ts +5 -5
- package/dist/common/src/types/generated/context_v3.d.ts +5 -5
- package/dist/common/src/types/generated/report_v3.d.ts +5 -5
- package/dist/common/src/types/generated/resolvedTests_v3.d.ts +10 -10
- package/dist/common/src/types/generated/spec_v3.d.ts +5 -5
- package/dist/common/src/types/generated/startSurface_v3.d.ts +7 -7
- package/dist/common/src/types/generated/step_v3.d.ts +7 -7
- package/dist/common/src/types/generated/test_v3.d.ts +19 -19
- package/dist/core/appium.d.ts +2 -1
- package/dist/core/appium.d.ts.map +1 -1
- package/dist/core/appium.js +47 -13
- package/dist/core/appium.js.map +1 -1
- package/dist/core/ptyProbeWorker.d.ts +2 -0
- package/dist/core/ptyProbeWorker.d.ts.map +1 -0
- package/dist/core/ptyProbeWorker.js +61 -0
- package/dist/core/ptyProbeWorker.js.map +1 -0
- package/dist/core/ptyWatchdog.d.ts +77 -0
- package/dist/core/ptyWatchdog.d.ts.map +1 -0
- package/dist/core/ptyWatchdog.js +179 -0
- package/dist/core/ptyWatchdog.js.map +1 -0
- package/dist/core/resolveTests.d.ts.map +1 -1
- package/dist/core/resolveTests.js +16 -4
- package/dist/core/resolveTests.js.map +1 -1
- package/dist/core/tests/androidEmulator.d.ts +2 -12
- package/dist/core/tests/androidEmulator.d.ts.map +1 -1
- package/dist/core/tests/androidEmulator.js +5 -17
- package/dist/core/tests/androidEmulator.js.map +1 -1
- package/dist/core/tests/appSurface.d.ts +55 -2
- package/dist/core/tests/appSurface.d.ts.map +1 -1
- package/dist/core/tests/appSurface.js +279 -23
- package/dist/core/tests/appSurface.js.map +1 -1
- package/dist/core/tests/browserWait.d.ts +1 -1
- package/dist/core/tests/browserWait.d.ts.map +1 -1
- package/dist/core/tests/browserWait.js +108 -84
- package/dist/core/tests/browserWait.js.map +1 -1
- package/dist/core/tests/findElement.d.ts.map +1 -1
- package/dist/core/tests/findElement.js +16 -5
- package/dist/core/tests/findElement.js.map +1 -1
- package/dist/core/tests/iosSimulator.d.ts +89 -0
- package/dist/core/tests/iosSimulator.d.ts.map +1 -0
- package/dist/core/tests/iosSimulator.js +453 -0
- package/dist/core/tests/iosSimulator.js.map +1 -0
- package/dist/core/tests/mobileBrowser.d.ts +46 -0
- package/dist/core/tests/mobileBrowser.d.ts.map +1 -0
- package/dist/core/tests/mobileBrowser.js +140 -0
- package/dist/core/tests/mobileBrowser.js.map +1 -0
- package/dist/core/tests/mobileDevice.d.ts +15 -0
- package/dist/core/tests/mobileDevice.d.ts.map +1 -0
- package/dist/core/tests/mobileDevice.js +24 -0
- package/dist/core/tests/mobileDevice.js.map +1 -0
- package/dist/core/tests/mobilePlatform.d.ts +1 -8
- package/dist/core/tests/mobilePlatform.d.ts.map +1 -1
- package/dist/core/tests/mobilePlatform.js +5 -26
- package/dist/core/tests/mobilePlatform.js.map +1 -1
- package/dist/core/tests.d.ts +7 -1
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +344 -61
- package/dist/core/tests.js.map +1 -1
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +26 -1
- package/dist/core/utils.js.map +1 -1
- package/dist/index.cjs +1835 -691
- package/dist/runtime/heavyDeps.d.ts +1 -1
- package/dist/runtime/heavyDeps.d.ts.map +1 -1
- package/dist/runtime/heavyDeps.js +1 -0
- package/dist/runtime/heavyDeps.js.map +1 -1
- package/dist/runtime/installCommand.d.ts +1 -1
- package/dist/runtime/installCommand.d.ts.map +1 -1
- package/dist/runtime/installCommand.js +21 -4
- package/dist/runtime/installCommand.js.map +1 -1
- package/dist/runtime/iosInstaller.d.ts +23 -0
- package/dist/runtime/iosInstaller.d.ts.map +1 -0
- package/dist/runtime/iosInstaller.js +104 -0
- package/dist/runtime/iosInstaller.js.map +1 -0
- package/dist/runtime/loader.d.ts.map +1 -1
- package/dist/runtime/loader.js +76 -0
- package/dist/runtime/loader.js.map +1 -1
- package/package.json +2 -1
package/dist/core/tests.js
CHANGED
|
@@ -34,10 +34,13 @@ import { clickElement } from "./tests/click.js";
|
|
|
34
34
|
import { runCode } from "./tests/runCode.js";
|
|
35
35
|
import { closeSurface } from "./tests/closeSurface.js";
|
|
36
36
|
import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, startAppSurface, teardownAppSession, } from "./tests/appSurface.js";
|
|
37
|
-
import { isMobileTargetPlatform
|
|
37
|
+
import { isMobileTargetPlatform } from "./tests/mobilePlatform.js";
|
|
38
|
+
import { mobileBrowserGate, buildMobileBrowserCapabilities, } from "./tests/mobileBrowser.js";
|
|
39
|
+
import { getCacheDir } from "../runtime/cacheDir.js";
|
|
38
40
|
import { detectAndroidSdk } from "../runtime/androidSdk.js";
|
|
39
41
|
import { hostAbi, listInstalledSystemImages, installAndroid, } from "../runtime/androidInstaller.js";
|
|
40
42
|
import { buildAcquireDeviceDeps, checkEmulatorAcceleration, hostHasKvm, planDeviceAcquisition, planAndroidToolchain, normalizeDeviceDescriptor, acquireDevice, createDeviceRegistry, teardownDeviceRegistry, } from "./tests/androidEmulator.js";
|
|
43
|
+
import { buildAcquireSimulatorDeps, planSimulatorAcquisition, acquireSimulator, createSimulatorRegistry, teardownSimulatorRegistry, } from "./tests/iosSimulator.js";
|
|
41
44
|
import { runBrowserScript } from "./tests/runBrowserScript.js";
|
|
42
45
|
import { dragAndDropElement } from "./tests/dragAndDrop.js";
|
|
43
46
|
import { createSessionRegistry, registerSession, activeDriver, sweepSessions, } from "./tests/browserSessions.js";
|
|
@@ -54,7 +57,7 @@ import http from "node:http";
|
|
|
54
57
|
import https from "node:https";
|
|
55
58
|
import { fileURLToPath } from "node:url";
|
|
56
59
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
57
|
-
export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, };
|
|
60
|
+
export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, };
|
|
58
61
|
// exports.appiumStart = appiumStart;
|
|
59
62
|
// exports.appiumIsReady = appiumIsReady;
|
|
60
63
|
// exports.driverStart = driverStart;
|
|
@@ -294,11 +297,18 @@ function jobDisplayResources(job, ctx) {
|
|
|
294
297
|
// emulator is GBs of RAM, so bound their concurrency to one at a time. This
|
|
295
298
|
// is exclusivity-as-bound on a single resource name (a counted semaphore is
|
|
296
299
|
// future work); it composes with any recording "display" resource above.
|
|
297
|
-
// Only android contexts that will actually attempt the emulator take it
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
+
// Only android contexts that will actually attempt the emulator take it.
|
|
301
|
+
// Native app contexts always do; a mobile-web context (phase A5) does when
|
|
302
|
+
// its browser gate proceeds — a context the gate deterministically SKIPs or
|
|
303
|
+
// FAILs (unsupported browser, mixed app+web, device-fixed config) never
|
|
304
|
+
// boots anything, so it must not needlessly serialize other jobs.
|
|
300
305
|
const attemptsEmulator = job.context?.platform === "android" &&
|
|
301
|
-
|
|
306
|
+
mobileBrowserGate({
|
|
307
|
+
platform: "android",
|
|
308
|
+
browser: job.context?.browser,
|
|
309
|
+
hasBrowserStep: isBrowserRequired({ test: job.context }),
|
|
310
|
+
hasAppStep: isAppDriverRequired({ test: job.context }),
|
|
311
|
+
}).action === "proceed";
|
|
302
312
|
const extra = attemptsEmulator ? ["android-emulator"] : [];
|
|
303
313
|
if (base.length || extra.length)
|
|
304
314
|
return [...new Set([...base, ...extra])];
|
|
@@ -398,20 +408,29 @@ function requiredAndroidOsVersions(context) {
|
|
|
398
408
|
}
|
|
399
409
|
// Android context preflight (native app phase A3b): host-capability probe →
|
|
400
410
|
// lazy toolchain install (when the run needs it) → per-device resolvability →
|
|
401
|
-
// UiAutomator2 driver install. Every gap is a gating SKIP (never
|
|
411
|
+
// UiAutomator2 driver install. Every environment gap is a gating SKIP (never
|
|
412
|
+
// FAIL); the one FAIL is authored device-fixed browser config (phase A5 gate),
|
|
413
|
+
// which is a contradiction to surface loudly, not a missing capability. The
|
|
402
414
|
// toolchain (SDK + system image) is NOT installed by default, but IS lazily
|
|
403
415
|
// installed — with a loud warning surfaced to the terminal AND the output
|
|
404
416
|
// report — when a run that reaches a capable host actually needs it. On ok,
|
|
405
417
|
// returns the Appium entry/home, SDK root, injected device-effect bundle, and
|
|
406
418
|
// any warnings to attach to the context report.
|
|
407
419
|
async function androidContextPreflight({ config, context, clog, }) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
420
|
+
// Mobile-web gate (phase A5) — before ANY toolchain work, so unsupported
|
|
421
|
+
// browsers, mixed app+web contexts, and device-fixed browser config land
|
|
422
|
+
// deterministically on every host without touching the SDK.
|
|
423
|
+
const gate = mobileBrowserGate({
|
|
424
|
+
platform: "android",
|
|
425
|
+
browser: context.browser,
|
|
426
|
+
hasBrowserStep: isBrowserRequired({ test: context }),
|
|
427
|
+
hasAppStep: isAppDriverRequired({ test: context }),
|
|
428
|
+
});
|
|
429
|
+
if (gate.action === "skip") {
|
|
430
|
+
return { ok: false, level: gate.level, reason: gate.reason };
|
|
431
|
+
}
|
|
432
|
+
if (gate.action === "fail") {
|
|
433
|
+
return { ok: false, level: "warning", reason: gate.reason, fail: true };
|
|
415
434
|
}
|
|
416
435
|
/* c8 ignore start */
|
|
417
436
|
// The rest is effectful (SDK detect/install, emulator probes) so it's
|
|
@@ -558,6 +577,7 @@ async function androidContextPreflight({ config, context, clog, }) {
|
|
|
558
577
|
sdkRoot: sdk.sdkRoot,
|
|
559
578
|
deviceDeps,
|
|
560
579
|
warnings,
|
|
580
|
+
mobileWebBrowserName: gate.browserName,
|
|
561
581
|
};
|
|
562
582
|
}
|
|
563
583
|
catch (error) {
|
|
@@ -569,6 +589,70 @@ async function androidContextPreflight({ config, context, clog, }) {
|
|
|
569
589
|
}
|
|
570
590
|
/* c8 ignore stop */
|
|
571
591
|
}
|
|
592
|
+
// iOS context preflight (native app phase A4 + mobile web phase A5): the
|
|
593
|
+
// mobile-browser gate decides first (support matrix / device-fixed config /
|
|
594
|
+
// mixed-context deferral — all pure, all pre-toolchain); then host
|
|
595
|
+
// capability/toolchain probes via appSurfacePreflight, then validation that
|
|
596
|
+
// the context's default simulator can be resolved (booted/created) via
|
|
597
|
+
// simctl. On success return the appium entry/home, the injected simctl effect
|
|
598
|
+
// bundle for the run's acquireSimulator closure, and the device browser (if
|
|
599
|
+
// any) to open a session for.
|
|
600
|
+
async function iosContextPreflight({ config, context, }) {
|
|
601
|
+
const gate = mobileBrowserGate({
|
|
602
|
+
platform: "ios",
|
|
603
|
+
browser: context.browser,
|
|
604
|
+
hasBrowserStep: isBrowserRequired({ test: context }),
|
|
605
|
+
hasAppStep: isAppDriverRequired({ test: context }),
|
|
606
|
+
});
|
|
607
|
+
if (gate.action === "skip") {
|
|
608
|
+
return { ok: false, level: gate.level, reason: gate.reason };
|
|
609
|
+
}
|
|
610
|
+
if (gate.action === "fail") {
|
|
611
|
+
return { ok: false, level: "warning", reason: gate.reason, fail: true };
|
|
612
|
+
}
|
|
613
|
+
const pre = await appSurfacePreflight({ config, platform: "ios" });
|
|
614
|
+
if (!pre.ok)
|
|
615
|
+
return { ok: false, level: "info", reason: pre.reason };
|
|
616
|
+
/* c8 ignore start */
|
|
617
|
+
// The ok path only runs on a capable macOS host (appSurfacePreflight's
|
|
618
|
+
// probeIosToolchain passed), so the simctl probes below are macOS-only and
|
|
619
|
+
// never execute in the cross-platform unit suite.
|
|
620
|
+
const simulatorDeps = buildAcquireSimulatorDeps((m) => log(config, "debug", m));
|
|
621
|
+
try {
|
|
622
|
+
const desc = normalizeDeviceDescriptor({
|
|
623
|
+
contextDevice: context.device,
|
|
624
|
+
platform: "ios",
|
|
625
|
+
});
|
|
626
|
+
const [devices, runtimes, deviceTypes] = await Promise.all([
|
|
627
|
+
simulatorDeps.listDevices(),
|
|
628
|
+
simulatorDeps.listRuntimes(),
|
|
629
|
+
simulatorDeps.listDeviceTypes(),
|
|
630
|
+
]);
|
|
631
|
+
const plan = planSimulatorAcquisition(desc, {
|
|
632
|
+
devices,
|
|
633
|
+
runtimes,
|
|
634
|
+
deviceTypes,
|
|
635
|
+
});
|
|
636
|
+
if (plan.action === "skip") {
|
|
637
|
+
return { ok: false, level: "info", reason: plan.reason };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
level: "info",
|
|
644
|
+
reason: `Skipping context on 'ios': couldn't probe the iOS simulator environment (${error?.message ?? error}). Check Xcode / simctl, or run \`doc-detective install ios --yes\`.`,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
ok: true,
|
|
649
|
+
appiumEntry: pre.appiumEntry,
|
|
650
|
+
appiumHome: pre.appiumHome,
|
|
651
|
+
simulatorDeps,
|
|
652
|
+
mobileWebBrowserName: gate.browserName,
|
|
653
|
+
};
|
|
654
|
+
/* c8 ignore stop */
|
|
655
|
+
}
|
|
572
656
|
function getDefaultBrowser({ runnerDetails }) {
|
|
573
657
|
let browser = {};
|
|
574
658
|
const browserNames = ["firefox", "chrome", "safari"];
|
|
@@ -1245,6 +1329,11 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1245
1329
|
// wanting the same device converge on one boot. Swept in the `finally` below —
|
|
1246
1330
|
// only devices Doc Detective booted are killed (launch-ownership).
|
|
1247
1331
|
const deviceRegistry = createDeviceRegistry();
|
|
1332
|
+
// Run-level simulator registry (native app phase A4): booted/created iOS
|
|
1333
|
+
// simulators keyed by resolved name, the simctl analogue of deviceRegistry.
|
|
1334
|
+
// Swept in the `finally` below — only simulators Doc Detective booted are
|
|
1335
|
+
// shut down (launch-ownership).
|
|
1336
|
+
const simulatorRegistry = createSimulatorRegistry();
|
|
1248
1337
|
// Kill every still-registered background process (and its child tree) and
|
|
1249
1338
|
// remove any deferred temp scripts. Awaits the kills so the process tree is
|
|
1250
1339
|
// actually gone before the run returns. Idempotent: closeSurface already
|
|
@@ -1344,6 +1433,7 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1344
1433
|
warmUpResults,
|
|
1345
1434
|
processRegistry,
|
|
1346
1435
|
deviceRegistry,
|
|
1436
|
+
simulatorRegistry,
|
|
1347
1437
|
logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
|
|
1348
1438
|
});
|
|
1349
1439
|
}
|
|
@@ -1430,6 +1520,7 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1430
1520
|
warmUpResults,
|
|
1431
1521
|
processRegistry,
|
|
1432
1522
|
deviceRegistry,
|
|
1523
|
+
simulatorRegistry,
|
|
1433
1524
|
platform,
|
|
1434
1525
|
markAutoRecord,
|
|
1435
1526
|
limit,
|
|
@@ -1495,6 +1586,16 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1495
1586
|
await killTree(entry.process?.pid);
|
|
1496
1587
|
});
|
|
1497
1588
|
}
|
|
1589
|
+
// Sweep the iOS simulator registry (native app phase A4): shut down only the
|
|
1590
|
+
// simulators Doc Detective booted (bootedByUs), leaving pre-existing booted
|
|
1591
|
+
// ones running. `simctl shutdown` via the injected effect bundle.
|
|
1592
|
+
if (simulatorRegistry.size > 0) {
|
|
1593
|
+
const simDeps = buildAcquireSimulatorDeps();
|
|
1594
|
+
await teardownSimulatorRegistry(simulatorRegistry, async (entry) => {
|
|
1595
|
+
log(config, "debug", `Shutting down simulator "${entry.name}" (${entry.udid}).`);
|
|
1596
|
+
await simDeps.shutdown(entry);
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1498
1599
|
/* c8 ignore stop */
|
|
1499
1600
|
process.off("SIGINT", onSignal);
|
|
1500
1601
|
process.off("SIGTERM", onSignal);
|
|
@@ -1560,7 +1661,7 @@ async function runSpecs({ resolvedTests }) {
|
|
|
1560
1661
|
* NEVER touches the summary — Phase-3 in runSpecs remains the sole tally site;
|
|
1561
1662
|
* here we only push reports.
|
|
1562
1663
|
*/
|
|
1563
|
-
async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, platform, markAutoRecord, limit, resourceRegistry, exclusivityCtx, }) {
|
|
1664
|
+
async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, platform, markAutoRecord, limit, resourceRegistry, exclusivityCtx, }) {
|
|
1564
1665
|
// Set once a `stop:spec` (or deferred `stop:run`) decision halts the spec; the
|
|
1565
1666
|
// remaining tests are recorded SKIPPED with the stop reason and not executed.
|
|
1566
1667
|
let stopRest = false;
|
|
@@ -1723,6 +1824,7 @@ async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDe
|
|
|
1723
1824
|
warmUpResults,
|
|
1724
1825
|
processRegistry,
|
|
1725
1826
|
deviceRegistry,
|
|
1827
|
+
simulatorRegistry,
|
|
1726
1828
|
logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
|
|
1727
1829
|
});
|
|
1728
1830
|
}
|
|
@@ -1837,6 +1939,14 @@ function selectWarmUpTargets(jobs, runnerDetails) {
|
|
|
1837
1939
|
// would defeat the warm-up/install de-racing the pre-pass exists for.
|
|
1838
1940
|
if (!context.platform)
|
|
1839
1941
|
context.platform = platform;
|
|
1942
|
+
// Mobile contexts (android/ios targets) never warm up a desktop engine:
|
|
1943
|
+
// their browser runs ON the device through the per-context app Appium
|
|
1944
|
+
// server (phase A5), so a desktop warm-up would launch the wrong browser
|
|
1945
|
+
// on the wrong machine — and must not write a desktop default browser
|
|
1946
|
+
// onto the context (runContext's mobile branch resolves the device
|
|
1947
|
+
// browser itself).
|
|
1948
|
+
if (isMobileTargetPlatform(context.platform))
|
|
1949
|
+
continue;
|
|
1840
1950
|
// Size and target the warm-up by isBrowserRequired (not isDriverRequired):
|
|
1841
1951
|
// app-only contexts run on their own per-context Appium server, so they
|
|
1842
1952
|
// must not pull a browser into the pre-pass or get a default browser
|
|
@@ -2288,7 +2398,7 @@ async function captureAutoScreenshot({ config, driver, spec, test, context, step
|
|
|
2288
2398
|
* report or summary counters — the caller owns aggregation, which keeps this
|
|
2289
2399
|
* function safe to run concurrently with sibling contexts.
|
|
2290
2400
|
*/
|
|
2291
|
-
async function runContext({ config, spec, test, context, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, logPrefix = "", }) {
|
|
2401
|
+
async function runContext({ config, spec, test, context, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, logPrefix = "", }) {
|
|
2292
2402
|
const platform = runnerDetails.environment.platform;
|
|
2293
2403
|
// `let`, not `const`: an on-demand browser install below re-detects available
|
|
2294
2404
|
// apps and reassigns this snapshot.
|
|
@@ -2317,8 +2427,13 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2317
2427
|
// If "browser" isn't defined but is required by the test, set it to the
|
|
2318
2428
|
// first available browser in the sequence of Firefox, Chrome, Safari.
|
|
2319
2429
|
// App-targeted steps don't count: they run on the app session, so an
|
|
2320
|
-
// app-only test never boots a browser it won't use.
|
|
2321
|
-
|
|
2430
|
+
// app-only test never boots a browser it won't use. Mobile contexts don't
|
|
2431
|
+
// count either: their browser is the device's (chrome/safari), resolved by
|
|
2432
|
+
// the mobile branch below — a desktop default here would be wrong on both
|
|
2433
|
+
// the engine and the machine.
|
|
2434
|
+
if (!context.browser &&
|
|
2435
|
+
isBrowserRequired({ test: context }) &&
|
|
2436
|
+
!isMobileTargetPlatform(context.platform)) {
|
|
2322
2437
|
context.browser = getDefaultBrowser({ runnerDetails });
|
|
2323
2438
|
}
|
|
2324
2439
|
// Set context report
|
|
@@ -2345,6 +2460,11 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2345
2460
|
// context that passes its preflight (phase A3b). Declared here so the mobile
|
|
2346
2461
|
// branch can set it and fall through to the shared step-execution path.
|
|
2347
2462
|
let appSession;
|
|
2463
|
+
// Mobile web (phase A5): set when the mobile preflight resolved a device
|
|
2464
|
+
// browser for this context. The try block below then opens the browser
|
|
2465
|
+
// session on the device (through the app session's Appium server) instead
|
|
2466
|
+
// of the desktop engine path.
|
|
2467
|
+
let mobileWebBrowserName;
|
|
2348
2468
|
const mobileTarget = isMobileTargetPlatform(context.platform);
|
|
2349
2469
|
if (mobileTarget) {
|
|
2350
2470
|
const requirementsSkip = contextRequirementsSkipMessage({ context });
|
|
@@ -2354,44 +2474,70 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2354
2474
|
contextReport.resultDescription = requirementsSkip;
|
|
2355
2475
|
return contextReport;
|
|
2356
2476
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
// Android (phase A3b): full preflight. SDK detection is lazy (probed only
|
|
2366
|
-
// here, only for android contexts), so a run that never targets android
|
|
2367
|
-
// pays nothing. On skip, land SKIPPED with the actionable reason; on ok,
|
|
2368
|
-
// prime the app session with the device layer and FALL THROUGH to run the
|
|
2369
|
-
// steps — none of the desktop engine/platform skips below apply to it (the
|
|
2370
|
-
// `!platformMatches` skip is guarded on `!appSession`).
|
|
2371
|
-
const pre = await androidContextPreflight({ config, context, clog });
|
|
2372
|
-
if (!pre.ok) {
|
|
2373
|
-
clog(pre.level, pre.reason);
|
|
2374
|
-
contextReport.result = "SKIPPED";
|
|
2477
|
+
// Both preflights run the mobile-browser gate first (support matrix,
|
|
2478
|
+
// device-fixed config, mixed app+web) — `fail: true` marks an authored
|
|
2479
|
+
// contradiction (FAIL loudly); everything else lands SKIPPED. Applied via
|
|
2480
|
+
// this shared closure so the two typed branches below stay narrowed to
|
|
2481
|
+
// their platform's ok-shape (no cross-platform union casts).
|
|
2482
|
+
const gateOutcome = (pre) => {
|
|
2483
|
+
clog(pre.fail ? "error" : pre.level, pre.reason);
|
|
2484
|
+
contextReport.result = pre.fail ? "FAIL" : "SKIPPED";
|
|
2375
2485
|
contextReport.resultDescription = pre.reason;
|
|
2376
2486
|
return contextReport;
|
|
2487
|
+
};
|
|
2488
|
+
let gateBrowserName;
|
|
2489
|
+
if (mobileTarget === "android") {
|
|
2490
|
+
// Android (phase A3b): SDK detection is lazy (probed only here, only
|
|
2491
|
+
// for android contexts), so a run that never targets android pays
|
|
2492
|
+
// nothing. On ok, prime the app session with the device layer and FALL
|
|
2493
|
+
// THROUGH to run the steps.
|
|
2494
|
+
const pre = await androidContextPreflight({ config, context, clog });
|
|
2495
|
+
if (!pre.ok)
|
|
2496
|
+
return gateOutcome(pre);
|
|
2497
|
+
/* c8 ignore start */
|
|
2498
|
+
// The ok path only runs on a host with a real SDK + emulator (CI legs).
|
|
2499
|
+
appSession = createAppSessionState();
|
|
2500
|
+
appSession.appiumEntry = pre.appiumEntry;
|
|
2501
|
+
appSession.appiumHome = pre.appiumHome;
|
|
2502
|
+
appSession.androidSdkRoot = pre.sdkRoot;
|
|
2503
|
+
appSession.androidDeviceRegistry = deviceRegistry;
|
|
2504
|
+
appSession.androidDeviceDeps = pre.deviceDeps;
|
|
2505
|
+
// Surface any preflight warnings (e.g. a lazy toolchain install) in the
|
|
2506
|
+
// output report — not just the terminal — so a run that quietly
|
|
2507
|
+
// downloaded the multi-GB SDK is auditable after the fact.
|
|
2508
|
+
if (pre.warnings.length)
|
|
2509
|
+
contextReport.warnings = pre.warnings;
|
|
2510
|
+
gateBrowserName = pre.mobileWebBrowserName;
|
|
2511
|
+
/* c8 ignore stop */
|
|
2512
|
+
}
|
|
2513
|
+
else {
|
|
2514
|
+
// iOS (phase A4): on ok, prime the app session with the simulator layer.
|
|
2515
|
+
const pre = await iosContextPreflight({ config, context });
|
|
2516
|
+
if (!pre.ok)
|
|
2517
|
+
return gateOutcome(pre);
|
|
2518
|
+
/* c8 ignore start */
|
|
2519
|
+
// The ok path only runs on a capable macOS host (CI fixture legs).
|
|
2520
|
+
appSession = createAppSessionState();
|
|
2521
|
+
appSession.appiumEntry = pre.appiumEntry;
|
|
2522
|
+
appSession.appiumHome = pre.appiumHome;
|
|
2523
|
+
appSession.iosSimulatorRegistry = simulatorRegistry;
|
|
2524
|
+
appSession.iosSimulatorDeps = pre.simulatorDeps;
|
|
2525
|
+
gateBrowserName = pre.mobileWebBrowserName;
|
|
2526
|
+
/* c8 ignore stop */
|
|
2377
2527
|
}
|
|
2378
2528
|
/* c8 ignore start */
|
|
2379
|
-
// The ok path only runs on a host with a real SDK + emulator (CI legs).
|
|
2380
|
-
appSession = createAppSessionState();
|
|
2381
|
-
appSession.appiumEntry = pre.appiumEntry;
|
|
2382
|
-
appSession.appiumHome = pre.appiumHome;
|
|
2383
2529
|
appSession.defaultDevice = context.device;
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2530
|
+
// resolved device (name) joins the context report the way resolved
|
|
2531
|
+
// browser versions do; the concrete udid is known once a device boots.
|
|
2532
|
+
contextReport.device = context.device ?? { platform: mobileTarget };
|
|
2533
|
+
if (gateBrowserName) {
|
|
2534
|
+
// Mobile web: pin the context's browser to the device browser the gate
|
|
2535
|
+
// resolved (the authored one, or the platform default) so the report
|
|
2536
|
+
// and the session capabilities agree.
|
|
2537
|
+
mobileWebBrowserName = gateBrowserName;
|
|
2538
|
+
context.browser = { ...(context.browser ?? {}), name: mobileWebBrowserName };
|
|
2539
|
+
contextReport.browser = context.browser;
|
|
2540
|
+
}
|
|
2395
2541
|
/* c8 ignore stop */
|
|
2396
2542
|
}
|
|
2397
2543
|
// `requires` capability gate: any unmet requirement skips the context with a
|
|
@@ -2521,8 +2667,10 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2521
2667
|
})
|
|
2522
2668
|
: [];
|
|
2523
2669
|
// A driver context with no startable engine is skipped with a diagnostic that
|
|
2524
|
-
// names the requested engine and the partial-download cause.
|
|
2525
|
-
|
|
2670
|
+
// names the requested engine and the partial-download cause. Mobile-web
|
|
2671
|
+
// contexts don't participate: their browser lives on the device, not in the
|
|
2672
|
+
// host's engine list (their session path is the mobile block below).
|
|
2673
|
+
if (driverRequired && !mobileWebBrowserName && candidateEngines.length === 0) {
|
|
2526
2674
|
const errorMessage = freshInstallRedetected
|
|
2527
2675
|
? freshInstallOutcome === "installed"
|
|
2528
2676
|
? `Skipping context '${requestedBrowserName}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
|
|
@@ -2571,7 +2719,120 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
|
|
|
2571
2719
|
"predicate disagreed; this is a bug.");
|
|
2572
2720
|
}
|
|
2573
2721
|
try {
|
|
2574
|
-
|
|
2722
|
+
/* c8 ignore start */
|
|
2723
|
+
// Mobile web (phase A5): only reachable on a capable host (the mobile
|
|
2724
|
+
// preflight gates everything else), so it's exercised by the
|
|
2725
|
+
// mobile-web fixture legs, not the unit suite.
|
|
2726
|
+
if (driverRequired && mobileWebBrowserName) {
|
|
2727
|
+
// The browser session lives ON the managed device, through the app
|
|
2728
|
+
// session's Appium server (homed where the mobile driver lives) — not
|
|
2729
|
+
// the desktop engine pool. Acquire/boot the context's default device,
|
|
2730
|
+
// then open one webdriver session with browserName set so Appium starts
|
|
2731
|
+
// it in a web context; goTo/find/click/screenshot then behave exactly
|
|
2732
|
+
// as on desktop.
|
|
2733
|
+
const env = {
|
|
2734
|
+
APPIUM_HOME: appSession.appiumHome,
|
|
2735
|
+
};
|
|
2736
|
+
if (appSession.androidSdkRoot) {
|
|
2737
|
+
env.ANDROID_HOME = appSession.androidSdkRoot;
|
|
2738
|
+
env.ANDROID_SDK_ROOT = appSession.androidSdkRoot;
|
|
2739
|
+
}
|
|
2740
|
+
// Chromedriver autodownload is an Appium insecure feature; opt in
|
|
2741
|
+
// scoped to the uiautomator2 driver on this run-owned server so it can
|
|
2742
|
+
// fetch the chromedriver matching the device's Chrome.
|
|
2743
|
+
const extraArgs = mobileTarget === "android"
|
|
2744
|
+
? ["--allow-insecure", "uiautomator2:chromedriver_autodownload"]
|
|
2745
|
+
: [];
|
|
2746
|
+
// Server start + device boot are environment work: any failure there
|
|
2747
|
+
// (port pressure, an emulator that can't finish booting on this host,
|
|
2748
|
+
// simctl trouble) is a gating SKIP with the reason named — never a
|
|
2749
|
+
// FAIL — matching the mobile rule that every environment gap SKIPs.
|
|
2750
|
+
let acquired;
|
|
2751
|
+
try {
|
|
2752
|
+
const server = await startAppiumServer(appSession.appiumEntry, config, undefined, env, extraArgs);
|
|
2753
|
+
appSession.server = { port: server.port, process: server.process };
|
|
2754
|
+
const desc = normalizeDeviceDescriptor({
|
|
2755
|
+
contextDevice: context.device,
|
|
2756
|
+
platform: mobileTarget,
|
|
2757
|
+
});
|
|
2758
|
+
acquired =
|
|
2759
|
+
mobileTarget === "android"
|
|
2760
|
+
? await acquireDevice({
|
|
2761
|
+
desc,
|
|
2762
|
+
registry: appSession.androidDeviceRegistry,
|
|
2763
|
+
sdkRoot: appSession.androidSdkRoot,
|
|
2764
|
+
deps: appSession.androidDeviceDeps,
|
|
2765
|
+
})
|
|
2766
|
+
: await acquireSimulator({
|
|
2767
|
+
desc,
|
|
2768
|
+
registry: appSession.iosSimulatorRegistry,
|
|
2769
|
+
deps: appSession.iosSimulatorDeps,
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
catch (error) {
|
|
2773
|
+
const errorMessage = `Skipping context on '${mobileTarget}': couldn't prepare the device for the ${mobileWebBrowserName} session (${error?.message ?? error}). Check the emulator/simulator toolchain (\`doc-detective install ${mobileTarget}\`).`;
|
|
2774
|
+
clog("warning", errorMessage);
|
|
2775
|
+
contextReport.result = "SKIPPED";
|
|
2776
|
+
contextReport.resultDescription = errorMessage;
|
|
2777
|
+
return contextReport;
|
|
2778
|
+
}
|
|
2779
|
+
if ("skip" in acquired) {
|
|
2780
|
+
clog("warning", acquired.skip);
|
|
2781
|
+
contextReport.result = "SKIPPED";
|
|
2782
|
+
contextReport.resultDescription = acquired.skip;
|
|
2783
|
+
return contextReport;
|
|
2784
|
+
}
|
|
2785
|
+
// The resolved device joins the context report the way resolved
|
|
2786
|
+
// browser versions do.
|
|
2787
|
+
contextReport.device = {
|
|
2788
|
+
...(typeof contextReport.device === "object"
|
|
2789
|
+
? contextReport.device
|
|
2790
|
+
: {}),
|
|
2791
|
+
platform: mobileTarget,
|
|
2792
|
+
name: acquired.entry.name,
|
|
2793
|
+
};
|
|
2794
|
+
const capabilities = buildMobileBrowserCapabilities({
|
|
2795
|
+
platform: mobileTarget,
|
|
2796
|
+
udid: acquired.entry.udid,
|
|
2797
|
+
// The resolved cache root (not the raw config field, which is
|
|
2798
|
+
// usually unset) — the chromedriver autodownload dir lives here.
|
|
2799
|
+
cacheDir: getCacheDir({ cacheDir: config?.cacheDir }),
|
|
2800
|
+
});
|
|
2801
|
+
try {
|
|
2802
|
+
driver = await driverStart(capabilities, appSession.server.port, 2, {
|
|
2803
|
+
cacheDir: config?.cacheDir,
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
catch (error) {
|
|
2807
|
+
// A capable host that can't open the device browser is an
|
|
2808
|
+
// environment gap (the absent-browser precedent): SKIP with the
|
|
2809
|
+
// likely cause named, never FAIL.
|
|
2810
|
+
const hint = mobileTarget === "android"
|
|
2811
|
+
? " Chrome may not be present on this emulator image — managed Android mobile-web needs a `google_apis` system image (see `doc-detective install android`)."
|
|
2812
|
+
: " Check that the simulator runtime includes Safari and that WebDriverAgent can build (see `doc-detective install ios`).";
|
|
2813
|
+
const errorMessage = `Skipping context on '${mobileTarget}': couldn't start the ${mobileWebBrowserName} session on device '${acquired.entry.name}' (${error?.message ?? error}).${hint}`;
|
|
2814
|
+
clog("warning", errorMessage);
|
|
2815
|
+
contextReport.result = "SKIPPED";
|
|
2816
|
+
contextReport.resultDescription = errorMessage;
|
|
2817
|
+
return contextReport;
|
|
2818
|
+
}
|
|
2819
|
+
// Register the device browser as the context's one browser surface so
|
|
2820
|
+
// surface-targeted steps resolve it by engine name, same as desktop.
|
|
2821
|
+
// No additional sessions: one device, one browser.
|
|
2822
|
+
browserSessions = createSessionRegistry({
|
|
2823
|
+
open: async () => {
|
|
2824
|
+
throw new Error("Additional browser sessions aren't supported on a managed device; the device browser is the context's only browser surface.");
|
|
2825
|
+
},
|
|
2826
|
+
isNameTaken: (name) => !!processRegistry?.has(name),
|
|
2827
|
+
});
|
|
2828
|
+
registerSession(browserSessions, {
|
|
2829
|
+
name: String(mobileWebBrowserName).toLowerCase(),
|
|
2830
|
+
engine: String(mobileWebBrowserName).toLowerCase(),
|
|
2831
|
+
driver,
|
|
2832
|
+
});
|
|
2833
|
+
/* c8 ignore stop */
|
|
2834
|
+
}
|
|
2835
|
+
else if (driverRequired) {
|
|
2575
2836
|
// Check out a server for this context's lifetime — released in the
|
|
2576
2837
|
// finally so the next queued context can reuse it.
|
|
2577
2838
|
appiumPort = await appiumPool.acquire();
|
|
@@ -3442,9 +3703,12 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
|
|
|
3442
3703
|
return { port: server.port, process: server.process };
|
|
3443
3704
|
},
|
|
3444
3705
|
startDriver: (capabilities, port) => driverStart(capabilities, port, 2, { cacheDir: config?.cacheDir }),
|
|
3445
|
-
//
|
|
3446
|
-
//
|
|
3447
|
-
//
|
|
3706
|
+
// Mobile device acquisition (boot/create-and-boot, or reuse), bound
|
|
3707
|
+
// to the run-level registry stashed on the app session. Android uses
|
|
3708
|
+
// the emulator layer; iOS uses the simctl simulator layer. Both
|
|
3709
|
+
// return the same { entry:{name,udid} } | { skip } shape, so
|
|
3710
|
+
// startAppSurface's mobile branch stays uniform. Only set for a
|
|
3711
|
+
// mobile app session (which only exists on a capable host).
|
|
3448
3712
|
/* c8 ignore start */
|
|
3449
3713
|
acquireDevice: appSession?.androidDeviceDeps
|
|
3450
3714
|
? (desc) => acquireDevice({
|
|
@@ -3453,7 +3717,13 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
|
|
|
3453
3717
|
sdkRoot: appSession.androidSdkRoot,
|
|
3454
3718
|
deps: appSession.androidDeviceDeps,
|
|
3455
3719
|
})
|
|
3456
|
-
:
|
|
3720
|
+
: appSession?.iosSimulatorDeps
|
|
3721
|
+
? (desc) => acquireSimulator({
|
|
3722
|
+
desc,
|
|
3723
|
+
registry: appSession.iosSimulatorRegistry,
|
|
3724
|
+
deps: appSession.iosSimulatorDeps,
|
|
3725
|
+
})
|
|
3726
|
+
: undefined,
|
|
3457
3727
|
/* c8 ignore stop */
|
|
3458
3728
|
},
|
|
3459
3729
|
});
|
|
@@ -3536,7 +3806,10 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
|
|
|
3536
3806
|
// Start one Appium server on a free port and resolve once it answers /status.
|
|
3537
3807
|
// Each concurrent runner gets its own server (own port) so parallel contexts
|
|
3538
3808
|
// never create sessions on the same Appium instance.
|
|
3539
|
-
async function startAppiumServer(appiumEntry, config, display, extraEnv
|
|
3809
|
+
async function startAppiumServer(appiumEntry, config, display, extraEnv,
|
|
3810
|
+
// Extra CLI args for the server, e.g. the scoped `--allow-insecure`
|
|
3811
|
+
// chromedriver-autodownload opt-in for android mobile-web sessions.
|
|
3812
|
+
extraArgs) {
|
|
3540
3813
|
const port = await findFreePort();
|
|
3541
3814
|
log(config, "debug", `Starting Appium on port ${port}`);
|
|
3542
3815
|
// When a virtual display is supplied (Linux Xvfb recording), launch the
|
|
@@ -3551,7 +3824,7 @@ async function startAppiumServer(appiumEntry, config, display, extraEnv) {
|
|
|
3551
3824
|
...(extraEnv ?? {}),
|
|
3552
3825
|
}
|
|
3553
3826
|
: process.env;
|
|
3554
|
-
const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port)], {
|
|
3827
|
+
const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port), ...(extraArgs ?? [])], {
|
|
3555
3828
|
windowsHide: true,
|
|
3556
3829
|
cwd: path.join(__dirname, "../.."),
|
|
3557
3830
|
env,
|
|
@@ -3611,6 +3884,16 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
|
|
|
3611
3884
|
// creation failure and propagates immediately.
|
|
3612
3885
|
const TRANSIENT = /ECONNREFUSED|ECONNRESET|socket hang up|could not proxy command|crashed during startup|cannot connect to|DevToolsActivePort|session not created/i;
|
|
3613
3886
|
const wdio = await loadHeavyDep("webdriverio", { ctx });
|
|
3887
|
+
// The wdio client aborts the POST /session request after connectionRetryTimeout.
|
|
3888
|
+
// A cold native session can take far longer to create than the 2-minute
|
|
3889
|
+
// default: the first XCUITest session builds WebDriverAgent via xcodebuild
|
|
3890
|
+
// (several minutes on a fresh macOS runner), and Mac2 builds WebDriverAgentMac
|
|
3891
|
+
// similarly. Derive the client timeout from whatever slow-startup ceiling the
|
|
3892
|
+
// capabilities declared (wdaLaunchTimeout / wdaConnectionTimeout /
|
|
3893
|
+
// serverStartupTimeout) so the client waits as long as the driver was told to,
|
|
3894
|
+
// never below the 2-minute floor. Browser/Windows/Android sessions carry none
|
|
3895
|
+
// of these caps and keep the 2-minute default unchanged.
|
|
3896
|
+
const startupCeiling = Math.max(120000, Number(capabilities?.["appium:wdaLaunchTimeout"]) || 0, Number(capabilities?.["appium:wdaConnectionTimeout"]) || 0, Number(capabilities?.["appium:serverStartupTimeout"]) || 0);
|
|
3614
3897
|
let lastError;
|
|
3615
3898
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3616
3899
|
try {
|
|
@@ -3621,7 +3904,7 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
|
|
|
3621
3904
|
path: "/",
|
|
3622
3905
|
logLevel: "error",
|
|
3623
3906
|
capabilities,
|
|
3624
|
-
connectionRetryTimeout:
|
|
3907
|
+
connectionRetryTimeout: startupCeiling,
|
|
3625
3908
|
waitforTimeout: 120000, // 2 minutes
|
|
3626
3909
|
});
|
|
3627
3910
|
// Per-context mutable state. `recordings` lives here (not on config)
|