@hominis/fireforge 0.40.0 → 0.41.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 (253) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +13 -3
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +16 -7
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +12 -59
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/build-audit-platform.js +6 -1
  118. package/dist/src/core/build-audit-registration.js +2 -0
  119. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  120. package/dist/src/core/build-audit-resolve.js +34 -13
  121. package/dist/src/core/build-audit.js +4 -20
  122. package/dist/src/core/build-baseline-types.d.ts +29 -8
  123. package/dist/src/core/build-baseline-types.js +2 -1
  124. package/dist/src/core/build-baseline.d.ts +10 -2
  125. package/dist/src/core/build-baseline.js +39 -8
  126. package/dist/src/core/build-prepare.d.ts +4 -0
  127. package/dist/src/core/build-prepare.js +7 -1
  128. package/dist/src/core/config-paths.d.ts +1 -1
  129. package/dist/src/core/config-paths.js +2 -0
  130. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  131. package/dist/src/core/config-validate.js +34 -36
  132. package/dist/src/core/config.js +16 -3
  133. package/dist/src/core/coverage-extend.d.ts +89 -0
  134. package/dist/src/core/coverage-extend.js +188 -0
  135. package/dist/src/core/destructive.js +3 -1
  136. package/dist/src/core/discard-baseline.js +18 -4
  137. package/dist/src/core/engine-precondition.d.ts +35 -0
  138. package/dist/src/core/engine-precondition.js +50 -0
  139. package/dist/src/core/engine-session-lock.d.ts +38 -1
  140. package/dist/src/core/engine-session-lock.js +78 -4
  141. package/dist/src/core/file-lock.d.ts +7 -0
  142. package/dist/src/core/file-lock.js +20 -30
  143. package/dist/src/core/firefox.js +5 -2
  144. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  145. package/dist/src/core/furnace-apply-helpers.js +12 -3
  146. package/dist/src/core/furnace-apply-output.js +9 -0
  147. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  148. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  149. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  150. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  151. package/dist/src/core/furnace-apply.d.ts +16 -4
  152. package/dist/src/core/furnace-apply.js +39 -43
  153. package/dist/src/core/furnace-config.js +2 -0
  154. package/dist/src/core/furnace-css-fragments.js +2 -0
  155. package/dist/src/core/furnace-jsconfig.js +14 -7
  156. package/dist/src/core/furnace-registration-ast.js +8 -8
  157. package/dist/src/core/furnace-registration-remove.js +6 -3
  158. package/dist/src/core/furnace-registration.js +9 -12
  159. package/dist/src/core/furnace-rollback.js +1 -1
  160. package/dist/src/core/furnace-scanner.js +2 -1
  161. package/dist/src/core/furnace-stale-export.js +2 -1
  162. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  163. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  164. package/dist/src/core/furnace-validate-helpers.js +33 -1
  165. package/dist/src/core/furnace-validate-registration.js +16 -0
  166. package/dist/src/core/furnace-validate.js +4 -2
  167. package/dist/src/core/git-diff.js +37 -2
  168. package/dist/src/core/lint-cache.d.ts +1 -1
  169. package/dist/src/core/lint-cache.js +33 -10
  170. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  171. package/dist/src/core/mach-build-artifacts.js +108 -2
  172. package/dist/src/core/mach-resource-shim.js +1 -0
  173. package/dist/src/core/marionette-port.d.ts +16 -0
  174. package/dist/src/core/marionette-port.js +93 -8
  175. package/dist/src/core/marionette-preflight.js +6 -5
  176. package/dist/src/core/patch-apply.d.ts +20 -13
  177. package/dist/src/core/patch-apply.js +32 -33
  178. package/dist/src/core/patch-export.js +3 -5
  179. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  180. package/dist/src/core/patch-lint-checkjs.js +78 -7
  181. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  182. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  183. package/dist/src/core/patch-lint-creators.js +45 -0
  184. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  185. package/dist/src/core/patch-lint-cross.js +16 -32
  186. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  187. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  188. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  189. package/dist/src/core/patch-lint-module-registration.js +88 -0
  190. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  191. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  192. package/dist/src/core/patch-lint-reexports.js +1 -0
  193. package/dist/src/core/patch-lint.d.ts +8 -0
  194. package/dist/src/core/patch-lint.js +84 -6
  195. package/dist/src/core/patch-lock.d.ts +12 -1
  196. package/dist/src/core/patch-lock.js +40 -4
  197. package/dist/src/core/patch-manifest-consistency.js +15 -3
  198. package/dist/src/core/patch-parse.d.ts +8 -0
  199. package/dist/src/core/patch-parse.js +10 -0
  200. package/dist/src/core/patch-policy.d.ts +7 -0
  201. package/dist/src/core/patch-policy.js +4 -1
  202. package/dist/src/core/patch-transform.d.ts +10 -0
  203. package/dist/src/core/patch-transform.js +13 -2
  204. package/dist/src/core/rebase-session.d.ts +44 -2
  205. package/dist/src/core/rebase-session.js +114 -17
  206. package/dist/src/core/register-browser-content.js +2 -1
  207. package/dist/src/core/register-shared-css.js +2 -1
  208. package/dist/src/core/smoke-patterns.d.ts +0 -8
  209. package/dist/src/core/smoke-patterns.js +3 -2
  210. package/dist/src/core/status-binary.d.ts +55 -0
  211. package/dist/src/core/status-binary.js +124 -0
  212. package/dist/src/core/status-classify.d.ts +6 -1
  213. package/dist/src/core/status-classify.js +101 -73
  214. package/dist/src/core/test-harness-crash.d.ts +32 -1
  215. package/dist/src/core/test-harness-crash.js +59 -4
  216. package/dist/src/core/test-path-scope.js +2 -0
  217. package/dist/src/core/test-stale-check.js +1 -20
  218. package/dist/src/core/toolchain-preflight.js +24 -26
  219. package/dist/src/core/tree-guard.d.ts +1 -1
  220. package/dist/src/core/tree-guard.js +67 -12
  221. package/dist/src/core/tree-store.d.ts +86 -10
  222. package/dist/src/core/tree-store.js +310 -72
  223. package/dist/src/core/typecheck.js +2 -1
  224. package/dist/src/core/wire-destroy.js +14 -3
  225. package/dist/src/core/wire-init.js +37 -40
  226. package/dist/src/core/wire-subscript.js +11 -3
  227. package/dist/src/core/wire-utils.js +4 -4
  228. package/dist/src/core/xpcshell-appdir.js +3 -0
  229. package/dist/src/errors/base.d.ts +13 -0
  230. package/dist/src/errors/base.js +13 -0
  231. package/dist/src/errors/rebase.d.ts +13 -0
  232. package/dist/src/errors/rebase.js +17 -0
  233. package/dist/src/types/commands/options.d.ts +86 -1
  234. package/dist/src/types/commands/patches.d.ts +8 -0
  235. package/dist/src/types/furnace.d.ts +7 -0
  236. package/dist/src/utils/build-info.d.ts +31 -0
  237. package/dist/src/utils/build-info.js +98 -0
  238. package/dist/src/utils/concurrency.d.ts +8 -0
  239. package/dist/src/utils/concurrency.js +21 -0
  240. package/dist/src/utils/errors.d.ts +39 -0
  241. package/dist/src/utils/errors.js +55 -0
  242. package/dist/src/utils/fs.js +5 -12
  243. package/dist/src/utils/logger.d.ts +14 -2
  244. package/dist/src/utils/logger.js +43 -13
  245. package/dist/src/utils/options.d.ts +21 -4
  246. package/dist/src/utils/options.js +26 -6
  247. package/dist/src/utils/package-root.js +7 -4
  248. package/dist/src/utils/process-group.js +13 -2
  249. package/dist/src/utils/stdio-drain.d.ts +28 -0
  250. package/dist/src/utils/stdio-drain.js +54 -0
  251. package/dist/src/utils/validation.d.ts +23 -2
  252. package/dist/src/utils/validation.js +29 -3
  253. package/package.json +9 -9
