@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -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/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -13,7 +13,7 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
13
13
  */
14
14
  export type FireforgeVerdictReason = 'crash' | 'harness-arguments' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
15
  /** Additive keys a run can stamp on its verdict line. */
16
- export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply';
16
+ export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply' | 'furnace-skipped' | 'control-build' | 'drift-tolerated' | 'lock' | 'jobs' | 'results';
17
17
  /** Records an additive attribute for this run's verdict line. */
18
18
  export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
19
19
  /**
@@ -46,6 +46,7 @@ export declare function emitAggregateVerdict(body: string): void;
46
46
  export declare function emitHarnessVerdict(verdict: HarnessRunVerdict, shards?: {
47
47
  passed: number;
48
48
  total: number;
49
+ failed?: readonly string[];
49
50
  }): void;
50
51
  /** Emits the pathless `--doctor` PASS verdict. */
51
52
  export declare function emitPassVerdict(): void;
@@ -19,4 +19,4 @@ import type { TestOptions } from '../types/commands/index.js';
19
19
  * @param options - Test options
20
20
  * @param underLock - Runs the rest of the command under the engine session lock
21
21
  */
22
- export declare function testCommand(projectRoot: string, testPaths: string[], options?: TestOptions, underLock?: (body: () => Promise<void>) => Promise<void>): Promise<void>;
22
+ export declare function testCommand(projectRoot: string, testPaths: string[], options?: TestOptions, underLock?: (body: () => Promise<void>, mode?: 'exclusive' | 'shared') => Promise<void>): Promise<void>;
@@ -1,8 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { assertEngineExists } from '../core/engine-precondition.js';
5
- import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
6
5
  import { hasBuildArtifacts } from '../core/mach.js';
7
6
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
8
7
  import { ensureLaunchableBrowserNotRunning } from '../core/marionette-port.js';
@@ -13,23 +12,22 @@ import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-sco
13
12
  import { cleanupProfileFiles, stageProfileFiles, } from '../core/test-profile-files.js';
14
13
  import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
15
14
  import { FireForgeError, GeneralError, LockContentionError, PreflightRefusalError, } from '../errors/base.js';
16
- import { BuildError } from '../errors/build.js';
17
15
  import { toError } from '../utils/errors.js';
18
16
  import { pathExists } from '../utils/fs.js';
19
- import { info, intro, notice, warn } from '../utils/logger.js';
17
+ import { info, intro, warn } from '../utils/logger.js';
20
18
  import { resolveWaitLockSeconds } from '../utils/options.js';
21
19
  import { stripEnginePrefix } from '../utils/paths.js';
22
20
  import { appendMarionetteForwardingArgs, ensureTestBrowserEnvironment, runDoctorPreflight, } from './test-browser-preflight.js';
23
21
  import { runTestBuildPhase } from './test-build-phase.js';
24
- import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
25
- import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed, shuffleTestGroups, } from './test-harness-env.js';
22
+ import { assertParallelTestUsage, dispatchTests } from './test-dispatch.js';
23
+ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
26
24
  import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
27
- import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
25
+ import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
28
26
  import { orchestrateTestRuns } from './test-orchestrate.js';
