doc-detective 4.15.2 → 4.16.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 (35) hide show
  1. package/dist/common/src/schemas/schemas.json +280 -0
  2. package/dist/common/src/types/generated/config_v3.d.ts +16 -0
  3. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  4. package/dist/common/src/types/generated/context_v3.d.ts +12 -0
  5. package/dist/common/src/types/generated/context_v3.d.ts.map +1 -1
  6. package/dist/common/src/types/generated/report_v3.d.ts +12 -0
  7. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  8. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +28 -0
  9. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  10. package/dist/common/src/types/generated/spec_v3.d.ts +12 -0
  11. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  12. package/dist/common/src/types/generated/test_v3.d.ts +16 -0
  13. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  14. package/dist/core/config.d.ts +28 -1
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +90 -13
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/resolveTests.d.ts.map +1 -1
  19. package/dist/core/resolveTests.js +5 -0
  20. package/dist/core/resolveTests.js.map +1 -1
  21. package/dist/core/tests.d.ts +65 -2
  22. package/dist/core/tests.d.ts.map +1 -1
  23. package/dist/core/tests.js +312 -77
  24. package/dist/core/tests.js.map +1 -1
  25. package/dist/hints/hints.js +1 -1
  26. package/dist/hints/hints.js.map +1 -1
  27. package/dist/index.cjs +1190 -569
  28. package/dist/runtime/browsers.d.ts +39 -0
  29. package/dist/runtime/browsers.d.ts.map +1 -1
  30. package/dist/runtime/browsers.js +269 -39
  31. package/dist/runtime/browsers.js.map +1 -1
  32. package/dist/utils.d.ts.map +1 -1
  33. package/dist/utils.js +18 -0
  34. package/dist/utils.js.map +1 -1
  35. package/package.json +1 -1
@@ -48,7 +48,7 @@ import http from "node:http";
48
48
  import https from "node:https";
49
49
  import { fileURLToPath } from "node:url";
50
50
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, isSupportedContext, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, };
51
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, };
52
52
  // exports.appiumStart = appiumStart;
53
53
  // exports.appiumIsReady = appiumIsReady;
54
54
  // exports.driverStart = driverStart;
@@ -262,6 +262,104 @@ function getDefaultBrowser({ runnerDetails }) {
262
262
  }
263
263
  return browser;
264
264
  }
