@hominis/fireforge 0.46.2 → 0.47.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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/config.js +1 -2
  4. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
  5. package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
  6. package/dist/src/commands/furnace/deploy.js +36 -3
  7. package/dist/src/commands/furnace/validation-output.d.ts +5 -0
  8. package/dist/src/commands/furnace/validation-output.js +35 -9
  9. package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
  10. package/dist/src/commands/patch/delete-gap-notice.js +46 -0
  11. package/dist/src/commands/patch/delete.js +11 -0
  12. package/dist/src/commands/re-export-lint.d.ts +26 -1
  13. package/dist/src/commands/re-export-lint.js +60 -20
  14. package/dist/src/commands/re-export-scan.d.ts +8 -0
  15. package/dist/src/commands/re-export-scan.js +12 -2
  16. package/dist/src/commands/re-export-single.js +24 -5
  17. package/dist/src/commands/run.js +42 -17
  18. package/dist/src/commands/test-harness-env.d.ts +24 -0
  19. package/dist/src/commands/test-harness-env.js +61 -0
  20. package/dist/src/commands/test-register.js +6 -2
  21. package/dist/src/commands/test-verdict.d.ts +3 -1
  22. package/dist/src/commands/test-verdict.js +17 -2
  23. package/dist/src/commands/test.js +9 -16
  24. package/dist/src/core/build-jar-escalation.d.ts +1 -4
  25. package/dist/src/core/build-jar-escalation.js +1 -4
  26. package/dist/src/core/build-prepare.js +72 -55
  27. package/dist/src/core/firefox-cache.js +1 -2
  28. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  29. package/dist/src/core/furnace-apply-output.js +19 -4
  30. package/dist/src/core/furnace-css-fragments.d.ts +7 -0
  31. package/dist/src/core/furnace-css-fragments.js +12 -3
  32. package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
  33. package/dist/src/core/furnace-fragment-includers.js +122 -0
  34. package/dist/src/core/git-diff-new-file.js +1 -2
  35. package/dist/src/core/git-file-ops.js +1 -2
  36. package/dist/src/core/git.js +1 -2
  37. package/dist/src/core/mach-build-artifacts.js +1 -2
  38. package/dist/src/core/mochitest-server-port.d.ts +43 -4
  39. package/dist/src/core/mochitest-server-port.js +64 -11
  40. package/dist/src/core/patch-lint-imports.js +4 -4
  41. package/dist/src/core/smoke-patterns.d.ts +24 -0
  42. package/dist/src/core/smoke-patterns.js +50 -5
  43. package/dist/src/types/commands/options.d.ts +6 -0
  44. package/docs/configuration.md +8 -0
  45. package/docs/exit-codes.md +19 -19
  46. package/docs/machine-output.md +22 -10
  47. package/docs/testing.md +13 -1
  48. package/package.json +5 -5
@@ -20,15 +20,18 @@
20
20
  * (`refreshQueueCtxEntry`), so later iterations of an `--all` run lint
21
21
  * against the just-refreshed body instead of the stale one.
22
22
  */
23
- import { buildPatchQueueContext, countNonBinaryDiffLines, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
23
+ import { buildPatchQueueContext, countNonBinaryDiffLines, detectNewFilesInDiff, formatPatchLintIssue, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
24
24
  import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/patch-lint-cache.js';
25
25
  import { invalidateNewFileCreatorsCache } from '../core/patch-lint-cross.js';
26
- import { extractAddedLinesPerFile } from '../core/patch-lint-diff.js';
27
26
  import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
27
+ import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
28
+ import { GeneralError } from '../errors/base.js';
28
29
  import { pathExists } from '../utils/fs.js';
29
- import { info } from '../utils/logger.js';
30
+ import { info, warn } from '../utils/logger.js';
30
31
  import { reportPatchLintOutcome } from './export-shared.js';
31
32
  import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
33
+ import { projectEntryBody } from './patch/entry-projection.js';
34
+ import { formatForwardImportRemedy } from './re-export-scan.js';
32
35
  /** Builds the once-per-invocation lint context. */
33
36
  export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
34
37
  const patchQueueCtx = (await pathExists(paths.patches))
@@ -50,6 +53,7 @@ export async function buildReExportLintContext(projectRoot, paths, config, noCac
50
53
  cacheDirty: false,
51
54
  reusedCacheEntries: 0,
52
55
  globalsEmitted: false,
56
+ queueBaselineWarned: false,
53
57
  };
54
58
  }
55
59
  /** The checkJs-relevant files this fresh diff creates. */
@@ -179,10 +183,61 @@ export async function storeReExportLintResult(lintCtx, patchFilename, projectedM
179
183
  });
