@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +46 -1
  10. package/dist/src/commands/export.js +52 -113
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -1,13 +1,13 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
- import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
5
+ import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, withBuildLock, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
- import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, createPostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
8
+ import { buildHarnessCrashMessage, detectHarnessCrashSignature, } from '../core/test-harness-crash.js';
9
+ import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
9
10
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
10
- import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
11
11
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
12
12
  import { GeneralError } from '../errors/base.js';
13
13
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
@@ -15,7 +15,8 @@ import { pathExists } from '../utils/fs.js';
15
15
  import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
16
16
  import { pickDefined } from '../utils/options.js';
17
17
  import { stripEnginePrefix } from '../utils/paths.js';
18
- import { maybeInjectAppdirArg } from './test-appdir.js';
18
+ import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
19
+ import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
19
20
  async function assertTestPathsExist(engineDir, testPaths) {
20
21
  const missingPaths = [];
21
22
  for (const testPath of testPaths) {
@@ -29,21 +30,6 @@ async function assertTestPathsExist(engineDir, testPaths) {
29
30
  throw new GeneralError(`Test path${missingPaths.length === 1 ? '' : 's'} not found under engine/: ${missingPaths.join(', ')}\n\n` +
30
31
  'If you expected these files to come from your patch stack, run "fireforge import" first.');
31
32
  }
32
- function buildUnknownTestMessage(testPaths) {
33
- return (`mach could not discover the requested test path${testPaths.length === 1 ? '' : 's'}: ${testPaths.join(', ')}\n\n` +
34
- 'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
35
- 'Check the nearest test manifest (for example browser.toml or xpcshell.toml), confirm the file is listed under the correct test type, and make sure each parent moz.build registers that manifest before retrying.');
36
- }
37
- function buildStaleBuildMessage(postRebuild) {
38
- if (postRebuild) {
39
- return ('Firefox test runtime still reported stale-artifact-shaped resource failures after the rebuild completed.\n\n' +
40
- 'FireForge already ran the requested rebuild before this focused test, so treat the remaining failure as a real runtime, registration, routing, or test-contract regression rather than another stale deployed-artifact-only blocker.\n\n' +
41
- 'Check the first post-rebuild failure above and the raw mach output for the concrete path or module that still fails.');
42
- }
43
- return ('Firefox test runtime appears to be using stale build artifacts.\n\n' +
44
- 'The failing output referenced missing branding or distribution resources, which usually means the current obj-* build does not match recent engine or branding changes.\n\n' +
45
- 'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
46
- }
47
33
  async function classifyTestHarnesses(engineDir, normalizedPaths) {
48
34
  const result = { xpcshell: [], nonXpcshell: [] };
49
35
  for (const testPath of normalizedPaths) {
@@ -57,6 +43,25 @@ async function classifyTestHarnesses(engineDir, normalizedPaths) {
57
43
  }
58
44
  return result;
59
45
  }
46
+ /**
47
+ * Picks the mach dispatch target for a (non-mixed) run. A single-suite run
48
+ * auto-routes to the suite-specific command (`mach xpcshell-test` /
49
+ * `mach mochitest`), which degrades a broken host resource monitor to a
50
+ * warning instead of crashing generic `mach test` at startup (E1). Mixed runs
51
+ * are rejected before this point; a path-less "run all" or an explicit
52
+ * `--generic-mach-test` opt-out stays on the generic command.
53
+ */
54
+ function resolveTestSuite(classification, forceGeneric) {
55
+ if (forceGeneric)
56
+ return 'generic';
57
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length === 0) {
58
+ return 'xpcshell';
59
+ }
60
+ if (classification.nonXpcshell.length > 0 && classification.xpcshell.length === 0) {
61
+ return 'mochitest';
62
+ }
63
+ return 'generic';
64
+ }
60
65
  function buildMixedHarnessMessage(classification) {
61
66
  return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
62
67
  'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
@@ -81,75 +86,6 @@ function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
81
86
  }
82
87
  return filtered;
83
88
  }
84
- function hasStaleBuildArtifactsSignal(output) {
85
- // Deliberately narrow: only fire on branding-specific resource paths
86
- // that are always a stale-artifact symptom. The earlier pattern also
87
- // matched `resource:///modules/distribution.sys.mjs`, which surfaced on
88
- // real packaging / module-resolution failures too (e.g. a fork's
89
- // `MyBrowserStore.sys.mjs` missing from the installed app dir after a
90
- // successful build). That false-positive pushed operators toward
91
- // "rebuild" advice for what was actually a module-registration issue.
92
- return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
93
- /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
94
- }
95
- /**
96
- * Fork-module-not-registered signal. 2026-04-21 eval Finding #14:
97
- * a fork's test failed with `Failed to load resource:///modules/mybrowser/
98
- * MyBrowserStore.sys.mjs`. The branding pattern happened to also match
99
- * because the test harness printed a branding warning during its
100
- * teardown, and the stale-build branch won by precedence — telling the
101
- * operator to rebuild when the real fix is to register the module in
102
- * the fork's `browser/modules/<binary>/moz.build`. Match a
103
- * `resource:///modules/<binaryName>/` pattern so fork-owned module
104
- * failures surface the right diagnosis.
105
- */
106
- function hasForkModuleSignal(output, binaryName) {
107
- const pattern = new RegExp(`Failed to load resource:\\/\\/\\/modules\\/${binaryName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\/`, 'i');
108
- return pattern.test(output);
109
- }
110
- function buildForkModuleMessage(binaryName) {
111
- return (`Test failed to load a fork-owned module at resource:///modules/${binaryName}/*.sys.mjs.\n\n` +
112
- 'This is almost always a module-registration issue, not a stale build. The fork module directory is missing an entry that maps its file into the resource URI tree, so `ChromeUtils.importESModule` cannot resolve it.\n\n' +
113
- 'Check that:\n' +
114
- ` - browser/modules/${binaryName}/moz.build lists the missing module in EXTRA_JS_MODULES.\n` +
115
- ` - browser/modules/moz.build references the ${binaryName}/ subdirectory (DIRS += [...]).\n` +
116
- ' - The last `fireforge build` (or `fireforge build --ui`) completed successfully against the current manifests. If the registration is new, the UI-faster build path may not pick it up — a full build may be required.\n\n' +
117
- 'Use `fireforge register browser/modules/' +
118
- binaryName +
119
- '/<file>.sys.mjs` to add the EXTRA_JS_MODULES entry if it is missing.');
120
- }
121
- // Detects the broader xpcshell symptom where every `resource:///modules/...`
122
- // import fails — the signature of xpcshell running with the wrong app-dir on
123
- // a manifest that sets `firefox-appdir = "browser"`. Checked AFTER the
124
- // stale-build signal (which matches the narrower `distribution.sys.mjs`
125
- // path) so the more specific diagnosis wins when both patterns apply.
126
- function hasXpcshellAppdirSignal(output) {
127
- return /Failed to load resource:\/\/\/modules\//i.test(output);
128
- }
129
- function buildXpcshellAppdirMessage(injectionAttempted) {
130
- const isMacos = process.platform === 'darwin';
131
- const macosNote = isMacos
132
- ? 'Detected: macOS host. On macOS the xpcshell harness binds `-a` to `<obj>/dist/<App>.app/Contents/Resources` by default and frequently ignores `--app-path` overrides when the `.app` bundle is present — the surest fix is the `<appname>-appdir` migration below rather than trying to force a different path.\n\n'
133
- : '';
134
- const triggerLines = injectionAttempted
135
- ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or (on macOS) the harness bound `-a` to the `.app/Contents/Resources` default and ignored the override.\n\n'
136
- : 'Likely triggers:\n' +
137
- ' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
138
- ' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
139
- return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
140
- 'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
141
- macosNote +
142
- triggerLines +
143
- 'Options:\n' +
144
- ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly. This is the most reliable fix on rebranded macOS builds.\n' +
145
- ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection, but see the macOS caveat above).\n' +
146
- ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
147
- ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
148
- }
149
- function buildHarnessSymlinkMessage() {
150
- return ('mach failed while preparing test harness symlinks before the requested tests ran.\n\n' +
151
- 'This usually means the objdir contains stale harness setup from an earlier run. Re-run with `fireforge test --build` to refresh the harness state, or remove the stale harness symlink in the active obj-* directory before retrying.');
152
- }
153
89
  async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
154
90
  if (!objDir)
155
91
  return undefined;
@@ -164,16 +100,31 @@ async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
164
100
  }