265
+ // `webkit` is the runtime alias for Safari (context resolution rewrites it),
266
+ // while getAvailableApps reports the engine as `safari`. Normalize so the two
267
+ // names compare and dedupe as one engine.
268
+ function normalizeBrowserName(name) {
269
+ return name === "webkit" ? "safari" : name ?? "";
270
+ }
271
+ /**
272
+ * Build the ordered list of browser engines to attempt for a context — the
273
+ * heart of the any-browser → any-available-browser fallback. The requested
274
+ * engine is tried first (when it's actually available); then, when the
275
+ * `browserFallback` policy permits, every *other* available engine follows in
276
+ * a stable preference order. Every returned name maps to an available engine —
277
+ * the requested name is preserved as authored (so `webkit` can be returned even
278
+ * though `availableApps` lists it as `safari`), while fallback names come
279
+ * straight from `availableApps`; `webkit` is normalized to `safari` only for
280
+ * the availability lookup and dedupe. getDriverCapabilities accepts both
281
+ * aliases, so either resolves to real binary/driver paths.
282
+ *
283
+ * Policy:
284
+ * - "auto" → fall back for both auto-selected and explicitly pinned browsers.
285
+ * - "explicit" → fall back only when the browser was auto-selected (not pinned).
286
+ * - "off" → never fall back; only the requested engine (if available).
287
+ *
288
+ * Pure and exported so the precedence is unit-testable without a driver.
289
+ */
290
+ function buildFallbackCandidates({ requestedName, explicit, policy, availableApps, }) {
291
+ const available = new Set((availableApps || []).map((a) => a.name));
292
+ const requestedNorm = normalizeBrowserName(requestedName);
293
+ const candidates = [];
294
+ // Requested engine first — only if it's actually available (has a working
295
+ // driver). If it isn't, we go straight to fallbacks (or skip).
296
+ if (available.has(requestedNorm))
297
+ candidates.push(requestedName);
298
+ const fallbackAllowed = policy === "auto" || (policy === "explicit" && !explicit);
299
+ if (fallbackAllowed) {
300
+ for (const name of ["firefox", "chrome", "safari"]) {
301
+ if (name === requestedNorm)
302
+ continue;
303
+ if (available.has(name))
304
+ candidates.push(name);
305
+ }
306
+ }
307
+ return candidates;
308
+ }
309
+ /**
310
+ * Resolve the effective `browserFallback` policy for a context. A context-level
311
+ * value (authored on the `runOn` entry) overrides the config-level value, which
312
+ * itself defaults to `auto`. Pure and exported so the precedence is testable.
313
+ */
314
+ function resolveBrowserFallbackPolicy({ context, config, }) {
315
+ return context?.browserFallback || config?.browserFallback || "auto";
316
+ }
317
+ /**
318
+ * Whether to attempt a driver repair before falling back away from a browser
319
+ * whose session just failed to start. We only repair the *requested* engine
320
+ * (so a fallback substitute that fails isn't itself repaired), only when it has
321
+ * installable driver assets (Safari ships with the OS — nothing to repair), and
322
+ * only once per browser per run (a prior attempt already recorded an outcome in
323
+ * `installAttempts`). This is what keeps a present-but-broken driver from
324
+ * causing an unnecessary fallback when a reinstall would have fixed it. Pure
325
+ * and exported so the decision is unit-testable.
326
+ */
327
+ function shouldRepairBeforeFallback({ candidateName, requestedName, installAttempts, }) {
328
+ if (normalizeBrowserName(candidateName) !== normalizeBrowserName(requestedName)) {
329
+ return false;
330
+ }
331
+ if (requiredBrowserAssets(candidateName).length === 0)
332
+ return false;
333
+ return !installAttempts.has((candidateName ?? "<none>").toLowerCase());
334
+ }
335
+ /**
336
+ * The diagnostic message recorded when no engine could start a session. Names
337
+ * the requested engine and whether a cross-engine fallback was even attempted,
338
+ * so a present-but-broken driver reads as actionable rather than a generic
339
+ * "Failed to start context" skip.
340
+ */
341
+ function driverSkipDiagnostic({ requestedName, platform, platformMatches, attemptedFallback, lastError, }) {
342
+ if (!platformMatches) {
343
+ return `Skipping context on '${platform}': this context targets a different platform.`;
344
+ }
345
+ // Name the driver for the actual requested engine so the hint doesn't always
346
+ // point users at geckodriver when Chrome or Safari is the broken one.
347
+ const driverHint = normalizeBrowserName(requestedName) === "firefox"
348
+ ? "geckodriver"
349
+ : normalizeBrowserName(requestedName) === "chrome"
350
+ ? "chromedriver"
351
+ : normalizeBrowserName(requestedName) === "safari"
352
+ ? "safaridriver"
353
+ : "driver";
354
+ let msg = `Skipping context: could not start a browser session for '${requestedName}' on '${platform}'`;
355
+ msg += attemptedFallback
356
+ ? `, and no other available browser could start either.`
357
+ : ` and cross-browser fallback is disabled or unavailable.`;
358
+ if (lastError)
359
+ msg += ` Last error: ${lastError}`;
360
+ msg += ` A present-but-broken driver (for example a partially downloaded ${driverHint}) can cause this; reinstall the driver or its browser.`;
361
+ return msg;
362
+ }
265
363
  // Set window size to match target viewport size
