@hominis/fireforge 0.30.0 → 0.31.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 (141) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +22 -5
  3. package/dist/src/commands/export-all.js +5 -15
  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 +36 -0
  10. package/dist/src/commands/export.js +47 -112
  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 +1 -1
  20. package/dist/src/commands/lint-per-patch.js +119 -78
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  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-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +3 -13
  94. package/dist/src/core/lint-cache.js +11 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
@@ -1,13 +1,12 @@
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 { createPostRebuildFailureContext } from '../core/test-harness-output.js';
9
9
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
10
- import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
11
10
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
12
11
  import { GeneralError } from '../errors/base.js';
13
12
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
@@ -15,7 +14,8 @@ import { pathExists } from '../utils/fs.js';
15
14
  import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
16
15
  import { pickDefined } from '../utils/options.js';
17
16
  import { stripEnginePrefix } from '../utils/paths.js';
18
- import { maybeInjectAppdirArg } from './test-appdir.js';
17
+ import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
18
+ import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
19
19
  async function assertTestPathsExist(engineDir, testPaths) {
20
20
  const missingPaths = [];
21
21
  for (const testPath of testPaths) {
@@ -29,21 +29,6 @@ async function assertTestPathsExist(engineDir, testPaths) {
29
29
  throw new GeneralError(`Test path${missingPaths.length === 1 ? '' : 's'} not found under engine/: ${missingPaths.join(', ')}\n\n` +
30
30
  'If you expected these files to come from your patch stack, run "fireforge import" first.');
31
31
  }
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
32
  async function classifyTestHarnesses(engineDir, normalizedPaths) {
48
33
  const result = { xpcshell: [], nonXpcshell: [] };
49
34
  for (const testPath of normalizedPaths) {
@@ -81,75 +66,6 @@ function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
81
66
  }
82
67
  return filtered;
83
68
  }
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
69
  async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
154
70
  if (!objDir)
155
71
  return undefined;
@@ -185,79 +101,116 @@ function logTestSelection(normalizedPaths) {
185
101
  }
186
102
  info('');
187
103
  }
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));
104
+ /**
105
+ * Validates the build-artifact preconditions for running tests: rejects
106
+ * ambiguous multi-objdir checkouts, platform-mismatched artifacts, and
107
+ * missing/incomplete builds with the actionable message for each. Returns
108
+ * the successful artifact probe for downstream objdir use.
109
+ */
110
+ async function assertTestBuildArtifacts(engineDir) {
111
+ const buildCheck = await hasBuildArtifacts(engineDir);
112
+ if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
113
+ throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
231
114
  }
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)));
115
+ const mismatchMessage = buildArtifactMismatchMessage(engineDir, buildCheck, 'Tests');
116
+ if (mismatchMessage) {
117
+ throw new GeneralError(mismatchMessage);
244
118
  }
245
- if (hasXpcshellAppdirSignal(combinedOutput)) {
246
- throwGeneral(buildXpcshellAppdirMessage(appdirInjectionAttempted));
119
+ if (!buildCheck.exists) {
120
+ const detail = buildCheck.objDir
121
+ ? `Build artifacts incomplete in ${buildCheck.objDir}/`
122
+ : 'No build artifacts found (obj-*/ directory missing)';
123
+ throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
124
+ "Run 'fireforge build' first, then run 'fireforge test'.");
247
125
  }
