doc-detective 4.19.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/common/src/schemas/schemas.json +86416 -6069
  2. package/dist/common/src/types/generated/closeSurface_v3.d.ts +79 -13
  3. package/dist/common/src/types/generated/closeSurface_v3.d.ts.map +1 -1
  4. package/dist/common/src/types/generated/config_v3.d.ts +24 -0
  5. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  6. package/dist/common/src/types/generated/context_v3.d.ts +24 -0
  7. package/dist/common/src/types/generated/context_v3.d.ts.map +1 -1
  8. package/dist/common/src/types/generated/report_v3.d.ts +24 -0
  9. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  10. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +48 -0
  11. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  12. package/dist/common/src/types/generated/screenshot_v3.d.ts +40 -3
  13. package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
  14. package/dist/common/src/types/generated/spec_v3.d.ts +24 -0
  15. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  16. package/dist/common/src/types/generated/startSurface_v3.d.ts +108 -0
  17. package/dist/common/src/types/generated/startSurface_v3.d.ts.map +1 -0
  18. package/dist/common/src/types/generated/startSurface_v3.js +7 -0
  19. package/dist/common/src/types/generated/startSurface_v3.js.map +1 -0
  20. package/dist/common/src/types/generated/step_v3.d.ts +532 -107
  21. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  22. package/dist/common/src/types/generated/surface_v3.d.ts +35 -2
  23. package/dist/common/src/types/generated/surface_v3.d.ts.map +1 -1
  24. package/dist/common/src/types/generated/test_v3.d.ts +1837 -851
  25. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  26. package/dist/common/src/types/generated/type_v3.d.ts +60 -10
  27. package/dist/common/src/types/generated/type_v3.d.ts.map +1 -1
  28. package/dist/common/src/types/generated/waitUntil_v3.d.ts +22 -8
  29. package/dist/common/src/types/generated/waitUntil_v3.d.ts.map +1 -1
  30. package/dist/core/detectTests.d.ts.map +1 -1
  31. package/dist/core/detectTests.js +26 -1
  32. package/dist/core/detectTests.js.map +1 -1
  33. package/dist/core/expressions.d.ts.map +1 -1
  34. package/dist/core/expressions.js +19 -1
  35. package/dist/core/expressions.js.map +1 -1
  36. package/dist/core/resolveTests.d.ts.map +1 -1
  37. package/dist/core/resolveTests.js +24 -6
  38. package/dist/core/resolveTests.js.map +1 -1
  39. package/dist/core/tests/appSurface.d.ts +106 -0
  40. package/dist/core/tests/appSurface.d.ts.map +1 -0
  41. package/dist/core/tests/appSurface.js +521 -0
  42. package/dist/core/tests/appSurface.js.map +1 -0
  43. package/dist/core/tests/click.d.ts +2 -1
  44. package/dist/core/tests/click.d.ts.map +1 -1
  45. package/dist/core/tests/click.js +2 -1
  46. package/dist/core/tests/click.js.map +1 -1
  47. package/dist/core/tests/closeSurface.d.ts +3 -1
  48. package/dist/core/tests/closeSurface.d.ts.map +1 -1
  49. package/dist/core/tests/closeSurface.js +43 -5
  50. package/dist/core/tests/closeSurface.js.map +1 -1
  51. package/dist/core/tests/findElement.d.ts +2 -1
  52. package/dist/core/tests/findElement.d.ts.map +1 -1
  53. package/dist/core/tests/findElement.js +59 -1
  54. package/dist/core/tests/findElement.js.map +1 -1
  55. package/dist/core/tests/runCode.d.ts.map +1 -1
  56. package/dist/core/tests/runCode.js +9 -0
  57. package/dist/core/tests/runCode.js.map +1 -1
  58. package/dist/core/tests/saveScreenshot.d.ts +2 -1
  59. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  60. package/dist/core/tests/saveScreenshot.js +57 -12
  61. package/dist/core/tests/saveScreenshot.js.map +1 -1
  62. package/dist/core/tests/startRecording.d.ts +2 -1
  63. package/dist/core/tests/startRecording.d.ts.map +1 -1
  64. package/dist/core/tests/startRecording.js +11 -5
  65. package/dist/core/tests/startRecording.js.map +1 -1
  66. package/dist/core/tests/typeKeys.d.ts +2 -1
  67. package/dist/core/tests/typeKeys.d.ts.map +1 -1
  68. package/dist/core/tests/typeKeys.js +84 -4
  69. package/dist/core/tests/typeKeys.js.map +1 -1
  70. package/dist/core/tests.d.ts +9 -2
  71. package/dist/core/tests.d.ts.map +1 -1
  72. package/dist/core/tests.js +233 -32
  73. package/dist/core/tests.js.map +1 -1
  74. package/dist/core/utils.d.ts +14 -1
  75. package/dist/core/utils.d.ts.map +1 -1
  76. package/dist/core/utils.js +90 -1
  77. package/dist/core/utils.js.map +1 -1
  78. package/dist/index.cjs +87807 -6698
  79. package/dist/runtime/loader.d.ts +8 -0
  80. package/dist/runtime/loader.d.ts.map +1 -1
  81. package/dist/runtime/loader.js +10 -0
  82. package/dist/runtime/loader.js.map +1 -1
  83. package/package.json +2 -1