165
101
  return bundleCheck.expectedPath;
166
102
  }
167
- async function runPreTestBuild(projectRoot, paths, projectConfig) {
103
+ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries) {
168
104
  await withBuildLock(projectRoot, async () => {
169
105
  await prepareBuildEnvironment(projectRoot, paths, projectConfig);
170
106
  const s = spinner('Running incremental build...');
171
- const buildResult = await buildUI(paths.engine);
172
- if (buildResult.exitCode !== 0) {
107
+ // The pre-test build runs through mach too, so the same resource-monitor
108
+ // startup crash that aborts `mach test` can abort `mach build faster`.
109
+ // Run the harness-crash classifier over the build output and retry within
110
+ // the same `--harness-retries` budget rather than hard-failing with a
111
+ // bare "Pre-test build failed" (field report E2).
112
+ const maxAttempts = Math.max(1, harnessRetries + 1);
113
+ for (let attempt = 1;; attempt += 1) {
114
+ const buildResult = await buildUI(paths.engine);
115
+ if (buildResult.exitCode === 0) {
116
+ s.stop('Build complete');
117
+ return;
118
+ }
119
+ const signature = detectHarnessCrashSignature(`${buildResult.stdout}\n${buildResult.stderr}`);
120
+ if (signature && attempt < maxAttempts) {
121
+ s.message(`Pre-test build hit a harness crash (${signature.reason}); ` +
122
+ `retrying (attempt ${attempt + 1} of ${maxAttempts})...`);
123
+ continue;
124
+ }
173
125
  s.error('Pre-test build failed');
174
- throw new BuildError('Pre-test build failed', 'mach build faster');
126
+ throw new BuildError(signature ? buildHarnessCrashMessage(signature, attempt) : 'Pre-test build failed', 'mach build faster');
175
127
  }
176
- s.stop('Build complete');
177
128
  });
178
129
  }
179
130
  function logTestSelection(normalizedPaths) {
@@ -185,79 +136,116 @@ function logTestSelection(normalizedPaths) {
185
136
  }
186
137
  info('');
187
138
  }
188
- // Detects the `AttributeError: 'MochitestDesktop' object has no attribute
189
- // 'http3Server'` teardown crash. The attribute is lazy-initialized inside
190
- // harness code paths that presume chrome://branding resolves correctly; a
191
- // missing or miswired branding registration short-circuits the setup and
192
- // leaves the cleanup path looking up an attribute that was never assigned.
193
- function hasMochitestHttp3ServerSignal(output) {
194
- return /'MochitestDesktop' object has no attribute 'http3Server'/.test(output);
195
- }
196
- function buildMochitestHttp3ServerMessage() {
197
- return ("Mochitest raised `AttributeError: 'MochitestDesktop' object has no attribute 'http3Server'`.\n\n" +
198
- 'This is almost always a symptom of `chrome://branding` not registering correctly in your fork — the mochitest harness lazy-initializes `http3Server` only after branding resolves, and a missing branding registration short-circuits setup. The cleanup path then trips the AttributeError, masking the real error.\n\n' +
199
- 'Check that:\n' +
200
- " - Your fork's branding directory is listed in `browser/branding/moz.build` (or equivalent) and ships a `brand.properties` / `brand.ftl`.\n" +
201
- ' - `chrome://branding/locale/brand.properties` resolves at runtime (try `fireforge run` and inspect the Browser Console).\n' +
202
- " - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
203
- 'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
204
- }
205
- function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName, postRebuildContext) {
206
- if (result.exitCode === 0 || result.exitCode === 130)
207
- return;
208
- const combinedOutput = `${result.stdout}\n${result.stderr}`;
209
- const failureContext = postRebuildContext
210
- ? completePostRebuildFailureContext(postRebuildContext, combinedOutput)
211
- : undefined;
212
- const withContext = (message) => prependPostRebuildFailureContext(message, failureContext);
213
- const throwGeneral = (message) => {
214
- throw new GeneralError(withContext(message));
215
- };
216
- if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
217
- throwGeneral(buildUnknownTestMessage(normalizedPaths));
218
- }
219
- const earlyExit = classifyHarnessEarlyExit(combinedOutput, normalizedPaths);
220
- if (earlyExit) {
221
- throwGeneral(buildHarnessEarlyExitMessage(earlyExit, normalizedPaths));
222
- }
223
- // Fork-owned module load failures must beat the branding stale-build
224
- // branch: 2026-04-21 eval (Finding #14) saw a fork's test fail with
225
- // `Failed to load resource:///modules/mybrowser/MyBrowserStore.sys.mjs`
226
- // while the harness teardown printed a branding warning that the old
227
- // stale-build pattern matched, so the operator was told to rebuild
228
- // when the real fix is to register the missing module.
229
- if (hasForkModuleSignal(combinedOutput, binaryName)) {
230
- throwGeneral(buildForkModuleMessage(binaryName));
139
+ /**
140
+ * Validates the build-artifact preconditions for running tests: rejects
141
+ * ambiguous multi-objdir checkouts, platform-mismatched artifacts, and
142
+ * missing/incomplete builds with the actionable message for each. Returns
143
+ * the successful artifact probe for downstream objdir use.
144
+ */
145
+ async function assertTestBuildArtifacts(engineDir) {
146
+ const buildCheck = await hasBuildArtifacts(engineDir);
147
+ if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
148
+ throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
231
149
  }
232
- // Branding-specific stale-build signals keep priority over the broader
233
- // xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
234
- // fails to resolve, the fix really is "rebuild", not "pass --app-path".
235
- // But the stale-build check is now narrower — it no longer matches
236
- // `resource:///modules/distribution.sys.mjs` alone, which was producing
237
- // false-positive rebuild advice on fork-custom module-load failures
238
- // (the eval saw this for `MyBrowserStore.sys.mjs`). Cases that once
239
- // landed on `distribution.sys.mjs` fall through to xpcshell-appdir,
240
- // which is the more useful diagnosis in practice for `Failed to load
241
- // resource:///modules/…`.
242
- if (hasStaleBuildArtifactsSignal(combinedOutput)) {
243
- throwGeneral(buildStaleBuildMessage(Boolean(failureContext)));
150
+ const mismatchMessage = buildArtifactMismatchMessage(engineDir, buildCheck, 'Tests');
151
+ if (mismatchMessage) {
152
+ throw new GeneralError(mismatchMessage);
244
153
  }
245
- if (hasXpcshellAppdirSignal(combinedOutput)) {
246
- throwGeneral(buildXpcshellAppdirMessage(appdirInjectionAttempted));
154
+ if (!buildCheck.exists) {
155
+ const detail = buildCheck.objDir
156
+ ? `Build artifacts incomplete in ${buildCheck.objDir}/`
157
+ : 'No build artifacts found (obj-*/ directory missing)';
158
+ throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
159
+ "Run 'fireforge build' first, then run 'fireforge test'.");
247
160
  }
248
- if (hasMochitestHttp3ServerSignal(combinedOutput)) {
249
- throwGeneral(buildMochitestHttp3ServerMessage());
161
+ return buildCheck;
162
+ }
163
+ /**
164
+ * Runs the `--doctor` marionette handshake probe. With no test paths the
165
+ * probe is the entire command (returns `'stop'` after reporting); with
166
+ * paths it gates the mach invocation — a FAIL throws before mach runs.
167
+ */
168
+ async function runDoctorPreflight(args) {
169
+ const { engineDir, effectivePort, hasTestPaths, objDir, binaryName, launchablePath } = args;
170
+ // Write the "Running marionette preflight..." banner via
171
+ // `process.stdout.write` directly before `info()` so non-TTY captures
172
+ // always see the banner even if clack's renderer defers output in
173
+ // pipe mode. `info()` is still called so TTY users keep the normal
174
+ // clack box-drawing framing.
175
+ process.stdout.write('Running marionette preflight...\n');
176
+ info('Running marionette preflight...');
177
+ const preflight = effectivePort !== undefined
178
+ ? await runMarionettePreflight(engineDir, { port: effectivePort })
179
+ : await runMarionettePreflight(engineDir);
180
+ // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
181
+ // `success()` + `outro()` + a direct `process.stdout.write` as a
182
+ // belt-and-suspenders but still reproducibly dropped the PASS summary
183
+ // under non-TTY capture (observed: `tee`-wrapped eval output saw only
184
+ // the intro). The fix writes the authoritative PASS/FAIL line via
185
+ // `process.stdout.write` as the very first output after the probe
186
+ // returns, so the captured stream has an unambiguous summary no
187
+ // matter what clack does on top. The clack-rendered banner
188
+ // (`info`/`warn`) is retained so TTY users keep the visual framing.
189
+ const directLine = formatMarionettePreflightLine(preflight);
190
+ process.stdout.write(`${directLine}\n`);
191
+ process.stdout.write(`Marionette preflight environment: objdir=${objDir ?? '(none)'}; binary=${binaryName}; app=${launchablePath ? `engine/${launchablePath}` : '(unknown)'}; port=${effectivePort ?? 2828}; elapsed=${preflight.durationMs}ms\n`);
192
+ reportMarionettePreflight(preflight);
193
+ if (!hasTestPaths) {
194
+ if (!preflight.ok) {
195
+ throw new GeneralError('Marionette preflight reported FAIL — see output above.');
196
+ }
197
+ success(directLine);
198
+ outro('Test completed');
199
+ return 'stop';
250
200
  }
251
- if (/FileExistsError/i.test(combinedOutput) &&
252
- /(mochitest|xpcshell|_tests)/i.test(combinedOutput)) {
253
- throwGeneral(buildHarnessSymlinkMessage());
201
+ if (!preflight.ok) {
202
+ throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
254
203
  }
255
- if (/invalid filename/i.test(combinedOutput) ||
256
- /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
257
- info('Hint: The test file may not be registered in browser.toml or jar.mn.');
258
- info('Run "fireforge register <test-path>" to register it.');
204
+ return 'continue';
205
+ }
206
+ /**
207
+ * Auto-forwards `--marionette-port` to mach (`--setpref=marionette.port`
208
+ * for the listener, `--marionette=127.0.0.1:<n>` for the mochitest
209
+ * client), skipping each piece the operator already forwarded via
210
+ * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
211
+ * Mutates `extraArgs` in place.
212
+ */
213
+ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
214
+ // Auto-forward the Marionette port to mach when `--marionette-port` is
215
+ // set. `--setpref=marionette.port=<n>` configures where the browser
216
+ // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
217
+ // client to connect there (default client is 127.0.0.1:2828). xpcshell
218
+ // ignores both for browser Marionette.
219
+ //
220
+ // Skip setpref forwarding when the operator already supplied an equivalent
221
+ // arg via `--mach-arg` — duplicates would be confusing without changing
222
+ // semantics. Skip when mach args explicitly request `--flavor=xpcshell`
223
+ // (or `xpcshell-tests`): the preflight still honours `--marionette-port`,
224
+ // but mach does not use the marionette.port pref on that harness. Any
225
+ // other arg shape still forwards so toolkit widget paths and mixed suites
226
+ // stay aligned with the probe without duplicate `--mach-arg` flags.
227
+ //
228
+ // Skip auto `--marionette=...` when `--mach-arg` already includes a client
229
+ // `--marionette=...` (or two-token `--marionette host:port`).
230
+ if (options.marionettePort === undefined)
231
+ return;
232
+ {
233
+ const operatorAlreadyForwarded = forwardedPort !== undefined;
234
+ const machArgs = options.machArg ?? [];
235
+ if (operatorAlreadyForwarded) {
236
+ info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
237
+ }
238
+ else if (shouldAutoForwardMarionettePortToMach(machArgs)) {
239
+ extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
240
+ }
241
+ else {
242
+ info(`--marionette-port=${options.marionettePort} applied to the preflight probe, but --flavor=xpcshell is set — mach is not auto-configured with --setpref=marionette.port or --marionette (xpcshell ignores the browser Marionette path). Pass --mach-arg --setpref=marionette.port=${options.marionettePort} explicitly if you still need mach to see the port.`);
243
+ }
244
+ if (shouldAutoForwardMarionettePortToMach(machArgs) &&
245
+ !forwardedMachArgsIncludeMarionetteClient(machArgs)) {
246
+ extraArgs.push(`--marionette=127.0.0.1:${options.marionettePort}`);
247
+ }
259
248
  }
260
- throw new BuildError(withContext(`Tests failed with exit code ${result.exitCode}. Check the output above for details.`), 'mach test');
261
249
  }
262
250
  /**
263
251
  * Runs the test command to execute mach tests.
@@ -272,22 +260,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
272
260
  if (!(await pathExists(paths.engine))) {
273
261
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
274
262
  }
275
- // Check for build artifacts before running tests
276
- const buildCheck = await hasBuildArtifacts(paths.engine);
277
- if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
278
- throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
279
- }
280
- const mismatchMessage = buildArtifactMismatchMessage(paths.engine, buildCheck, 'Tests');
281
- if (mismatchMessage) {
282
- throw new GeneralError(mismatchMessage);
283
- }
284
- if (!buildCheck.exists) {
285
- const detail = buildCheck.objDir
286
- ? `Build artifacts incomplete in ${buildCheck.objDir}/`
287
- : 'No build artifacts found (obj-*/ directory missing)';
288
- throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
289
- "Run 'fireforge build' first, then run 'fireforge test'.");
290
- }
263
+ const buildCheck = await assertTestBuildArtifacts(paths.engine);
291
264
  // Load the project config once so both the build and the port
292
265
  // probe have access to `binaryName` (the port probe uses it to
293
266
  // recognise a fork-branded browser holding the Marionette port).
@@ -301,9 +274,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
301
274
  // missing-bundle path instead of a cryptic `Browser process exited
302
275
  // during spawn (exit code 1, signal none). stderr tail: (empty)`.
303
276
  const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
277
+ const harnessRetries = options.harnessRetries ?? DEFAULT_HARNESS_RETRIES;
304
278
  // Run incremental build if requested
305
279
  if (options.build) {
306
- await runPreTestBuild(projectRoot, paths, projectConfig);
280
+ await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries);
307
281
  info('');
308
282
  }