@@ -1,25 +1,24 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join, resolve } from 'node:path';
3
- import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
4
- import { prepareBuildEnvironment } from '../core/build-prepare.js';
5
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
4
  import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
7
- import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, runProtectedMachBuild, withBuildLock, } from '../core/mach.js';
8
- import { assertMarionettePortAvailable, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
5
+ import { hasBuildArtifacts, hasRunnableBundle } from '../core/mach.js';
6
+ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
7
+ import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
9
8
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
10
- import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
11
9
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
12
10
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
11
+ import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
13
12
  import { GeneralError } from '../errors/base.js';
14
- import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
15
- import { toError } from '../utils/errors.js';
13
+ import { BuildError } from '../errors/build.js';
16
14
  import { pathExists } from '../utils/fs.js';
17
- import { info, intro, outro, spinner, success, verbose } from '../utils/logger.js';
15
+ import { info, intro, outro, success, verbose } from '../utils/logger.js';
18
16
  import { stripEnginePrefix } from '../utils/paths.js';
17
+ import { runTestBuildPhase } from './test-build-phase.js';
19
18
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
20
19
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
21
- import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
22
- import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
20
+ import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
21
+ import { emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
23
22
  async function assertTestPathsExist(engineDir, testPaths) {
24
23
  const missingPaths = [];
25
24
  for (const testPath of testPaths) {
@@ -84,57 +83,6 @@ async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
84
83
  }
85
84
  return bundleCheck.expectedPath;
86
85
  }
87
- async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage) {
88
- await withBuildLock(projectRoot, async () => {
89
- // Pass the previous baseline exactly like `fireforge build` does, so
90
- // auto-configure runs under the same conditions on both paths. The
91
- // pre-test build must never invalidate more of the objdir than a plain
92
- // `mach build faster` would (field incident: a failed pre-test build
93
- // was followed by a ~64-minute full rebuild where an incremental one
94
- // should have sufficed).
95
- const previousBaseline = await readBuildBaseline(projectRoot);
96
- await prepareBuildEnvironment(projectRoot, paths, projectConfig, { previousBaseline });
97
- const s = spinner('Running incremental build...');
98
- // The pre-test build routes through the same protected mach dispatch as
99
- // `fireforge build` / `build --ui`: in-venv resource-monitor guard plus
100
- // the uniform recognized-crash retry budget, with a fresh mach process
101
- // (and fresh guard install) per attempt. prepareBuildEnvironment runs
102
- // ONCE, outside the retry loop — retries never re-run mach configure.
103
- const result = await runProtectedMachBuild('faster', paths.engine, {
104
- retries: harnessRetries,
105
- onRetry: (signature, nextAttempt, maxAttempts) => {
106
- s.message(`Pre-test build hit a harness crash (${signature.reason}); ` +
107
- `retrying (attempt ${nextAttempt} of ${maxAttempts})...`);
108
- },
109
- });
110
- if (result.exitCode === 0) {
111
- s.stop('Build complete');
112
- // Same record, same failure tolerance as `fireforge build` /
113
- // `build --ui` (build.ts): a green pre-test build refreshes the
114
- // stale-build baseline so a later plain `fireforge test` over the
115
- // same files — in any invocation shape — is not refused. A failed
116
- // write never fails the run. The coverage claim is scoped to the
117
- // requested test paths, since a file-scoped `test --build` only
118
- // guarantees packaging for those manifests. The previous baseline is
119
- // passed through so a scoped write carries the static-components
120
- // anchor forward (a `mach build faster` does not rebake
121
- // components.conf into the compiled table).
122
- try {
123
- await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline, testPackagingCoverage === 'full'
124
- ? 'fireforge test --build'
125
- : `fireforge test --build ${testPackagingCoverage.join(' ')}`);
126
- }
127
- catch (baselineError) {
128
- verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
129
- }
130
- return;
131
- }
132
- s.error('Pre-test build failed');
133
- throw new BuildError(result.crashSignature
134
- ? buildHarnessCrashMessage(result.crashSignature, result.attempts, 'mach build faster')
135
- : 'Pre-test build failed', 'mach build faster');
136
- });
137
- }
138
86
  function logTestSelection(scopes) {
139
87
  if (scopes.length > 0) {
140
88
  const labels = scopes.map((scope) => scope.isDirectory && scope.testFileCount > 0
@@ -147,30 +95,6 @@ function logTestSelection(scopes) {
147
95
  }
148
96
  info('');
149
97
  }
150
- /**
151
- * Validates the build-artifact preconditions for running tests: rejects
152
- * ambiguous multi-objdir checkouts, platform-mismatched artifacts, and
153
- * missing/incomplete builds with the actionable message for each. Returns
154
- * the successful artifact probe for downstream objdir use.
155
- */
156
- async function assertTestBuildArtifacts(engineDir) {
157
- const buildCheck = await hasBuildArtifacts(engineDir);
158
- if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
159
- throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
160
- }
161
- const mismatchMessage = buildArtifactMismatchMessage(engineDir, buildCheck, 'Tests');
162
- if (mismatchMessage) {
163
- throw new GeneralError(mismatchMessage);
164
- }
165
- if (!buildCheck.exists) {
166
- const detail = buildCheck.objDir
167
- ? `Build artifacts incomplete in ${buildCheck.objDir}/`
168
- : 'No build artifacts found (obj-*/ directory missing)';
169
- throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
170
- "Run 'fireforge build' first, then run 'fireforge test'.");
171
- }
172
- return buildCheck;
173
- }
174
98
  /**
175
99
  * Runs the `--doctor` marionette handshake probe. With no test paths the
176
100
  * probe is the entire command (returns `'stop'` after reporting); with
@@ -203,13 +127,19 @@ async function runDoctorPreflight(args) {
203
127
  reportMarionettePreflight(preflight);
204
128
  if (!hasTestPaths) {
205
129
  if (!preflight.ok) {
130
+ emitFailVerdict('preflight');
206
131
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
207
132
  }
208
133
  success(directLine);
134
+ // Doctor-only runs end here, so they carry their own verdict line
135
+ // (reason=preflight on failure); with test paths the verdict comes
136
+ // from the actual harness run downstream.
137
+ emitPassVerdict();
209
138
  outro('Test completed');
210
139
  return 'stop';
211
140
  }
212
141
  if (!preflight.ok) {
142
+ emitFailVerdict('preflight');
213
143
  throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
214
144
  }
215
145
  return 'continue';
@@ -267,6 +197,8 @@ function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcsh
267
197
  }
268
198
  }
269
199
  async function ensureTestMarionettePortAvailable(port, binaryName, options, skip) {
200
+ // Refuse a stale listener before mach surfaces a generic bind failure.
201
+ // This also recognizes a fork-branded browser via binaryName.
270
202
  if (skip.xpcshellOnly && !skip.doctor) {
271
203
  // xpcshell does not bind the browser Marionette port, so a developer's
272
204
  // interactive browser holding 2828 must not kill an xpcshell run
@@ -288,21 +220,87 @@ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip
288
220
  }
289
221
  await assertMarionettePortAvailable(port, { binaryName });
290
222
  }
223
+ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, binaryName, options) {
224
+ // A timed-out mochitest can leave the built app alive after its Marionette
225
+ // listener has disappeared. The port probe cannot see that case, but the
226
+ // survivor can still steal focus and wedge every later headed run.
227
+ if (!xpcshellOnly && launchablePath) {
228
+ await ensureLaunchableBrowserNotRunning(join(engineDir, launchablePath), {
229
+ killStaleBrowser: options.killStaleMarionette === true,
230
+ });
231
+ }
232
+ const forwardedPort = options.machArg
233
+ ? extractForwardedMarionettePort(options.machArg)
234
+ : undefined;
235
+ const effectivePort = options.marionettePort ?? forwardedPort;
236
+ await ensureTestMarionettePortAvailable(effectivePort, binaryName, options, {
237
+ xpcshellOnly,
238
+ doctor: options.doctor === true,
239
+ });
240
+ return { forwardedPort, effectivePort };
241
+ }
242
+ /** Build-artifact preflight wording for `fireforge test`. */
243
+ const TEST_BUILD_PREFLIGHT = {
244
+ label: 'Tests',
245
+ requirement: 'Tests require a completed build.',
246
+ remediation: "Run 'fireforge build' first, then run 'fireforge test'.",
247
+ requireExisting: true,
248
+ };
291
249
  /**
292
250
  * Runs the test command to execute mach tests.
251
+ *
252
+ * Owns the run's exactly-one-`FIREFORGE-VERDICT:`-line guarantee: the sink
253
+ * is re-armed at entry, and any failure that reaches this boundary without
254
+ * an inner writer having emitted — the preflight ladder (missing engine or
255
+ * build, config errors, stale-build and port gates, missing paths), and a
256
+ * harness process that failed to start — emits `FAIL reason=preflight`,
257
+ * since no harness classification exists for such a run. Writers closer to
258
+ * the harness (doctor, canary, single, sharded, the engine-generation
259
+ * guard) emit first and win.
293
260
  * @param projectRoot - Root directory of the project
294
261
  * @param testPaths - Test file or directory paths
295
262
  * @param options - Test options
296
263
  */