180
184
  lintCtx.cacheDirty = true;
181
185
  }
186
+ /**
187
+ * Projects the rewritten body into the hoisted queue context and refuses
188
+ * when the projection introduces error-severity cross-patch findings the
189
+ * current queue does not have (a forward import an earlier owner gained,
190
+ * say). Mirrors `re-export --files` (its projected-state conflict) and the
191
+ * `--scan` adoption gate, which only covered files a scan adopted: a plain
192
+ * re-export of an unchanged file set with new CONTENT used to pass, and the
193
+ * queue carried the error until the next `export` placement refused it.
194
+ *
195
+ * Runs in dry-run. `--force-unsafe` downgrades the refusal to warnings;
196
+ * `--skip-lint` does not bypass it, same as `--files`. The baseline is the
197
+ * hoisted context, which {@link refreshQueueCtxEntry} keeps equal to the
198
+ * on-disk queue across an `--all` loop, so patch N+1 is projected against
199
+ * the queue as it will exist.
200
+ */
201
+ export function assertRefreshedBodyHasNoNewQueueErrors(args) {
202
+ const { lintCtx, patchFilename, diffContent, forceUnsafe } = args;
203
+ const ctx = lintCtx.patchQueueCtx;
204
+ if (!ctx)
205
+ return;
206
+ const projection = projectEntryBody(diffContent);
207
+ const projectedEntries = ctx.entries.map((entry) => entry.filename === patchFilename ? { ...entry, ...projection } : entry);
208
+ const isError = (issue) => issue.severity === 'error';
209
+ const baseline = lintPatchQueue(ctx).filter(isError);
210
+ const projected = lintPatchQueue({
211
+ entries: projectedEntries,
212
+ ...(ctx.patchPolicy ? { patchPolicy: ctx.patchPolicy } : {}),
213
+ }).filter(isError);
214
+ const regressions = computeProjectedLintRegressions(baseline, projected);
215
+ if (regressions.length === 0) {
216
+ if (baseline.length > 0 && !lintCtx.queueBaselineWarned) {
217
+ lintCtx.queueBaselineWarned = true;
218
+ warn(`Note: projected queue still has ${baseline.length} pre-existing ` +
219
+ 'cross-patch error(s) unrelated to this re-export. Run "fireforge verify" to list them.');
220
+ }
221
+ return;
222
+ }
223
+ const details = regressions.map((issue) => ` ${formatPatchLintIssue(issue)}`).join('\n');
224
+ const hasForwardImport = regressions.some((issue) => issue.check === 'forward-import');
225
+ const message = `Refusing to re-export ${patchFilename} because the refreshed body introduces ` +
226
+ `${regressions.length} new cross-patch lint error${regressions.length === 1 ? '' : 's'}:\n${details}` +
227
+ (hasForwardImport ? `\n${formatForwardImportRemedy('re-running the re-export')}` : '');
228
+ if (forceUnsafe) {
229
+ warn(message);
230
+ warn('Proceeding because --force-unsafe was provided.');
231
+ return;
232
+ }
233
+ throw new GeneralError(message);
234
+ }
182
235
  /**
183
236
  * Refreshes the in-memory queue entry for a just-rewritten patch so later
184
237
  * iterations (and later cache keys) lint against the new body instead of
185
- * the stale one. The on-disk write is the authority. This mirrors it.
238
+ * the stale one. The on-disk write is the authority. This mirrors it, via
239
+ * the same projection `buildPatchQueueContext` derives an entry from, so
240
+ * `createdFiles` and `modifiedFileAdditions` stay in step with a rebuild.
186
241
  */
