@hominis/fireforge 0.50.0 → 0.52.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 (212) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/status-output.js +5 -1
  63. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  64. package/dist/src/commands/test-build-furnace.js +8 -2
  65. package/dist/src/commands/test-build-phase.d.ts +2 -0
  66. package/dist/src/commands/test-build-phase.js +34 -17
  67. package/dist/src/commands/test-checkjs.js +3 -2
  68. package/dist/src/commands/test-dispatch.d.ts +39 -0
  69. package/dist/src/commands/test-dispatch.js +220 -0
  70. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  71. package/dist/src/commands/test-furnace-reach.js +132 -0
  72. package/dist/src/commands/test-orchestrate.d.ts +14 -0
  73. package/dist/src/commands/test-orchestrate.js +25 -4
  74. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  75. package/dist/src/commands/test-perf-scope.js +55 -0
  76. package/dist/src/commands/test-prelock.js +100 -4
  77. package/dist/src/commands/test-register.js +18 -3
  78. package/dist/src/commands/test-results.d.ts +62 -0
  79. package/dist/src/commands/test-results.js +62 -0
  80. package/dist/src/commands/test-run.d.ts +13 -55
  81. package/dist/src/commands/test-run.js +73 -101
  82. package/dist/src/commands/test-shards.d.ts +110 -0
  83. package/dist/src/commands/test-shards.js +255 -0
  84. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  85. package/dist/src/commands/test-stale-gate.js +11 -2
  86. package/dist/src/commands/test-verdict.d.ts +2 -1
  87. package/dist/src/commands/test.d.ts +1 -1
  88. package/dist/src/commands/test.js +68 -75
  89. package/dist/src/commands/token-lint.js +48 -8
  90. package/dist/src/commands/verify.js +9 -2
  91. package/dist/src/core/build-baseline-types.d.ts +13 -0
  92. package/dist/src/core/build-baseline-validate.js +9 -0
  93. package/dist/src/core/build-baseline.js +7 -0
  94. package/dist/src/core/build-prepare.d.ts +6 -0
  95. package/dist/src/core/build-prepare.js +29 -3
  96. package/dist/src/core/config-paths.d.ts +1 -1
  97. package/dist/src/core/config-paths.js +2 -0
  98. package/dist/src/core/config-validate-check.js +83 -7
  99. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  100. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  101. package/dist/src/core/config-validate-token-scales.js +47 -5
  102. package/dist/src/core/config-validate.js +6 -0
  103. package/dist/src/core/coverage-extend.js +2 -2
  104. package/dist/src/core/coverage-records.d.ts +11 -0
  105. package/dist/src/core/coverage-records.js +34 -0
  106. package/dist/src/core/engine-session-lock.d.ts +6 -0
  107. package/dist/src/core/engine-session-lock.js +10 -2
  108. package/dist/src/core/file-lock-shared.d.ts +57 -0
  109. package/dist/src/core/file-lock-shared.js +117 -0
  110. package/dist/src/core/file-lock.d.ts +9 -0
  111. package/dist/src/core/file-lock.js +58 -2
  112. package/dist/src/core/furnace-apply.js +10 -10
  113. package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
  114. package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
  115. package/dist/src/core/furnace-config-validate.js +7 -1
  116. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  117. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  118. package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
  119. package/dist/src/core/furnace-validate-accessibility.js +75 -30
  120. package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
  121. package/dist/src/core/furnace-validate-exceptions.js +59 -4
  122. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  123. package/dist/src/core/history-log-file.js +8 -1
  124. package/dist/src/core/line-prefixer.d.ts +18 -0
  125. package/dist/src/core/line-prefixer.js +34 -0
  126. package/dist/src/core/mach.d.ts +7 -0
  127. package/dist/src/core/mach.js +24 -9
  128. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  129. package/dist/src/core/module-resolution-preflight.js +24 -6
  130. package/dist/src/core/negative-control-compiled.d.ts +36 -0
  131. package/dist/src/core/negative-control-compiled.js +87 -0
  132. package/dist/src/core/negative-control-diff.d.ts +14 -2
  133. package/dist/src/core/negative-control-diff.js +49 -6
  134. package/dist/src/core/negative-control.d.ts +12 -0
  135. package/dist/src/core/negative-control.js +48 -5
  136. package/dist/src/core/park-store.d.ts +63 -0
  137. package/dist/src/core/park-store.js +216 -0
  138. package/dist/src/core/patch-export-update.d.ts +4 -0
  139. package/dist/src/core/patch-export-update.js +12 -3
  140. package/dist/src/core/patch-export.d.ts +3 -2
  141. package/dist/src/core/patch-export.js +21 -14
  142. package/dist/src/core/patch-files.js +26 -8
  143. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  144. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  145. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  146. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  147. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  148. package/dist/src/core/patch-lint-checkjs.js +13 -3
  149. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  150. package/dist/src/core/patch-lint-cross.js +5 -0
  151. package/dist/src/core/patch-lint-css.js +14 -10
  152. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  153. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  154. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  155. package/dist/src/core/patch-lint-ignore.js +173 -43
  156. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  157. package/dist/src/core/patch-lint-ownership.js +26 -0
  158. package/dist/src/core/patch-lint-size.js +9 -1
  159. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  160. package/dist/src/core/patch-lint-token-scale.js +206 -29
  161. package/dist/src/core/patch-lint.js +2 -2
  162. package/dist/src/core/patch-manifest-io.js +8 -1
  163. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  164. package/dist/src/core/patch-manifest-key-order.js +61 -0
  165. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  166. package/dist/src/core/patch-manifest-stage.js +96 -0
  167. package/dist/src/core/patch-manifest-validate.js +27 -1
  168. package/dist/src/core/patch-order-legal.d.ts +2 -1
  169. package/dist/src/core/patch-order-legal.js +7 -2
  170. package/dist/src/core/patch-policy.d.ts +6 -2
  171. package/dist/src/core/patch-policy.js +14 -7
  172. package/dist/src/core/patch-retired.d.ts +26 -4
  173. package/dist/src/core/patch-retired.js +40 -2
  174. package/dist/src/core/run-log.d.ts +6 -0
  175. package/dist/src/core/run-log.js +17 -1
  176. package/dist/src/core/test-harness-crash.d.ts +2 -1
  177. package/dist/src/core/test-harness-crash.js +5 -8
  178. package/dist/src/core/test-harness-signature.d.ts +16 -0
  179. package/dist/src/core/test-harness-signature.js +19 -1
  180. package/dist/src/core/test-raw-log.d.ts +66 -0
  181. package/dist/src/core/test-raw-log.js +122 -0
  182. package/dist/src/core/test-run-completeness.d.ts +47 -7
  183. package/dist/src/core/test-run-completeness.js +70 -23
  184. package/dist/src/core/test-stale-check.d.ts +10 -1
  185. package/dist/src/core/test-stale-check.js +15 -1
  186. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  187. package/dist/src/core/test-xpcshell-retry.js +2 -1
  188. package/dist/src/core/token-lint.d.ts +5 -2
  189. package/dist/src/core/token-lint.js +8 -3
  190. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  191. package/dist/src/core/token-sibling-sheets.js +119 -0
  192. package/dist/src/core/tree-guard.js +2 -0
  193. package/dist/src/core/typecheck-shim.d.ts +6 -1
  194. package/dist/src/core/typecheck-shim.js +139 -2
  195. package/dist/src/core/typecheck.js +3 -1
  196. package/dist/src/index.d.ts +9 -3
  197. package/dist/src/index.js +4 -1
  198. package/dist/src/types/commands/index.d.ts +2 -2
  199. package/dist/src/types/commands/options.d.ts +68 -7
  200. package/dist/src/types/commands/patches.d.ts +43 -0
  201. package/dist/src/types/config.d.ts +74 -2
  202. package/dist/src/types/furnace.d.ts +29 -0
  203. package/dist/src/types/index.d.ts +1 -1
  204. package/dist/src/utils/options.d.ts +3 -2
  205. package/dist/src/utils/options.js +9 -4
  206. package/docs/configuration.md +110 -9
  207. package/docs/furnace.md +61 -1
  208. package/docs/lifecycle-invariants.md +13 -11
  209. package/docs/machine-output.md +68 -18
  210. package/docs/patch-workflow.md +174 -13
  211. package/docs/testing.md +89 -5
  212. package/package.json +3 -3
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge test --build --furnace-apply reached`: deploy only the Furnace
4
+ * components a requested test reaches.
5
+ *
6
+ * The pre-test build's Furnace apply runs over the whole `components/`
7
+ * tree, so on a shared engine a peer's `test --build` deployed every
8
+ * session's undeployed component edits into the shared objdir, including
9
+ * edits another session was holding back for a before-capture. `reached`
10
+ * keeps the apply to the components the run can observe and names the rest.
11
+ *
12
+ * "Reaches" is textual, and errs towards applying: a component is reached
13
+ * when a requested test file, a `head*.js` beside it, or any test file
14
+ * under a requested directory names the component's tag, or when the test
15
+ * path itself names it. A component no test names cannot change what the
16
+ * run observes through its own code, so leaving its undeployed edit out
17
+ * keeps the run honest about what it packaged.
18
+ */
19
+ import { readdir, readFile, stat } from 'node:fs/promises';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { listStaleFurnaceComponents } from '../core/furnace-staleness.js';
22
+ import { toError } from '../utils/errors.js';
23
+ import { verbose } from '../utils/logger.js';
24
+ /** Files read per run, so a huge directory request cannot stall the build. */
25
+ const MAX_SCANNED_FILES = 4000;
26
+ const SCANNED_EXTENSIONS = /\.(?:m?js|html|xhtml|toml|ini)$/;
27
+ /**
28
+ * Resolves the mode from the flag and `test.furnaceApply`. `false`
29
+ * (`--no-furnace-apply`) is `none`.
30
+ */
31
+ export function resolveFurnaceApplyMode(options, config) {
32
+ if (options.furnaceApply === false)
33
+ return 'none';
34
+ if (typeof options.furnaceApply === 'string')
35
+ return options.furnaceApply;
36
+ return config.test?.furnaceApply ?? 'all';
37
+ }
38
+ async function collectFiles(path, out) {
39
+ if (out.length >= MAX_SCANNED_FILES)
40
+ return;
41
+ let info;
42
+ try {
43
+ info = await stat(path);
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ if (info.isFile()) {
49
+ if (SCANNED_EXTENSIONS.test(path))
50
+ out.push(path);
51
+ return;
52
+ }
53
+ if (!info.isDirectory())
54
+ return;
55
+ let entries;
56
+ try {
57
+ entries = await readdir(path);
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ for (const entry of entries.sort()) {
63
+ if (out.length >= MAX_SCANNED_FILES)
64
+ return;
65
+ await collectFiles(join(path, entry), out);
66
+ }
67
+ }
68
+ /** The files whose text decides reach: the request plus `head*.js` beside each file. */
69
+ async function reachFiles(engineDir, testPaths) {
70
+ const files = [];
71
+ for (const path of testPaths) {
72
+ const absolute = join(engineDir, path);
73
+ await collectFiles(absolute, files);
74
+ try {
75
+ if ((await stat(absolute)).isFile()) {
76
+ const dir = dirname(absolute);
77
+ for (const entry of await readdir(dir)) {
78
+ if (/^head.*\.js$/.test(entry))
79
+ files.push(join(dir, entry));
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // A missing path is refused later by the path-existence check.
85
+ }
86
+ }
87
+ return [...new Set(files)];
88
+ }
89
+ /**
90
+ * The stale components a request reaches, and the stale ones it does not.
91
+ *
92
+ * @param projectRoot - Project root
93
+ * @param engineDir - Absolute engine directory
94
+ * @param testPaths - Engine-relative requested paths
95
+ */
96
+ export async function partitionReachedComponents(projectRoot, engineDir, testPaths) {
97
+ let stale;
98
+ try {
99
+ stale = await listStaleFurnaceComponents(projectRoot);
100
+ }
101
+ catch (error) {
102
+ verbose(`Furnace pending-edit probe failed: ${toError(error).message}`);
103
+ return { reached: [], skipped: [] };
104
+ }
105
+ if (stale.length === 0)
106
+ return { reached: [], skipped: [] };
107
+ const names = new Set();
108
+ for (const path of testPaths) {
109
+ for (const name of stale)
110
+ if (path.includes(name))
111
+ names.add(name);
112
+ }
113
+ for (const file of await reachFiles(engineDir, testPaths)) {
114
+ if (names.size === stale.length)
115
+ break;
116
+ let text;
117
+ try {
118
+ text = await readFile(file, 'utf8');
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ for (const name of stale) {
124
+ if (text.includes(name) || basename(file).includes(name))
125
+ names.add(name);
126
+ }
127
+ }
128
+ return {
129
+ reached: stale.filter((name) => names.has(name)),
130
+ skipped: stale.filter((name) => !names.has(name)),
131
+ };
132
+ }
@@ -25,6 +25,20 @@ export interface TestRunHooks {
25
25
  coveragePaths?: string[];
26
26
  /** Runs after every preflight gate and the pre-test build, right before dispatch. */
27
27
  beforeDispatch?: () => Promise<void>;
28
+ /**
29
+ * Runs right before the pre-test build: a negative control on compiled
30
+ * source, which only reaches the binary when the build compiles it.
31
+ */
32
+ beforeBuild?: () => Promise<void>;
33
+ /**
34
+ * Why the pre-test build must be a full `mach build`: `mach build faster`
35
+ * compiles nothing, so a compiled control's binary needs the full one.
36
+ */
37
+ fullBuildReason?: string;
38
+ /** `--repeat` run this sub-run is (1-based), for `--results`. */
39
+ repetition?: number;
40
+ /** Where dispatches write their mozlog raw logs (`--results`, `--shard-size`). */
41
+ rawLogDir?: string;
28
42
  }
29
43
  /** The single-run body (`runTestCommandBody`), injected to avoid an import cycle. */
30
44
  export type TestRunBody = (paths: string[], options: TestOptions, hooks: TestRunHooks) => Promise<void>;
@@ -17,15 +17,16 @@
17
17
  */
18
18
  import { getProjectPaths } from '../core/config.js';
19
19
  import { applyNegativeControl, planNegativeControl, recoverPendingNegativeControl, restoreNegativeControl, } from '../core/negative-control.js';
20
+ import { formatCompiledControlBuildNotice, formatCompiledControlRefusal, } from '../core/negative-control-compiled.js';
20
21
  import { getActiveRunLogPath, writeToActiveRunLog } from '../core/run-log.js';
21
- import { GeneralError, InvalidArgumentError } from '../errors/base.js';
22
+ import { GeneralError, InvalidArgumentError, PreflightRefusalError } from '../errors/base.js';
22
23
  import { TestFailureError } from '../errors/build.js';
23
24
  import { toError } from '../utils/errors.js';
24
25
  import { info, notice, success } from '../utils/logger.js';
25
26
  import { stripEnginePrefix } from '../utils/paths.js';
26
27
  import { classifyTestHarnesses } from './test-modes.js';
27
28
  import { deriveControlOutcome, describeControlOutcome, formatAggregateBody, formatRunLine, parseCapturedVerdict, worstRun, } from './test-multi-verdict.js';
28
- import { emitAggregateVerdict, emitFailVerdict, setVerdictCapture, verdictEmitted, } from './test-verdict.js';
29
+ import { emitAggregateVerdict, emitFailVerdict, setVerdictCapture, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
29
30
  function assertMultiRunUsage(testPaths, options) {
30
31
  const flag = options.negativeControl !== undefined ? '--negative-control' : '--repeat';
31
32
  if (testPaths.length === 0) {
@@ -84,7 +85,10 @@ async function planRuns(engineDir, testPaths, options) {
84
85
  ...(group.harness === undefined ? {} : { harness: group.harness }),
85
86
  paths: group.paths,
86
87
  options: first ? options : laterRunOptions(options),
87
- hooks: first && union !== undefined ? { coveragePaths: union } : {},
88
+ hooks: {
89
+ repetition,
90
+ ...(first && union !== undefined ? { coveragePaths: union } : {}),
91
+ },
88
92
  });
89
93
  }
90
94
  }
@@ -158,6 +162,16 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
158
162
  : await planNegativeControl(projectRoot, projectPaths.engine, options.negativeControl, {
159
163
  deferApplyCheck: options.build === true,
160
164
  });
165
+ // A compiled target only reaches the binary through a build, so its
166
+ // control goes in before the first run's pre-test build.
167
+ const compiled = plan?.compiled;
168
+ const compiledControl = compiled !== undefined;
169
+ if (compiledControl) {
170
+ if (options.build !== true) {
171
+ throw new PreflightRefusalError(formatCompiledControlRefusal(compiled), 'negative-control');
172
+ }
173
+ setVerdictRunAttribute('control-build', 'compiled');
174
+ }
161
175
  const results = [];
162
176
  let restoreError;
163
177
  for (const [index, run] of runs.entries()) {
@@ -170,7 +184,14 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
170
184
  try {
171
185
  await body(run.paths, run.options, {
172
186
  ...run.hooks,
173
- ...(plan === undefined ? {} : { beforeDispatch: controlHook(controlPaths, plan, slot) }),
187
+ ...(plan === undefined
188
+ ? {}
189
+ : compiledControl && index === 0
190
+ ? {
191
+ beforeBuild: controlHook(controlPaths, plan, slot),
192
+ fullBuildReason: formatCompiledControlBuildNotice(compiled),
193
+ }
194
+ : { beforeDispatch: controlHook(controlPaths, plan, slot) }),
174
195
  });
175
196
  }
176
197
  catch (caught) {
@@ -27,3 +27,24 @@ export declare function describePerfStaleImpact(engineDir: string, stalePaths: r
27
27
  * @param engineDir - Absolute engine directory
28
28
  */
29
29
  export declare function perfGenerationTolerance(engineDir: string): (before: string, after: string) => Promise<boolean>;
30
+ /**
31
+ * Tolerance for `assertEngineGenerationUnchanged` on an ordinary run: true
32
+ * (with a warning, and `drift-tolerated=<n>` on the verdict) when HEAD did
33
+ * not move and every changed path is one the running browser cannot see.
34
+ *
35
+ * A peer deleting an unrelated chrome script used to make a whole run
36
+ * inconclusive. What a running browser can see mid-run is narrower than
37
+ * "anything under engine/": a file the objdir links to its source (`live`),
38
+ * one the harness stages at dispatch (`at-dispatch`), and the run's own
39
+ * test files and the staging inputs their manifests name. A compiled
40
+ * source, or a file the build copied into the package, is already fixed in
41
+ * the running binary or package; the next run's stale-build gate judges it.
42
+ *
43
+ * Perf runs keep {@link perfGenerationTolerance}: a sample is also void
44
+ * when a packaged path changes.
45
+ *
46
+ * @param engineDir - Absolute engine directory
47
+ * @param requested - Normalized request paths of the run
48
+ * @param onTolerated - Receives the tolerated path count
49
+ */
50
+ export declare function runGenerationTolerance(engineDir: string, requested: readonly string[], onTolerated: (count: number) => void): (before: string, after: string) => Promise<boolean>;
@@ -81,3 +81,58 @@ export function perfGenerationTolerance(engineDir) {
81
81
  return true;
82
82
  };
83
83
  }
84
+ /**
85
+ * Tolerance for `assertEngineGenerationUnchanged` on an ordinary run: true
86
+ * (with a warning, and `drift-tolerated=<n>` on the verdict) when HEAD did
87
+ * not move and every changed path is one the running browser cannot see.
88
+ *
89
+ * A peer deleting an unrelated chrome script used to make a whole run
90
+ * inconclusive. What a running browser can see mid-run is narrower than
91
+ * "anything under engine/": a file the objdir links to its source (`live`),
92
+ * one the harness stages at dispatch (`at-dispatch`), and the run's own
93
+ * test files and the staging inputs their manifests name. A compiled
94
+ * source, or a file the build copied into the package, is already fixed in
95
+ * the running binary or package; the next run's stale-build gate judges it.
96
+ *
97
+ * Perf runs keep {@link perfGenerationTolerance}: a sample is also void
98
+ * when a packaged path changes.
99
+ *
100
+ * @param engineDir - Absolute engine directory
101
+ * @param requested - Normalized request paths of the run
102
+ * @param onTolerated - Receives the tolerated path count
103
+ */
104
+ export function runGenerationTolerance(engineDir, requested, onTolerated) {
105
+ return async (before, after) => {
106
+ const delta = generationDeltaPaths(before, after);
107
+ if (delta.headMoved || delta.paths.length === 0)
108
+ return false;
109
+ const index = await loadEngineInstallIndex(engineDir);
110
+ if (index === undefined)
111
+ return false;
112
+ const inputs = [...requested];
113
+ for (const path of requested)
114
+ inputs.push(...(await collectStagingInputs(engineDir, path)));
115
+ for (const path of delta.paths) {
116
+ const bare = path.endsWith('/') ? path.slice(0, -1) : path;
117
+ if (inputs.some((input) => isUnderPath(bare, input) || isUnderPath(input, bare))) {
118
+ return false;
119
+ }
120
+ // A path-less run requests every test, so any test file is its own.
121
+ if (requested.length === 0 && isTestPath(path))
122
+ return false;
123
+ if (path.endsWith('/')) {
124
+ if (installsFromDirectory(index, path))
125
+ return false;
126
+ continue;
127
+ }
128
+ const delivery = await classifyRuntimeDelivery(index, engineDir, path);
129
+ if (delivery === 'live' || delivery === 'at-dispatch')
130
+ return false;
131
+ }
132
+ warn(`engine/ changed while this run was in flight, but only in files the running browser ` +
133
+ `cannot see (${capList(delta.paths)}); the verdict stands. The next run's stale-build ` +
134
+ 'gate judges whether they need a rebuild.');
135
+ onTolerated(delta.paths.length);
136
+ return true;
137
+ };
138
+ }
@@ -22,10 +22,13 @@ import { join } from 'node:path';
22
22
  import { describeBuildRunState, readBuildRunState } from '../core/build-run-record.js';