297
264
  export async function testCommand(projectRoot, testPaths, options = {}) {
298
265
  intro('FireForge Test');
266
+ resetVerdictEmission();
267
+ try {
268
+ await runTestCommandBody(projectRoot, testPaths, options);
269
+ }
270
+ catch (error) {
271
+ if (!verdictEmitted())
272
+ emitFailVerdict('preflight');
273
+ throw error;
274
+ }
275
+ }
276
+ /**
277
+ * Verifies `engine/` did not change under the run before any PASS verdict
278
+ * may print. On failure the run's verdict is `FAIL reason=inconclusive` —
279
+ * the harness result exists but cannot be trusted (`preflight` would
280
+ * misdescribe a run whose harness already executed) — emitted here so the
281
+ * guard's throw can never leave a stale or missing verdict line behind.
282
+ */
283
+ async function verifyEngineGenerationOrEmitInconclusive(engineDir, before) {
284
+ try {
285
+ await assertEngineGenerationUnchanged(engineDir, before);
286
+ }
287
+ catch (error) {
288
+ emitFailVerdict('inconclusive');
289
+ throw error;
290
+ }
291
+ }
292
+ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
299
293
  const paths = getProjectPaths(projectRoot);
300
294
  // Check if engine exists
301
295
  if (!(await pathExists(paths.engine))) {
302
296
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
303
297
  }