187
242
  export function refreshQueueCtxEntry(lintCtx, patchFilename, diffContent) {
188
243
  const ctx = lintCtx.patchQueueCtx;
@@ -191,22 +246,7 @@ export function refreshQueueCtxEntry(lintCtx, patchFilename, diffContent) {
191
246
  const entry = ctx.entries.find((e) => e.filename === patchFilename);
192
247
  if (!entry)
193
248
  return;
194
- entry.diff = diffContent;
195
- const newFilePaths = detectNewFilesInDiff(diffContent);
196
- const addedLinesByFile = extractAddedLinesPerFile(diffContent);
197
- const newFiles = new Map();
198
- const modifiedFileAdditions = new Map();
199
- for (const [file, lines] of addedLinesByFile) {
200
- if (newFilePaths.has(file)) {
201
- // A created file's content is its added lines.
202
- newFiles.set(file, lines.join('\n'));
203
- }
204
- else {
205
- modifiedFileAdditions.set(file, lines.join('\n'));
206
- }
207
- }
208
- entry.newFiles = newFiles;
209
- entry.modifiedFileAdditions = modifiedFileAdditions;
249
+ Object.assign(entry, projectEntryBody(diffContent));
210
250
  invalidateNewFileCreatorsCache(ctx);
211
251
  }
212
252
  /** Persists cache writes and reports reuse, once after the loop. */
@@ -45,6 +45,14 @@ export declare function assertScanAdoptionsHaveNoForwardImports(args: {
45
45
  patchFilename: string;
46
46
  added: readonly string[];
47
47
  }): Promise<void>;
48
+ /**
49
+ * The one remedy sentence for a forward import a patch body cannot carry.
50
+ * Shared by the `--scan` adoption gate and the plain re-export projection
51
+ * gate so both refusals point at the same commands.
52
+ *
53
+ * @param reRunHint - What to do after the fix ("re-running the scan")
54
+ */
55
+ export declare function formatForwardImportRemedy(reRunHint: string): string;
48
56
  /** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
49
57
  export declare function assertScanFileAdditionsHaveDiffHunks(args: {
50
58
  diffContent: string;
@@ -201,9 +201,19 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
201
201
  const details = offending.map((issue) => ` - ${issue.file}: ${issue.message}`).join('\n');
202
202
  throw new GeneralError(`Refusing to adopt ${offending.length} scanned file${offending.length === 1 ? '' : 's'} into ${patchFilename} ` +
203
203
  `because they import modules created by later patches:\n${details}\n` +
204
- 'Export those files as their own later patch ("fireforge export --order <n>" / ' +
204
+ formatForwardImportRemedy('re-running the scan'));
205
+ }
206
+ /**
207
+ * The one remedy sentence for a forward import a patch body cannot carry.
208
+ * Shared by the `--scan` adoption gate and the plain re-export projection
209
+ * gate so both refusals point at the same commands.
210
+ *
211
+ * @param reRunHint - What to do after the fix ("re-running the scan")
212
+ */
213
+ export function formatForwardImportRemedy(reRunHint) {
214
+ return ('Export those files as their own later patch ("fireforge export --order <n>" / ' +
205
215
  '"fireforge patch split --order <n>") or declare the intentional dependency with ' +
206
- '"fireforge patch staged-dependency --add" before re-running the scan.');
216
+ `"fireforge patch staged-dependency --add" before ${reRunHint}.`);
207
217
  }