@@ -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 } 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, } from "./utils.js";
16
+ import { log, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, } 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";
@@ -33,6 +33,7 @@ import { httpRequest } from "./tests/httpRequest.js";
33
33
  import { clickElement } from "./tests/click.js";
34
34
  import { runCode } from "./tests/runCode.js";
35
35
  import { closeSurface } from "./tests/closeSurface.js";
36
+ import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, startAppSurface, teardownAppSession, } from "./tests/appSurface.js";
36
37
  import { runBrowserScript } from "./tests/runBrowserScript.js";
37
38
  import { dragAndDropElement } from "./tests/dragAndDrop.js";
38
39
  import { createSessionRegistry, registerSession, activeDriver, sweepSessions, } from "./tests/browserSessions.js";
@@ -49,7 +50,7 @@ import http from "node:http";
49
50
  import https from "node:https";
50
51
  import { fileURLToPath } from "node:url";
51
52
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, };
53
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, };
53
54
  // exports.appiumStart = appiumStart;
54
55
  // exports.appiumIsReady = appiumIsReady;
55
56
  // exports.driverStart = driverStart;
@@ -319,6 +320,48 @@ function isSupportedContext({ context, apps, platform }) {
319
320
  // Return boolean
320
321
  return Boolean(isSupportedApp && isSupportedPlatform);
321
322
  }