266
364
  async function setViewportSize(context, driver) {
267
365
  if (context.browser?.viewport?.width || context.browser?.viewport?.height) {
@@ -1469,6 +1567,8 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
1469
1567
  ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
1470
1568
  log,
1471
1569
  },
1570
+ // Repair a present-but-broken driver, not just install-if-missing.
1571
+ repair: true,
1472
1572
  });
1473
1573
  if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
1474
1574
  clearAppCache(config);
@@ -1906,6 +2006,10 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
1906
2006
  // only fires for a genuinely-missing browser (rare) and the app list only
1907
2007
  // grows, so a sibling reading a slightly stale snapshot still re-detects.
1908
2008
  let freshInstallRedetected = false;
2009
+ // The actual on-demand outcome, so the skip message can distinguish "repaired
2010
+ // but still undetected" from "repair failed" instead of always claiming the
2011
+ // dependency was installed.
2012
+ let freshInstallOutcome;
1909
2013
  if (!supportedContext &&
1910
2014
  context.platform === platform &&
1911
2015
  // Mirror isSupportedContext's own guard: isDriverRequired iterates
@@ -1925,12 +2029,17 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
1925
2029
  ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
1926
2030
  log,
1927
2031
  },
2032
+ // The browser is unavailable here — possibly because its driver is
2033
+ // present-but-broken — so repair (force a clean driver reinstall +
2034
+ // re-validation), not just install-if-missing.
2035
+ repair: true,
1928
2036
  });
1929
2037
  // Re-detect after a real attempt regardless of outcome: a "failed" install
1930
2038
  // can still have materialized assets before it threw, so a stale snapshot
1931
2039
  // could wrongly skip a now-usable browser.
1932
2040
  if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
1933
2041
  freshInstallRedetected = true;
2042
+ freshInstallOutcome = outcome;
1934
2043
  clearAppCache(config);
1935
2044
  availableApps = await getAvailableApps({ config });
1936
2045
  runnerDetails.availableApps = availableApps;
@@ -1941,14 +2050,52 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
1941
2050
  });
1942
2051
  }
1943
2052
  }