208
218
  /** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
209
219
  export function assertScanFileAdditionsHaveDiffHunks(args) {
@@ -16,7 +16,7 @@ import { info, success, warn } from '../utils/logger.js';
16
16
  import { sleep } from '../utils/sleep.js';
17
17
  import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
18
18
  import { reportForeignDrift } from './re-export-drift.js';
19
- import { lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
19
+ import { assertRefreshedBodyHasNoNewQueueErrors, lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
20
20
  import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, scanPatchFilesForReExport, } from './re-export-scan.js';
21
21
  const GIT_INDEX_LOCK_RETRY_MS = 300;
22
22
  /**
@@ -161,10 +161,16 @@ async function reExportSinglePatch(args) {
161
161
  return false;
162
162
  }
163
163
  const { updates, ignoreChecks, effectiveTier, existingIgnoreSet, flagIgnoreSet } = computeReExportUpdates(patch, options, currentFilesAffected);
164
- enforcePatchPolicy({
165
- config,
166
- manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
167
- command: 're-export',
164
+ enforceProjectedManifestPolicy({ config, manifest, patch, updates, options });
165
+ // Cross-patch projection gate (runs in dry-run too): the refreshed body
166
+ // must not introduce queue-level errors the current queue lacks, e.g. an
167
+ // earlier owner gaining an import of a later patch's module. Cheap
168
+ // (in-memory) and more actionable than the per-patch lint, so it fires
169
+ // first.
170
+ assertRefreshedBodyHasNoNewQueueErrors({
171
+ lintCtx,
172
+ patchFilename: patch.filename,
173
+ diffContent,
168
174
  forceUnsafe: options.forceUnsafe === true,
169
175
  });
170
176
  // Lint against the once-per-invocation hoisted context (queue context +
@@ -190,6 +196,9 @@ async function reExportSinglePatch(args) {
190
196
  if (addedIgnores.length > 0) {
191
197
  info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
192
198
  }
199
+ // Memory-only: later `--all --dry-run` iterations project against this
200
+ // patch's would-be body, exactly as the real run does after its write.
201
+ refreshQueueCtxEntry(lintCtx, patch.filename, diffContent);
193
202
  }
194
203
  else {
195
204
  await commitRefreshedPatch({
@@ -208,6 +217,16 @@ async function reExportSinglePatch(args) {
208
217
  }
209
218
  return true;
210
219
  }
220
+ /** Manifest-level policy gate over the projected row (ranges, gaps, collisions). */
221
+ function enforceProjectedManifestPolicy(args) {
222
+ const { config, manifest, patch, updates, options } = args;
223
+ enforcePatchPolicy({
224
+ config,
225
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
226
+ command: 're-export',
227
+ forceUnsafe: options.forceUnsafe === true,
228
+ });
229
+ }
211
230
  /**
212
231
  * Writes the refreshed body + manifest row atomically under the patch
213
232
  * directory lock (a split write would let a concurrent queue mutation
@@ -8,7 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
8
8
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
9
9
  import { hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke } from '../core/mach.js';
10
10
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
11
- import { compileAllowlistFromFile, compileAllowlistFromStrings, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
11
+ import { compileAllowlistFromFile, compileAllowlistFromStrings, findOverCapAllowlistEntries, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
12
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
13
  import { BuildError } from '../errors/build.js';
14
14
  import { ExitCode } from '../errors/codes.js';
@@ -192,17 +192,7 @@ async function runSmokeExit(engineDir, options) {
192
192
  }
193
193
  findings.push({ stream, line });
194
194
  };
195
- // A headed smoke window on a developer desktop absorbs live input: a human
196
- // interacting with the window mid-run can trigger console errors (a
197
- // password-manager import scan probing an unreadable profile dir, say)
198
- // that fail the smoke run looking like a product regression. CI hosts (CI
199
- // env var set) are assumed display-free and unattended, so the notice
200
- // stays quiet there.
201
- if (!options.headless && !process.env['CI']) {
202
- warn('Headed smoke window: keyboard/mouse input during the window will contaminate the ' +
203
- 'console capture and can fail the run. Pass --headless (or run on an unattended host) ' +
204
- 'for reliable smoke checks.');
205
- }
195
+ warnIfHeadedOnDesktop(options.headless === true);
206
196
  info(`Launching browser (smoke-exit after ${smokeExit}s)...\n`);
207
197
  const startedAt = Date.now();
208
198
  let result;
@@ -222,6 +212,7 @@ async function runSmokeExit(engineDir, options) {
222
212
  captureStream?.end();
223
213
  }
224
214
  const elapsedMs = Date.now() - startedAt;
215
+ const overCap = findOverCapAllowlistEntries(allowlist, allowlistHits);
225
216
  reportSmokeSummary({
226
217
  smokeTimeoutMs,
227
218
  elapsedMs,
@@ -230,12 +221,14 @@ async function runSmokeExit(engineDir, options) {
230
221
  allowlistedTotalHits,
231
222
  allowlist,
232
223
  allowlistHits,
224
+ overCap,
233
225
  findings,
234
226
  exitCode: result.exitCode,
235
227
  });
236
- // Exit contract (precedence: unallowed errors dominate timed-out).
237
- if (findings.length > 0) {
238
- throw new SmokeRunError(`Smoke run observed ${findings.length} unallowed console error(s).`, ExitCode.SMOKE_EXIT_FAILURE);
228
+ // Exit contract (precedence: unallowed errors and over-ceiling floods
229
+ // dominate timed-out).
230
+ if (findings.length > 0 || overCap.length > 0) {
231
+ throw new SmokeRunError(describeSmokeExitFailure(findings.length, overCap.length), ExitCode.SMOKE_EXIT_FAILURE);
239
232
  }
240
233
  if (result.timedOut) {
241
234
  // Clean window: SIGTERM came from us. Treat as success.
@@ -273,6 +266,30 @@ async function buildAllowlist(options) {
273
266
  }
274
267
  return allow;
275
268
  }
269
+ /**
270
+ * A headed smoke window on a developer desktop absorbs live input: a human
271
+ * interacting with the window mid-run can trigger console errors (a
272
+ * password-manager import scan probing an unreadable profile dir, say)
273
+ * that fail the smoke run looking like a product regression. CI hosts (CI
274
+ * env var set) are assumed display-free and unattended, so the notice
275
+ * stays quiet there.
276
+ */
277
+ function warnIfHeadedOnDesktop(headless) {
278
+ if (headless || process.env['CI'])
279
+ return;
280
+ warn('Headed smoke window: keyboard/mouse input during the window will contaminate the ' +
281
+ 'console capture and can fail the run. Pass --headless (or run on an unattended host) ' +
282
+ 'for reliable smoke checks.');
283
+ }
284
+ /** The SMOKE_EXIT_FAILURE message, naming whichever of the two causes fired. */
285
+ function describeSmokeExitFailure(unallowed, overCap) {
286
+ const parts = [];
287
+ if (unallowed > 0)
288
+ parts.push(`${unallowed} unallowed console error(s)`);
289
+ if (overCap > 0)
290
+ parts.push(`${overCap} allowlist entr${overCap === 1 ? 'y' : 'ies'} over its max-hits ceiling`);
291
+ return `Smoke run observed ${parts.join(' and ')}.`;
292
+ }
276
293
  /**
277
294
  * Prints the human-readable summary block that follows every smoke run.
278
295
  * Called once, right before the exit-code decision. Keeps the reporting
@@ -302,9 +319,17 @@ function reportSmokeSummary(args) {
302
319
  args.allowlist.forEach((entry, index) => {
303
320
  const hits = args.allowlistHits[index] ?? 0;
304
321
  const zeroSuffix = hits === 0 ? ' (never matched — candidate for removal)' : '';
305
- info(` ${hits}× ${entry.origin} ${entry.source}${zeroSuffix}`);
322
+ const cap = entry.maxHits === undefined ? '' : `/${entry.maxHits}`;
323
+ info(` ${hits}×${cap} ${entry.origin} ${entry.source}${zeroSuffix}`);
306
324
  });
307
325
  }
326
+ // An allowlisted shape firing past its `# max-hits` ceiling is a flood the
327
+ // allowlist was never meant to absorb. It is reported next to the
328
+ // attribution it came from and counts toward the exit code below.
329
+ for (const over of args.overCap) {
330
+ warn(` allowlisted shape "${over.entry.source}" (${over.entry.origin}) fired ${over.hits}× ` +
331
+ `over its ceiling of ${over.maxHits}`);
332
+ }
308
333
  if (args.findings.length === 0)
309
334
  return;
310
335
  warn('');
@@ -330,7 +355,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
330
355
  return parsed;
331
356
  }))
332
357
  .option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', ...stringListOption())
333
- .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
358
+ .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored. A "# max-hits: N" comment directly above an entry caps how many lines it may match in one run; more than that fails the run with exit 12.')
334
359
  .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
335
360
  .option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
336
361
  .action(withErrorHandling(
@@ -0,0 +1,24 @@
1
+ import type { TestSuite } from './test-run.js';
2
+ /**
3
+ * Builds the perf-sample env contract for the harness run:
4
+ * `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
5
+ * artifact file a budget checker consumes after the run.
6
+ */
7
+ export declare function buildPerfSampleEnv(projectRoot: string, binaryName: string, perfSamples: string | undefined): Record<string, string> | undefined;
8
+ /**
9
+ * Merges the per-feature harness env contracts into one map, or `undefined`
10
+ * when no feature exported anything (so `TestRunContext.env` stays absent).
11
+ */
12
+ export declare function mergeHarnessEnv(...parts: (Record<string, string> | undefined)[]): Record<string, string> | undefined;
13
+ /**
14
+ * Resolves the `--shuffle` seed: a fresh one when the flag was bare, the
15
+ * operator's when it carried one. Mach's `--shuffle` is an UNSEEDED
16
+ * Fisher–Yates over `Math.random` (SimpleTest/setup.js), so a red found by
17
+ * shuffling could not be reproduced. FireForge draws the seed, exports it
18
+ * as `FIREFORGE_SHUFFLE_SEED` for harness code that reorders inside a file,
19
+ * prints it, and stamps it on the verdict line.
20
+ *
21
+ * Mochitest-only: the xpcshell harness has no shuffle, and generic `mach
22
+ * test` dispatch would forward the flag to every suite it fans out to.
23
+ */
24
+ export declare function resolveShuffleSeed(shuffle: number | boolean | undefined, suite: TestSuite): number | undefined;
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Per-feature environment and argument contracts `fireforge test` exports to
4
+ * the harness process: the perf-sample artifact path and the seeded
5
+ * test-order shuffle. Split out of `test.ts` to keep the command body inside
6
+ * its file budget.
7
+ */
8
+ import { randomInt } from 'node:crypto';
9
+ import { resolve } from 'node:path';
10
+ import { GeneralError } from '../errors/base.js';
11
+ import { info } from '../utils/logger.js';
12
+ import { setVerdictRunAttribute } from './test-verdict.js';
13
+ /**
14
+ * Builds the perf-sample env contract for the harness run:
15
+ * `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
16
+ * artifact file a budget checker consumes after the run.
17
+ */
18
+ export function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
19
+ if (!perfSamples)
20
+ return undefined;
21
+ const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
22
+ const artifactPath = resolve(projectRoot, perfSamples);
23
+ info(`Perf sample contract: ${envName}=${artifactPath}`);
24
+ return { [envName]: artifactPath };
25
+ }
26
+ /**
27
+ * Merges the per-feature harness env contracts into one map, or `undefined`
28
+ * when no feature exported anything (so `TestRunContext.env` stays absent).
29
+ */
30
+ export function mergeHarnessEnv(...parts) {
31
+ const merged = {};
32
+ for (const part of parts) {
33
+ if (part)
34
+ Object.assign(merged, part);
35
+ }
36
+ return Object.keys(merged).length > 0 ? merged : undefined;
37
+ }
38
+ /**
39
+ * Resolves the `--shuffle` seed: a fresh one when the flag was bare, the
40
+ * operator's when it carried one. Mach's `--shuffle` is an UNSEEDED
41
+ * Fisher–Yates over `Math.random` (SimpleTest/setup.js), so a red found by
42
+ * shuffling could not be reproduced. FireForge draws the seed, exports it
43
+ * as `FIREFORGE_SHUFFLE_SEED` for harness code that reorders inside a file,
44
+ * prints it, and stamps it on the verdict line.
45
+ *
46
+ * Mochitest-only: the xpcshell harness has no shuffle, and generic `mach
47
+ * test` dispatch would forward the flag to every suite it fans out to.
48
+ */
49
+ export function resolveShuffleSeed(shuffle, suite) {
50
+ if (shuffle === undefined || shuffle === false)
51
+ return undefined;
52
+ if (suite !== 'mochitest') {
53
+ throw new GeneralError("--shuffle forwards the mochitest harness's file-order shuffle; the xpcshell harness has " +
54
+ 'none, and generic `mach test` dispatch would hand it to every suite. Narrow the paths to ' +
55
+ 'browser-chrome/mochitest files (and drop --generic-mach-test).');
56
+ }
57
+ const seed = shuffle === true ? randomInt(1, 2 ** 31 - 1) : shuffle;
58
+ setVerdictRunAttribute('shuffle', String(seed));
59
+ info(`Test order shuffle: seed=${seed} (reproduce with "fireforge test --shuffle=${seed}")`);
60
+ return seed;
61
+ }
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import { withEngineSessionLock } from '../core/engine-session-lock.js';
4
4
  import { GeneralError, LockContentionError } from '../errors/base.js';