304
298
  assertPathlessTestMode(testPaths, options);
305
- const buildCheck = await assertTestBuildArtifacts(paths.engine);
299
+ const buildCheck = await hasBuildArtifacts(paths.engine);
300
+ assertBuildArtifacts(paths.engine, buildCheck, TEST_BUILD_PREFLIGHT);
301
+ // Inside a verification tree, only the objdir the marker records was
302
+ // proven rewritten-and-reconfigured to the tree; refuse any other.
303
+ await assertObjdirMatchesTreeMarker(projectRoot, buildCheck.objDir);
306
304
  // Load the project config once so both the build and the port
307
305
  // probe have access to `binaryName` (the port probe uses it to
308
306
  // recognise a fork-branded browser holding the Marionette port).
@@ -326,24 +324,12 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
326
324
  // stale/coverage refusals keep precedence over missing-path errors.)
327
325
  const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
328
326
  const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
329
- const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths);
330
- // Run incremental build if requested
331
- if (options.build) {
332
- // A path-less `test --build` runs (and packages for) the full suite;
333
- // a scoped invocation only vouches for the requested paths. A SCOPED
334
- // rebuild also cannot regenerate the compiled StaticComponents table,
335
- // so it runs the components.conf gate up-front (the path-less shape
336
- // refreshes the anchor itself and skips it).
337
- const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
338
- if (normalizedPaths.length > 0) {
339
- const previousBaseline = await readBuildBaseline(projectRoot);
340
- await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
341
- }
342
- await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, coverage);
343
- info('');
344
- }
345
- else {
346
- await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths);
327
+ const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths, { allowMixed: options.buildOnly === true });
328
+ if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
329
+ classification,
330
+ normalizedPaths,
331
+ })) {
332
+ return;
347
333
  }