248
- if (hasMochitestHttp3ServerSignal(combinedOutput)) {
249
- throwGeneral(buildMochitestHttp3ServerMessage());
126
+ return buildCheck;
127
+ }
128
+ /**
129
+ * Runs the `--doctor` marionette handshake probe. With no test paths the
130
+ * probe is the entire command (returns `'stop'` after reporting); with
131
+ * paths it gates the mach invocation — a FAIL throws before mach runs.
132
+ */
133
+ async function runDoctorPreflight(args) {
134
+ const { engineDir, effectivePort, hasTestPaths, objDir, binaryName, launchablePath } = args;
135
+ // Write the "Running marionette preflight..." banner via
136
+ // `process.stdout.write` directly before `info()` so non-TTY captures
137
+ // always see the banner even if clack's renderer defers output in
138
+ // pipe mode. `info()` is still called so TTY users keep the normal
139
+ // clack box-drawing framing.
140
+ process.stdout.write('Running marionette preflight...\n');
141
+ info('Running marionette preflight...');
142
+ const preflight = effectivePort !== undefined
143
+ ? await runMarionettePreflight(engineDir, { port: effectivePort })
144
+ : await runMarionettePreflight(engineDir);
145
+ // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
146
+ // `success()` + `outro()` + a direct `process.stdout.write` as a
147
+ // belt-and-suspenders but still reproducibly dropped the PASS summary
148
+ // under non-TTY capture (observed: `tee`-wrapped eval output saw only
149
+ // the intro). The fix writes the authoritative PASS/FAIL line via
150
+ // `process.stdout.write` as the very first output after the probe
151
+ // returns, so the captured stream has an unambiguous summary no
152
+ // matter what clack does on top. The clack-rendered banner
153
+ // (`info`/`warn`) is retained so TTY users keep the visual framing.
154
+ const directLine = formatMarionettePreflightLine(preflight);
155
+ process.stdout.write(`${directLine}\n`);
156
+ process.stdout.write(`Marionette preflight environment: objdir=${objDir ?? '(none)'}; binary=${binaryName}; app=${launchablePath ? `engine/${launchablePath}` : '(unknown)'}; port=${effectivePort ?? 2828}; elapsed=${preflight.durationMs}ms\n`);
157
+ reportMarionettePreflight(preflight);
158
+ if (!hasTestPaths) {
159
+ if (!preflight.ok) {
160
+ throw new GeneralError('Marionette preflight reported FAIL — see output above.');
161
+ }
162
+ success(directLine);
163
+ outro('Test completed');
164
+ return 'stop';
250
165
  }
251
- if (/FileExistsError/i.test(combinedOutput) &&
252
- /(mochitest|xpcshell|_tests)/i.test(combinedOutput)) {
253
- throwGeneral(buildHarnessSymlinkMessage());
166
+ if (!preflight.ok) {
167
+ throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
254
168
  }
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.');
169
+ return 'continue';
170
+ }
171
+ /**
172
+ * Auto-forwards `--marionette-port` to mach (`--setpref=marionette.port`
173
+ * for the listener, `--marionette=127.0.0.1:<n>` for the mochitest
174
+ * client), skipping each piece the operator already forwarded via
175
+ * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
176
+ * Mutates `extraArgs` in place.
177
+ */
178
+ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort) {
179
+ // Auto-forward the Marionette port to mach when `--marionette-port` is
180
+ // set. `--setpref=marionette.port=<n>` configures where the browser
181
+ // listener binds; `--marionette=127.0.0.1:<n>` tells the mochitest harness
182
+ // client to connect there (default client is 127.0.0.1:2828). xpcshell
183
+ // ignores both for browser Marionette.
184
+ //
185
+ // Skip setpref forwarding when the operator already supplied an equivalent
186
+ // arg via `--mach-arg` — duplicates would be confusing without changing
187
+ // semantics. Skip when mach args explicitly request `--flavor=xpcshell`
188
+ // (or `xpcshell-tests`): the preflight still honours `--marionette-port`,
189
+ // but mach does not use the marionette.port pref on that harness. Any
190
+ // other arg shape still forwards so toolkit widget paths and mixed suites
191
+ // stay aligned with the probe without duplicate `--mach-arg` flags.
192
+ //
193
+ // Skip auto `--marionette=...` when `--mach-arg` already includes a client
194
+ // `--marionette=...` (or two-token `--marionette host:port`).
195
+ if (options.marionettePort === undefined)
196
+ return;
197
+ {
198
+ const operatorAlreadyForwarded = forwardedPort !== undefined;
199
+ const machArgs = options.machArg ?? [];
200
+ if (operatorAlreadyForwarded) {
201
+ info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
202
+ }
203
+ else if (shouldAutoForwardMarionettePortToMach(machArgs)) {
204
+ extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
205
+ }
206
+ else {
207
+ 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.`);
208
+ }
209
+ if (shouldAutoForwardMarionettePortToMach(machArgs) &&
210
+ !forwardedMachArgsIncludeMarionetteClient(machArgs)) {
211
+ extraArgs.push(`--marionette=127.0.0.1:${options.marionettePort}`);
212
+ }
259
213
  }
260
- throw new BuildError(withContext(`Tests failed with exit code ${result.exitCode}. Check the output above for details.`), 'mach test');
261
214
  }
262
215
  /**
263
216
  * Runs the test command to execute mach tests.
@@ -272,22 +225,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
272
225
  if (!(await pathExists(paths.engine))) {
273
226
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
274
227
  }
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
- }
228
+ const buildCheck = await assertTestBuildArtifacts(paths.engine);
291
229
  // Load the project config once so both the build and the port
292
230
  // probe have access to `binaryName` (the port probe uses it to
293
231
  // recognise a fork-branded browser holding the Marionette port).
@@ -343,45 +281,17 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
343
281
  // `-marionette` process from `fresh/` poisoned a later test run in
344
282
  // the sibling `mybrowser/` workspace.
345
283
  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
284
  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');
285
+ const doctorOutcome = await runDoctorPreflight({
286
+ engineDir: paths.engine,
287
+ effectivePort,
288
+ hasTestPaths: testPaths.length > 0,
289
+ objDir: buildCheck.objDir,
290
+ binaryName: projectConfig.binaryName,
291
+ launchablePath,
292
+ });
293
+ if (doctorOutcome === 'stop')
380
294
  return;
381
- }
382
- if (!preflight.ok) {
383
- throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
384
- }
385
295
  }
386
296
  const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
387
297
  await assertTestPathsExist(paths.engine, normalizedPaths);
@@ -404,60 +314,53 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
404
314
  if (forwardedMachArgs.length > 0) {
405
315
  extraArgs.push(...forwardedMachArgs);
406
316
  }
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);
317
+ appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
318
+ // xpcshell appdir auto-injection happens per harness invocation inside
319
+ // `runTestsWithRetries` (src/commands/test-run.ts) so sharded runs probe
320
+ // the manifest for each file individually. See src/core/xpcshell-appdir.ts
321
+ // for the full motivation.
449
322
  logTestSelection(normalizedPaths);
450
- let result;
323
+ const perfSampleEnv = buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples);
324
+ const runCtx = {
325
+ engineDir: paths.engine,
326
+ objDir: buildCheck.objDir,
327
+ classification,
328
+ baseExtraArgs: extraArgs,
329
+ harnessRetries: options.harnessRetries ?? DEFAULT_HARNESS_RETRIES,
330
+ ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
331
+ };
332
+ const postRebuildContext = options.build
333
+ ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
334
+ : undefined;
335
+ // Multi-file requests shard into sequential single-file harness runs by
336
+ // default (field report C3): one shared mochitest profile across files
337
+ // bleeds pref/media-query state into later files. --no-shard restores
338
+ // the combined invocation.
339
+ if (normalizedPaths.length > 1 && options.shard !== false) {
340
+ await runShardedTests(runCtx, normalizedPaths, (outcome, path) => diagnoseShardOutcome(outcome, path, projectConfig.binaryName, postRebuildContext));
341
+ return;
342
+ }
343
+ let outcome;
451
344
  try {
452
- result = await testWithOutput(paths.engine, normalizedPaths, extraArgs);
345
+ outcome = await runTestsWithRetries(runCtx, normalizedPaths);
453
346
  }
454
347
  catch (error) {
455
348
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
456
349
  }
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);
350
+ finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext);
351
+ }
352
+ /**
353
+ * Builds the perf-sample env contract for the harness run (field report
354
+ * C4): `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON`
355
+ * naming the artifact file a budget checker consumes after the run.
356
+ */
357
+ function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
358
+ if (!perfSamples)
359
+ return undefined;
360
+ const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
361
+ const artifactPath = resolve(projectRoot, perfSamples);
362
+ info(`Perf sample contract: ${envName}=${artifactPath}`);
363
+ return { [envName]: artifactPath };
461
364
  }
462
365
  /** Registers the test command on the CLI program. */
463
366
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
@@ -471,6 +374,15 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
471
374
  acc.push(value);
472
375
  return acc;
473
376
  }, [])
377
+ .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) => {
378
+ const n = Number.parseInt(raw, 10);
379
+ if (!Number.isFinite(n) || n < 0 || n > 10) {
380
+ throw new GeneralError(`--harness-retries must be an integer in 0..10 (got "${raw}")`);
381
+ }
382
+ return n;
383
+ })
384
+ .option('--no-shard', 'Run multiple test paths in one combined mach invocation instead of sequential per-file shards')
385
+ .option('--perf-samples <path>', 'Publish a perf-sample artifact path to the harness via <BINARYNAME>_PERF_SAMPLE_JSON (resolved against the project root)')
474
386
  .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
387
  const n = Number.parseInt(raw, 10);
476
388
  if (!Number.isFinite(n) || n < 1 || n > 65535) {
@@ -19,6 +19,14 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
19
19
  if (furnaceConfig.tokenPrefix) {
20
20
  const strippedPrefix = furnaceConfig.tokenPrefix.replace(/^--/, '').replace(/-$/, '');
21
21
  const strippedName = rawTokenName.replace(/^--/, '');
22
+ // A bare name that already starts with the configured prefix text is
23
+ // treated as fully qualified — blindly prepending would silently
24
+ // produce a double-prefixed token (e.g. "--hominis-hominis-shadow-low").
25
+ if (strippedName === strippedPrefix || strippedName.startsWith(`${strippedPrefix}-`)) {
26
+ info(`Token name "${rawTokenName}" already starts with the configured prefix ` +
27
+ `"${strippedPrefix}"; using --${strippedName} instead of prefixing it again.`);
28
+ return normalizeTokenName(strippedName);
29
+ }
22
30
  return `--${strippedPrefix}-${strippedName}`;
23
31
  }
24
32
  }
@@ -65,12 +73,13 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
65
73
  mode: options.mode,
66
74
  ...(options.description !== undefined ? { description: options.description } : {}),
67
75
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
76
+ ...(options.createCategory === true ? { createCategory: true } : {}),
68
77
  dryRun: true,
69
78
  });
70
79
  info('[dry-run] Would add token:');
71
80
  info(` Name: ${tokenName}`);
72
81
  info(` Value: ${value}`);
73
- info(` Category: ${options.category}`);
82
+ info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
74
83
  info(` Mode: ${options.mode}`);
75
84
  if (options.description)
76
85
  info(` Description: ${options.description}`);
@@ -86,6 +95,7 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
86
95
  mode: options.mode,
87
96
  ...(options.description !== undefined ? { description: options.description } : {}),
88
97
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
98
+ ...(options.createCategory === true ? { createCategory: true } : {}),
89
99
  });
90
100
  if (result.skipped) {
91
101
  info(`Token ${tokenName} already exists (skipped)`);
@@ -93,6 +103,8 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
93
103
  else {
94
104
  const forgeConfig = await loadConfig(projectRoot);
95
105
  const tokensCssFile = getTokensCssPath(forgeConfig.binaryName).split('/').pop();
106
+ if (result.categoryCreated)
107
+ success(`Created category "${options.category}"`);
96
108
  if (result.cssAdded)
97
109
  success(`Added ${tokenName} to ${tokensCssFile}`);
98
110
  if (result.docsAdded)
@@ -139,6 +151,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
139
151
  .makeOptionMandatory(true))
140
152
  .option('--description <desc>', 'Comment description for the CSS file')
141
153
  .option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
154
+ .option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
142
155
  .option('--dry-run', 'Show what would be changed without writing')
143
156
  .action(withErrorHandling(async (tokenName, value, options) => {
144
157
  await tokenAddCommand(getProjectRoot(), tokenName, value, {
@@ -148,6 +161,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
148
161
  description: options.description,
149
162
  darkValue: options.darkValue,
150
163
  dryRun: options.dryRun,
164
+ createCategory: options.createCategory,
151
165
  }),
152
166
  });
153
167
  }));