29
- import { perfGenerationTolerance } from './test-perf-scope.js';
30
27
  import { resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
31
- import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
32
- import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
28
+ import { resetTestResults, withRawLogDir, writeTestResults } from './test-results.js';
29
+ import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
30
+ import { emitFailVerdict, resetVerdictEmission, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
33
31
  async function assertTestPathsExist(engineDir, testPaths) {
34
32
  const missingPaths = [];
35
33
  for (const testPath of testPaths) {
@@ -126,12 +124,33 @@ export async function testCommand(projectRoot, testPaths, options = {}, underLoc
126
124
  staged = await stageProfileFiles(projectRoot, options.profileFile);
127
125
  options = { ...options, machArg: [...(options.machArg ?? []), ...staged.args] };
128
126
  }
127
+ assertParallelTestUsage(testPaths, options);
129
128
  const lockedOptions = await runTestPreLockPreflight(projectRoot, testPaths, options);
130
129
  const profileEnv = staged?.env;
131
- await underLock(() => orchestrateTestRuns(projectRoot, testPaths, lockedOptions, (paths, runOptions, hooks) => runTestCommandBody(projectRoot, paths, runOptions, profileEnv, hooks), (error) => {
130
+ // The pre-lock pass resolved `sharedLock` against config and the run's shape.
131
+ const lockMode = lockedOptions.sharedLock === true ? 'shared' : 'exclusive';
132
+ if (lockMode === 'shared')
133
+ setVerdictRunAttribute('lock', 'shared');
134
+ resetTestResults();
135
+ if (options.results !== undefined) {
136
+ setVerdictRunAttribute('results', resolve(projectRoot, options.results));
137
+ }
138
+ const orchestrate = (rawLogDir) => orchestrateTestRuns(projectRoot, testPaths, lockedOptions, (paths, runOptions, hooks) => runTestCommandBody(projectRoot, paths, runOptions, profileEnv, {
139
+ ...hooks,
140
+ ...(rawLogDir === undefined ? {} : { rawLogDir }),
141
+ }), (error) => {
132
142
  renderPreflightRefusal(error);
133
143
  emitFailVerdict('preflight', refusalNote(error));
134
- }));
144
+ });
145
+ const collectsResults = options.results !== undefined || options.shardSize !== undefined;
146
+ try {
147
+ await underLock(() => (collectsResults ? withRawLogDir((dir) => orchestrate(dir)) : orchestrate()), lockMode);
148
+ }
149
+ finally {
150
+ if (options.results !== undefined) {
151
+ await writeResultsFile(resolve(projectRoot, options.results));
152
+ }
153
+ }
135
154
  }
136
155
  catch (error) {
137
156
  if (!verdictEmitted()) {
@@ -164,6 +183,15 @@ export async function testCommand(projectRoot, testPaths, options = {}, underLoc
164
183
  }
165
184
  }
166
185
  }
186
+ /** Writes `--results`; a failure warns, since the run's verdict is already out. */
187
+ async function writeResultsFile(path) {
188
+ try {
189
+ await writeTestResults(path);
190
+ }
191
+ catch (error) {
192
+ warn(`Could not write --results ${path}: ${toError(error).message}`);
193
+ }
194
+ }
167
195
  /** The refusal class a preflight gate named, when it named one. */
168
196
  function refusalNote(error) {
169
197
  return error instanceof PreflightRefusalError ? error.note : undefined;
@@ -198,26 +226,6 @@ function renderPreflightRefusal(error) {
198
226
  process.stdout.write(block);
199
227
  writeToActiveRunLog(block);
200
228
  }
201
- /**
202
- * Verifies `engine/` did not change under the run before any PASS verdict
203
- * may print. On failure the run's verdict is `FAIL reason=inconclusive`,
204
- * because the harness result exists but cannot be trusted (`preflight` would
205
- * misdescribe a run whose harness already executed). It is emitted here so the
206
- * guard's throw can never leave a stale or missing verdict line behind.
207
- */
208
- async function verifyEngineGenerationOrEmitInconclusive(engineDir, before, powerChanged = false, perfSamples) {
209
- try {
210
- await (perfSamples === undefined
211
- ? assertEngineGenerationUnchanged(engineDir, before)
212
- : assertEngineGenerationUnchanged(engineDir, before, perfGenerationTolerance(engineDir)));
213
- if (powerChanged)
214
- throw new GeneralError('Perf power source changed during the run; repeat this sitting on a stable power source.');
215
- }
216
- catch (error) {
217
- emitFailVerdict('inconclusive');
218
- throw error;
219
- }
220
- }
221
229
  async function guardBuildBrowser(engineDir, launchablePath, options) {
222
230
  if ((options.build || options.buildOnly) && launchablePath) {
223
231
  await waitForPreflight(() => ensureLaunchableBrowserNotRunning(join(engineDir, launchablePath), {
@@ -254,7 +262,12 @@ function prepareDispatchArguments(classification, options, canaryPath, projectCo
254
262
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
255
263
  : [];
256
264
  const extraArgs = prepareExtraMachArgs(options, shuffleSeed, canaryPath, projectConfig, forwardedMachArgs);
257
- extraArgs.push(...restartBetweenTestsArgs(options, suite, forwardedMachArgs));
265
+ // A `--shard-size` chunk runs several files in one browser; mochitest
266
+ // restarts it between them so a chunk judges each file as a shard would.
267
+ const restart = options.shardSize !== undefined && suite === 'mochitest'
268
+ ? { ...options, restartBetweenTests: true }
269
+ : options;
270
+ extraArgs.push(...restartBetweenTestsArgs(restart, suite, forwardedMachArgs));
258
271
  return { suite, shuffleSeed, extraArgs };
259
272
  }
260
273
  /**
@@ -327,6 +340,9 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
327
340
  // documented `--mach-arg --marionette-port=NNNN` route still hits the
328
341
  // wrapper preflight refusing on 2828 before the forwarded arg reaches
329
342
  // mach.
343
+ // A negative control on compiled source goes in here, so the build
344
+ // compiles it (see negative-control-compiled.ts).
345
+ await hooks.beforeBuild?.();
330
346
  if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
331
347
  classification,
332
348
  normalizedPaths: hooks.coveragePaths ?? normalizedPaths,
@@ -364,6 +380,8 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
364
380
  ...(options.fullOutput === true ? { fullOutput: true } : {}),
365
381
  ...(harnessEnv ? { env: harnessEnv } : {}),
366
382
  pgidFile: options.pgidFile,
383
+ ...(hooks.rawLogDir === undefined ? {} : { rawLogDir: hooks.rawLogDir }),
384
+ ...(hooks.repetition === undefined ? {} : { repetition: hooks.repetition }),
367
385
  };
368
386
  const postRebuildContext = options.build
369
387
  ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
@@ -376,46 +394,18 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
376
394
  // into later files. Sharding is per path argument: a directory argument
377
395
  // keeps its enumerated files together in one invocation, preserving the
378
396
  // one-browser-instance semantics of a directory run. `--no-shard` restores
379
- // the combined invocation. The default must not be silent: a cross-file
380
- // pollution repro otherwise "passes" because the comparison run was
381
- // sharded without saying so, misattributing a suite-context bug upstream.
382
- // Hence the one-line notice stating what sharding does and does not
383
- // exercise.
384
- if (canaryPath === undefined && dispatchGroups.length > 1 && options.shard !== false) {
385
- notice(`Sharding: running ${dispatchGroups.length} test path arguments in isolated browser instances ` +
386
- '(one mach invocation per argument; a directory argument keeps its files in one instance). ' +
387
- 'Cross-argument state is NOT exercised — pass --no-shard for a combined single-instance run.');
388
- const generationBefore = await snapshotEngineGeneration(paths.engine);
389
- let summary;
390
- try {
391
- const orderedGroups = shuffleSeed === undefined ? dispatchGroups : shuffleTestGroups(dispatchGroups, shuffleSeed);
392
- summary = await runShardedTests(runCtx, orderedGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
393
- }
394
- finally {
395
- // Runs before the aggregate verdict below: a mutated engine/ emits
396
- // `FAIL reason=inconclusive` and throws, so an invalidated run can
397
- // never print `PASS shards=N/N` first. (A throw here masks an
398
- // in-flight shard error, as the plain assert always did.)
399
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged, options.perfSamples);
400
- }
401
- finalizeShardedOutcome(summary);
402
- return;
403
- }
404
- const combinedDispatchPaths = dispatchGroups.flatMap((group) => group.paths);
405
- let outcome;
406
- const generationBefore = await snapshotEngineGeneration(paths.engine);
407
- try {
408
- outcome = await runTestsWithRetries(runCtx, combinedDispatchPaths);
409
- }
410
- catch (error) {
411
- throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
412
- }
413
- finally {
414
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged, options.perfSamples);
415
- }
416
- if (canaryPath !== undefined) {
417
- reportCanaryOutcome(outcome);
418
- return;
419
- }
420
- finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
397
+ // the combined invocation; `--shard-size` and `--jobs` reshape it (see
398
+ // test-dispatch.ts).
399
+ await dispatchTests({
400
+ engineDir: paths.engine,
401
+ runCtx,
402
+ dispatchGroups,
403
+ options,
404
+ projectConfig,
405
+ canaryPath,
406
+ shuffleSeed,
407
+ normalizedPaths,
408
+ xpcshellOnly,
409
+ postRebuildContext,
410
+ });
421
411
  }
@@ -8,15 +8,18 @@
8
8
  import { join } from 'node:path';
9
9
  import { getProjectPaths, loadConfig } from '../core/config.js';
10
10
  import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
11
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
12
+ import { buildJarTargetIndex } from '../core/patch-lint-forward-registration-extended.js';
11
13
  import { loadPatchesManifest } from '../core/patch-manifest.js';
12
14
  import { collectDeclaredTokenNames, lintTokenLockstep, lintUndeclaredTokenReferences, } from '../core/token-lint.js';
13
15
  import { getTokensCssPath } from '../core/token-manager.js';
16
+ import { buildSiblingSheetMap } from '../core/token-sibling-sheets.js';
14
17
  import { GeneralError } from '../errors/base.js';
15
18
  import { toError } from '../utils/errors.js';
16
19
  import { pathExists, readText } from '../utils/fs.js';
17
20
  import { info, intro, outro, setMachineOutputMode, setStdoutSealed, success, warn, } from '../utils/logger.js';
18
21
  import { emitMachineError, MACHINE_OUTPUT_SCHEMA_VERSION } from '../utils/machine-output.js';
19
- /** Token prefix and exempt names from furnace.json, with the init default. */
22
+ /** Token prefix, exempt names and sibling sheets from furnace.json, with the init default. */
20
23
  async function loadTokenSettings(projectRoot, binaryName) {
21
24
  const fallback = { tokenPrefix: `--${binaryName}-`, exempt: new Set() };
22
25
  if (!(await furnaceConfigExists(projectRoot)))
@@ -26,6 +29,7 @@ async function loadTokenSettings(projectRoot, binaryName) {
26
29
  return {
27
30
  tokenPrefix: furnace.tokenPrefix ?? fallback.tokenPrefix,
28
31
  exempt: new Set([...(furnace.tokenAllowlist ?? []), ...(furnace.runtimeVariables ?? [])]),
32
+ siblingSheets: furnace.tokenSiblingSheets,
29
33
  };
30
34
  }
31
35
  catch (error) {
@@ -41,7 +45,7 @@ async function collectTokenLint(projectRoot) {
41
45
  if (!(await pathExists(tokensFile))) {
42
46
  throw new GeneralError(`Token CSS file not found: ${tokensCssPath}. Run "fireforge furnace init" to seed it.`);
43
47
  }
44
- const { tokenPrefix, exempt } = await loadTokenSettings(projectRoot, config.binaryName);
48
+ const { tokenPrefix, exempt, siblingSheets } = await loadTokenSettings(projectRoot, config.binaryName);
45
49
  const tokensCss = await readText(tokensFile);
46
50
  const lines = tokensCss.split('\n');
47
51
  const declared = collectDeclaredTokenNames(lines);
@@ -52,19 +56,55 @@ async function collectTokenLint(projectRoot) {
52
56
  // Every CSS file the patch queue touches: the fork's own stylesheets and
53
57
  // the upstream ones it edits.
54
58
  const manifest = await loadPatchesManifest(paths.patches);
55
- const cssFiles = [
56
- ...new Set((manifest?.patches ?? []).flatMap((patch) => patch.filesAffected.filter((file) => file.endsWith('.css') && file !== tokensCssPath))),
57
- ].sort();
59
+ const touched = [...new Set((manifest?.patches ?? []).flatMap((patch) => patch.filesAffected))];
60
+ const cssFiles = touched.filter((file) => file.endsWith('.css') && file !== tokensCssPath).sort();
61
+ const contents = await readEngineFiles(paths.engine, touched.filter((file) => LINKING_FILE.test(file)));
62
+ const siblings = buildSiblingSheetMap({
63
+ documents: contents,
64
+ jarTargets: buildJarTargetIndex((await buildPatchQueueContext(paths.patches)).entries),
65
+ sheets: cssFiles,
66
+ explicit: siblingSheets,
67
+ });
58
68
  let filesScanned = 1;
59
69
  for (const file of cssFiles) {
60
- const absolute = join(paths.engine, file);
61
- if (!(await pathExists(absolute)))
70
+ const css = contents.get(file);
71
+ if (css === undefined)
62
72
  continue;
63
73
  filesScanned++;
64
- findings.push(...lintUndeclaredTokenReferences(file, await readText(absolute), tokenPrefix, declared, exempt));
74
+ findings.push(...lintUndeclaredTokenReferences(file, css, tokenPrefix, await withSiblingDeclarations(paths.engine, declared, siblings.get(file), contents), exempt));
65
75
  }
66
76
  return { tokensCssPath, tokenPrefix, filesScanned, findings };
67
77
  }
78
+ /** Files whose content can link a stylesheet: markup, scripts, and sheets (`@import`). */
79
+ const LINKING_FILE = /\.(?:css|xhtml|html|xml|mjs|js)$/;
80
+ /** Reads the engine files that exist, keyed by engine-relative path. */
81
+ async function readEngineFiles(engineDir, files) {
82
+ const contents = new Map();
83
+ for (const file of files) {
84
+ const absolute = join(engineDir, file);
85
+ if (await pathExists(absolute))
86
+ contents.set(file, await readText(absolute));
87
+ }
88
+ return contents;
89
+ }
90
+ /** The tokens CSS declarations plus every declaration of a sheet's siblings. */
91
+ async function withSiblingDeclarations(engineDir, declared, siblings, contents) {
92
+ if (siblings === undefined || siblings.size === 0)
93
+ return declared;
94
+ const merged = new Set(declared);
95
+ for (const sibling of siblings) {
96
+ if (!contents.has(sibling)) {
97
+ const absolute = join(engineDir, sibling);
98
+ if (!(await pathExists(absolute)))
99
+ continue;
100
+ contents.set(sibling, await readText(absolute));
101
+ }
102
+ const css = contents.get(sibling) ?? '';
103
+ for (const name of collectDeclaredTokenNames(css.split('\n')))
104
+ merged.add(name);
105
+ }
106
+ return merged;
107
+ }
68
108
  function failureMessage(count) {
69
109
  return `token lint found ${String(count)} finding(s).`;
70
110
  }
@@ -21,7 +21,7 @@ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
21
21
  import { isGitRepository } from '../core/git.js';
22
22
  import { withPrivateGitIndex } from '../core/git-readonly-index.js';
23
23
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
24
- import { findUnresolvedSystemModuleImports, formatUnresolvedSystemModuleImports, } from '../core/module-resolution-preflight.js';
24
+ import { collectWorktreeModuleFiles, findUnresolvedSystemModuleImports, formatUnresolvedSystemModuleImports, } from '../core/module-resolution-preflight.js';
25
25
  import { buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
26
26
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
27
27
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
@@ -206,8 +206,15 @@ export async function collectPatchQueueHealth(projectRoot, scope) {
206
206
  // CREATES. This asks the same question of the engine, so an import ADDED
207
207
  // to an existing module is named as text instead of surfacing later as a
208
208
  // bare `xpcshell return code: -11` with zero output.
209
+ // Unscoped, the engine worktree joins in: an unexported chrome script
210
+ // importing an unexported, unregistered module crashes every objdir
211
+ // start before either file is in the queue. A scoped run never judges
212
+ // unmanaged files.
209
213
  if (await pathExists(paths.engine)) {
210
- const unresolved = await findUnresolvedSystemModuleImports(paths.engine, scopePatches(manifest.patches, scope).flatMap((patch) => patch.filesAffected));
214
+ const worktree = scope === undefined && (await isGitRepository(paths.engine))
215
+ ? await collectWorktreeModuleFiles(paths.engine)
216
+ : undefined;
217
+ const unresolved = await findUnresolvedSystemModuleImports(paths.engine, scopePatches(manifest.patches, scope).flatMap((patch) => patch.filesAffected), worktree);
211
218
  if (unresolved.length > 0) {
212
219
  groups.push({
213
220
  title: `Unresolvable system-module imports (${unresolved.length})`,
@@ -42,6 +42,12 @@ export interface PrepareBuildOptions {
42
42
  * (`fireforge test --build --no-furnace-apply`).
43
43
  */
44
44
  skipFurnaceApply?: boolean | undefined;
45
+ /**
46
+ * Apply only these components (`fireforge test --build --furnace-apply
47
+ * reached`). Undefined applies every component with undeployed edits; an
48
+ * empty list applies none.
49
+ */
50
+ furnaceComponents?: readonly string[] | undefined;
45
51
  }
46
52
  /**
47
53
  * Describes an exit code in the shell's 128+N signal convention. Truncated
@@ -170,14 +170,35 @@ async function reportUnexportedDriftBeforeOverwrite(projectRoot, config, options
170
170
  }
171
171
  notice(message);
172
172
  }
173
+ /**
174
+ * Applies the named components one at a time, each under its own Furnace
175
+ * mutation (one rollback journal per apply), and merges the results.
176
+ */
177
+ async function applySelectedComponents(projectRoot, names) {
178
+ const merged = {
179
+ applied: [],
180
+ skipped: [],
181
+ errors: [],
182
+ warnings: [],
183
+ };
184
+ for (const componentName of names) {
185
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx, componentName }));
186
+ merged.applied.push(...result.applied);
187
+ merged.skipped.push(...result.skipped);
188
+ merged.errors.push(...result.errors);
189
+ merged.warnings.push(...(result.warnings ?? []));
190
+ }
191
+ return merged;
192
+ }
173
193
  /**
174
194
  * Applies Furnace components before the build when `furnace.json` exists.
175
195
  * Prints every warning the apply computed, then the loud sync banner.
176
196
  *
177
197
  * @param projectRoot - Project root
198
+ * @param only - Apply just these components; every component when undefined
178
199
  * @returns Names of the components written to engine/
179
200
  */
180
- async function applyFurnaceBeforeBuild(projectRoot) {
201
+ async function applyFurnaceBeforeBuild(projectRoot, only) {
181
202
  if (!(await furnaceConfigExists(projectRoot)))
182
203
  return [];
183
204
  const furnaceConfig = await loadFurnaceConfig(projectRoot);
@@ -187,7 +208,10 @@ async function applyFurnaceBeforeBuild(projectRoot) {
187
208
  const furnaceSpinner = spinner('Applying Furnace components...');
188
209
  let result;
189
210
  try {
190
- result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
211
+ result =
212
+ only === undefined
213
+ ? await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }))
214
+ : await applySelectedComponents(projectRoot, only);
191
215
  }
192
216
  catch (error) {
193
217
  furnaceSpinner.error('Failed to apply Furnace components');
@@ -349,7 +373,9 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
349
373
  throw error;
350
374
  }
351
375
  }
352
- const furnaceAppliedNames = options.skipFurnaceApply === true ? [] : await applyFurnaceBeforeBuild(projectRoot);
376
+ const furnaceAppliedNames = options.skipFurnaceApply === true || options.furnaceComponents?.length === 0
377
+ ? []
378
+ : await applyFurnaceBeforeBuild(projectRoot, options.furnaceComponents);
353
379
  // Generate mozconfig
354
380
  const mozconfigSpinner = spinner('Generating mozconfig...');
355
381
  let mozconfigWritten;
@@ -7,7 +7,7 @@ export declare const FIREFORGE_DIR = ".fireforge";
7
7
  /** Supported top-level fireforge.json keys backed by the current schema. */
8
8
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "buildAudit", "test", "history", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment", "check"];
9
9
  /** Supported config paths that can be read or set without --force. */
10
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "test.mochitestPorts", "history", "history.path", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchLint.forwardRegistration", "patchLint.fileSizeMetric", "patchLint.patchSizeMetric", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment", "check"];
10
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "test.mochitestPorts", "test.lockMode", "test.furnaceApply", "history", "history.path", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchLint.forwardRegistration", "patchLint.fileSizeMetric", "patchLint.patchSizeMetric", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment", "check"];
11
11
  /**
12
12
  * Gets all project paths based on a root directory.
13
13
  * @param root - Root directory of the project
@@ -57,6 +57,8 @@ export const SUPPORTED_CONFIG_PATHS = [
57
57
  'test.canaryTimeoutSeconds',
58
58
  'test.reapOrphans',
59
59
  'test.mochitestPorts',
60
+ 'test.lockMode',
61
+ 'test.furnaceApply',
60
62
  'history',
61
63
  'history.path',
62
64
  'externalToolchains',
@@ -78,7 +78,7 @@ function parseExtraStep(raw, field) {
78
78
  if (!isObject(raw)) {
79
79
  throw new ConfigError(`Config field "${field}" must be a plain object with "name" and "command"`);
80
80
  }
81
- assertKnownKeys(raw, field, ['name', 'command', 'cwd', 'timeout']);
81
+ assertKnownKeys(raw, field, ['name', 'command', 'cwd', 'timeout', 'after']);
82
82
  const name = raw['name'];
83
83
  if (typeof name !== 'string' || !STEP_NAME_PATTERN.test(name)) {
84
84
  throw new ConfigError(`Config field "${field}.name" must be lowercase letters, digits and dashes (e.g. "policy-audit")`);
@@ -99,14 +99,63 @@ function parseExtraStep(raw, field) {
99
99
  step.cwd = cwd;
100
100
  }
101
101
  if (raw['timeout'] !== undefined) {
102
- const timeout = parseNonNegativeInteger(raw['timeout'], `${field}.timeout`);
103
- if (timeout === 0) {
104
- throw new ConfigError(`Config field "${field}.timeout" must be a positive number of seconds`);
105
- }
106
- step.timeout = timeout;
102
+ step.timeout = parsePositiveSeconds(raw['timeout'], `${field}.timeout`);
107
103
  }
104
+ if (raw['after'] !== undefined)
105
+ step.after = parseStringList(raw['after'], `${field}.after`);
108
106
  return step;
109
107
  }
108
+ function parsePositiveSeconds(raw, field) {
109
+ const seconds = parseNonNegativeInteger(raw, field);
110
+ if (seconds === 0) {
111
+ throw new ConfigError(`Config field "${field}" must be a positive number of seconds`);
112
+ }
113
+ return seconds;
114
+ }
115
+ /**
116
+ * Checks every `after` names a known step and the extra steps' ordering
117
+ * has no cycle: a cycle would leave its steps waiting forever.
118
+ */
119
+ function assertExtraStepOrdering(steps) {
120
+ const extras = new Map(steps.map((step) => [step.name, step]));
121
+ for (const [index, step] of steps.entries()) {
122
+ for (const dependency of step.after ?? []) {
123
+ if (dependency === step.name) {
124
+ throw new ConfigError(`Config field "check.extraSteps[${String(index)}].after" names the step itself`);
125
+ }
126
+ if (!isBuiltinStep(dependency) && !extras.has(dependency)) {
127
+ throw new ConfigError(`Config field "check.extraSteps[${String(index)}].after" names unknown step "${dependency}"`);
128
+ }
129
+ }
130
+ }
131
+ const state = new Map();
132
+ const visit = (name, path) => {
133
+ if (state.get(name) === 'done')
134
+ return;
135
+ if (state.get(name) === 'visiting') {
136
+ throw new ConfigError(`Config field "check.extraSteps" orders its steps in a cycle: ${[...path, name].join(' -> ')}`);
137
+ }
138
+ state.set(name, 'visiting');
139
+ for (const dependency of extras.get(name)?.after ?? []) {
140
+ if (extras.has(dependency))
141
+ visit(dependency, [...path, name]);
142
+ }
143
+ state.set(name, 'done');
144
+ };
145
+ for (const step of steps)
146
+ visit(step.name, []);
147
+ }
148
+ function parseTimeouts(raw) {
149
+ if (!isObject(raw))
150
+ throw new ConfigError('Config field "check.timeouts" must be a plain object');
151
+ assertKnownKeys(raw, 'check.timeouts', [...CHECK_BUILTIN_STEPS]);
152
+ const out = {};
153
+ for (const [name, value] of Object.entries(raw)) {
154
+ if (isBuiltinStep(name))
155
+ out[name] = parsePositiveSeconds(value, `check.timeouts.${name}`);
156
+ }
157
+ return out;
158
+ }
110
159
  function parseExtraSteps(raw) {
111
160
  if (!Array.isArray(raw))
112
161
  throw new ConfigError('Config field "check.extraSteps" must be an array');
@@ -118,6 +167,7 @@ function parseExtraSteps(raw) {
118
167
  }
119
168
  seen.add(step.name);
120
169
  }
170
+ assertExtraStepOrdering(steps);
121
171
  return steps;
122
172
  }
123
173
  /**
@@ -129,7 +179,16 @@ function parseExtraSteps(raw) {
129
179
  export function parseCheckConfig(raw) {
130
180
  if (!isObject(raw))
131
181
  throw new ConfigError('Config field "check" must be a plain object');
132
- assertKnownKeys(raw, 'check', ['skip', 'status', 'maxWarnings', 'extraSteps']);
182
+ assertKnownKeys(raw, 'check', [
183
+ 'skip',
184
+ 'status',
185
+ 'maxWarnings',
186
+ 'extraSteps',
187
+ 'timeout',
188
+ 'timeouts',
189
+ 'failOnSkipped',
190
+ 'lintReport',
191
+ ]);
133
192
  const out = {};
134
193
  if (raw['skip'] !== undefined)
135
194
  out.skip = parseSkip(raw['skip']);
@@ -139,5 +198,22 @@ export function parseCheckConfig(raw) {
139
198
  out.maxWarnings = parseMaxWarnings(raw['maxWarnings']);
140
199
  if (raw['extraSteps'] !== undefined)
141
200
  out.extraSteps = parseExtraSteps(raw['extraSteps']);
201
+ if (raw['timeout'] !== undefined)
202
+ out.timeout = parsePositiveSeconds(raw['timeout'], 'check.timeout');
203
+ if (raw['timeouts'] !== undefined)
204
+ out.timeouts = parseTimeouts(raw['timeouts']);
205
+ if (raw['failOnSkipped'] !== undefined) {
206
+ if (typeof raw['failOnSkipped'] !== 'boolean') {
207
+ throw new ConfigError('Config field "check.failOnSkipped" must be a boolean');
208
+ }
209
+ out.failOnSkipped = raw['failOnSkipped'];
210
+ }
211
+ if (raw['lintReport'] !== undefined) {
212
+ const path = raw['lintReport'];
213
+ if (typeof path !== 'string' || path.trim() === '' || !isContainedRelativePath(path)) {
214
+ throw new ConfigError('Config field "check.lintReport" must be a project-relative path');
215
+ }
216
+ out.lintReport = path;
217
+ }
142
218
  return out;
143
219
  }
@@ -59,6 +59,20 @@ export function parseTestBlock(rec, config) {
59
59
  }
60
60
  out.mochitestPorts = mochitestPorts;
61
61
  }
62
+ const lockMode = testRec.raw('lockMode');
63
+ if (lockMode !== undefined) {
64
+ if (lockMode !== 'exclusive' && lockMode !== 'shared') {
65
+ throw new ConfigError('Config field "test.lockMode" must be "exclusive" or "shared"');
66
+ }
67
+ out.lockMode = lockMode;
68
+ }
69
+ const furnaceApply = testRec.raw('furnaceApply');
70
+ if (furnaceApply !== undefined) {
71
+ if (furnaceApply !== 'all' && furnaceApply !== 'reached' && furnaceApply !== 'none') {
72
+ throw new ConfigError('Config field "test.furnaceApply" must be "all", "reached" or "none"');
73
+ }
74
+ out.furnaceApply = furnaceApply;
75
+ }
62
76
  config.test = out;
63
77
  }
64
78
  /** Parses optional project-specific external toolchain doctor probes. */
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Validation for `fireforge.json#patchLint.tokenScales`.
3
+ *
4
+ * Unknown keys are refused: a misspelt `allowInCalc` or `minFiles` would
5
+ * otherwise loosen or drop a scale without a word.
3
6
  */
4
7
  import type { PatchLintTokenScale } from '../types/config.js';
5
8
  /** Parses and validates `patchLint.tokenScales`. */