348
334
  // Resolve the effective Marionette port. Operator precedence:
349
335
  // 1. `--marionette-port` (first-class option, parsed at the CLI layer)
@@ -355,22 +341,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
355
341
  // documented `--mach-arg --marionette-port=NNNN` workaround would still
356
342
  // hit the wrapper preflight refusing on 2828 before the forwarded arg
357
343
  // ever reached mach.
358
- const forwardedPort = options.machArg
359
- ? extractForwardedMarionettePort(options.machArg)
360
- : undefined;
361
- const effectivePort = options.marionettePort ?? forwardedPort;
362
- // Stale-browser probe: an interrupted earlier test run can leave a
363
- // Firefox/ForgeFresh/fork instance listening on the Marionette
364
- // control port, which breaks the next mach test launch with a
365
- // bind error that points nowhere near the real cause. Raise a
366
- // targeted refusal up front instead of letting mach surface the
367
- // generic bind failure. 2026-04-21 eval (Finding #20): a stale
368
- // `-marionette` process from `fresh/` poisoned a later test run in
369
- // the sibling `mybrowser/` workspace.
370
- await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
371
- xpcshellOnly,
372
- doctor: options.doctor === true,
373
- });
344
+ const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig.binaryName, options);
374
345
  if (options.doctor) {
375
346
  const doctorOutcome = await runDoctorPreflight({
376
347
  engineDir: paths.engine,
@@ -464,12 +435,18 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
464
435
  '(one mach invocation per argument; a directory argument keeps its files in one instance). ' +
465
436
  'Cross-argument state is NOT exercised — pass --no-shard for a combined single-instance run.');
466
437
  const generationBefore = await snapshotEngineGeneration(paths.engine);
438
+ let summary;
467
439
  try {
468
- await runShardedTests(runCtx, dispatchGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
440
+ summary = await runShardedTests(runCtx, dispatchGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
469
441
  }
470
442
  finally {
471
- await assertEngineGenerationUnchanged(paths.engine, generationBefore);
443
+ // Runs BEFORE the aggregate verdict below: a mutated engine/ emits
444
+ // `FAIL reason=inconclusive` and throws, so an invalidated run can
445
+ // never print `PASS shards=N/N` first. (A throw here masks an
446
+ // in-flight shard error, as the plain assert always did.)
447
+ await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore);
472
448
  }
449
+ finalizeShardedOutcome(summary);
473
450
  return;
474
451
  }
475
452
  const combinedDispatchPaths = dispatchGroups.flatMap((group) => group.paths);
@@ -482,7 +459,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
482
459
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
483
460
  }
484
461
  finally {
485
- await assertEngineGenerationUnchanged(paths.engine, generationBefore);
462
+ await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore);
486
463
  }
