@hominis/fireforge 0.46.2 → 0.47.1

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 (72) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/bin/fireforge.js +6 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/config.js +1 -2
  5. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
  6. package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
  7. package/dist/src/commands/furnace/deploy.js +36 -3
  8. package/dist/src/commands/furnace/validation-output.d.ts +5 -0
  9. package/dist/src/commands/furnace/validation-output.js +35 -9
  10. package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
  11. package/dist/src/commands/patch/delete-gap-notice.js +46 -0
  12. package/dist/src/commands/patch/delete.js +11 -0
  13. package/dist/src/commands/re-export-lint.d.ts +26 -1
  14. package/dist/src/commands/re-export-lint.js +60 -20
  15. package/dist/src/commands/re-export-scan.d.ts +8 -0
  16. package/dist/src/commands/re-export-scan.js +12 -2
  17. package/dist/src/commands/re-export-single.js +24 -5
  18. package/dist/src/commands/run.js +42 -17
  19. package/dist/src/commands/test-harness-env.d.ts +24 -0
  20. package/dist/src/commands/test-harness-env.js +61 -0
  21. package/dist/src/commands/test-harness-teardown.d.ts +62 -0
  22. package/dist/src/commands/test-harness-teardown.js +128 -0
  23. package/dist/src/commands/test-register.js +8 -3
  24. package/dist/src/commands/test-run.d.ts +2 -0
  25. package/dist/src/commands/test-run.js +36 -7
  26. package/dist/src/commands/test-verdict.d.ts +21 -4
  27. package/dist/src/commands/test-verdict.js +37 -4
  28. package/dist/src/commands/test.js +27 -26
  29. package/dist/src/core/build-jar-escalation.d.ts +1 -4
  30. package/dist/src/core/build-jar-escalation.js +1 -4
  31. package/dist/src/core/build-prepare.js +72 -55
  32. package/dist/src/core/config-paths.d.ts +1 -1
  33. package/dist/src/core/config-paths.js +1 -0
  34. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  35. package/dist/src/core/file-lock.js +16 -9
  36. package/dist/src/core/firefox-cache.js +1 -2
  37. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  38. package/dist/src/core/furnace-apply-output.js +19 -4
  39. package/dist/src/core/furnace-css-fragments.d.ts +7 -0
  40. package/dist/src/core/furnace-css-fragments.js +12 -3
  41. package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
  42. package/dist/src/core/furnace-fragment-includers.js +122 -0
  43. package/dist/src/core/git-diff-new-file.js +1 -2
  44. package/dist/src/core/git-file-ops.js +1 -2
  45. package/dist/src/core/git.js +1 -2
  46. package/dist/src/core/harness-helper-pids.d.ts +98 -0
  47. package/dist/src/core/harness-helper-pids.js +240 -0
  48. package/dist/src/core/harness-orphans.d.ts +53 -8
  49. package/dist/src/core/harness-orphans.js +125 -44
  50. package/dist/src/core/mach-build-artifacts.js +1 -2
  51. package/dist/src/core/mach.d.ts +16 -0
  52. package/dist/src/core/mach.js +6 -1
  53. package/dist/src/core/marionette-preflight.js +9 -3
  54. package/dist/src/core/mochitest-server-port.d.ts +43 -4
  55. package/dist/src/core/mochitest-server-port.js +64 -11
  56. package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
  57. package/dist/src/core/parent-exit-watchdog.js +69 -0
  58. package/dist/src/core/patch-lint-imports.js +4 -4
  59. package/dist/src/core/smoke-patterns.d.ts +24 -0
  60. package/dist/src/core/smoke-patterns.js +50 -5
  61. package/dist/src/types/commands/options.d.ts +17 -1
  62. package/dist/src/types/config.d.ts +7 -0
  63. package/dist/src/utils/process-group.d.ts +5 -1
  64. package/dist/src/utils/process-group.js +7 -3
  65. package/dist/src/utils/process.d.ts +18 -0
  66. package/dist/src/utils/process.js +14 -2
  67. package/docs/configuration.md +15 -1
  68. package/docs/exit-codes.md +19 -19
  69. package/docs/lifecycle-invariants.md +17 -1
  70. package/docs/machine-output.md +44 -19
  71. package/docs/testing.md +80 -4
  72. 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
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Owns the kill path of one mach test dispatch for `fireforge test`.
3
+ *
4
+ * Three things the run must do around every `runMachTestSuite` call, kept
5
+ * together because each one exists for the same incident (a supervisor
6
+ * killed the process above FireForge at its bound and the harness httpd it
7
+ * had launched spun for hours):
8
+ *
9
+ * 1. Track the helper pids the harness announces, and after mach has
10
+ * exited terminate any that outlived it (`harness-helper-pids.ts`).
11
+ * 2. Publish the harness process group (`--pgid-file`), so a supervisor
12
+ * that can only SIGKILL FireForge can still take the whole tree with
13
+ * one `kill -- -<pgid>` afterwards.
14
+ * 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
15
+ * vanishes mid-run, write the killed verdict, reap the group, and let
16
+ * the dispatch loop end the run instead of retrying.
17
+ *
18
+ * Every reap adds to the `orphans-reaped=` verdict attribute.
19
+ */
20
+ import type { MachTestSuiteOptions } from '../core/mach.js';
21
+ /** Inputs for {@link createHarnessTeardown}. */
22
+ export interface HarnessTeardownInputs {
23
+ /** Absolute objdir of this project, when known: the anchor for every kill. */
24
+ objDir: string | undefined;
25
+ /** `--pgid-file`: where to publish the harness process group id. */
26
+ pgidFile: string | undefined;
27
+ }
28
+ /** One dispatch's teardown: the hooks to spread into `runMachTestSuite`, and its lifecycle. */
29
+ export interface HarnessTeardown {
30
+ /** Hooks for {@link MachTestSuiteOptions.teardown}. */
31
+ hooks: NonNullable<MachTestSuiteOptions['teardown']>;
32
+ /**
33
+ * Records the same-objdir helpers alive right now, before the dispatch
34
+ * spawns anything, so the post-close reap can tell this dispatch's
35
+ * unannounced helpers from an earlier run's survivors. Await it before
36
+ * `runMachTestSuite`.
37
+ */
38
+ prepare(): Promise<void>;
39
+ /** Stops the parent watchdog. Call after the dispatch resolved or threw. */
40
+ dispose(): void;
41
+ }
42
+ /**
43
+ * Builds the teardown for one dispatch (one retry attempt of one shard).
44
+ * The pid tracker and the group id are per dispatch; the parent watchdog
45
+ * runs only while that dispatch is in flight.
46
+ */
47
+ export declare function createHarnessTeardown(inputs: HarnessTeardownInputs): HarnessTeardown;
48
+ /** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
49
+ export declare function setActivePgidFile(path: string | undefined): void;
50
+ /**
51
+ * Removes the pgid file at the end of a run that ended under FireForge's
52
+ * control. A file left behind after a normal exit would name a group id
53
+ * the kernel may have reused; the file must only outlive FireForge when
54
+ * FireForge itself was killed without notice, which is what it is for.
55
+ */
56
+ export declare function removePgidFile(path: string | undefined): Promise<void>;
57
+ /**
58
+ * Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
59
+ * file, if one is registered. Called by the bin entry point after the child
60
+ * shutdown wait, so the group the file names has already been signalled.
61
+ */
62
+ export declare function removeActivePgidFile(): Promise<void>;
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Owns the kill path of one mach test dispatch for `fireforge test`.
4
+ *
5
+ * Three things the run must do around every `runMachTestSuite` call, kept
6
+ * together because each one exists for the same incident (a supervisor
7
+ * killed the process above FireForge at its bound and the harness httpd it
8
+ * had launched spun for hours):
9
+ *
10
+ * 1. Track the helper pids the harness announces, and after mach has
11
+ * exited terminate any that outlived it (`harness-helper-pids.ts`).
12
+ * 2. Publish the harness process group (`--pgid-file`), so a supervisor
13
+ * that can only SIGKILL FireForge can still take the whole tree with
14
+ * one `kill -- -<pgid>` afterwards.
15
+ * 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
16
+ * vanishes mid-run, write the killed verdict, reap the group, and let
17
+ * the dispatch loop end the run instead of retrying.
18
+ *
19
+ * Every reap adds to the `orphans-reaped=` verdict attribute.
20
+ */
21
+ import { unlink, writeFile } from 'node:fs/promises';
22
+ import { createHelperPidTracker, reapTrackedHarnessPids, snapshotObjdirHelpers, } from '../core/harness-helper-pids.js';
23
+ import { startParentExitWatchdog } from '../core/parent-exit-watchdog.js';
24
+ import { toError } from '../utils/errors.js';
25
+ import { verbose, warn } from '../utils/logger.js';
26
+ import { sweepProcessGroup } from '../utils/process-group.js';
27
+ import { addVerdictRunCount, emitKilledVerdict } from './test-verdict.js';
28
+ /**
29
+ * Builds the teardown for one dispatch (one retry attempt of one shard).
30
+ * The pid tracker and the group id are per dispatch; the parent watchdog
31
+ * runs only while that dispatch is in flight.
32
+ */
33
+ export function createHarnessTeardown(inputs) {
34
+ const tracker = createHelperPidTracker();
35
+ let pgid;
36
+ let baseline;
37
+ const watchdog = startParentExitWatchdog({
38
+ onParentExit: ({ originalPpid, currentPpid }) => {
39
+ // Verdict first, before anything that can stall: the parent that would
40
+ // have read it is gone, but the run log and any tee of stdout are not.
41
+ emitKilledVerdict('parent-exit');
42
+ warn(`Parent process ${originalPpid} exited while the harness was running (this process now ` +
43
+ `reports parent ${currentPpid}); ending the run and terminating the harness tree so it ` +
44
+ 'cannot run on unsupervised.');
45
+ if (pgid !== undefined) {
46
+ void sweepProcessGroup(pgid, undefined, 'after the parent process exited').catch((error) => {
47
+ warn(`Reaping harness group ${pgid} after parent exit failed: ${toError(error).message}`);
48
+ });
49
+ }
50
+ },
51
+ });
52
+ return {
53
+ hooks: {
54
+ onOutputChunk: (stream, chunk) => {
55
+ tracker.feed(stream, chunk);
56
+ },
57
+ onProcessGroup: (id) => {
58
+ pgid = id;
59
+ verbose(`Harness process group: ${id} (mach is the group leader).`);
60
+ if (inputs.pgidFile !== undefined)
61
+ void publishPgid(inputs.pgidFile, id);
62
+ },
63
+ postCloseSweep: async () => {
64
+ const reaped = await reapTrackedHarnessPids(tracker.tracked(), inputs.objDir, baseline);
65
+ addVerdictRunCount('orphans-reaped', reaped);
66
+ },
67
+ },
68
+ prepare: async () => {
69
+ baseline = await snapshotObjdirHelpers(inputs.objDir);
70
+ },
71
+ dispose: () => {
72
+ watchdog.stop();
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Writes `<pgid>\n` to the pgid file. Best-effort: a supervisor that asked
78
+ * for the file and cannot get it is warned, and the run proceeds, because
79
+ * the file is a courtesy to the supervisor and not a precondition of the
80
+ * tests.
81
+ */
82
+ async function publishPgid(path, pgid) {
83
+ try {
84
+ await writeFile(path, `${pgid}\n`, 'utf8');
85
+ }
86
+ catch (error) {
87
+ warn(`Could not write --pgid-file ${path}: ${toError(error).message}`);
88
+ }
89
+ }
90
+ /**
91
+ * The pgid file of the run in flight, for the signal pipeline. `testCommand`'s
92
+ * own `finally` removes the file on every path it controls, but on SIGTERM
93
+ * that `finally` races the bin handler's `process.exit` and loses, and a
94
+ * SIGTERM is notice: the file must not outlive a run FireForge itself
95
+ * ended. Set when the run starts, cleared by {@link removePgidFile}.
96
+ */
97
+ let activePgidFile;
98
+ /** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
99
+ export function setActivePgidFile(path) {
100
+ activePgidFile = path;
101
+ }
102
+ /**
103
+ * Removes the pgid file at the end of a run that ended under FireForge's
104
+ * control. A file left behind after a normal exit would name a group id
105
+ * the kernel may have reused; the file must only outlive FireForge when
106
+ * FireForge itself was killed without notice, which is what it is for.
107
+ */
108
+ export async function removePgidFile(path) {
109
+ if (path === undefined)
110
+ return;
111
+ if (activePgidFile === path)
112
+ activePgidFile = undefined;
113
+ try {
114
+ await unlink(path);
115
+ }
116
+ catch (error) {
117
+ // ENOENT is the usual case when no dispatch ever spawned.
118
+ verbose(`--pgid-file ${path} not removed: ${toError(error).message}`);
119
+ }
120
+ }
121
+ /**
122
+ * Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
123
+ * file, if one is registered. Called by the bin entry point after the child
124
+ * shutdown wait, so the group the file names has already been signalled.
125
+ */
126
+ export async function removeActivePgidFile() {
127
+ await removePgidFile(activePgidFile);
128
+ }
@@ -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,13 @@ 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')
72
- .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.')
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.")
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 (or test.reapOrphans: "reap" in fireforge.json) opts into the kill. Reaps are stamped on the verdict line as orphans-reaped=<n>.')
76
+ .option('--pgid-file <path>', 'Write the harness process-group id (mach is the group leader) to this file on every spawn, so a supervisor that had to SIGKILL fireforge can still take the whole harness tree with "kill -- -$(cat <path>)". Removed when the run ends normally.')
73
77
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
74
78
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
75
79
  .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
80
+ .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
81
  .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
82
  const n = Number.parseInt(raw, 10);
78
83
  if (!Number.isFinite(n) || n < 0 || n > 10) {
@@ -45,6 +45,8 @@ export interface TestRunContext {
45
45
  * the coding-agent markers that quiet it. Off by default.
46
46
  */
47
47
  fullOutput?: boolean;
48
+ /** `--pgid-file`: where each dispatch publishes its harness process group id. */
49
+ pgidFile?: string | undefined;
48
50
  }
49
51
  /** Outcome of one (possibly retried) harness invocation. */
50
52
  export interface TestRunOutcome {