23
23
  import { getProjectPaths, loadConfig } from '../core/config.js';
24
24
  import { assertEngineExists } from '../core/engine-precondition.js';
25
+ import { autoPortsRequested } from '../core/harness-port-fallback.js';
25
26
  import { hasBuildArtifacts, hasRunnableBundle } from '../core/mach.js';
26
27
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
27
28
  import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, extractForwardedMarionettePort, } from '../core/marionette-port.js';
28
29
  import { ensureMochitestServerPortAvailable } from '../core/mochitest-server-port.js';
30
+ import { planNegativeControl } from '../core/negative-control.js';
31
+ import { formatCompiledControlRefusal } from '../core/negative-control-compiled.js';
29
32
  import { waitBeforeLock } from '../core/preflight-wait.js';
30
33
  import { PreflightRefusalError } from '../errors/base.js';
31
34
  import { info } from '../utils/logger.js';
@@ -33,6 +36,7 @@ import { resolveWaitLockSeconds } from '../utils/options.js';
33
36
  import { stripEnginePrefix } from '../utils/paths.js';
34
37
  import { checkRequestedTestTypes } from './test-checkjs.js';
35
38
  import { assertPathlessTestMode, classifyBeforeDispatch, resolveCanaryPath } from './test-modes.js';
39
+ import { enforceStaleBuildGate } from './test-stale-gate.js';
36
40
  /**
37
41
  * Resolves the launchable binary a test run needs, or refuses naming why
38
42
  * it is missing. The cause comes from the build run record, which any
@@ -95,10 +99,11 @@ async function loadPreLockContext(projectRoot, testPaths, options) {
95
99
  * @param options - Parsed test options
96
100
  */