323
+ // Like isDriverRequired, but only counts driver steps that need a BROWSER: a
324
+ // step whose payload targets an app surface (object form) is driven by the
325
+ // app session instead, and the synthetic autoRecord capture is an ffmpeg
326
+ // screen grab — neither may force a default browser into existence (in a
327
+ // browser test the authored steps already require one, so excluding the
328
+ // synthetic step never changes the outcome there).
329
+ function isBrowserRequired({ test }) {
330
+ if (!Array.isArray(test?.steps))
331
+ return false;
332
+ return test.steps.some((step) => !step?.__autoRecord &&
333
+ driverActions.some((action) => typeof step[action] !== "undefined") &&
334
+ !stepTargetsAppSurface(step));
335
+ }
336
+ // Size the browser Appium server pool: the number of concurrent runner jobs
337
+ // that will actually create a BROWSER session. App-only jobs are excluded —
338
+ // they provision their own per-context Appium server (homed where the native
339
+ // driver resolves), so counting them here would start an idle browser server
340
+ // (and, on Linux, an unused Xvfb display). Using isBrowserRequired keeps this
341
+ // count in lockstep with the per-context acquire predicate. Exported for a
342
+ // focused unit test; the deep pool wiring is exercised end-to-end by CI.
343
+ export function browserJobCount(jobs) {
344
+ if (!Array.isArray(jobs))
345
+ return 0;
346
+ return jobs.filter((job) => isBrowserRequired({ test: job?.context }))
347
+ .length;
348
+ }
349
+ // Evaluate a context's `requires` capability gate. Returns null when the gate
350
+ // is absent or fully met; otherwise a skip message naming every unmet
351
+ // requirement, so the context lands as SKIPPED (the same non-failing outcome
352
+ // as a `platforms` mismatch). `deps` is passed through to
353
+ // evaluateContextRequirements for hermetic tests.
354
+ function contextRequirementsSkipMessage({ context, deps, }) {
355
+ if (context?.requires === undefined || context?.requires === null)
356
+ return null;
357
+ const { met, missing } = evaluateContextRequirements({
358
+ requires: context.requires,
359
+ deps,
360
+ });
361
+ if (met)
362
+ return null;
363
+ return `Skipping context on '${context.platform}': unmet requirements — ${missing.join(", ")}.`;
364
+ }
322
365
  function getDefaultBrowser({ runnerDetails }) {
323
366
  let browser = {};
324
367
  const browserNames = ["firefox", "chrome", "safari"];
@@ -913,12 +956,14 @@ async function runSpecs({ resolvedTests }) {
913
956
  else if (concurrency.overlappingCaptures && limit > 1) {
914
957
  log(config, "warning", "autoRecord is running ffmpeg recordings concurrently on a shared display, so the captured videos will overlap (each context records the whole screen). For isolated concurrent recordings, run on Linux with Xvfb installed.");
915
958
  }
916
- // Start one Appium server per concurrent runner that will actually use a
917
- // driver (capped at the number of driver contexts). Each server owns a
918
- // distinct port, so parallel contexts never create sessions on the same
919
- // server — that contention crashed ChromeDriver when every context shared
920
- // one server. Non-driver runs start none.
921
- const driverJobCount = sizingJobs.filter((job) => isDriverRequired({ test: job.context })).length;
959
+ // Start one Appium server per concurrent runner that will actually create a
960
+ // BROWSER session (capped at the number of browser contexts). Each server
961
+ // owns a distinct port, so parallel contexts never create sessions on the
962
+ // same server — that contention crashed ChromeDriver when every context
963
+ // shared one server. App-only contexts run on their own per-context server
964
+ // (see startAppSurface) and are excluded here, so an app-only run starts no
965
+ // browser server. Non-driver runs start none.
966
+ const browserPoolJobCount = browserJobCount(sizingJobs);
922
967
  let appiumServers = [];
923
968
  let appiumPool;
924
969
  // Per-server virtual displays (Linux Xvfb) for concurrent ffmpeg recording,
@@ -927,7 +972,7 @@ async function runSpecs({ resolvedTests }) {
927
972
  const xvfbProcesses = [];
928
973
  const useXvfbDisplays = concurrency.xvfbContexts.length > 0;
929
974
  let portToDisplay;
930
- if (driverJobCount > 0) {
975
+ if (browserPoolJobCount > 0) {
931
976
  setAppiumHome({ cacheDir: config?.cacheDir });
932
977
  // Resolve appium's actual JS entrypoint via `require.resolve` (shim
933
978
  // node_modules first, runtime cache second) and invoke it with
@@ -942,7 +987,7 @@ async function runSpecs({ resolvedTests }) {
942
987
  if (!appiumEntry) {
943
988
  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`.");
944
989
  }
945
- const serverCount = Math.min(limit, driverJobCount);
990
+ const serverCount = Math.min(limit, browserPoolJobCount);
946
991
  log(config, "debug", `Starting ${serverCount} Appium server(s).`);
947
992
  // Start servers one at a time rather than all at once: concurrent
948
993
  // findFreePort() calls share a close-to-rebind window (two could hand out
@@ -1565,10 +1610,14 @@ function selectWarmUpTargets(jobs, runnerDetails) {
1565
1610
  // would defeat the warm-up/install de-racing the pre-pass exists for.
1566
1611
  if (!context.platform)
1567
1612
  context.platform = platform;
1568
- if (!context.browser && isDriverRequired({ test: context })) {
1613
+ // Size and target the warm-up by isBrowserRequired (not isDriverRequired):
1614
+ // app-only contexts run on their own per-context Appium server, so they
1615
+ // must not pull a browser into the pre-pass or get a default browser
1616
+ // written onto their context. Mirrors the browser pool sizing.
1617
+ if (!context.browser && isBrowserRequired({ test: context })) {
1569
1618
  context.browser = getDefaultBrowser({ runnerDetails });
1570
1619
  }
1571
- if (!isDriverRequired({ test: context }))
1620
+ if (!isBrowserRequired({ test: context }))
1572
1621
  continue;
1573
1622
  // No resolvable browser — runContext skips these per-context with its own
1574
1623
  // message; nothing to warm up.
@@ -2038,8 +2087,11 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2038
2087
  ...config.integrations.openApi,
2039
2088
  ];
2040
2089
  }
2041
- // 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
2042
- if (!context.browser && isDriverRequired({ test: context })) {
2090
+ // If "browser" isn't defined but is required by the test, set it to the
2091
+ // first available browser in the sequence of Firefox, Chrome, Safari.
2092
+ // App-targeted steps don't count: they run on the app session, so an
2093
+ // app-only test never boots a browser it won't use.
2094
+ if (!context.browser && isBrowserRequired({ test: context })) {
2043
2095
  context.browser = getDefaultBrowser({ runnerDetails });
2044
2096
  }
2045
2097
  // Set context report
@@ -2051,11 +2103,45 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2051
2103
  };
2052
2104
  // Set meta values (create-if-missing — ids aren't guaranteed unique)
2053
2105
  metaValues.specs[spec.specId].tests[test.testId].contexts[context.contextId] ??= { steps: {} };
2106
+ // `requires` capability gate: any unmet requirement skips the context with a
2107
+ // message naming what's missing. Evaluated only on the platform the context
2108
+ // targets — a different-platform context keeps its platform skip reason, and
2109
+ // requirements are host facts that would be meaningless to probe elsewhere.
2110
+ if (context.platform === platform) {
2111
+ const requirementsSkip = contextRequirementsSkipMessage({ context });
2112
+ if (requirementsSkip) {
2113
+ clog("warning", requirementsSkip);
2114
+ contextReport.result = "SKIPPED";
2115
+ contextReport.resultDescription = requirementsSkip;
2116
+ return contextReport;
2117
+ }
2118
+ }
2119
+ // App-surface preflight (native app phase A1): when the test provisions or
2120
+ // targets app surfaces, verify the platform supports them and the native
2121
+ // driver is (or can be) installed. Unmet -> SKIPPED with the reason, same
2122
+ // gating semantics as `requires`. On success, the context gets an app
2123
+ // session primed with the resolved Appium entry/home for the app server.
2124
+ // Scoped to the current platform like the `requires` gate above: a context
2125
+ // targeting another platform must skip with the platform-mismatch reason,
2126
+ // not pay (or misreport) a driver-install attempt on this host.
2127
+ let appSession;
2128
+ if (context.platform === platform && isAppDriverRequired({ test: context })) {
2129
+ const preflight = await appSurfacePreflight({ config, platform });
2130
+ if (!preflight.ok) {
2131
+ clog("warning", preflight.reason);
2132
+ contextReport.result = "SKIPPED";
2133
+ contextReport.resultDescription = preflight.reason;
2134
+ return contextReport;
2135
+ }
2136
+ appSession = createAppSessionState();
2137
+ appSession.appiumEntry = preflight.appiumEntry;
2138
+ appSession.appiumHome = preflight.appiumHome;
2139
+ }
2054
2140
  // If a driver is required but no browser could be resolved (e.g.
2055
2141
  // getDefaultBrowser found nothing installed, or the context supplied a
2056
2142
  // browser object with no name), skip with an explicit reason instead of
2057
2143
  // letting it fail later as "Failed to start context 'undefined'".
2058
- if (isDriverRequired({ test: context }) && !context.browser?.name) {
2144
+ if (isBrowserRequired({ test: context }) && !context.browser?.name) {
2059
2145
  const errorMessage = `Skipping context on '${context.platform}': no supported browser is available in the current environment.`;
2060
2146
  clog("warning", errorMessage);
2061
2147
  contextReport.result = "SKIPPED";
@@ -2133,7 +2219,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2133
2219
  // Context-level browserFallback (authored on the runOn entry) overrides the
2134
2220
  // config-level policy; config defaults to "auto".
2135
2221
  const fallbackPolicy = resolveBrowserFallbackPolicy({ context, config });
2136
- const driverRequired = isDriverRequired({ test: context });
2222
+ // Browser-required (not app): app-targeted steps run on the app session.
2223
+ const driverRequired = isBrowserRequired({ test: context });
2137
2224
  const candidateEngines = platformMatches && driverRequired
2138
2225
  ? buildFallbackCandidates({
2139
2226
  requestedName: requestedBrowserName,
@@ -2185,8 +2272,9 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2185
2272
  let fellBackNote = "";
2186
2273
  let fellBackPinned = false;
2187
2274
  if (driverRequired && !appiumPool) {
2188
- throw new Error("Driver requested but no Appium server pool was created; " +
2189
- "driverJobCount and isDriverRequired(context) disagreed; this is a bug.");
2275
+ throw new Error("Browser driver requested but no Appium server pool was created; " +
2276
+ "the pool sizing (browserJobCount) and this context's isBrowserRequired " +
2277
+ "predicate disagreed; this is a bug.");
2190
2278
  }
2191
2279
  try {
2192
2280
  if (driverRequired) {
@@ -2605,6 +2693,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2605
2693
  openApiDefinitions: context.openApi || [],
2606
2694
  },
2607
2695
  processRegistry: processRegistry,
2696
+ appSession: appSession,
2608
2697
  });
2609
2698
  clog("debug", `RESULT: ${r.status}\n${JSON.stringify(r, null, 2)}`);
2610
2699
  r.result = r.status;
@@ -2740,10 +2829,19 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2740
2829
  // Stop every recording still active at the end of the context (the
2741
2830
  // synthetic autoRecord capture plus any explicit record steps the author
2742
2831
  // didn't stop). Each produces an ordered stopRecord step report.
2743
- // Recordings live per session, so sweep every registered driver.
2832
+ // Recordings live per session, so sweep every registered driver — and the
2833
+ // app session's recordingHost, which holds app-only-context recordings.
2744
2834
  for (const d of sessionDrivers(browserSessions, driver)) {
2745
2835
  await stopAllRecordings({ config, context, driver: d, contextReport });
2746
2836
  }
2837
+ if (appSession?.recordingHost.state.recordings.length) {
2838
+ await stopAllRecordings({
2839
+ config,
2840
+ context,
2841
+ driver: appSession.recordingHost,
2842
+ contextReport,
2843
+ });
2844
+ }
2747
2845
  }
2748
2846
  finally {
2749
2847
  // Safety net: if the context threw before the normal sweep above, recordings
@@ -2759,6 +2857,14 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2759
2857
  for (const d of sessionDrivers(browserSessions, driver)) {
2760
2858
  await stopAllRecordings({ config, context, driver: d, contextReport });
2761
2859
  }
2860
+ if (appSession?.recordingHost.state.recordings.length) {
2861
+ await stopAllRecordings({
2862
+ config,
2863
+ context,
2864
+ driver: appSession.recordingHost,
2865
+ contextReport,
2866
+ });
2867
+ }
2762
2868
  }
2763
2869
  catch (error) {
2764
2870
  clog("error", `Failed to stop recordings during cleanup: ${error?.message ?? error}`);
@@ -2780,6 +2886,27 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2780
2886
  clog("error", `Failed to delete driver session: ${error.message}`);
2781
2887
  }
2782
2888
  }
2889
+ // Tear down app surfaces: close every remaining app session (the driver
2890
+ // terminates apps it launched) and stop the app Appium server. Tree-kill
2891
+ // in callback form so the server process is actually gone before the
2892
+ // context returns (mirrors the run-level killTree closure in runSpecs).
2893
+ if (appSession) {
2894
+ try {
2895
+ await teardownAppSession(appSession, (pid) => new Promise((resolve) => {
2896
+ if (!pid)
2897
+ return resolve();
2898
+ try {
2899
+ kill(pid, "SIGTERM", () => resolve());
2900
+ }
2901
+ catch {
2902
+ resolve();
2903
+ }
2904
+ }));
2905
+ }
2906
+ catch (error) {
2907
+ clog("error", `Failed to tear down app surfaces: ${error?.message ?? error}`);
2908
+ }
2909
+ }
2783
2910
  // Return the Appium server to the pool for the next queued context. Always
2784
2911
  // runs (even on the driver-start-failure early return) so a port can't
2785
2912
  // leak out of the pool and starve later contexts.
@@ -2867,7 +2994,7 @@ async function stopAllRecordings({ config, context, driver, contextReport, }) {
2867
2994
  }
2868
2995
  }
2869
2996
  // Run a specific step
2870
- async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, processRegistry, }) {
2997
+ async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, processRegistry, appSession, }) {
2871
2998
  let actionResult;
2872
2999
  // Load values from environment variables
2873
3000
  step = replaceEnvs(step);
@@ -2876,6 +3003,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2876
3003
  config: config,
2877
3004
  step: step,
2878
3005
  driver: driver,
3006
+ appSession,
2879
3007
  });
2880
3008
  }
2881
3009
  else if (typeof step.dragAndDrop !== "undefined") {
@@ -2889,10 +3017,15 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2889
3017
  actionResult = await checkLink({ config: config, step: step });
2890
3018
  }
2891
3019
  else if (typeof step.find !== "undefined") {
2892
- actionResult = await findElement({ config: config, step: step, driver });
3020
+ actionResult = await findElement({ config: config, step: step, driver, appSession });
2893
3021
  }
2894
3022
  else if (typeof step.stopRecord !== "undefined") {
2895
- actionResult = await stopRecording({ config: config, step: step, driver });
3023
+ actionResult = await stopRecording({
3024
+ config: config,
3025
+ step: step,
3026
+ // App-only contexts keep recordings on the app session's host.
3027
+ driver: driver ?? appSession?.recordingHost,
3028
+ });
2896
3029
  }
2897
3030
  else if (typeof step.goTo !== "undefined") {
2898
3031
  actionResult = await goTo({ config: config, step: step, driver: driver });
@@ -2922,24 +3055,51 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2922
3055
  });
2923
3056
  }
2924
3057
  else if (typeof step.record !== "undefined") {
3058
+ // App-only contexts have no browser driver: recordings live on the app
3059
+ // session's recordingHost (same `.state.recordings` shape) instead.
3060
+ // Threaded into startRecording too, so its already-recording dedupe
3061
+ // checks consult the same store this block pushes into.
3062
+ const recordingHost = driver ?? appSession?.recordingHost;
2925
3063
  actionResult = await startRecording({
2926
3064
  config: config,
2927
3065
  context: context,
2928
3066
  step: step,
2929
3067
  driver: driver,
3068
+ recordingHost,
2930
3069
  });
2931
3070
  // Push the started recording onto the per-context stack so several can
2932
3071
  // overlap. Carry the step's `id`/`name` so a later stopRecord can target
2933
3072
  // it (by name) and end-of-context cleanup can identify the synthetic one.
2934
- if (actionResult.recording) {
2935
- if (!Array.isArray(driver.state.recordings))
2936
- driver.state.recordings = [];
3073
+ if (actionResult.recording && !recordingHost) {
3074
+ // Defensive: no driver session AND no app session — nowhere to track
3075
+ // the handle, so the end-of-context sweep could never stop it. Kill
3076
+ // the capture now and FAIL loudly rather than leak the process.
3077
+ try {
3078
+ actionResult.recording.process?.kill?.();
3079
+ }
3080
+ catch {
3081
+ // best-effort
3082
+ }
3083
+ delete actionResult.recording;
3084
+ actionResult.status = "FAIL";
3085
+ actionResult.description =
3086
+ "A recording started with no driver session or app session to own it; it was stopped. This context cannot record.";
3087
+ }
3088
+ else if (actionResult.recording) {
3089
+ if (!Array.isArray(recordingHost.state.recordings))
3090
+ recordingHost.state.recordings = [];
2937
3091
  const handle = actionResult.recording;
2938
3092
  handle.id = handle.id ?? randomUUID();
2939
3093
  handle.name = handle.name ?? recordStepName(step.record);
2940
- if (step.__autoRecord)
3094
+ if (step.__autoRecord) {
2941
3095
  handle.synthetic = true;
2942
- driver.state.recordings.push(handle);
3096
+ // App-only context: no window exists yet to crop to. Mark the handle
3097
+ // so the first app surface to open late-binds its window rect as the
3098
+ // crop (startAppSurface), scoping the capture to the app under test.
3099
+ if (!driver && appSession)
3100
+ handle.pendingAppWindowCrop = true;
3101
+ }
3102
+ recordingHost.state.recordings.push(handle);
2943
3103
  }
2944
3104
  }
2945
3105
  else if (typeof step.runCode !== "undefined") {
@@ -2956,13 +3116,43 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2956
3116
  actionResult = await runShell({ config: config, step: step, driver, processRegistry });
2957
3117
  }
2958
3118
  else if (typeof step.closeSurface !== "undefined") {
2959
- actionResult = await closeSurface({ config: config, step: step, driver, processRegistry });
3119
+ actionResult = await closeSurface({ config: config, step: step, driver, processRegistry, appSession });
3120
+ }
3121
+ else if (typeof step.startSurface !== "undefined") {
3122
+ if (!appSession) {
3123
+ actionResult = {
3124
+ status: "FAIL",
3125
+ description: "startSurface ran without an app session; this is a runner bug (runContext must preflight app-driver tests).",
3126
+ };
3127
+ }
3128
+ else {
3129
+ actionResult = await startAppSurface({
3130
+ config: config,
3131
+ step: step,
3132
+ appSession,
3133
+ platform: context?.platform ?? "",
3134
+ serverDeps: {
3135
+ startServer: async (appiumEntry, appiumHome) => {
3136
+ // APPIUM_HOME points the server at the node_modules that holds
3137
+ // the native driver (shim or runtime cache). The preflight has
3138
+ // already invalidated a stale extensions manifest there, so the
3139
+ // server's startup scan discovers the driver.
3140
+ const server = await startAppiumServer(appiumEntry, config, undefined, {
3141
+ APPIUM_HOME: appiumHome,
3142
+ });
3143
+ return { port: server.port, process: server.process };
3144
+ },
3145
+ startDriver: (capabilities, port) => driverStart(capabilities, port, 2, { cacheDir: config?.cacheDir }),
3146
+ },
3147
+ });
3148
+ }
2960
3149
  }
2961
3150
  else if (typeof step.screenshot !== "undefined") {
2962
3151
  actionResult = await saveScreenshot({
2963
3152
  config: config,
2964
3153
  step: step,
2965
3154
  driver: driver,
3155
+ appSession,
2966
3156
  });
2967
3157
  }
2968
3158
  else if (typeof step.type !== "undefined") {
@@ -2971,6 +3161,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2971
3161
  step: step,
2972
3162
  driver: driver,
2973
3163
  processRegistry,
3164
+ appSession,
2974
3165
  });
2975
3166
  }
2976
3167
  else if (typeof step.wait !== "undefined") {
@@ -2982,8 +3173,10 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
2982
3173
  description: `Unknown step action: ${JSON.stringify(step)}`,
2983
3174
  };
2984
3175
  }
2985
- // If recording, wait until browser is loaded, then instantiate cursor
2986
- if (isRecordingActive(driver)) {
3176
+ // If recording, wait until browser is loaded, then instantiate cursor.
3177
+ // The `getUrl` guard skips the synthetic-cursor dance when `driver` is the
3178
+ // app session's recordingHost (a bare state holder, not a browser session).
3179
+ if (isRecordingActive(driver) && typeof driver.getUrl === "function") {
2987
3180
  const currentUrl = await driver.getUrl();
2988
3181
  if (currentUrl !== driver.state.url) {
2989
3182
  driver.state.url = currentUrl;
@@ -3031,13 +3224,21 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
3031
3224
  // Start one Appium server on a free port and resolve once it answers /status.
3032
3225
  // Each concurrent runner gets its own server (own port) so parallel contexts
3033
3226
  // never create sessions on the same Appium instance.
3034
- async function startAppiumServer(appiumEntry, config, display) {
3227
+ async function startAppiumServer(appiumEntry, config, display, extraEnv) {
3035
3228
  const port = await findFreePort();
3036
3229
  log(config, "debug", `Starting Appium on port ${port}`);
3037
3230
  // When a virtual display is supplied (Linux Xvfb recording), launch the
3038
3231
  // server with DISPLAY set so the browser it spawns (via chromedriver)
3039
3232
  // renders on that display — which is what ffmpeg x11grab then captures.
3040
- const env = display ? { ...process.env, DISPLAY: display } : process.env;
3233
+ // `extraEnv` overrides (e.g. APPIUM_HOME for the app-surface server, which
3234
+ // must be homed where the lazily-installed native driver lives).
3235
+ const env = display || extraEnv
3236
+ ? {
3237
+ ...process.env,
3238
+ ...(display ? { DISPLAY: display } : {}),
3239
+ ...(extraEnv ?? {}),
3240
+ }
3241
+ : process.env;
3041
3242
  const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port)], {
3042
3243
  windowsHide: true,
3043
3244
  cwd: path.join(__dirname, "../.."),