309
283
  else {
@@ -343,45 +317,17 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
343
317
  // `-marionette` process from `fresh/` poisoned a later test run in
344
318
  // the sibling `mybrowser/` workspace.
345
319
  await assertMarionettePortAvailable(effectivePort, { binaryName: projectConfig.binaryName });
346
- // `--doctor` runs a short marionette handshake probe. When test paths are
347
- // supplied the probe gates the mach test invocation (a FAIL bails out). When
348
- // no paths are supplied this is the only step — it's the fastest way to tell
349
- // marionette-wedged apart from test-discovery-failure.
350
320
  if (options.doctor) {
351
- // Write the "Running marionette preflight..." banner via
352
- // `process.stdout.write` directly before `info()` so non-TTY captures
353
- // always see the banner even if clack's renderer defers output in
354
- // pipe mode. `info()` is still called so TTY users keep the normal
355
- // clack box-drawing framing.
356
- process.stdout.write('Running marionette preflight...\n');
357
- info('Running marionette preflight...');
358
- const preflight = effectivePort !== undefined
359
- ? await runMarionettePreflight(paths.engine, { port: effectivePort })
360
- : await runMarionettePreflight(paths.engine);
361
- // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
362
- // `success()` + `outro()` + a direct `process.stdout.write` as a
363
- // belt-and-suspenders but still reproducibly dropped the PASS summary
364
- // under non-TTY capture (observed: `tee`-wrapped eval output saw only
365
- // the intro). The fix writes the authoritative PASS/FAIL line via
366
- // `process.stdout.write` as the very first output after the probe
367
- // returns, so the captured stream has an unambiguous summary no
368
- // matter what clack does on top. The clack-rendered banner
369
- // (`info`/`warn`) is retained so TTY users keep the visual framing.
370
- const directLine = formatMarionettePreflightLine(preflight);
371
- process.stdout.write(`${directLine}\n`);
372
- process.stdout.write(`Marionette preflight environment: objdir=${buildCheck.objDir ?? '(none)'}; binary=${projectConfig.binaryName}; app=${launchablePath ? `engine/${launchablePath}` : '(unknown)'}; port=${effectivePort ?? 2828}; elapsed=${preflight.durationMs}ms\n`);
373
- reportMarionettePreflight(preflight);
374
- if (testPaths.length === 0) {
375
- if (!preflight.ok) {
376
- throw new GeneralError('Marionette preflight reported FAIL — see output above.');
377
- }
378
- success(directLine);
379
- outro('Test completed');
321
+ const doctorOutcome = await runDoctorPreflight({
322
+ engineDir: paths.engine,
323
+ effectivePort,
324
+ hasTestPaths: testPaths.length > 0,
325
+ objDir: buildCheck.objDir,
326
+ binaryName: projectConfig.binaryName,
327
+ launchablePath,
328
+ });
329
+ if (doctorOutcome === 'stop')
380
330
  return;
381
- }
382
- if (!preflight.ok) {
383
- throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
384
- }
385
331
  }
386
332
  const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
387
333
  await assertTestPathsExist(paths.engine, normalizedPaths);
@@ -389,6 +335,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
389
335
  if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
390
336
  throw new GeneralError(buildMixedHarnessMessage(classification));
391
337
  }