1944
- // If context isn't supported, skip it
1945
- if (!supportedContext) {
1946
- // Distinguish "we installed the dependency but still can't see it" from a
1947
- // plain unsupported context, so the skip reason points at the real problem.
2053
+ // Resolve which engine(s) this context can actually run on. Platform is an
2054
+ // absolute gate (a context authored for another OS never runs and never
2055
+ // falls back); engine availability is not a broken or unavailable browser
2056
+ // falls back to any other available engine when the `browserFallback` policy
2057
+ // permits. `supportedContext` (computed above for the requested browser) is
2058
+ // now just one input: even when it's false, a fallback engine may carry the run.
2059
+ const platformMatches = context.platform === platform;
2060
+ const requestedBrowserName = context.browser?.name;
2061
+ const explicitlyRequested = context.browser?.explicit === true;
2062
+ // Context-level browserFallback (authored on the runOn entry) overrides the
2063
+ // config-level policy; config defaults to "auto".
2064
+ const fallbackPolicy = resolveBrowserFallbackPolicy({ context, config });
2065
+ const driverRequired = isDriverRequired({ test: context });
2066
+ const candidateEngines = platformMatches && driverRequired
2067
+ ? buildFallbackCandidates({
2068
+ requestedName: requestedBrowserName,
2069
+ explicit: explicitlyRequested,
2070
+ policy: fallbackPolicy,
2071
+ availableApps,
2072
+ })
2073
+ : [];
2074
+ // A driver context with no startable engine is skipped with a diagnostic that
2075
+ // names the requested engine and the partial-download cause.
2076
+ if (driverRequired && candidateEngines.length === 0) {
1948
2077
  const errorMessage = freshInstallRedetected
1949
- ? `Skipping context '${context.browser?.name}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
1950
- : `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`;
1951
- clog(freshInstallRedetected ? "warning" : "info", errorMessage);
2078
+ ? freshInstallOutcome === "installed"
2079
+ ? `Skipping context '${requestedBrowserName}' on '${context.platform}': the missing browser dependency was installed but still could not be detected.`
2080
+ : `Skipping context '${requestedBrowserName}' on '${context.platform}': the on-demand install/repair of its browser dependency failed.`
2081
+ : driverSkipDiagnostic({
2082
+ requestedName: requestedBrowserName ?? "<none>",
2083
+ // driverSkipDiagnostic treats `platform` as the *current* runner
2084
+ // platform (as in the !startedName path), so pass that — not the
2085
+ // context's target platform — or the mismatch message mislabels it.
2086
+ platform,
2087
+ platformMatches,
2088
+ attemptedFallback: false,
2089
+ });
2090
+ clog(platformMatches ? "warning" : "info", errorMessage);
2091
+ contextReport.result = "SKIPPED";
2092
+ contextReport.resultDescription = errorMessage;
2093
+ return contextReport;
2094
+ }
2095
+ // A non-driver context that targets a different platform has nothing to run.
2096
+ if (!driverRequired && !platformMatches) {
2097
+ const errorMessage = `Skipping context. The current system doesn't support this context: {"platform": "${context.platform}", "apps": ${JSON.stringify(context.apps)}}`;
2098
+ clog("info", errorMessage);
1952
2099
  contextReport.result = "SKIPPED";
1953
2100
  contextReport.resultDescription = errorMessage;
1954
2101
  return contextReport;
@@ -1956,26 +2103,17 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
1956
2103
  clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
1957
2104
  let driver;
1958
2105
  let appiumPort;
1959
- const driverRequired = isDriverRequired({ test: context });
2106
+ // Layer 5 bookkeeping: set when the context ran on a different engine than
2107
+ // requested, so the final result can be annotated — and downgraded PASS →
2108
+ // WARNING when an explicitly pinned engine was substituted.
2109
+ let fellBackNote = "";
2110
+ let fellBackPinned = false;
1960
2111
  if (driverRequired && !appiumPool) {
1961
2112
  throw new Error("Driver requested but no Appium server pool was created; " +
1962
2113
  "driverJobCount and isDriverRequired(context) disagreed; this is a bug.");
1963
2114
  }
1964
- // Warm-up memoization. The first context of each combination acts as the
1965
- // warm-up; if that combination already failed to start a driver earlier in
1966
- // this run, skip it outright instead of paying driverStart's retry/backoff
1967
- // again. Under concurrency this is a best-effort speedup, not correctness —
1968
- // same-combo contexts may start before one records a result.
1969
- const combo = combinationKey(context);
1970
2115
  try {
1971
2116
  if (driverRequired) {
1972
- if (warmUpDecision(warmUpResults.get(combo)) === "skip") {
1973
- const errorMessage = `Skipping context '${context.browser?.name}' on '${context.platform}': this context combination could not start a driver earlier in this run.`;
1974
- clog("warning", errorMessage);
1975
- contextReport.result = "SKIPPED";
1976
- contextReport.resultDescription = errorMessage;
1977
- return contextReport;
1978
- }
1979
2117
  // Check out a server for this context's lifetime — released in the
1980
2118
  // finally so the next queued context can reuse it.
1981
2119
  appiumPort = await appiumPool.acquire();
@@ -1991,70 +2129,149 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
1991
2129
  context.__displaySize = XVFB_SCREEN_SIZE;
1992
2130
  }
1993
2131
  }
1994
- // Define driver capabilities
1995
- // TODO: Support custom apps
1996
2132
  // Per-context recording identifiers so concurrent Chrome recordings
1997
2133
  // auto-select their own window and download to their own dir.
1998
2134
  const recordOptions = {
1999
2135
  captureSourceTitle: browserCaptureTitle(context.contextId),
2000
2136
  downloadDir: browserDownloadDir(context.contextId),
2001
2137
  };
2002
- let caps = getDriverCapabilities({
2003
- runnerDetails: runnerDetails,
2004
- name: context.browser.name,
2005
- options: {
2006
- width: context.browser?.window?.width || 1200,
2007
- height: context.browser?.window?.height || 800,
2008
- headless: context.browser?.headless !== false,
2009
- ...recordOptions,
2010
- },
2011
- });
2012
- clog("debug", "CAPABILITIES:");
2013
- clog("debug", caps);
2014
- // Instantiate driver
2015
- try {
2016
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
2017
- }
2018
- catch (error) {
2138
+ // Start a session for one engine, headed first then (on failure) headless.
2139
+ const startDriverForBrowser = async (browserName) => {
2140
+ const wantHeadless = context.browser?.headless !== false;
2141
+ const buildCaps = (headless) => getDriverCapabilities({
2142
+ runnerDetails,
2143
+ name: browserName,
2144
+ options: {
2145
+ width: context.browser?.window?.width || 1200,
2146
+ height: context.browser?.window?.height || 800,
2147
+ headless,
2148
+ ...recordOptions,
2149
+ },
2150
+ });
2151
+ const startFailure = () => {
2152
+ let error = `Failed to start context '${browserName}' on '${platform}'.`;
2153
+ if (browserName === "safari" || browserName === "webkit") {
2154
+ error +=
2155
+ " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
2156
+ }
2157
+ return { ok: false, error };
2158
+ };
2019
2159
  try {
2020
- // If driver fails to start, try again as headless
2021
- clog("warning", `Failed to start context '${context.browser?.name}' on '${platform}'. Retrying as headless.`);
2022
- context.browser.headless = true;
2023
- caps = getDriverCapabilities({
2024
- runnerDetails: runnerDetails,
2025
- name: context.browser.name,
2026
- options: {
2027
- width: context.browser?.window?.width || 1200,
2028
- height: context.browser?.window?.height || 800,
2029
- headless: context.browser?.headless !== false,
2030
- ...recordOptions,
2160
+ const d = await driverStart(buildCaps(wantHeadless), appiumPort, 4, {
2161
+ cacheDir: config?.cacheDir,
2162
+ });
2163
+ return { ok: true, driver: d, headless: wantHeadless };
2164
+ }
2165
+ catch {
2166
+ // The headed→headless retry only changes inputs when the first
2167
+ // attempt was headed. If it was already headless, a second identical
2168
+ // attempt would just pay driverStart's backoff again — fail fast.
2169
+ if (wantHeadless)
2170
+ return startFailure();
2171
+ try {
2172
+ clog("warning", `Failed to start context '${browserName}' on '${platform}'. Retrying as headless.`);
2173
+ const d = await driverStart(buildCaps(true), appiumPort, 4, {
2174
+ cacheDir: config?.cacheDir,
2175
+ });
2176
+ return { ok: true, driver: d, headless: true };
2177
+ }
2178
+ catch {
2179
+ return startFailure();
2180
+ }
2181
+ }
2182
+ };
2183
+ // Try the requested engine first, then fall back across every other
2184
+ // available engine (policy permitting). The warm-up memo lets a known-bad
2185
+ // combination be skipped without paying driverStart's backoff again.
2186
+ let startedName;
2187
+ let startedHeadless = false;
2188
+ let lastError = "";
2189
+ for (const candidateName of candidateEngines) {
2190
+ const candidateCombo = combinationKey({
2191
+ platform: context.platform,
2192
+ browser: { name: candidateName },
2193
+ });
2194
+ if (warmUpDecision(warmUpResults.get(candidateCombo)) === "skip") {
2195
+ lastError = `context combination '${candidateName}' on '${platform}' could not start a driver earlier in this run.`;
2196
+ continue;
2197
+ }
2198
+ let res = await startDriverForBrowser(candidateName);
2199
+ // The requested engine's session failed — its driver may be present but
2200
+ // broken (e.g. a partial download Layer 2 couldn't pre-validate). Repair
2201
+ // it once and retry before falling back to a different browser, so we
2202
+ // don't substitute engines unnecessarily.
2203
+ if (!res.ok &&
2204
+ shouldRepairBeforeFallback({
2205
+ candidateName,
2206
+ requestedName: requestedBrowserName,
2207
+ installAttempts,
2208
+ })) {
2209
+ const outcome = await ensureContextBrowserInstalled({
2210
+ browserName: candidateName,
2211
+ config,
2212
+ installAttempts,
2213
+ deps: {
2214
+ ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
2215
+ log,
2031
2216
  },
2217
+ repair: true,
2032
2218
  });
2033
- driver = await driverStart(caps, appiumPort, 4, { cacheDir: config?.cacheDir });
2219
+ if (outcome === "installed") {
2220
+ clog("info", `Repaired '${candidateName}' driver after a start failure; retrying before falling back.`);
2221
+ res = await startDriverForBrowser(candidateName);
2222
+ }
2034
2223
  }
2035
- catch (error) {
2036
- let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`;
2037
- // `safari` is normalized to `webkit` during context resolution, so
2038
- // match both or this Safari-specific hint never fires on real runs.
2039
- if (context.browser?.name === "safari" ||
2040
- context.browser?.name === "webkit")
2041
- errorMessage =
2042
- errorMessage +
2043
- " Make sure you've run `safaridriver --enable` in a terminal and enabled 'Allow Remote Automation' in Safari's Develop menu.";
2044
- clog("error", errorMessage);
2045
- // Record the combination as failed so every later context that shares
2046
- // it is skipped instantly (see the warm-up check above).
2047
- if (!warmUpResults.has(combo))
2048
- warmUpResults.set(combo, "failed");
2049
- contextReport.result = "SKIPPED";
2050
- contextReport.resultDescription = errorMessage;
2051
- return contextReport;
2224
+ if (res.ok) {
2225
+ driver = res.driver;
2226
+ startedName = candidateName;
2227
+ startedHeadless = res.headless;
2228
+ if (!warmUpResults.has(candidateCombo))
2229
+ warmUpResults.set(candidateCombo, "ok");
2230
+ break;
2052
2231
  }
2232
+ clog("error", res.error);
2233
+ // Record the combination as failed so every later context that shares
2234
+ // it is skipped instantly (see the warm-up check above).
2235
+ if (!warmUpResults.has(candidateCombo))
2236
+ warmUpResults.set(candidateCombo, "failed");
2237
+ lastError = res.error;
2238
+ }
2239
+ if (!startedName) {
2240
+ const errorMessage = driverSkipDiagnostic({
2241
+ requestedName: requestedBrowserName ?? "<none>",
2242
+ platform,
2243
+ platformMatches: true,
2244
+ attemptedFallback: candidateEngines.length > 1,
2245
+ lastError,
2246
+ });
2247
+ clog("error", errorMessage);
2248
+ contextReport.result = "SKIPPED";
2249
+ contextReport.resultDescription = errorMessage;
2250
+ return contextReport;
2251
+ }
2252
+ // Reflect a headless fallback on the context so downstream logic and the
2253
+ // report agree with what actually launched. Replacing context.browser
2254
+ // makes a new object, so re-point contextReport.browser (set earlier to
2255
+ // the original) at it or the report keeps stale headless metadata.
2256
+ if (startedHeadless) {
2257
+ context.browser = { ...context.browser, headless: true };
2258
+ contextReport.browser = context.browser;
2259
+ }
2260
+ // Cross-engine fallback: re-point the context at the engine that ran so
2261
+ // recording, capabilities, and the report all reflect reality, and stash
2262
+ // the Layer 5 note applied to the context result below.
2263
+ if (normalizeBrowserName(startedName) !==
2264
+ normalizeBrowserName(requestedBrowserName)) {
2265
+ fellBackNote = `${requestedBrowserName} unavailable; ran on ${startedName}.`;
2266
+ fellBackPinned = explicitlyRequested;
2267
+ context.browser = { ...context.browser, name: startedName };
2268
+ contextReport.browser = context.browser;
2269
+ contextReport.fallback = {
2270
+ requested: requestedBrowserName,
2271
+ used: startedName,
2272
+ };
2273
+ clog("warning", fellBackNote);
2053
2274
  }
2054
- // Driver started (first attempt or headless retry) — mark this
2055
- // combination as known-good for the rest of the run.
2056
- if (!warmUpResults.has(combo))
2057
- warmUpResults.set(combo, "ok");
2058
2275
  if (context.browser?.viewport?.width ||
2059
2276
  context.browser?.viewport?.height) {
2060
2277
  // Set driver viewport size
@@ -2451,6 +2668,18 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2451
2668
  }
2452
2669
  }
2453
2670
  contextReport.result = rollUpResults(contextReport.steps);
2671
+ // Layer 5: a context that ran on a fallback engine is annotated so the
2672
+ // substitution is never silent. When the requested engine was explicitly
2673
+ // pinned and the run otherwise passed, downgrade PASS → WARNING so a degraded
2674
+ // run isn't reported as a clean success. An auto-selected browser keeps PASS.
2675
+ if (fellBackNote) {
2676
+ if (fellBackPinned && contextReport.result === "PASS") {
2677
+ contextReport.result = "WARNING";
2678
+ }
2679
+ contextReport.resultDescription = contextReport.resultDescription
2680
+ ? `${fellBackNote} ${contextReport.resultDescription}`
2681
+ : fellBackNote;
2682
+ }
2454
2683
  return contextReport;
2455
2684
  }
2456
2685
  // Stop every recording still active on the driver, pushing an ordered
@@ -2865,7 +3094,7 @@ async function ensureChromeAvailable(config, deps) {
2865
3094
  * @returns "installed" when all assets installed, "failed" when an install
2866
3095
  * threw, or "notInstallable" for browsers with no installable asset (safari).
2867
3096
  */
2868
- async function ensureContextBrowserInstalled({ browserName, config, installAttempts, deps, }) {
3097
+ async function ensureContextBrowserInstalled({ browserName, config, installAttempts, deps, repair = false, }) {
2869
3098
  const key = (browserName ?? "<none>").toLowerCase();
2870
3099
  const cached = installAttempts.get(key);
2871
3100
  if (cached)
@@ -2880,9 +3109,15 @@ async function ensureContextBrowserInstalled({ browserName, config, installAttem
2880
3109
  // "warn" → "warning" the same way provisionChromeRuntime does.
2881
3110
  const logger = (msg, level = "info") => deps.log?.(config, level === "warn" ? "warning" : level, msg);
2882
3111
  try {
2883
- deps.log?.(config, "info", `Browser '${browserName}' is not available; attempting on-demand install of: ${assets.join(", ")}.`);
3112
+ deps.log?.(config, "info", `Browser '${browserName}' is not available; attempting on-demand ${repair ? "repair" : "install"} of: ${assets.join(", ")}.`);
2884
3113
  for (const asset of assets) {
2885
- await deps.ensureBrowser(asset, { ctx, deps: { logger } });
3114
+ // On repair, force every component (browser binary + driver) so a
3115
+ // present-but-broken one is replaced, not just installed-if-missing.
3116
+ await deps.ensureBrowser(asset, {
3117
+ ctx,
3118
+ deps: { logger },
3119
+ force: !!repair,
3120
+ });
2886
3121
  }
2887
3122
  installAttempts.set(key, "installed");
2888
3123
  return "installed";