@hominis/fireforge 0.37.0 → 0.39.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 (99) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +13 -8
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/download.js +5 -2
  7. package/dist/src/commands/export-flow.d.ts +6 -2
  8. package/dist/src/commands/export-flow.js +17 -11
  9. package/dist/src/commands/export-placement-gate.js +4 -6
  10. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  11. package/dist/src/commands/export-placement-policy.js +32 -29
  12. package/dist/src/commands/export-shared.js +17 -2
  13. package/dist/src/commands/export.js +5 -5
  14. package/dist/src/commands/furnace/index.js +23 -18
  15. package/dist/src/commands/import.js +5 -0
  16. package/dist/src/commands/lint-per-patch.js +15 -5
  17. package/dist/src/commands/lint.js +1 -1
  18. package/dist/src/commands/patch/lint-ignore.js +20 -1
  19. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  20. package/dist/src/commands/patch/move-files-create.js +116 -0
  21. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  22. package/dist/src/commands/patch/move-files-into.js +245 -0
  23. package/dist/src/commands/patch/move-files.d.ts +3 -1
  24. package/dist/src/commands/patch/move-files.js +74 -15
  25. package/dist/src/commands/patch/rename.js +4 -1
  26. package/dist/src/commands/patch/split.d.ts +11 -1
  27. package/dist/src/commands/patch/split.js +6 -5
  28. package/dist/src/commands/re-export-scan.d.ts +17 -0
  29. package/dist/src/commands/re-export-scan.js +51 -0
  30. package/dist/src/commands/re-export.js +17 -6
  31. package/dist/src/commands/reset.js +5 -0
  32. package/dist/src/commands/source.js +28 -3
  33. package/dist/src/commands/status-output.d.ts +9 -0
  34. package/dist/src/commands/status-output.js +33 -1
  35. package/dist/src/commands/status.js +17 -3
  36. package/dist/src/commands/test-diagnose.d.ts +1 -1
  37. package/dist/src/commands/test-diagnose.js +9 -3
  38. package/dist/src/commands/test-modes.d.ts +21 -0
  39. package/dist/src/commands/test-modes.js +42 -0
  40. package/dist/src/commands/test-register.js +6 -5
  41. package/dist/src/commands/test-run.d.ts +2 -0
  42. package/dist/src/commands/test-run.js +8 -2
  43. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  44. package/dist/src/commands/test-stale-gate.js +81 -0
  45. package/dist/src/commands/test.js +52 -81
  46. package/dist/src/core/build-audit.d.ts +12 -0
  47. package/dist/src/core/build-audit.js +14 -0
  48. package/dist/src/core/build-baseline-types.d.ts +41 -0
  49. package/dist/src/core/build-baseline.d.ts +6 -1
  50. package/dist/src/core/build-baseline.js +48 -7
  51. package/dist/src/core/build-prepare.d.ts +9 -0
  52. package/dist/src/core/build-prepare.js +24 -0
  53. package/dist/src/core/config-paths.d.ts +1 -1
  54. package/dist/src/core/config-paths.js +1 -0
  55. package/dist/src/core/config-validate.js +14 -1
  56. package/dist/src/core/discard-baseline.d.ts +60 -0
  57. package/dist/src/core/discard-baseline.js +232 -0
  58. package/dist/src/core/engine-session-lock.d.ts +10 -1
  59. package/dist/src/core/engine-session-lock.js +28 -2
  60. package/dist/src/core/file-lock.d.ts +33 -0
  61. package/dist/src/core/file-lock.js +32 -6
  62. package/dist/src/core/firefox-archive.d.ts +2 -1
  63. package/dist/src/core/firefox-archive.js +32 -8
  64. package/dist/src/core/firefox.d.ts +8 -3
  65. package/dist/src/core/firefox.js +11 -6
  66. package/dist/src/core/furnace-validate-helpers.js +28 -1
  67. package/dist/src/core/license-headers.d.ts +24 -0
  68. package/dist/src/core/license-headers.js +103 -1
  69. package/dist/src/core/patch-export.d.ts +10 -1
  70. package/dist/src/core/patch-export.js +12 -2
  71. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  72. package/dist/src/core/patch-lint-checkjs.js +30 -14
  73. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  74. package/dist/src/core/patch-lint-cross.js +69 -9
  75. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  76. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  77. package/dist/src/core/patch-lint.d.ts +3 -0
  78. package/dist/src/core/patch-lint.js +29 -8
  79. package/dist/src/core/patch-policy.d.ts +14 -1
  80. package/dist/src/core/patch-policy.js +13 -2
  81. package/dist/src/core/status-classify.d.ts +11 -2
  82. package/dist/src/core/status-classify.js +49 -25
  83. package/dist/src/core/test-harness-crash.d.ts +10 -0
  84. package/dist/src/core/test-harness-crash.js +16 -0
  85. package/dist/src/core/test-stale-check.d.ts +56 -1
  86. package/dist/src/core/test-stale-check.js +114 -4
  87. package/dist/src/core/token-manager.js +39 -2
  88. package/dist/src/core/token-variant.d.ts +7 -0
  89. package/dist/src/core/token-variant.js +9 -0
  90. package/dist/src/core/typecheck-shim.d.ts +13 -0
  91. package/dist/src/core/typecheck-shim.js +17 -2
  92. package/dist/src/core/typecheck.js +14 -3
  93. package/dist/src/types/commands/options.d.ts +69 -3
  94. package/dist/src/types/config.d.ts +22 -0
  95. package/dist/src/utils/options.d.ts +20 -0
  96. package/dist/src/utils/options.js +39 -0
  97. package/dist/src/utils/validation.d.ts +5 -0
  98. package/dist/src/utils/validation.js +7 -0
  99. package/package.json +3 -3