338
+ const suite = resolveTestSuite(classification, options.genericMachTest === true);
392
339
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
393
340
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
394
341
  : [];
@@ -404,60 +351,54 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
404
351
  if (forwardedMachArgs.length > 0) {
405
352
  extraArgs.push(...forwardedMachArgs);
406
353
  }
407
- // Auto-forward the Marionette port to mach when `--marionette-port` is
408
- // set. `--setpref=marionette.port=<n>` configures where the browser
409
- // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
410
- // client to connect there (default client is 127.0.0.1:2828). xpcshell
411
- // ignores both for browser Marionette.
412
- //
413
- // Skip setpref forwarding when the operator already supplied an equivalent
414
- // arg via `--mach-arg` — duplicates would be confusing without changing
415
- // semantics. Skip when mach args explicitly request `--flavor=xpcshell`
416
- // (or `xpcshell-tests`): the preflight still honours `--marionette-port`,
417
- // but mach does not use the marionette.port pref on that harness. Any
418
- // other arg shape still forwards so toolkit widget paths and mixed suites
419
- // stay aligned with the probe without duplicate `--mach-arg` flags.
420
- //
421
- // Skip auto `--marionette=...` when `--mach-arg` already includes a client
422
- // `--marionette=...` (or two-token `--marionette host:port`).
423
- if (options.marionettePort !== undefined) {
424
- const operatorAlreadyForwarded = forwardedPort !== undefined;
425
- const machArgs = options.machArg ?? [];
426
- if (operatorAlreadyForwarded) {
427
- info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
428
- }
429
- else if (shouldAutoForwardMarionettePortToMach(machArgs)) {
430
- extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
431
- }
432
- else {
433
- info(`--marionette-port=${options.marionettePort} applied to the preflight probe, but --flavor=xpcshell is set — mach is not auto-configured with --setpref=marionette.port or --marionette (xpcshell ignores the browser Marionette path). Pass --mach-arg --setpref=marionette.port=${options.marionettePort} explicitly if you still need mach to see the port.`);
434
- }
435
- if (shouldAutoForwardMarionettePortToMach(machArgs) &&
436
- !forwardedMachArgsIncludeMarionetteClient(machArgs)) {
437
- extraArgs.push(`--marionette=127.0.0.1:${options.marionettePort}`);
438
- }
439
- }
440
- // xpcshell appdir auto-injection — see src/core/xpcshell-appdir.ts for the
441
- // full motivation. On rebranded forks (appname != "firefox") the upstream
442
- // harness silently ignores `firefox-appdir = "browser"` directives in the
443
- // xpcshell.toml, so every `resource:///modules/…` import throws. We probe
444
- // the nearest manifest, compute the absolute appdir under obj-*/dist/, and
445
- // inject `--app-path=<abs>` so the harness uses the right root. Operator
446
- // overrides via `--mach-arg=--app-path=…` always win — we skip injection
447
- // when the operator already passed one.
448
- const appdirInjection = await maybeInjectAppdirArg(paths.engine, normalizedPaths, buildCheck.objDir, extraArgs);
354
+ appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
355
+ // xpcshell appdir auto-injection happens per harness invocation inside
356
+ // `runTestsWithRetries` (src/commands/test-run.ts) so sharded runs probe
357
+ // the manifest for each file individually. See src/core/xpcshell-appdir.ts
358
+ // for the full motivation.
449
359
  logTestSelection(normalizedPaths);