97
101
  export async function runTestPreLockPreflight(projectRoot, testPaths, options) {
98
- const context = await loadPreLockContext(projectRoot, testPaths, options);
99
- if (context === undefined)
100
- return options;
101
- const { paths, config, objDir } = context;
102
+ const loaded = await loadPreLockContext(projectRoot, testPaths, options);
103
+ if (loaded === undefined)
104
+ return withLockMode(options, 'exclusive');
105
+ const { paths, config, objDir } = loaded;
106
+ options = withLockMode(options, resolveTestLockMode(options, config));
102
107
  let launchablePath;
103
108
  try {
104
109
  launchablePath = await resolveLaunchablePathForTests(projectRoot, paths.engine, config.binaryName, objDir);
@@ -115,11 +120,102 @@ export async function runTestPreLockPreflight(projectRoot, testPaths, options) {
115
120
  if (options.build === true || options.buildOnly === true) {
116
121
  await checkRequestedTestTypes(paths, config, normalizedPaths);
117
122
  }
123
+ else {
124
+ await refuseCompiledControlBeforeLock(projectRoot, paths.engine, options);
125
+ await refuseStalePackagingBeforeLock(projectRoot, paths.engine, options, normalizedPaths);
126
+ }
118
127
  return waitForResourcesBeforeLock(paths.engine, config, options, {
119
128
  launchablePath,
120
129
  normalizedPaths,
121
130
  });
122
131
  }
132
+ /** `options` with `sharedLock` set to the resolved decision. */
133
+ function withLockMode(options, mode) {
134
+ if (mode === 'exclusive' && options.sharedLock === undefined)
135
+ return options;
136
+ const rest = { ...options };
137
+ delete rest.sharedLock;
138
+ return mode === 'shared' ? { ...rest, sharedLock: true } : rest;
139
+ }
140
+ /**
141
+ * How this run holds the engine session lock. Shared only on request
142
+ * (`--shared-lock` or `test.lockMode: "shared"`), and only for a run that
143
+ * writes nothing into engine/ or the objdir and can move its ports: no
144
+ * build, no negative control, auto ports. Every other run stays exclusive,
145
+ * so a shared holder can never overlap a build or a re-export.
146
+ */
147
+ function resolveTestLockMode(options, config) {
148
+ const requested = options.sharedLock === true || config.test?.lockMode === 'shared';
149
+ if (!requested)
150
+ return 'exclusive';
151
+ const writes = options.build === true ||
152
+ options.buildOnly === true ||
153
+ options.negativeControl !== undefined ||
154
+ options.canary !== undefined ||
155
+ options.doctor === true;
156
+ if (writes || !autoPortsRequested(options, config)) {
157
+ info('Shared test lock requested, but this run ' +
158
+ (writes
159
+ ? 'builds, applies a negative control, or runs a canary/doctor preflight'
160
+ : 'does not use auto ports (--auto-ports or test.mochitestPorts: "auto")') +
161
+ ', so it holds the engine lock exclusively.');
162
+ return 'exclusive';
163
+ }
164
+ return 'shared';
165
+ }
166
+ /**
167
+ * Refuses a build-less `--negative-control` on compiled source before the
168
+ * lock: applied after the build it is never compiled, so the run could only
169
+ * report `control=missed` against the unmutated binary.
170
+ */
171
+ async function refuseCompiledControlBeforeLock(projectRoot, engineDir, options) {
172
+ if (options.negativeControl === undefined)
173
+ return;
174
+ let plan;
175
+ try {
176
+ plan = await planNegativeControl(projectRoot, engineDir, options.negativeControl);
177
+ }
178
+ catch {
179
+ // Any other refusal (including a control a killed run left applied,
180
+ // which only the in-lock recovery may undo) is the in-lock pass's.
181
+ return;
182
+ }
183
+ if (plan.compiled === undefined)
184
+ return;
185
+ throw new PreflightRefusalError(formatCompiledControlRefusal(plan.compiled), 'negative-control');
186
+ }
187
+ /**
188
+ * Runs the build-less run's packaging gates (coverage, stale content,
189
+ * StaticComponents) before the lock, so a refusal surfaces before the run
190
+ * queues instead of after it reached the head of the queue. The same gate
191
+ * runs again under the lock, where a peer's build may since have changed
192
+ * the answer; this pass only refuses early what would refuse anyway.
193
+ *
194
+ * Skipped while a build runs (it will rewrite the record this reads) and
195
+ * under `--allow-stale-build`/`--allow-stale-components`, whose warnings
196
+ * belong to the in-lock pass alone. A `--negative-control` refusal says to
197
+ * add `--build`: without it the control has nothing current to run on, and
198
+ * the invocation used to end `control=inconclusive` after a full queue.
199
+ */
200
+ async function refuseStalePackagingBeforeLock(projectRoot, engineDir, options, normalizedPaths) {
201
+ if (options.allowStaleBuild === true || options.allowStaleComponents === true)
202
+ return;
203
+ if ((await readBuildRunState(projectRoot)).kind === 'running')
204
+ return;
205
+ try {
206
+ await enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths, {
207
+ quiet: true,
208
+ });
209
+ }
210
+ catch (error) {
211
+ if (options.negativeControl === undefined || !(error instanceof PreflightRefusalError)) {
212
+ throw error;
213
+ }
214
+ throw new PreflightRefusalError(`${error.message}\n\nThis --negative-control run has no --build, so its control would ` +
215
+ 'run on this packaging and end inconclusive. Add --build: the control is then planned ' +
216
+ 'against a fresh build of the requested paths.', error.note);
217
+ }
218
+ }
123
219
  /** `options` without one wait budget, which this pass already spent. */
124
220
  function withoutOption(options, key) {
125
221
  const rest = { ...options };
@@ -106,7 +106,21 @@ function registerTestSharingOptions(test) {
106
106
  function registerTestBuildOptions(test) {
107
107
  return test
108
108
  .option('--extend-coverage', 'With --build/--build-only and explicit paths, refuse instead of dropping any earlier packaging claim. Scoped builds keep each earlier path by default while its own staging inputs (manifest directory and the fixtures its manifest names) and engine/mozconfig are unchanged; this flag turns a drop into a refusal before the build.')
109
- .option('--no-furnace-apply', 'With --build/--build-only, skip the Furnace apply so the build packages engine/ as it stands; names the components whose undeployed components/ edits it leaves out');
109
+ .option('--furnace-apply <mode>', 'With --build/--build-only: all (default; deploy every component with undeployed edits), reached (only components a requested test names; the rest are reported as furnace-skipped=<names>), or none. Same as test.furnaceApply.', commanderArgParser((raw) => {
110
+ if (raw !== 'all' && raw !== 'reached' && raw !== 'none') {
111
+ throw new GeneralError(`--furnace-apply must be all, reached or none (got "${raw}")`);
112
+ }
113
+ return raw;
114
+ }))
115
+ .option('--no-furnace-apply', 'With --build/--build-only, skip the Furnace apply so the build packages engine/ as it stands; names the components whose undeployed components/ edits it leaves out (same as --furnace-apply none)');
116
+ }
117
+ /** Options that run a request on several harness instances, or judge it per file. */
118
+ function registerTestParallelOptions(test) {
119
+ return test
120
+ .option('--jobs <n>', 'Run shards on n harness instances at once inside one lock hold, each with its own profile, Marionette port and (via auto ports) server ports. With --no-shard, or one directory argument, the files are split in order into n contiguous chunks. Prints a FIREFORGE-SHARD line per shard and one aggregate verdict. Requires --auto-ports or test.mochitestPorts: "auto".', commanderArgParser((raw) => parsePositiveIntegerFlag('--jobs', raw)))
121
+ .option('--shard-size <n>', 'Dispatch n files per mach invocation (mochitest restarts the browser between files) and judge each file from the structured log: shards= then counts files and failed-shards= names failed files. Combines with --jobs.', commanderArgParser((raw) => parsePositiveIntegerFlag('--shard-size', raw)))
122
+ .option('--results <path>', 'Write a JSON record per test file and --repeat run (status, expected, duration, run-log line) from the mozlog structured log, and name it on the verdict as results=<path>')
123
+ .option('--shared-lock', 'Hold the engine session lock shared when this run is build-less, applies no negative control and uses auto ports, so peers\' build-less runs on this objdir overlap; builds and re-exports still wait. Same as test.lockMode: "shared".');
110
124
  }
111
125
  /** Registers the test command on the CLI program. */
112
126
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
@@ -146,13 +160,14 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
146
160
  return n;
147
161
  }))