@@ -4,7 +4,7 @@ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock
4
4
  import { resolveArchive } from '../core/firefox-archive.js';
5
5
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
6
6
  import { info, intro, outro, success } from '../utils/logger.js';
7
- import { isValidFirefoxProduct } from '../utils/validation.js';
7
+ import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
8
8
  const SOURCE_PRODUCTS = [
9
9
  'firefox',
10
10
  'firefox-esr',
@@ -27,6 +27,12 @@ function parseSourceProduct(product) {
27
27
  }
28
28
  throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
29
29
  }
30
+ function parseSourceCandidate(candidate) {
31
+ if (isValidFirefoxCandidate(candidate)) {
32
+ return candidate;
33
+ }
34
+ throw new InvalidArgumentError(`--candidate must look like "buildN" (e.g. "build2"), got "${candidate}"`, '--candidate');
35
+ }
30
36
  /**
31
37
  * Atomically updates the Firefox source tuple in fireforge.json.
32
38
  */
@@ -38,6 +44,9 @@ export async function sourceSetCommand(projectRoot, options) {
38
44
  if (options.sha256 !== undefined && options.clearSha256 === true) {
39
45
  throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
40
46
  }
47
+ if (options.candidate !== undefined && options.clearCandidate === true) {
48
+ throw new InvalidArgumentError('--candidate cannot be combined with --clear-candidate', '--candidate');
49
+ }
41
50
  const written = await withConfigFileLock(projectRoot, async () => {
42
51
  const raw = await loadRawConfigDocument(projectRoot);
43
52
  const updated = cloneRawConfig(raw);
@@ -50,6 +59,12 @@ export async function sourceSetCommand(projectRoot, options) {
50
59
  else if (options.sha256 !== undefined) {
51
60
  firefox['sha256'] = options.sha256;
52
61
  }
62
+ if (options.clearCandidate === true) {
63
+ delete firefox['candidate'];
64
+ }
65
+ else if (options.candidate !== undefined) {
66
+ firefox['candidate'] = options.candidate;
67
+ }
53
68
  updated['firefox'] = firefox;
54
69
  const validated = validateConfig(updated);
55
70
  if (validated.firefox.sha256 !== undefined) {
@@ -58,7 +73,7 @@ export async function sourceSetCommand(projectRoot, options) {
58
73
  await writeConfigDocument(projectRoot, updated);
59
74
  return validated.firefox;
60
75
  });
61
- const archive = resolveArchive(written.version, written.product);
76
+ const archive = resolveArchive(written.version, written.product, written.candidate);
62
77
  success(`Set firefox.version = ${written.version}`);
63
78
  success(`Set firefox.product = ${written.product}`);
64
79
  success(`Resolved source URL: ${archive.url}`);
@@ -68,6 +83,12 @@ export async function sourceSetCommand(projectRoot, options) {
68
83
  else if (options.clearSha256 === true) {
69
84
  info('Cleared firefox.sha256');
70
85
  }
86
+ if (written.candidate !== undefined) {
87
+ success(`Set firefox.candidate = ${written.candidate}`);
88
+ }
89
+ else if (options.clearCandidate === true) {
90
+ info('Cleared firefox.candidate');
91
+ }
71
92
  outro('');
72
93
  }
73
94
  /** Registers the source command on the CLI program. */
@@ -82,13 +103,17 @@ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
82
103
  .makeOptionMandatory())
83
104
  .option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
84
105
  .option('--clear-sha256', 'Clear any existing pinned SHA-256')
106
+ .option('--candidate <buildN>', 'Release-candidate build directory (e.g. "build2")', parseSourceCandidate)
107
+ .option('--clear-candidate', 'Clear any existing release-candidate build directory')
85
108
  .action(withErrorHandling(async (options) => {
86
- const { product, version, sha256, clearSha256 } = options;
109
+ const { product, version, sha256, clearSha256, candidate, clearCandidate } = options;
87
110
  await sourceSetCommand(getProjectRoot(), {
88
111
  version,
89
112
  product: parseSourceProduct(product),
90
113
  ...(sha256 !== undefined ? { sha256 } : {}),
91
114
  ...(clearSha256 !== undefined ? { clearSha256 } : {}),
115
+ ...(candidate !== undefined ? { candidate } : {}),
116
+ ...(clearCandidate !== undefined ? { clearCandidate } : {}),
92
117
  });
93
118
  }));
94
119
  }
@@ -1,3 +1,4 @@
1
+ import type { BuildBaseline } from '../core/build-baseline-types.js';
1
2
  import type { ClassifiedFile } from '../core/status-classify.js';
2
3
  export interface ClassifiedBuckets {
3
4
  conflict: ClassifiedFile[];
@@ -11,3 +12,11 @@ export interface ClassifiedBuckets {
11
12
  export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
12
13
  /** Renders the default classified status buckets. */
13
14
  export declare function renderDefaultStatus(totalModified: number, buckets: ClassifiedBuckets, projectRoot: string, binaryName: string): Promise<void>;
15
+ /**
16
+ * Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
17
+ * of the last build baseline's test-packaging coverage. Before this
18
+ * existed, the only way to learn the recorded coverage scope — which
19
+ * concurrent sessions sharing one engine tree overwrite constantly — was
20
+ * to trip the out-of-coverage refusal on a real test run.
21
+ */
22
+ export declare function renderTestCoverageStatus(baseline: BuildBaseline | undefined): void;
@@ -1,4 +1,3 @@
1
- // SPDX-License-Identifier: EUPL-1.2
2
1
  import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
3
2
  import { getPrimaryStatusCode } from '../core/status-classify.js';
4
3
  import { GeneralError } from '../errors/base.js';
@@ -182,3 +181,36 @@ export async function renderDefaultStatus(totalModified, buckets, projectRoot, b
182
181
  .map((section) => `${section.files.length} ${section.label}`);
183
182
  outro(parts.join(', '));
184
183
  }
184
+ /**
185
+ * Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
186
+ * of the last build baseline's test-packaging coverage. Before this
187
+ * existed, the only way to learn the recorded coverage scope — which
188
+ * concurrent sessions sharing one engine tree overwrite constantly — was
189
+ * to trip the out-of-coverage refusal on a real test run.
190
+ */
191
+ export function renderTestCoverageStatus(baseline) {
192
+ if (baseline === undefined) {
193
+ info('No build baseline recorded (.fireforge/last-build.json missing or unreadable).\n' +
194
+ 'Run "fireforge build" to record one.');
195
+ outro('No test-packaging coverage recorded');
196
+ return;
197
+ }
198
+ info(`Last build: ${baseline.builtAt} (binary: ${baseline.binaryName}, ` +
199
+ `engine HEAD: ${baseline.engineHeadSha || '(unborn)'})`);
200
+ info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}`);
201
+ const coverage = baseline.testPackagingCoverage;
202
+ if (coverage === undefined) {
203
+ info('Test packaging coverage: full (implicit — baseline predates coverage recording)');
204
+ outro('Coverage: full');
205
+ return;
206
+ }
207
+ if (coverage === 'full') {
208
+ info('Test packaging coverage: full');
209
+ outro('Coverage: full');
210
+ return;
211
+ }
212
+ const list = coverage.map((path) => ` - ${path}`).join('\n');
213
+ info(`Test packaging coverage: scoped to ${String(coverage.length)} path(s):\n${list}\n` +
214
+ 'A "fireforge test" over paths outside this list will be refused as uncovered.');
215
+ outro(`Coverage: scoped (${String(coverage.length)} paths)`);
216
+ }
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { Command } from 'commander';
3
+ import { readBuildBaseline } from '../core/build-baseline.js';
3
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
5
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
5
6
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
@@ -12,7 +13,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
12
13
  import { ExitCode } from '../errors/codes.js';
13
14
  import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
14
15
  import { info, intro, isMachineOutputMode, outro, setMachineOutputMode, warn, } from '../utils/logger.js';
15
- import { renderDefaultStatus, renderUnmanagedOnly, } from './status-output.js';
16
+ import { renderDefaultStatus, renderTestCoverageStatus, renderUnmanagedOnly, } from './status-output.js';
16
17
  /**
17
18
  * Renders raw worktree status as machine-parseable porcelain-style output.
18
19
  * Each line is: STATUS<tab>FILE
@@ -170,13 +171,25 @@ export async function statusCommand(projectRoot, options = {}) {
170
171
  setMachineOutputMode(true);
171
172
  }
172
173
  try {
173
- const modeCount = [options.raw, options.unmanaged, options.ownership, options.json].filter((v) => v === true).length;
174
+ const modeCount = [
175
+ options.raw,
176
+ options.unmanaged,
177
+ options.ownership,
178
+ options.testCoverage,
179
+ options.json,
180
+ ].filter((v) => v === true).length;
174
181
  if (modeCount > 1) {
175
- throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, and --json together. Pick at most one.');
182
+ throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
176
183
  }
177
184
  if (!options.raw && !options.json) {
178
185
  intro('FireForge Status');
179
186
  }
187
+ // Test-coverage mode needs only the project root (the baseline lives
188
+ // in .fireforge/), so it short-circuits before any engine/git guards.
189
+ if (options.testCoverage) {
190
+ renderTestCoverageStatus(await readBuildBaseline(projectRoot));
191
+ return;
192
+ }
180
193
  const paths = getProjectPaths(projectRoot);
181
194
  const config = await loadConfig(projectRoot);
182
195
  const emitJsonError = (code, message) => {
@@ -309,6 +322,7 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
309
322
  .option('--raw', 'Show raw worktree status without patch classification')
310
323
  .option('--unmanaged', 'Show only unmanaged changes (not covered by patches or tools)')
311
324
  .option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
325
+ .option('--test-coverage', 'Show what the last recorded build covers for test packaging (full or scoped paths)')
312
326
  .option('--json', 'Output classified file status as JSON')
313
327
  .action(withErrorHandling(async (options) => {
314
328
  await statusCommand(getProjectRoot(), options);
@@ -14,7 +14,7 @@ import type { TestRunOutcome } from './test-run.js';
14
14
  * harness problems with their own messages; everything else flows into
15
15
  * the regular non-zero-exit diagnosis chain.
16
16
  */
17
- export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined): void;
17
+ export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined, headless?: boolean): void;
18
18
  /**
19
19
  * Shard-mode adapter over {@link handleNonZeroTestExit}: produces the
20
20
  * diagnosis text as a string (to warn per shard) instead of throwing, so
@@ -7,11 +7,12 @@
7
7
  * `test-harness-crash.ts` for single and sharded invocations. Split out of
8
8
  * `test.ts` to keep both files within the per-file line budget.
9
9
  */
10
- import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, } from '../core/test-harness-crash.js';
10
+ import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
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
14
  import { info } from '../utils/logger.js';
15
+ import { getPlatform } from '../utils/platform.js';
15
16
  function buildUnknownTestMessage(testPaths) {
16
17
  return (`mach could not discover the requested test path${testPaths.length === 1 ? '' : 's'}: ${testPaths.join(', ')}\n\n` +
17
18
  'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
@@ -179,9 +180,14 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
179
180
  * harness problems with their own messages; everything else flows into
180
181
  * the regular non-zero-exit diagnosis chain.
181
182
  */
182
- export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext) {
183
+ export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless = false) {
183
184
  if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
184
- throw new GeneralError(buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts));
185
+ const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
186
+ const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
187
+ headless,
188
+ platform: getPlatform(),
189
+ });
190
+ throw new GeneralError(hint ? `${base}\n\n${hint}` : base);
185
191
  }
186
192
  if (outcome.verdict.kind === 'tests-ran-ok') {
187
193
  // The verdict is authoritative over the raw exit code: a completed
@@ -12,4 +12,25 @@ export declare function resolveCanaryPath(options: TestOptions, projectConfig: P
12
12
  export declare function canaryTimeoutSeconds(projectConfig: ProjectConfig): number;
13
13
  /** Emits or throws the one-word canary verdict. */
14
14
  export declare function reportCanaryOutcome(outcome: TestRunOutcome): void;
15
+ /** Per-harness bucketing of the requested test paths. */
16
+ export interface HarnessClassification {
17
+ xpcshell: string[];
18
+ nonXpcshell: string[];
19
+ }
20
+ /**
21
+ * Harness classification is a pure path→manifest lookup and must precede
22
+ * the build dispatch: a mixed xpcshell+mochitest request can never
23
+ * dispatch, so refusing it before spending minutes in a pre-test build
24
+ * is strictly better (FORGE F7). A nonexistent path classifies as
25
+ * nonXpcshell (findNearestXpcshellManifest returns null), so classifying
26
+ * before assertTestPathsExist is harmless — existence is still asserted
27
+ * after the stale/coverage gates, preserving their precedence over
28
+ * missing-path errors. `xpcshellOnly` (non-empty request, zero
29
+ * nonXpcshell paths) gates the Marionette preflight and the mochitest
30
+ * client flags (FORGE F10); pathless runs keep the full-suite behavior.
31
+ */
32
+ export declare function classifyBeforeDispatch(engineDir: string, normalizedPaths: string[]): Promise<{
33
+ classification: HarnessClassification;
34
+ xpcshellOnly: boolean;
35
+ }>;
15
36
  export {};
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { loadConfig } from '../core/config.js';
3
+ import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
3
4
  import { GeneralError } from '../errors/base.js';
4
5
  import { success } from '../utils/logger.js';
5
6
  /** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
@@ -59,3 +60,44 @@ function buildPathlessTestMessage() {
59
60
  ' - `fireforge test --doctor` to run the Marionette preflight only\n' +
60
61
  ' - `fireforge test --canary` to run the configured short harness canary');
61
62
  }
63
+ async function classifyTestHarnesses(engineDir, normalizedPaths) {
64
+ const result = { xpcshell: [], nonXpcshell: [] };
65
+ for (const testPath of normalizedPaths) {
66
+ const manifest = await findNearestXpcshellManifest(engineDir, testPath);
67
+ if (manifest) {
68
+ result.xpcshell.push(testPath);
69
+ }
70
+ else {
71
+ result.nonXpcshell.push(testPath);
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function buildMixedHarnessMessage(classification) {
77
+ return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
78
+ 'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
79
+ ` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
80
+ ` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
81
+ }
82
+ /**
83
+ * Harness classification is a pure path→manifest lookup and must precede
84
+ * the build dispatch: a mixed xpcshell+mochitest request can never
85
+ * dispatch, so refusing it before spending minutes in a pre-test build
86
+ * is strictly better (FORGE F7). A nonexistent path classifies as
87
+ * nonXpcshell (findNearestXpcshellManifest returns null), so classifying
88
+ * before assertTestPathsExist is harmless — existence is still asserted
89
+ * after the stale/coverage gates, preserving their precedence over
90
+ * missing-path errors. `xpcshellOnly` (non-empty request, zero
91
+ * nonXpcshell paths) gates the Marionette preflight and the mochitest
92
+ * client flags (FORGE F10); pathless runs keep the full-suite behavior.
93
+ */
94
+ export async function classifyBeforeDispatch(engineDir, normalizedPaths) {
95
+ const classification = await classifyTestHarnesses(engineDir, normalizedPaths);
96
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
97
+ throw new GeneralError(buildMixedHarnessMessage(classification));
98
+ }
99
+ return {
100
+ classification,
101
+ xpcshellOnly: normalizedPaths.length > 0 && classification.nonXpcshell.length === 0,
102
+ };
103
+ }
@@ -2,18 +2,19 @@
2
2
  import { Command } from 'commander';
3
3
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
4
4
  import { GeneralError } from '../errors/base.js';
5
- import { commanderArgParser, pickDefined } from '../utils/options.js';
5
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
6
6
  import { testCommand } from './test.js';
7
7
  import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
8
8
  /** Registers the test command on the CLI program. */
9
9
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
10
- program
10
+ const test = program
11
11
  .command('test [paths...]')
12
12
  .description('Run tests via mach test')
13
13
  .option('--headless', 'Run tests in headless mode')
14
14
  .option('--build', 'Run incremental UI build before testing')
15
15
  .option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
16
16
  .option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
17
+ .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)')
17
18
  .option('--kill-stale-marionette', 'Terminate a recognized stale browser process holding the Marionette port before running tests')
18
19
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
19
20
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
@@ -56,9 +57,9 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
56
57
  'runs (one browser instance per argument) by default, which does',
57
58
  'not exercise cross-argument state; --no-shard restores the',
58
59
  'combined single-instance invocation.',
59
- ].join('\n'))
60
- .action(withErrorHandling(async (paths, options) => {
60
+ ].join('\n'));
61
+ addWaitLockOption(test).action(withErrorHandling(async (paths, options) => {
61
62
  const projectRoot = getProjectRoot();
62
- await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)));
63
+ await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
63
64
  }));
64
65
  }
@@ -36,6 +36,8 @@ export interface TestRunContext {
36
36
  baseExtraArgs: readonly string[];
37
37
  /** Bounded harness-crash retry budget (0 disables retries). */
38
38
  harnessRetries: number;
39
+ /** Whether --headless was requested (crash-hint copy only). */
40
+ headless: boolean;
39
41
  /** Extra environment variables for the mach process. */
40
42
  env?: Record<string, string>;
41
43
  }
@@ -12,10 +12,11 @@
12
12
  * combined invocation stays available via `--no-shard`.
13
13
  */
14
14
  import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
15
- import { buildHarnessCrashMessage, classifyHarnessRun, } from '../core/test-harness-crash.js';
15
+ import { buildHarnessCrashMessage, classifyHarnessRun, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
16
16
  import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
17
17
  import { BuildError } from '../errors/build.js';
18
18
  import { info, note, warn } from '../utils/logger.js';
19
+ import { getPlatform } from '../utils/platform.js';
19
20
  import { maybeInjectAppdirArg } from './test-appdir.js';
20
21
  /** Default bounded retry budget for recognized harness crashes. */
21
22
  export const DEFAULT_HARNESS_RETRIES = 2;
@@ -78,7 +79,12 @@ export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
78
79
  const outcome = await runTestsWithRetries(ctx, group.paths);
79
80
  shards.push({ label: group.label, outcome });
80
81
  if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
81
- warn(buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts));
82
+ const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
83
+ const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
84
+ headless: ctx.headless,
85
+ platform: getPlatform(),
86
+ });
87
+ warn(hint ? `${base}\n\n${hint}` : base);
82
88
  }
83
89
  else if (outcome.verdict.kind !== 'tests-ran-ok') {
84
90
  const diagnosis = diagnoseShardFailure(outcome, group.label);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pre-dispatch gates for `fireforge test` that stand between the request
3
+ * and a run against a stale or under-packaged runtime: the packaging
4
+ * coverage refusal, the stale-content refusal, and the compiled
5
+ * StaticComponents refusal. Split out of `test.ts` so the command module
6
+ * stays wiring; the probes and refusal copy live in
7
+ * `src/core/test-stale-check.ts`.
8
+ */
9
+ import type { BuildBaseline } from '../core/build-baseline-types.js';
10
+ import type { TestOptions } from '../types/commands/index.js';
11
+ /**
12
+ * Stale-build preflight — when `--build` was NOT requested, detect
13
+ * packageable engine edits since the last successful build and fail
14
+ * UP-FRONT unless the operator explicitly accepts the stale package risk.
15
+ *
16
+ * Packaging COVERAGE is checked first, on EVERY non-`--build` run and
17
+ * regardless of staleness or `--allow-stale-build`: a runtime packaged by
18
+ * a file-scoped `test --build` can lack support fixtures for OTHER
19
+ * manifests even when nothing changed since — dispatching such a run
20
+ * hangs on missing fixtures rather than failing, so the flag (which only
21
+ * accepts stale CONTENT) must not be the trigger. Field incident: a
22
+ * three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
23
+ * later run over different files timed out twice at 45s waiting on
24
+ * `DOMAudioPlaybackStarted`.
25
+ *
26
+ * Exception: a path-less `test --doctor` stops at the Marionette health
27
+ * check (`runDoctorPreflight` returns 'stop' when no test paths were
28
+ * given) and never dispatches a test, so it needs no packaging coverage —
29
+ * treating it as a full-suite request would refuse a probe that touches
30
+ * no fixtures. The stale-content refusal still applies to it unchanged.
31
+ */
32
+ export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[]): Promise<void>;
33
+ /**
34
+ * Compiled-StaticComponents gate — refuses runs whose child process would
35
+ * resolve a stale compiled component table. `components.conf` entries bake
36
+ * into compiled code that only a FULL build regenerates; a scoped
37
+ * `test --build` repackages the file but the failure surfaces as
38
+ * `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
39
+ * (after the coverage refusal) AND to scoped `test --build` runs (before
40
+ * the pre-test build — that build cannot fix the table). A path-less
41
+ * `test --build` is exempt: its full build refreshes the anchor itself.
42
+ * `--allow-stale-components` downgrades the refusal to a warning.
43
+ */
44
+ export declare function enforceStaticComponentsGate(engineDir: string, baseline: BuildBaseline | undefined, options: TestOptions): Promise<void>;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Pre-dispatch gates for `fireforge test` that stand between the request
4
+ * and a run against a stale or under-packaged runtime: the packaging
5
+ * coverage refusal, the stale-content refusal, and the compiled
6
+ * StaticComponents refusal. Split out of `test.ts` so the command module
7
+ * stays wiring; the probes and refusal copy live in
8
+ * `src/core/test-stale-check.ts`.
9
+ */
10
+ import { checkStaleBuildForTest, checkStaticComponentsStale, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
11
+ import { GeneralError } from '../errors/base.js';
12
+ import { warn } from '../utils/logger.js';
13
+ /**
14
+ * Stale-build preflight — when `--build` was NOT requested, detect
15
+ * packageable engine edits since the last successful build and fail
16
+ * UP-FRONT unless the operator explicitly accepts the stale package risk.
17
+ *
18
+ * Packaging COVERAGE is checked first, on EVERY non-`--build` run and
19
+ * regardless of staleness or `--allow-stale-build`: a runtime packaged by
20
+ * a file-scoped `test --build` can lack support fixtures for OTHER
21
+ * manifests even when nothing changed since — dispatching such a run
22
+ * hangs on missing fixtures rather than failing, so the flag (which only
23
+ * accepts stale CONTENT) must not be the trigger. Field incident: a
24
+ * three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
25
+ * later run over different files timed out twice at 45s waiting on
26
+ * `DOMAudioPlaybackStarted`.
27
+ *
28
+ * Exception: a path-less `test --doctor` stops at the Marionette health
29
+ * check (`runDoctorPreflight` returns 'stop' when no test paths were
30
+ * given) and never dispatches a test, so it needs no packaging coverage —
31
+ * treating it as a full-suite request would refuse a probe that touches
32
+ * no fixtures. The stale-content refusal still applies to it unchanged.
33
+ */
34
+ export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
35
+ const stale = await checkStaleBuildForTest(projectRoot, engineDir);
36
+ const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
37
+ if (!dispatchesNoTests) {
38
+ const recordedCoverage = stale.baseline?.testPackagingCoverage;
39
+ const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
40
+ if (uncovered.length > 0) {
41
+ throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
42
+ }
43
+ }
44
+ await enforceStaticComponentsGate(engineDir, stale.baseline, options);
45
+ const staleMessage = stale.stale
46
+ ? `${formatStaleBuildWarning(stale)}\n\n` +
47
+ 'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
48
+ '`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
49
+ : undefined;
50
+ if (staleMessage !== undefined) {
51
+ if (options.allowStaleBuild === true) {
52
+ warn(staleMessage);
53
+ }
54
+ else {
55
+ throw new GeneralError(staleMessage);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Compiled-StaticComponents gate — refuses runs whose child process would
61
+ * resolve a stale compiled component table. `components.conf` entries bake
62
+ * into compiled code that only a FULL build regenerates; a scoped
63
+ * `test --build` repackages the file but the failure surfaces as
64
+ * `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
65
+ * (after the coverage refusal) AND to scoped `test --build` runs (before
66
+ * the pre-test build — that build cannot fix the table). A path-less
67
+ * `test --build` is exempt: its full build refreshes the anchor itself.
68
+ * `--allow-stale-components` downgrades the refusal to a warning.
69
+ */
70
+ export async function enforceStaticComponentsGate(engineDir, baseline, options) {
71
+ const result = await checkStaticComponentsStale(engineDir, baseline);
72
+ if (!result.stale) {
73
+ return;
74
+ }
75
+ const message = formatStaticComponentsRefusal(result.changedManifests);
76
+ if (options.allowStaleComponents === true) {
77
+ warn(message);
78
+ return;
79
+ }
80
+ throw new GeneralError(message);
81
+ }