450
- let result;
360
+ const perfSampleEnv = buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples);
361
+ const runCtx = {
362
+ engineDir: paths.engine,
363
+ objDir: buildCheck.objDir,
364
+ classification,
365
+ suite,
366
+ baseExtraArgs: extraArgs,
367
+ harnessRetries,
368
+ ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
369
+ };
370
+ const postRebuildContext = options.build
371
+ ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
372
+ : undefined;
373
+ // Multi-file requests shard into sequential single-file harness runs by
374
+ // default (field report C3): one shared mochitest profile across files
375
+ // bleeds pref/media-query state into later files. --no-shard restores
376
+ // the combined invocation.
377
+ if (normalizedPaths.length > 1 && options.shard !== false) {
378
+ await runShardedTests(runCtx, normalizedPaths, (outcome, path) => diagnoseShardOutcome(outcome, path, projectConfig.binaryName, postRebuildContext));
379
+ return;
380
+ }
381
+ let outcome;
451
382
  try {
452
- result = await testWithOutput(paths.engine, normalizedPaths, extraArgs);
383
+ outcome = await runTestsWithRetries(runCtx, normalizedPaths);
453
384
  }
454
385
  catch (error) {
455
386
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
456
387
  }
457
- result = await retryAfterXpcshellSymlinkRepair(paths.engine, buildCheck.objDir, result, classification, normalizedPaths, extraArgs);
458
- handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName, options.build
459
- ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
460
- : undefined);
388
+ finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext);
389
+ }
390
+ /**
391
+ * Builds the perf-sample env contract for the harness run (field report
392
+ * C4): `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON`
393
+ * naming the artifact file a budget checker consumes after the run.
394
+ */
395
+ function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
396
+ if (!perfSamples)
397
+ return undefined;
398
+ const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
399
+ const artifactPath = resolve(projectRoot, perfSamples);
400
+ info(`Perf sample contract: ${envName}=${artifactPath}`);
401
+ return { [envName]: artifactPath };
461
402
  }