148
162
  .addHelpText('after', TEST_HELP_TEXT);
149
- addWaitLockOption(registerTestBuildOptions(registerTestMultiRunOptions(registerTestSharingOptions(test)))).action(withErrorHandling(async (paths, options) => {
163
+ addWaitLockOption(registerTestParallelOptions(registerTestBuildOptions(registerTestMultiRunOptions(registerTestSharingOptions(test))))).action(withErrorHandling(async (paths, options) => {
150
164
  const projectRoot = getProjectRoot();
151
165
  // Fallback for a verdict testCommand did not emit (it takes the lock).
152
166
  resetVerdictEmission();
153
167
  try {
154
- await testCommand(projectRoot, paths, pickDefined(options), (body) => withEngineSessionLock(projectRoot, 'test', body, {
168
+ await testCommand(projectRoot, paths, pickDefined(options), (body, mode) => withEngineSessionLock(projectRoot, 'test', body, {
155
169
  waitLockSeconds: resolveWaitLockSeconds(options.waitLock),
170
+ ...(mode === 'shared' ? { mode } : {}),
156
171
  }));
157
172
  }
158
173
  catch (error) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `fireforge test --results <path>`: one record per test file and per
3
+ * `--repeat` run, from the mozlog raw log each dispatch writes.
4
+ *
5
+ * Gates and retry wrappers downstream named a run's failing files by
6
+ * reading `TEST_START`/`TEST_END` lines and the prose of the "sharded test
7
+ * run(s) did not pass" error out of the human log, which nothing promised
8
+ * to keep stable. The results file is the stable form: JSON with a
9
+ * `schemaVersion`, written once the invocation has finished, and named on
10
+ * the verdict line as `results=<path>`.
11
+ */
12
+ import type { RawTestResult } from '../core/test-raw-log.js';
13
+ /** One file's result in one dispatch. */
14
+ export interface TestFileResult extends RawTestResult {
15
+ /** `--repeat` run (1-based); 1 for a single run. */
16
+ run: number;
17
+ /** The shard (path argument or `--shard-size` chunk) the file ran in. */
18
+ shard: string;
19
+ /** Line of the run log where this file's dispatch began (1-based). */
20
+ logLine: number;
21
+ }
22
+ /** The document `--results` writes. */
23
+ export interface TestResultsDocument {
24
+ schemaVersion: 1;
25
+ /** Files in dispatch-completion order. */
26
+ files: TestFileResult[];
27
+ /** Started files that never ended, per dispatch. */
28
+ unfinished: Array<{
29
+ test: string;
30
+ run: number;
31
+ shard: string;
32
+ }>;
33
+ }
34
+ /** Clears the collector at the start of an invocation. */
35
+ export declare function resetTestResults(): void;
36
+ /**
37
+ * Adds one dispatch's parsed raw log to the invocation's results.
38
+ *
39
+ * @param results - The dispatch's finished files
40
+ * @param unfinished - Its started-but-unfinished files
41
+ * @param context - Run, shard and run-log line of the dispatch
42
+ */
43
+ export declare function recordTestResults(results: readonly RawTestResult[], unfinished: readonly string[], context: {
44
+ run: number;
45
+ shard: string;
46
+ logLine: number;
47
+ }): void;
48
+ /** The results collected so far (for tests and the shard summary). */
49
+ export declare function collectedTestResults(): TestResultsDocument;
50
+ /**
51
+ * Writes the collected results.
52
+ *
53
+ * @param path - Destination of `--results`
54
+ */
55
+ export declare function writeTestResults(path: string): Promise<void>;
56
+ /**
57
+ * Creates the scratch directory the dispatches' raw logs go to, runs
58
+ * `body`, and removes it afterwards.
59
+ *
60
+ * @param body - Receives the directory
61
+ */
62
+ export declare function withRawLogDir<T>(body: (dir: string) => Promise<T>): Promise<T>;
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge test --results <path>`: one record per test file and per
4
+ * `--repeat` run, from the mozlog raw log each dispatch writes.
5
+ *
6
+ * Gates and retry wrappers downstream named a run's failing files by
7
+ * reading `TEST_START`/`TEST_END` lines and the prose of the "sharded test
8
+ * run(s) did not pass" error out of the human log, which nothing promised
9
+ * to keep stable. The results file is the stable form: JSON with a
10
+ * `schemaVersion`, written once the invocation has finished, and named on
11
+ * the verdict line as `results=<path>`.
12
+ */
13
+ import { mkdtemp, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { writeJson } from '../utils/fs.js';
17
+ let collected = { schemaVersion: 1, files: [], unfinished: [] };
18
+ /** Clears the collector at the start of an invocation. */
19
+ export function resetTestResults() {
20
+ collected = { schemaVersion: 1, files: [], unfinished: [] };
21
+ }
22
+ /**
23
+ * Adds one dispatch's parsed raw log to the invocation's results.
24
+ *
25
+ * @param results - The dispatch's finished files
26
+ * @param unfinished - Its started-but-unfinished files
27
+ * @param context - Run, shard and run-log line of the dispatch
28
+ */
29
+ export function recordTestResults(results, unfinished, context) {
30
+ for (const result of results)
31
+ collected.files.push({ ...result, ...context });
32
+ for (const test of unfinished) {
33
+ collected.unfinished.push({ test, run: context.run, shard: context.shard });
34
+ }
35
+ }
36
+ /** The results collected so far (for tests and the shard summary). */
37
+ export function collectedTestResults() {
38
+ return collected;
39
+ }
40
+ /**
41
+ * Writes the collected results.
42
+ *
43
+ * @param path - Destination of `--results`
44
+ */
45
+ export async function writeTestResults(path) {
46
+ await writeJson(path, collectedTestResults());
47
+ }
48
+ /**
49
+ * Creates the scratch directory the dispatches' raw logs go to, runs
50
+ * `body`, and removes it afterwards.
51
+ *
52
+ * @param body - Receives the directory
53
+ */
54
+ export async function withRawLogDir(body) {
55
+ const dir = await mkdtemp(join(tmpdir(), 'fireforge-raw-log-'));
56
+ try {
57
+ return await body(dir);
58
+ }
59
+ finally {
60
+ await rm(dir, { recursive: true, force: true });
61
+ }
62
+ }