@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
@@ -11,8 +11,11 @@ import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTest
11
11
  import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
12
12
  import { GeneralError } from '../errors/base.js';
13
13
  import { BuildError } from '../errors/build.js';
14
+ import { toError } from '../utils/errors.js';
14
15
  import { info } from '../utils/logger.js';
15
16
  import { getPlatform } from '../utils/platform.js';
17
+ import { escapeRegex } from '../utils/regex.js';
18
+ import { emitHarnessVerdict } from './test-verdict.js';
16
19
  function buildUnknownTestMessage(testPaths) {
17
20
  return (`mach could not discover the requested test path${testPaths.length === 1 ? '' : 's'}: ${testPaths.join(', ')}\n\n` +
18
21
  'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
@@ -51,7 +54,15 @@ function hasStaleBuildArtifactsSignal(output) {
51
54
  * failures surface the right diagnosis.
52
55
  */
53
56
  function hasForkModuleSignal(output, binaryName) {
54
- const pattern = new RegExp(`Failed to load resource:\\/\\/\\/modules\\/${binaryName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\/`, 'i');
57
+ // `binaryName` comes from fireforge.json, and config validation
58
+ // (`config-validate.ts:59-70`) bars only `..`, path separators, NUL and
59
+ // absolute paths — regex metacharacters get through. The inline escape
60
+ // this replaced wrote its character class as `[.*+?^${}()|[\\]\\\\]`,
61
+ // which closes early after the escaped backslash, so it escaped nothing:
62
+ // `my.browser` matched `myXbrowser`, and an unbalanced `(` or `[` threw a
63
+ // SyntaxError out of the failure-diagnosis path, replacing the real test
64
+ // failure with an opaque regex error.
65
+ const pattern = new RegExp(`Failed to load resource:\\/\\/\\/modules\\/${escapeRegex(binaryName)}\\/`, 'i');
55
66
  return pattern.test(output);
56
67
  }
57
68
  function buildForkModuleMessage(binaryName) {
@@ -115,7 +126,12 @@ function buildMochitestHttp3ServerMessage() {
115
126
  'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
116
127
  }
117
128
  /**
129
+ * Maps a non-zero mach exit to the most specific actionable diagnosis the
130
+ * captured output supports, throwing it as a domain error.
118
131
  *
132
+ * The branch order is load-bearing: narrower signals are checked before
133
+ * broader ones so, for example, a fork-owned module failure is not reported as
134
+ * a stale build (2026-04-21 eval Finding #14).
119
135
  */
120
136
  function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName, postRebuildContext) {
121
137
  if (result.exitCode === 0 || result.exitCode === 130)
@@ -181,6 +197,18 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
181
197
  * the regular non-zero-exit diagnosis chain.
182
198
  */
183
199
  export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless = false) {
200
+ // The FIREFORGE-VERDICT line is the LAST stdout write of the run, on the
201
+ // pass return and every throw path alike — written raw (not via clack,
202
+ // whose renderer drops output under non-TTY capture) so harness-verdict
203
+ // consumers grep one stable line instead of mach internals.
204
+ try {
205
+ applySingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless);
206
+ }
207
+ finally {
208
+ emitHarnessVerdict(outcome.verdict);
209
+ }
210
+ }
211
+ function applySingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless) {
184
212
  if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
185
213
  const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
186
214
  const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
@@ -243,7 +271,7 @@ export function diagnoseShardOutcome(outcome, path, binaryName, postRebuildConte
243
271
  return undefined;
244
272
  }
245
273
  catch (error) {
246
- const diagnosis = error instanceof Error ? error.message : String(error);
274
+ const diagnosis = toError(error).message;
247
275
  const blocks = formatFailureBlocks(outcome.verdict.realFailureBlocks);
248
276
  return blocks !== undefined ? `${blocks}\n${diagnosis}` : diagnosis;
249
277
  }
@@ -29,8 +29,16 @@ export interface HarnessClassification {
29
29
  * nonXpcshell paths) gates the Marionette preflight and the mochitest
30
30
  * client flags (FORGE F10); pathless runs keep the full-suite behavior.
31
31
  */
32
- export declare function classifyBeforeDispatch(engineDir: string, normalizedPaths: string[]): Promise<{
32
+ export declare function classifyBeforeDispatch(engineDir: string, normalizedPaths: string[], options?: {
33
+ allowMixed?: boolean;
34
+ }): Promise<{
33
35
  classification: HarnessClassification;
34
36
  xpcshellOnly: boolean;
35
37
  }>;
38
+ /**
39
+ * Prints the `--build-only` completion guidance: the union build is
40
+ * recorded as the packaging-coverage claim, so each harness half can now
41
+ * run build-less without tripping the stale/coverage gate.
42
+ */
43
+ export declare function reportBuildOnlyCompletion(classification: HarnessClassification, normalizedPaths: readonly string[]): void;
36
44
  export {};
@@ -2,12 +2,14 @@
2
2
  import { loadConfig } from '../core/config.js';
3
3
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
4
4
  import { GeneralError } from '../errors/base.js';
5
- import { success } from '../utils/logger.js';
5
+ import { info, success } from '../utils/logger.js';
6
+ import { emitHarnessVerdict } from './test-verdict.js';
6
7
  /** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
7
8
  export function assertPathlessTestMode(testPaths, options) {
8
9
  if (testPaths.length === 0 &&
9
10
  options.auto !== true &&
10
11
  options.doctor !== true &&
12
+ options.buildOnly !== true &&
11
13
  options.canary === undefined) {
12
14
  throw new GeneralError(buildPathlessTestMessage());
13
15
  }
@@ -24,6 +26,10 @@ export function assertTestModeCombinations(testPaths, options, canaryPath) {
24
26
  throw new GeneralError('No test canary path is configured.\n\n' +
25
27
  'Pass `fireforge test --canary <path>` or set `test.canaryPath` in fireforge.json.');
26
28
  }
29
+ if (options.buildOnly === true &&
30
+ (options.doctor === true || options.canary !== undefined || options.auto === true)) {
31
+ throw new GeneralError('`fireforge test --build-only` builds and exits without dispatching tests; it cannot be combined with --doctor, --canary, or --auto.');
32
+ }
27
33
  }
28
34
  /** Resolves the canary path from CLI or config. */
29
35
  export function resolveCanaryPath(options, projectConfig) {
@@ -39,18 +45,25 @@ export function canaryTimeoutSeconds(projectConfig) {
39
45
  }
40
46
  /** Emits or throws the one-word canary verdict. */
41
47
  export function reportCanaryOutcome(outcome) {
42
- if (outcome.verdict.kind === 'tests-ran-ok') {
43
- success('Canary: green');
44
- return;
45
- }
46
- if (outcome.verdict.kind === 'harness-crash' &&
47
- outcome.verdict.signature?.reason.includes('no-output timeout')) {
48
- throw new GeneralError(`Canary: hang\n\n${outcome.verdict.signature.line}`);
48
+ // Written raw as the run's last stdout line on green and throw paths
49
+ // alike — see the FIREFORGE-VERDICT contract in test-harness-crash.ts.
50
+ try {
51
+ if (outcome.verdict.kind === 'tests-ran-ok') {
52
+ success('Canary: green');
53
+ return;
54
+ }
55
+ if (outcome.verdict.kind === 'harness-crash' &&
56
+ outcome.verdict.signature?.reason.includes('no-output timeout')) {
57
+ throw new GeneralError(`Canary: hang\n\n${outcome.verdict.signature.line}`);
58
+ }
59
+ if (outcome.verdict.kind === 'harness-crash') {
60
+ throw new GeneralError(`Canary: crash\n\n${outcome.verdict.signature ? outcome.verdict.signature.line : 'Harness crashed before the canary completed.'}`);
61
+ }
62
+ throw new GeneralError('Canary: crash\n\nThe canary did not complete green; see mach output above.');
49
63
  }
50
- if (outcome.verdict.kind === 'harness-crash') {
51
- throw new GeneralError(`Canary: crash\n\n${outcome.verdict.signature ? outcome.verdict.signature.line : 'Harness crashed before the canary completed.'}`);
64
+ finally {
65
+ emitHarnessVerdict(outcome.verdict);
52
66
  }
53
- throw new GeneralError('Canary: crash\n\nThe canary did not complete green; see mach output above.');
54
67
  }
55
68
  function buildPathlessTestMessage() {
56
69
  return ('`fireforge test` now requires an explicit test path unless you choose a pathless mode.\n\n' +
@@ -91,13 +104,33 @@ function buildMixedHarnessMessage(classification) {
91
104
  * nonXpcshell paths) gates the Marionette preflight and the mochitest
92
105
  * client flags (FORGE F10); pathless runs keep the full-suite behavior.
93
106
  */
94
- export async function classifyBeforeDispatch(engineDir, normalizedPaths) {
107
+ export async function classifyBeforeDispatch(engineDir, normalizedPaths, options) {
95
108
  const classification = await classifyTestHarnesses(engineDir, normalizedPaths);
96
109
  if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
97
- throw new GeneralError(buildMixedHarnessMessage(classification));
110
+ // `--build-only` never dispatches, so a mixed request is legal there —
111
+ // packaging the union is the whole point (FORGE J9).
112
+ if (options?.allowMixed !== true) {
113
+ throw new GeneralError(buildMixedHarnessMessage(classification));
114
+ }
98
115
  }
99
116
  return {
100
117
  classification,
101
118
  xpcshellOnly: normalizedPaths.length > 0 && classification.nonXpcshell.length === 0,
102
119
  };
103
120
  }
121
+ /**
122
+ * Prints the `--build-only` completion guidance: the union build is
123
+ * recorded as the packaging-coverage claim, so each harness half can now
124
+ * run build-less without tripping the stale/coverage gate.
125
+ */
126
+ export function reportBuildOnlyCompletion(classification, normalizedPaths) {
127
+ info('Build complete. The recorded packaging coverage includes every requested path.');
128
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
129
+ info('Run each harness separately without --build:');
130
+ info(` fireforge test ${classification.xpcshell.join(' ')}`);
131
+ info(` fireforge test ${classification.nonXpcshell.join(' ')}`);
132
+ }
133
+ else if (normalizedPaths.length > 0) {
134
+ info(`Run: fireforge test ${normalizedPaths.join(' ')}`);
135
+ }
136
+ }
@@ -1,10 +1,49 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { Command } from 'commander';
3
3
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
4
- import { GeneralError } from '../errors/base.js';
5
- import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
4
+ import { GeneralError, LockContentionError } from '../errors/base.js';
5
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
6
6
  import { testCommand } from './test.js';
7
7
  import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
8
+ import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
9
+ const TEST_HELP_TEXT = [
10
+ '',
11
+ '[paths...] semantics: a directory argument selects EXACTLY that',
12
+ 'directory. FireForge enumerates the test files of exactly that',
13
+ 'directory and passes the explicit file list to mach, because mach',
14
+ 'resolves test paths by string prefix and a bare directory name',
15
+ 'silently sweeps in sibling directories sharing the prefix (e.g.',
16
+ 'foo also running foo-extras) — an explicit file list cannot',
17
+ 'prefix-match anything. The directory still runs as ONE mach',
18
+ 'invocation (one browser instance), so cross-file state carries',
19
+ 'within it. When prefix-named siblings exist, the excluded',
20
+ 'directories are listed with their test-file counts; pass them as',
21
+ 'separate paths to include them.',
22
+ '',
23
+ 'Multiple path arguments shard into sequential isolated harness',
24
+ 'runs (one browser instance per argument) by default, which does',
25
+ 'not exercise cross-argument state; --no-shard restores the',
26
+ 'combined single-instance invocation.',
27
+ '',
28
+ 'Machine-readable verdict: every run (single, sharded, --canary,',
29
+ '--doctor) ends with exactly one raw stdout line',
30
+ ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
31
+ ' no-tests|preflight|inconclusive|lock-timeout] [checks=<n>]',
32
+ ' [unexpected=<n>] [shards=<p>/<t>]',
33
+ 'The status follows the harness classifier, not the raw exit code:',
34
+ 'a crash-classified run says FAIL reason=crash even at exit 0, and',
35
+ 'a green-summary-override pass says PASS despite a non-zero mach',
36
+ 'exit. reason=preflight covers any failure before the harness ran',
37
+ '(missing/stale build, invalid paths, port conflicts, config',
38
+ 'errors); reason=inconclusive means engine/ changed while the',
39
+ 'tests ran, so the harness result cannot be trusted;',
40
+ 'reason=lock-timeout means the run never started because another',
41
+ 'engine-mutating command held the lock past the wait budget. A sharded',
42
+ 'aggregate reason is the most structural failing shard (crash,',
43
+ 'then no-tests, then test-failures). Count keys are omitted when',
44
+ 'the embedded summary did not print them. Key on this line',
45
+ 'instead of mach internals.',
46
+ ].join('\n');
8
47
  /** Registers the test command on the CLI program. */
9
48
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
10
49
  const test = program
@@ -12,16 +51,15 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
12
51
  .description('Run tests via mach test')
13
52
  .option('--headless', 'Run tests in headless mode')
14
53
  .option('--build', 'Run incremental UI build before testing')
54
+ .option('--build-only', 'Run the scoped pre-test build (packaging exactly [paths...], mixed harnesses allowed) and exit without dispatching tests — one build covers both harness halves, then run each half without --build')
55
+ .option('--extend-coverage', 'With --build/--build-only and explicit paths, union those paths into the recorded test-packaging coverage instead of replacing it, so an earlier scoped build stays covered. Refused when the build anchor moved (engine HEAD, mozconfig, or a previously fingerprinted packageable file changed).')
15
56
  .option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
16
57
  .option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
17
58
  .option('--allow-stale-components', 'Run tests despite components.conf changes that only a full "fireforge build" compiles in (the packaged child process will resolve the OLD StaticComponents table)')
18
- .option('--kill-stale-marionette', 'Terminate a recognized stale browser process holding the Marionette port before running tests')
59
+ .option('--kill-stale-marionette', 'Terminate a recognized stale browser from this objdir or one holding the Marionette port before running tests')
19
60
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
20
61
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
21
- .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', (value, acc) => {
22
- acc.push(value);
23
- return acc;
24
- }, [])
62
+ .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
25
63
  .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)}.`, commanderArgParser((raw) => {
26
64
  const n = Number.parseInt(raw, 10);
27
65
  if (!Number.isFinite(n) || n < 0 || n > 10) {
@@ -39,27 +77,21 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
39
77
  }
40
78
  return n;
41
79
  }))
42
- .addHelpText('after', [
43
- '',
44
- '[paths...] semantics: a directory argument selects EXACTLY that',
45
- 'directory. FireForge enumerates the test files of exactly that',
46
- 'directory and passes the explicit file list to mach, because mach',
47
- 'resolves test paths by string prefix and a bare directory name',
48
- 'silently sweeps in sibling directories sharing the prefix (e.g.',
49
- 'foo also running foo-extras) — an explicit file list cannot',
50
- 'prefix-match anything. The directory still runs as ONE mach',
51
- 'invocation (one browser instance), so cross-file state carries',
52
- 'within it. When prefix-named siblings exist, the excluded',
53
- 'directories are listed with their test-file counts; pass them as',
54
- 'separate paths to include them.',
55
- '',
56
- 'Multiple path arguments shard into sequential isolated harness',
57
- 'runs (one browser instance per argument) by default, which does',
58
- 'not exercise cross-argument state; --no-shard restores the',
59
- 'combined single-instance invocation.',
60
- ].join('\n'));
80
+ .addHelpText('after', TEST_HELP_TEXT);
61
81
  addWaitLockOption(test).action(withErrorHandling(async (paths, options) => {
62
82
  const projectRoot = getProjectRoot();
63
- await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
83
+ // The engine session lock is acquired outside testCommand's own
84
+ // exactly-one-verdict guarantee, so a lock failure must emit here or
85
+ // the run ends with no FIREFORGE-VERDICT line at all.
86
+ resetVerdictEmission();
87
+ try {
88
+ await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
89
+ }
90
+ catch (error) {
91
+ if (!verdictEmitted()) {
92
+ emitFailVerdict(error instanceof LockContentionError ? 'lock-timeout' : 'preflight');
93
+ }
94
+ throw error;
95
+ }
64
96
  }));
65
97
  }
@@ -74,13 +74,39 @@ export interface ShardOutcome {
74
74
  label: string;
75
75
  outcome: TestRunOutcome;
76
76
  }
77
+ /** Aggregate result of a sharded run, verdict emission deferred to the caller. */
78
+ export interface ShardedRunSummary {
79
+ shards: ShardOutcome[];
80
+ passed: number;
81
+ total: number;
82
+ /** Aggregate classifier verdict — see {@link deriveAggregateShardVerdict}. */
83
+ aggregate: HarnessRunVerdict;
84
+ }
85
+ /**
86
+ * Derives the aggregate classification for a sharded run: all-green is
87
+ * `tests-ran-ok`; otherwise the most structural failure wins — a crashed
88
+ * shard makes the whole run `harness-crash`, then `no-tests`, then plain
89
+ * `test-failures` — so the aggregate `reason=` keeps the classifier-driven
90
+ * contract instead of collapsing every failure to `test-failures`.
91
+ */
92
+ export declare function deriveAggregateShardVerdict(shards: ShardOutcome[]): HarnessRunVerdict;
77
93
  /**
78
94
  * Runs each requested path argument as its own sequential harness
79
95
  * invocation (a directory argument's enumerated files stay together in
80
96
  * one invocation — see {@link ShardGroup}) and prints an aggregate
81
97
  * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
82
98
  * (which receives the throwing diagnosis chain from the command layer)
83
- * but downgraded to warnings so every shard runs; a single aggregate
84
- * error is thrown at the end when any shard did not pass.
99
+ * but downgraded to warnings so every shard runs. The aggregate verdict
100
+ * line and the aggregate error are NOT produced here — the caller runs
101
+ * the engine-generation integrity check first and then calls
102
+ * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
103
+ * `engine/` mutation can never print `PASS` before the check fails.
104
+ */
105
+ export declare function runShardedTests(ctx: TestRunContext, groups: ShardGroup[], diagnoseShardFailure: (outcome: TestRunOutcome, label: string) => string | undefined): Promise<ShardedRunSummary>;
106
+ /**
107
+ * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
108
+ * throws the aggregate error when any shard did not pass — the same
109
+ * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
110
+ * the engine-generation check succeeded.
85
111
  */
86
- export declare function runShardedTests(ctx: TestRunContext, groups: ShardGroup[], diagnoseShardFailure: (outcome: TestRunOutcome, label: string) => string | undefined): Promise<void>;
112
+ export declare function finalizeShardedOutcome(summary: ShardedRunSummary): void;
@@ -19,6 +19,7 @@ import { BuildError } from '../errors/build.js';
19
19
  import { info, note, warn } from '../utils/logger.js';
20
20
  import { getPlatform } from '../utils/platform.js';
21
21
  import { maybeInjectAppdirArg } from './test-appdir.js';
22
+ import { emitHarnessVerdict } from './test-verdict.js';
22
23
  /** Default bounded retry budget for recognized harness crashes. */
23
24
  export const DEFAULT_HARNESS_RETRIES = 2;
24
25
  /** Resolves the capturing mach dispatcher for a suite. */
@@ -78,14 +79,34 @@ const SHARD_STATUS_LABEL = {
78
79
  'harness-crash': 'CRASH',
79
80
  'no-tests': 'NO-TESTS',
80
81
  };
82
+ /**
83
+ * Derives the aggregate classification for a sharded run: all-green is
84
+ * `tests-ran-ok`; otherwise the most structural failure wins — a crashed
85
+ * shard makes the whole run `harness-crash`, then `no-tests`, then plain
86
+ * `test-failures` — so the aggregate `reason=` keeps the classifier-driven
87
+ * contract instead of collapsing every failure to `test-failures`.
88
+ */
89
+ export function deriveAggregateShardVerdict(shards) {
90
+ const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
91
+ if (kinds.has('harness-crash'))
92
+ return { kind: 'harness-crash' };
93
+ if (kinds.has('no-tests'))
94
+ return { kind: 'no-tests' };
95
+ if (kinds.has('test-failures'))
96
+ return { kind: 'test-failures' };
97
+ return { kind: 'tests-ran-ok' };
98
+ }
81
99
  /**
82
100
  * Runs each requested path argument as its own sequential harness
83
101
  * invocation (a directory argument's enumerated files stay together in
84
102
  * one invocation — see {@link ShardGroup}) and prints an aggregate
85
103
  * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
86
104
  * (which receives the throwing diagnosis chain from the command layer)
87
- * but downgraded to warnings so every shard runs; a single aggregate
88
- * error is thrown at the end when any shard did not pass.
105
+ * but downgraded to warnings so every shard runs. The aggregate verdict
106
+ * line and the aggregate error are NOT produced here — the caller runs
107
+ * the engine-generation integrity check first and then calls
108
+ * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
109
+ * `engine/` mutation can never print `PASS` before the check fails.
89
110
  */
90
111
  export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
91
112
  const shards = [];
@@ -111,8 +132,24 @@ export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
111
132
  (outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : ''));
112
133
  const failing = shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
113
134
  note(`${lines.join('\n')}\n\n${shards.length - failing.length}/${shards.length} shard(s) passed`, 'Sharded Test Summary');
114
- if (failing.length > 0) {
115
- throw new BuildError(`${failing.length} of ${shards.length} sharded test run(s) did not pass: ` +
135
+ return {
136
+ shards,
137
+ passed: shards.length - failing.length,
138
+ total: shards.length,
139
+ aggregate: deriveAggregateShardVerdict(shards),
140
+ };
141
+ }
142
+ /**
143
+ * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
144
+ * throws the aggregate error when any shard did not pass — the same
145
+ * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
146
+ * the engine-generation check succeeded.
147
+ */
148
+ export function finalizeShardedOutcome(summary) {
149
+ emitHarnessVerdict(summary.aggregate, { passed: summary.passed, total: summary.total });
150
+ if (summary.passed < summary.total) {
151
+ const failing = summary.shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
152
+ throw new BuildError(`${failing.length} of ${summary.total} sharded test run(s) did not pass: ` +
116
153
  `${failing.map(({ label }) => label).join(', ')}. ` +
117
154
  'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test');
118
155
  }
@@ -0,0 +1,32 @@
1
+ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
2
+ /**
3
+ * Reason codes the emission layer can put on a `FIREFORGE-VERDICT: FAIL`
4
+ * line. The first three mirror the harness classifier; `preflight` covers
5
+ * every failure before harness classification (missing/stale builds,
6
+ * invalid paths, port conflicts, config errors, spawn failures),
7
+ * `inconclusive` marks a run whose harness result exists but cannot be
8
+ * trusted because `engine/` changed (or became unprobeable one-sidedly)
9
+ * while the tests ran, and `lock-timeout` marks a run that never started
10
+ * because the engine session lock stayed contended past the wait budget.
11
+ */
12
+ export type FireforgeVerdictReason = 'crash' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout';
13
+ /**
14
+ * Re-arms the verdict sink for a new run. Called at `testCommand` entry so
15
+ * repeated programmatic invocations (and tests) each get their own
16
+ * exactly-one-line guarantee.
17
+ */
18
+ export declare function resetVerdictEmission(): void;
19
+ /** True once this run has written its `FIREFORGE-VERDICT:` line. */
20
+ export declare function verdictEmitted(): boolean;
21
+ /**
22
+ * Emits the classifier-derived verdict for a completed harness run, with
23
+ * the aggregate `shards=<passed>/<total>` suffix for sharded runs.
24
+ */
25
+ export declare function emitHarnessVerdict(verdict: HarnessRunVerdict, shards?: {
26
+ passed: number;
27
+ total: number;
28
+ }): void;
29
+ /** Emits the pathless `--doctor` PASS verdict. */
30
+ export declare function emitPassVerdict(): void;
31
+ /** Emits a FAIL verdict carrying an emission-layer reason code. */
32
+ export declare function emitFailVerdict(reason: FireforgeVerdictReason): void;
@@ -0,0 +1,48 @@
1
+ import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
2
+ import { setStdoutSealed } from '../utils/logger.js';
3
+ let emitted = false;
4
+ /**
5
+ * Re-arms the verdict sink for a new run. Called at `testCommand` entry so
6
+ * repeated programmatic invocations (and tests) each get their own
7
+ * exactly-one-line guarantee.
8
+ */
9
+ export function resetVerdictEmission() {
10
+ emitted = false;
11
+ setStdoutSealed(false);
12
+ }
13
+ /** True once this run has written its `FIREFORGE-VERDICT:` line. */
14
+ export function verdictEmitted() {
15
+ return emitted;
16
+ }
17
+ /**
18
+ * Writes the run's single verdict line — raw stdout (clack's renderer can
19
+ * drop output under non-TTY capture), first write wins. Every later call
20
+ * is a no-op: the writer closest to the harness result runs first, so the
21
+ * fallback layers (the engine-generation guard, the preflight wrapper)
22
+ * can emit unconditionally without risking a second line. Writing also
23
+ * seals stdout: the verdict must stay the run's LAST stdout write, so all
24
+ * subsequent logger output — including the CLI-boundary error/cancel
25
+ * rendering after an emit-then-throw path — routes to stderr.
26
+ */
27
+ function writeVerdictLine(line) {
28
+ if (emitted)
29
+ return;
30
+ emitted = true;
31
+ process.stdout.write(`${line}\n`);
32
+ setStdoutSealed(true);
33
+ }
34
+ /**
35
+ * Emits the classifier-derived verdict for a completed harness run, with
36
+ * the aggregate `shards=<passed>/<total>` suffix for sharded runs.
37
+ */
38
+ export function emitHarnessVerdict(verdict, shards) {
39
+ writeVerdictLine(formatFireforgeVerdictLine(verdict, shards));
40
+ }
41
+ /** Emits the pathless `--doctor` PASS verdict. */
42
+ export function emitPassVerdict() {
43
+ writeVerdictLine('FIREFORGE-VERDICT: PASS');
44
+ }
45
+ /** Emits a FAIL verdict carrying an emission-layer reason code. */
46
+ export function emitFailVerdict(reason) {
47
+ writeVerdictLine(`FIREFORGE-VERDICT: FAIL reason=${reason}`);
48
+ }
@@ -1,6 +1,15 @@
1
1
  import type { TestOptions } from '../types/commands/index.js';
2
2
  /**
3
3
  * Runs the test command to execute mach tests.
4
+ *
5
+ * Owns the run's exactly-one-`FIREFORGE-VERDICT:`-line guarantee: the sink
6
+ * is re-armed at entry, and any failure that reaches this boundary without
7
+ * an inner writer having emitted — the preflight ladder (missing engine or
8
+ * build, config errors, stale-build and port gates, missing paths), and a
9
+ * harness process that failed to start — emits `FAIL reason=preflight`,
10
+ * since no harness classification exists for such a run. Writers closer to
11
+ * the harness (doctor, canary, single, sharded, the engine-generation
12
+ * guard) emit first and win.
4
13
  * @param projectRoot - Root directory of the project
5
14
  * @param testPaths - Test file or directory paths
6
15
  * @param options - Test options