462
403
  /** Registers the test command on the CLI program. */
463
404
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
@@ -471,6 +412,16 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
471
412
  acc.push(value);
472
413
  return acc;
473
414
  }, [])
415
+ .option('--harness-retries <n>', `Retry budget for recognized harness crashes (resource-monitor tracebacks, pre-test hangs, post-green shutdown re-entry). 0 disables retries. Default: ${String(DEFAULT_HARNESS_RETRIES)}.`, (raw) => {
416
+ const n = Number.parseInt(raw, 10);
417
+ if (!Number.isFinite(n) || n < 0 || n > 10) {
418
+ throw new GeneralError(`--harness-retries must be an integer in 0..10 (got "${raw}")`);
419
+ }
420
+ return n;
421
+ })
422
+ .option('--generic-mach-test', 'Force dispatch through generic `mach test` instead of the suite-specific `mach xpcshell-test` / `mach mochitest` a single-suite run auto-selects (the suite-specific commands skip the mozlog resource monitor that crashes `mach test` on some hosts).')
423
+ .option('--no-shard', 'Run multiple test paths in one combined mach invocation instead of sequential per-file shards')
424
+ .option('--perf-samples <path>', 'Publish a perf-sample artifact path to the harness via <BINARYNAME>_PERF_SAMPLE_JSON (resolved against the project root)')
474
425
  .option('--marionette-port <port>', 'Override the Marionette control port (default 2828) for the stale-browser probe, the --doctor preflight, and (unless --mach-arg includes --flavor=xpcshell) auto-forwarded mach args: --setpref=marionette.port=<n> (browser listener) and --marionette=127.0.0.1:<n> (mochitest client). Omits the client flag when --mach-arg already sets --marionette. Use when 2828 is busy or CI assigns another port.', (raw) => {
475
426
  const n = Number.parseInt(raw, 10);
476
427
  if (!Number.isFinite(n) || n < 1 || n > 65535) {