5
5
  import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
6
+ import { parsePositiveIntegerFlag } from '../utils/validation.js';
6
7
  import { testCommand } from './test.js';
7
8
  import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
8
9
  import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
@@ -29,7 +30,9 @@ const TEST_HELP_TEXT = [
29
30
  '--doctor) ends with exactly one raw stdout line',
30
31
  ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
31
32
  ' no-tests|preflight|inconclusive|lock-timeout|killed] [checks=<n>]',
32
- ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [log=<path>]',
33
+ ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [shuffle=<seed>]',
34
+ ' [log=<path>]',
35
+ 'shuffle=<seed> is the seed a --shuffle run exported to the harness.',
33
36
  "log=<path> names this run's complete output, written to",
34
37
  '.fireforge/logs/ as the run streams, so a piped or truncated run',
35
38
  'still leaves a re-readable artifact.',
@@ -68,11 +71,12 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
68
71
  .option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
69
72
  .option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
70
73
  .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)')
71
- .option('--kill-stale-marionette', 'Terminate a recognized stale browser from this objdir or one holding the Marionette port before running tests')
74
+ .option('--kill-stale-marionette', "Terminate a recognized stale browser from this objdir, one holding the Marionette port, or this checkout's own leftover mochitest httpd before running tests. Never touches another checkout's live harness.")
72
75
  .option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag opts into the kill.')