487
464
  if (canaryPath !== undefined) {
488
465
  reportCanaryOutcome(outcome);
@@ -1,12 +1,12 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
+ import { assertEngineGitReady } from '../core/engine-precondition.js';
4
5
  import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
5
- import { isGitRepository } from '../core/git.js';
6
6
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
7
7
  import { measureTokenCoverage } from '../core/token-coverage.js';
8
8
  import { getTokensCssPath } from '../core/token-manager.js';
9
- import { GeneralError } from '../errors/base.js';
9
+ import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText } from '../utils/fs.js';
11
11
  import { info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  /**
@@ -16,12 +16,7 @@ import { info, intro, outro, success, warn } from '../utils/logger.js';
16
16
  export async function tokenCoverageCommand(projectRoot) {
17
17
  intro('Token Coverage');
18
18
  const paths = getProjectPaths(projectRoot);
19
- if (!(await pathExists(paths.engine))) {
20
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
21
- }
22
- if (!(await isGitRepository(paths.engine))) {
23
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
24
- }
19
+ await assertEngineGitReady(paths.engine);
25
20
  const config = await loadConfig(projectRoot);
26
21
  const tokensCssPath = getTokensCssPath(config.binaryName);
27
22
  // Expand collapsed `?? dir/` untracked entries so untracked CSS files
@@ -153,7 +148,7 @@ async function collectFurnaceCustomCssFiles(projectRoot, engineDir, tokensCssPat
153
148
  furnaceConfig = await loadFurnaceConfig(projectRoot);
154
149
  }
155
150
  catch (error) {
156
- warn(`Could not load furnace.json for token coverage — scanning modified files only (${error.message})`);
151
+ warn(`Could not load furnace.json for token coverage — scanning modified files only (${toError(error).message})`);
157
152
  return [];
158
153
  }
159
154
  const results = [];
@@ -3,6 +3,8 @@ import type { CommandContext } from '../types/cli.js';
3
3
  /** Creates a verification tree under `.fireforge/trees/<name>`. */
4
4
  export declare function treeCreateCommand(projectRoot: string, name: string, options?: {
5
5
  forceCopy?: boolean;
6
+ withObjdir?: boolean;
7
+ waitLockSeconds?: number | undefined;
6
8
  }): Promise<void>;
7
9
  /** Prints the tree list with staleness against the current primary state. */
8
10
  export declare function treeListCommand(projectRoot: string, options?: {
@@ -11,6 +13,7 @@ export declare function treeListCommand(projectRoot: string, options?: {
11
13
  /** Removes one tree (or all of them with `--all`). */
12
14
  export declare function treeRemoveCommand(projectRoot: string, name: string | undefined, options?: {
13
15
  all?: boolean;
16
+ force?: boolean;
14
17
  }): Promise<void>;
15
18
  /** Registers the tree command group on the CLI program. */
16
19
  export declare function registerTree(program: Command, context: CommandContext): void;