73
76
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
74
77
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
75
78
  .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
79
+ .option('--shuffle [seed]', 'Run the mochitest files in a seeded random order: forwards mach --shuffle and exports FIREFORGE_SHUFFLE_SEED=<seed> to the harness (a fresh seed when omitted). The seed rides the FIREFORGE-VERDICT line as shuffle=<seed>; replay a red with --shuffle=<seed>. Mochitest-only. Write it as --shuffle=<seed> or after the paths, since a bare "--shuffle <path>" reads the path as the seed.', commanderArgParser((raw) => parsePositiveIntegerFlag('--shuffle', raw)))
76
80
  .option('--harness-retries <n>', `Retry budget for recognized harness crashes (resource-monitor tracebacks, pre-test hangs, post-green shutdown re-entry). 0 disables retries. Default: ${DEFAULT_HARNESS_RETRIES}.`, commanderArgParser((raw) => {
77
81
  const n = Number.parseInt(raw, 10);
78
82
  if (!Number.isFinite(n) || n < 0 || n > 10) {
@@ -12,6 +12,8 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
12
12
  * of the others.
13
13
  */
14
14
  export type FireforgeVerdictReason = 'crash' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
+ /** Records an additive attribute for this run's verdict line. */
16
+ export declare function setVerdictRunAttribute(key: 'shuffle', value: string): void;
15
17
  /**
16
18
  * Re-arms the verdict sink for a new run. Called at `testCommand` entry so
17
19
  * repeated programmatic invocations (and tests) each get their own
@@ -34,7 +36,7 @@ export declare function emitPassVerdict(): void;
34
36
  * Emits a FAIL verdict carrying an emission-layer reason code, and
35
37
  * optionally the refusal class that produced it.
36
38
  *
37
- * `note=` is additive, like the `log=`, `signal=` and `shards=` keys already
39
+ * `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
38
40
  * on this line: `reason=` is unchanged and still comes from the closed set
39
41
  * in `docs/machine-output.md`, so a consumer that tokenises `key=value`
40
42
  * pairs is unaffected. It exists because `reason=preflight` covers every
@@ -3,6 +3,19 @@ import { getActiveRunLogPath } from '../core/run-log.js';
3
3
  import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
4
4
  import { setStdoutSealed } from '../utils/logger.js';
5
5
  let emitted = false;
6
+ /**
7
+ * Additive `key=value` attributes of the run in flight, appended to
8
+ * whichever verdict line ends it. `shuffle` is the seed a `--shuffle` run
9
+ * exported to the harness, so a red found by shuffling carries its own
10
+ * reproduction on the one line an unattended reader keeps. Cleared by
11
+ * {@link resetVerdictEmission}, so a later run in the same process cannot
12
+ * inherit it.
13
+ */
14
+ const runAttributes = new Map();
15
+ /** Records an additive attribute for this run's verdict line. */
16
+ export function setVerdictRunAttribute(key, value) {
17
+ runAttributes.set(key, value);
18
+ }
6
19
  /**
7
20
  * True once a test run has begun in this process.
8
21
  *
@@ -20,6 +33,7 @@ let armed = false;
20
33
  export function resetVerdictEmission() {
21
34
  emitted = false;
22
35
  armed = true;
36
+ runAttributes.clear();
23
37
  setStdoutSealed(false);
24
38
  }
25
39
  /** True once this run has written its `FIREFORGE-VERDICT:` line. */
@@ -45,9 +59,10 @@ function writeVerdictLine(line) {
45
59
  // break that contract, and one before it is the first thing a `tail` cuts.
46
60
  // Appended as an additive `key=value`, so consumers that tokenize the line
47
61
  // are unaffected and a truncated tail still says where the full output is.
62
+ const attributes = [...runAttributes].map(([key, value]) => ` ${key}=${value}`).join('');
48
63
  const logPath = getActiveRunLogPath();
49
64
  const suffix = logPath === undefined ? '' : ` log=${logPath}`;
50
- process.stdout.write(`${line}${suffix}\n`);
65
+ process.stdout.write(`${line}${attributes}${suffix}\n`);
51
66
  setStdoutSealed(true);
52
67
  }
53
68
  /**
@@ -65,7 +80,7 @@ export function emitPassVerdict() {
65
80
  * Emits a FAIL verdict carrying an emission-layer reason code, and
66
81
  * optionally the refusal class that produced it.
67
82
  *
68
- * `note=` is additive, like the `log=`, `signal=` and `shards=` keys already
83
+ * `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
69
84
  * on this line: `reason=` is unchanged and still comes from the closed set
70
85
  * in `docs/machine-output.md`, so a consumer that tokenises `key=value`
71
86
  * pairs is unaffected. It exists because `reason=preflight` covers every
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join, resolve } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { assertEngineExists } from '../core/engine-precondition.js';
5
5
  import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
@@ -21,6 +21,7 @@ import { info, intro, notice, outro, verbose } from '../utils/logger.js';
21
21
  import { stripEnginePrefix } from '../utils/paths.js';
22
22
  import { runTestBuildPhase } from './test-build-phase.js';
23
23
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
24
+ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
24
25
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
25
26
  import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
26
27
  import { emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
@@ -241,6 +242,7 @@ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellO
241
242
  // does not use the httpd, so an xpcshell-only run is never blocked by it.
242
243
  if (!xpcshellOnly) {
243
244
  await ensureMochitestServerPortAvailable(undefined, {
245
+ engineDir,
244
246
  killStaleServer: options.killStaleMarionette === true,
245
247
  });
246
248
  }
@@ -418,6 +420,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
418
420
  }
419
421
  await assertTestPathsExist(paths.engine, normalizedPaths);
420
422
  const suite = resolveTestSuite(classification, options.genericMachTest === true);
423
+ const shuffleSeed = resolveShuffleSeed(options.shuffle, suite);
421
424
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
422
425
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
423
426
  : [];
@@ -425,6 +428,9 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
425
428
  if (options.headless) {
426
429
  extraArgs.push('--headless');
427
430
  }
431
+ if (shuffleSeed !== undefined) {
432
+ extraArgs.push('--shuffle');
433
+ }
428
434
  if (options.auto === true) {
429
435
  extraArgs.push('--auto');
430
436
  }
@@ -465,7 +471,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
465
471
  // the manifest for each file individually. See src/core/xpcshell-appdir.ts
466
472
  // for the full motivation.
467
473
  logTestSelection(scopes);
468
- const perfSampleEnv = buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples);
474
+ const harnessEnv = mergeHarnessEnv(buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples), shuffleSeed === undefined ? undefined : { FIREFORGE_SHUFFLE_SEED: String(shuffleSeed) });
469
475
  const runCtx = {
470
476
  engineDir: paths.engine,
471
477
  objDir: buildCheck.objDir,
@@ -475,7 +481,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
475
481
  harnessRetries,
476
482
  headless: options.headless === true,
477
483
  ...(options.fullOutput === true ? { fullOutput: true } : {}),
478
- ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
484
+ ...(harnessEnv ? { env: harnessEnv } : {}),
479
485
  };
480
486
  const postRebuildContext = options.build
481
487
  ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
@@ -527,16 +533,3 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
527
533
  }
528
534
  finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
529
535
  }
530
- /**
531
- * Builds the perf-sample env contract for the harness run:
532
- * `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
533
- * artifact file a budget checker consumes after the run.
534
- */
535
- function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
536
- if (!perfSamples)
537
- return undefined;
538
- const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
539
- const artifactPath = resolve(projectRoot, perfSamples);
540
- info(`Perf sample contract: ${envName}=${artifactPath}`);
541
- return { [envName]: artifactPath };
542
- }
@@ -28,7 +28,6 @@
28
28
  * parse a manifest escalates: this narrows a conservative rule, so its
29
29
  * error direction stays the slow-but-correct one.
30
30
  */
31
- import type { BuildBaseline } from './build-baseline-types.js';
32
31
  /** Why one changed `jar.mn` still forces a full build. */
33
32
  export interface JarEscalationCause {
34
33
  /** Engine-relative manifest path. */
@@ -51,11 +50,9 @@ export interface JarEscalationDecision {
51
50
  * @param engineDir - Absolute engine directory
52
51
  * @param changedPaths - Engine-relative paths changed since the last
53
52
  * successful build (non-`jar.mn` entries are ignored)
54
- * @param baseline - Last successful build's baseline (reserved: the
55
- * fingerprint map already gated which paths reach here)
56
53
  * @returns The escalation decision with per-manifest causes
57
54
  */
58
- export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[], baseline: BuildBaseline | undefined): Promise<JarEscalationDecision>;
55
+ export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[]): Promise<JarEscalationDecision>;
59
56
  /**
60
57
  * Renders the operator-facing escalation notice. Names the manifest and the
61
58
  * reason: "a jar.mn changed" is the sentence that made the previous rule
@@ -68,12 +68,9 @@ async function isNewJarManifest(engineDir, path) {
68
68
  * @param engineDir - Absolute engine directory
69
69
  * @param changedPaths - Engine-relative paths changed since the last
70
70
  * successful build (non-`jar.mn` entries are ignored)
71
- * @param baseline - Last successful build's baseline (reserved: the
72
- * fingerprint map already gated which paths reach here)
73
71
  * @returns The escalation decision with per-manifest causes
74
72
  */
75
- export async function evaluateJarManifestEscalation(engineDir, changedPaths, baseline) {
76
- void baseline;
73
+ export async function evaluateJarManifestEscalation(engineDir, changedPaths) {
77
74
  const causes = [];
78
75
  const cleared = [];
79
76
  for (const path of changedPaths.filter(isJarManifestPath)) {