@hominis/fireforge 0.47.1 → 0.49.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 (167) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli.js +16 -28
  5. package/dist/src/commands/build.js +30 -4
  6. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  7. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  8. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  9. package/dist/src/commands/doctor-macos-sdk.js +158 -0
  10. package/dist/src/commands/doctor-source-pin.js +9 -2
  11. package/dist/src/commands/doctor.js +4 -0
  12. package/dist/src/commands/download.js +64 -3
  13. package/dist/src/commands/furnace/apply.js +10 -4
  14. package/dist/src/commands/furnace/create-templates.js +0 -3
  15. package/dist/src/commands/import.js +5 -2
  16. package/dist/src/commands/lint-per-patch.js +44 -6
  17. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  18. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  19. package/dist/src/commands/lint.js +14 -0
  20. package/dist/src/commands/manifest.d.ts +1 -2
  21. package/dist/src/commands/patch/delete.js +18 -4
  22. package/dist/src/commands/re-export-lint.d.ts +17 -2
  23. package/dist/src/commands/re-export-lint.js +33 -4
  24. package/dist/src/commands/re-export-options.js +7 -0
  25. package/dist/src/commands/re-export-register.js +1 -0
  26. package/dist/src/commands/re-export-removed.d.ts +4 -0
  27. package/dist/src/commands/re-export-removed.js +49 -0
  28. package/dist/src/commands/re-export-single.js +4 -1
  29. package/dist/src/commands/re-export.js +8 -2
  30. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  31. package/dist/src/commands/rebase/confirm.js +6 -3
  32. package/dist/src/commands/rebase/index.js +77 -13
  33. package/dist/src/commands/run.js +43 -7
  34. package/dist/src/commands/status.js +5 -3
  35. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  36. package/dist/src/commands/test-browser-preflight.js +181 -0
  37. package/dist/src/commands/test-build-phase.js +30 -7
  38. package/dist/src/commands/test-checkjs.d.ts +3 -0
  39. package/dist/src/commands/test-checkjs.js +27 -0
  40. package/dist/src/commands/test-diagnose.js +3 -0
  41. package/dist/src/commands/test-harness-env.d.ts +5 -6
  42. package/dist/src/commands/test-harness-env.js +27 -7
  43. package/dist/src/commands/test-modes.d.ts +2 -4
  44. package/dist/src/commands/test-register.js +11 -5
  45. package/dist/src/commands/test-run.d.ts +2 -0
  46. package/dist/src/commands/test-run.js +21 -1
  47. package/dist/src/commands/test-verdict.d.ts +2 -2
  48. package/dist/src/commands/test.js +83 -211
  49. package/dist/src/commands/typecheck.d.ts +5 -0
  50. package/dist/src/commands/typecheck.js +7 -1
  51. package/dist/src/core/build-baseline-types.d.ts +12 -14
  52. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  53. package/dist/src/core/build-baseline-validate.js +53 -0
  54. package/dist/src/core/build-baseline.d.ts +4 -3
  55. package/dist/src/core/build-baseline.js +23 -11
  56. package/dist/src/core/build-prepare.d.ts +2 -0
  57. package/dist/src/core/build-prepare.js +67 -35
  58. package/dist/src/core/component-source-guard.d.ts +20 -0
  59. package/dist/src/core/component-source-guard.js +68 -0
  60. package/dist/src/core/config-paths.d.ts +1 -1
  61. package/dist/src/core/config-paths.js +1 -0
  62. package/dist/src/core/config-validate.js +7 -0
  63. package/dist/src/core/coverage-extend.d.ts +5 -34
  64. package/dist/src/core/coverage-extend.js +26 -37
  65. package/dist/src/core/discard-baseline.js +2 -1
  66. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  67. package/dist/src/core/engine-write-boundary.js +41 -0
  68. package/dist/src/core/forward-registrations.d.ts +33 -0
  69. package/dist/src/core/forward-registrations.js +16 -0
  70. package/dist/src/core/furnace-apply-helpers.js +8 -2
  71. package/dist/src/core/furnace-apply.js +7 -0
  72. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  73. package/dist/src/core/furnace-validate.js +0 -43
  74. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  75. package/dist/src/core/gecko-dom-types.js +17 -0
  76. package/dist/src/core/git-file-ops.d.ts +21 -0
  77. package/dist/src/core/git-file-ops.js +75 -0
  78. package/dist/src/core/git-status.d.ts +14 -0
  79. package/dist/src/core/git-status.js +41 -2
  80. package/dist/src/core/lit-template-regions.d.ts +4 -0
  81. package/dist/src/core/lit-template-regions.js +50 -0
  82. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  83. package/dist/src/core/mach-build-artifacts.js +13 -7
  84. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  85. package/dist/src/core/mach-mozconfig.js +2 -0
  86. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  87. package/dist/src/core/mach-resource-shim.js +2 -0
  88. package/dist/src/core/marionette-port.d.ts +2 -0
  89. package/dist/src/core/marionette-port.js +38 -53
  90. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  91. package/dist/src/core/mochitest-server-port.js +20 -17
  92. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  93. package/dist/src/core/objdir-maintenance.js +151 -0
  94. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  95. package/dist/src/core/patch-apply-fuzz.js +27 -6
  96. package/dist/src/core/patch-apply.d.ts +13 -0
  97. package/dist/src/core/patch-apply.js +27 -2
  98. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  99. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  100. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  101. package/dist/src/core/patch-lint-checkjs.js +10 -2
  102. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  103. package/dist/src/core/patch-lint-cross.js +13 -12
  104. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  105. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  106. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  107. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  108. package/dist/src/core/patch-lint.d.ts +11 -0
  109. package/dist/src/core/patch-lint.js +20 -2
  110. package/dist/src/core/patch-transform.d.ts +14 -13
  111. package/dist/src/core/patch-transform.js +29 -17
  112. package/dist/src/core/preflight-wait.d.ts +2 -0
  113. package/dist/src/core/preflight-wait.js +24 -0
  114. package/dist/src/core/process-owner.d.ts +9 -0
  115. package/dist/src/core/process-owner.js +43 -0
  116. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  117. package/dist/src/core/rebase-dry-run.js +105 -0
  118. package/dist/src/core/run-profile.d.ts +50 -0
  119. package/dist/src/core/run-profile.js +131 -0
  120. package/dist/src/core/status-binary.d.ts +1 -1
  121. package/dist/src/core/status-binary.js +3 -1
  122. package/dist/src/core/status-classify.js +29 -2
  123. package/dist/src/core/test-harness-crash.d.ts +1 -1
  124. package/dist/src/core/test-harness-crash.js +5 -5
  125. package/dist/src/core/test-host-state.d.ts +14 -0
  126. package/dist/src/core/test-host-state.js +80 -0
  127. package/dist/src/core/test-profile-files.d.ts +17 -0
  128. package/dist/src/core/test-profile-files.js +75 -0
  129. package/dist/src/core/test-stale-check.d.ts +6 -7
  130. package/dist/src/core/test-stale-check.js +9 -32
  131. package/dist/src/core/token-category.d.ts +7 -26
  132. package/dist/src/core/token-category.js +68 -121
  133. package/dist/src/core/token-inventory.js +12 -7
  134. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  135. package/dist/src/core/toolchain-preflight.js +1 -1
  136. package/dist/src/core/typecheck.d.ts +11 -1
  137. package/dist/src/core/typecheck.js +86 -12
  138. package/dist/src/errors/rebase.d.ts +9 -0
  139. package/dist/src/errors/rebase.js +20 -0
  140. package/dist/src/index.d.ts +3 -1
  141. package/dist/src/index.js +2 -1
  142. package/dist/src/types/commands/options.d.ts +29 -3
  143. package/dist/src/types/config.d.ts +10 -0
  144. package/dist/src/utils/assert.js +2 -7
  145. package/dist/src/utils/child-env.d.ts +0 -3
  146. package/dist/src/utils/child-env.js +0 -3
  147. package/dist/src/utils/concurrency.d.ts +2 -2
  148. package/dist/src/utils/concurrency.js +15 -4
  149. package/dist/src/utils/fs.js +11 -1
  150. package/dist/src/utils/options.d.ts +16 -1
  151. package/dist/src/utils/options.js +5 -3
  152. package/dist/src/utils/paths.js +3 -2
  153. package/dist/src/utils/process-group.d.ts +1 -2
  154. package/dist/src/utils/process-group.js +1 -2
  155. package/dist/src/utils/process-input.d.ts +27 -0
  156. package/dist/src/utils/process-input.js +46 -0
  157. package/dist/src/utils/process.d.ts +5 -2
  158. package/dist/src/utils/process.js +21 -8
  159. package/dist/src/utils/validation.d.ts +11 -0
  160. package/dist/src/utils/validation.js +43 -0
  161. package/docs/configuration.md +82 -0
  162. package/docs/exit-codes.md +1 -1
  163. package/docs/lifecycle-invariants.md +13 -8
  164. package/docs/machine-output.md +36 -19
  165. package/docs/patch-workflow.md +50 -1
  166. package/docs/testing.md +77 -12
  167. package/package.json +10 -5
@@ -36,12 +36,9 @@ export function mergeHarnessEnv(...parts) {
36
36
  return Object.keys(merged).length > 0 ? merged : undefined;
37
37
  }
38
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.
39
+ * Resolves the seed for FireForge's isolated shard order and exports it for
40
+ * custom in-file task code. Native mach file shuffling remains unseeded;
41
+ * replaying this seed guarantees only the FireForge shard permutation.
45
42
  *
46
43
  * Mochitest-only: the xpcshell harness has no shuffle, and generic `mach
47
44
  * test` dispatch would forward the flag to every suite it fans out to.
@@ -56,6 +53,29 @@ export function resolveShuffleSeed(shuffle, suite) {
56
53
  }
57
54
  const seed = shuffle === true ? randomInt(1, 2 ** 31 - 1) : shuffle;
58
55
  setVerdictRunAttribute('shuffle', String(seed));
59
- info(`Test order shuffle: seed=${seed} (reproduce with "fireforge test --shuffle=${seed}")`);
56
+ info(`Test shard order: seed=${seed} (replay with "fireforge test --shuffle=${seed}"). ` +
57
+ 'Native harness file shuffle remains unseeded.');
60
58
  return seed;
61
59
  }
60
+ /** Returns a deterministic Fisher–Yates permutation without mutating the selection. */
61
+ export function shuffleTestGroups(groups, seed) {
62
+ const shuffled = [...groups];
63
+ // Hash the decimal seed so every accepted safe integer contributes, rather
64
+ // than silently discarding its high bits in the 32-bit PRNG.
65
+ let state = 2166136261;
66
+ for (const character of String(seed))
67
+ state = Math.imul(state ^ character.charCodeAt(0), 16777619);
68
+ state = state >>> 0 || 1;
69
+ for (let index = shuffled.length - 1; index > 0; index--) {
70
+ state ^= state << 13;
71
+ state ^= state >>> 17;
72
+ state ^= state << 5;
73
+ const destination = Math.floor(((state >>> 0) / 2 ** 32) * (index + 1));
74
+ // Both indexes are bounded by the array length, including under
75
+ // noUncheckedIndexedAccess; T may itself contain undefined.
76
+ const selected = shuffled[index];
77
+ shuffled[index] = shuffled[destination];
78
+ shuffled[destination] = selected;
79
+ }
80
+ return shuffled;
81
+ }
@@ -1,15 +1,14 @@
1
1
  import type { TestOptions } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import type { TestRunOutcome } from './test-run.js';
4
- type ProjectConfig = FireForgeConfig;
5
4
  /** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
6
5
  export declare function assertPathlessTestMode(testPaths: readonly string[], options: TestOptions): void;
7
6
  /** Rejects invalid combinations for mode-like `fireforge test` options. */
8
7
  export declare function assertTestModeCombinations(testPaths: readonly string[], options: TestOptions, canaryPath: string | undefined): void;
9
8
  /** Resolves the canary path from CLI or config. */
10
- export declare function resolveCanaryPath(options: TestOptions, projectConfig: ProjectConfig): string | undefined;
9
+ export declare function resolveCanaryPath(options: TestOptions, projectConfig: FireForgeConfig): string | undefined;
11
10
  /** Returns the configured canary no-output budget, defaulting to 60 seconds. */
12
- export declare function canaryTimeoutSeconds(projectConfig: ProjectConfig): number;
11
+ export declare function canaryTimeoutSeconds(projectConfig: FireForgeConfig): number;
13
12
  /** Emits or throws the one-word canary verdict. */
14
13
  export declare function reportCanaryOutcome(outcome: TestRunOutcome): void;
15
14
  /** Per-harness bucketing of the requested test paths. */
@@ -41,4 +40,3 @@ export declare function classifyBeforeDispatch(engineDir: string, normalizedPath
41
40
  * run build-less without tripping the stale/coverage gate.
42
41
  */
43
42
  export declare function reportBuildOnlyCompletion(classification: HarnessClassification, normalizedPaths: readonly string[]): void;
44
- export {};
@@ -28,7 +28,7 @@ const TEST_HELP_TEXT = [
28
28
  '',
29
29
  'Machine-readable verdict: every run (single, sharded, --canary,',
30
30
  '--doctor) ends with exactly one raw stdout line',
31
- ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
31
+ ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|harness-arguments|test-failures|',
32
32
  ' no-tests|preflight|inconclusive|lock-timeout|killed] [checks=<n>]',
33
33
  ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [shuffle=<seed>]',
34
34
  ' [log=<path>]',
@@ -41,8 +41,8 @@ const TEST_HELP_TEXT = [
41
41
  'a green-summary-override pass says PASS despite a non-zero mach',
42
42
  'exit. reason=preflight covers any failure before the harness ran',
43
43
  '(missing/stale build, invalid paths, port conflicts, config',
44
- 'errors); reason=inconclusive means engine/ changed while the',
45
- 'tests ran, so the harness result cannot be trusted;',
44
+ 'errors); reason=inconclusive means engine/ or perf power source',
45
+ 'changed while tests ran, so the result cannot be trusted;',
46
46
  'reason=lock-timeout means the run never started because another',
47
47
  'engine-mutating command held the lock past the wait budget;',
48
48
  'reason=killed means a signal terminated the run before it reached',
@@ -57,6 +57,12 @@ const TEST_HELP_TEXT = [
57
57
  'stall whose display was measured asleep. Key on this line',
58
58
  'instead of mach internals.',
59
59
  ].join('\n');
60
+ function registerTestSharingOptions(test) {
61
+ return test
62
+ .option('--wait-browser [seconds]', 'Wait for this objdir browser before any deployment/build (default 60)', commanderArgParser((raw) => resolveWaitLockSeconds(raw)))
63
+ .option('--wait-port [seconds]', 'Wait for the mochitest and Marionette ports (default 60)', commanderArgParser((raw) => resolveWaitLockSeconds(raw)))
64
+ .option('--profile-file <source=destination>', 'Merge a file into the harness profile, e.g. sheet.css=chrome/userChrome.css. Repeatable.', ...stringListOption());
65
+ }
60
66
  /** Registers the test command on the CLI program. */
61
67
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
62
68
  const test = program
@@ -77,7 +83,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
77
83
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
78
84
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
79
85
  .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)))
86
+ .option('--shuffle [seed]', 'Shuffle isolated mochitest path arguments in a repeatable seeded order (fresh seed when omitted). Native mach --shuffle within each invocation remains unseeded. Export FIREFORGE_SHUFFLE_SEED for custom task code and stamp shuffle=<seed> on the verdict. --no-shard disables the seeded argument permutation. Write --shuffle=<seed> or place it after the paths.', commanderArgParser((raw) => parsePositiveIntegerFlag('--shuffle', raw)))
81
87
  .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) => {
82
88
  const n = Number.parseInt(raw, 10);
83
89
  if (!Number.isFinite(n) || n < 0 || n > 10) {
@@ -96,7 +102,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
96
102
  return n;
97
103
  }))
98
104
  .addHelpText('after', TEST_HELP_TEXT);
99
- addWaitLockOption(test).action(withErrorHandling(async (paths, options) => {
105
+ addWaitLockOption(registerTestSharingOptions(test)).action(withErrorHandling(async (paths, options) => {
100
106
  const projectRoot = getProjectRoot();
101
107
  // The engine session lock is acquired outside testCommand's own
102
108
  // exactly-one-verdict guarantee, so a lock failure must emit here or
@@ -24,6 +24,8 @@ export declare const DEFAULT_HARNESS_RETRIES = 2;
24
24
  export type TestSuite = 'xpcshell' | 'mochitest' | 'generic';
25
25
  /** Inputs shared by every harness invocation in one `fireforge test` run. */
26
26
  export interface TestRunContext {
27
+ /** A perf run changed power source; no green result can be trusted. */
28
+ powerChanged?: boolean;
27
29
  engineDir: string;
28
30
  objDir: string | undefined;
29
31
  classification: {
@@ -17,6 +17,7 @@ import { parentExited } from '../core/parent-exit-watchdog.js';
17
17
  import { getActiveRunLogPath } from '../core/run-log.js';
18
18
  import { changedPrefNoiseVerdictNote } from '../core/test-changed-prefs.js';
19
19
  import { buildHarnessCrashMessage, classifyHarnessRun, headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
20
+ import { reportTestHost, sampleTestHost, stampPerfHost } from '../core/test-host-state.js';
20
21
  import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
21
22
  import { withXpcshellProfileDir } from '../core/xpcshell-profile-dir.js';
22
23
  import { TestFailureError } from '../errors/build.js';
@@ -24,7 +25,7 @@ import { info, note, warn } from '../utils/logger.js';
24
25
  import { getPlatform } from '../utils/platform.js';
25
26
  import { maybeInjectAppdirArg } from './test-appdir.js';
26
27
  import { createHarnessTeardown } from './test-harness-teardown.js';
27
- import { emitHarnessVerdict } from './test-verdict.js';
28
+ import { emitHarnessVerdict, setVerdictRunAttribute } from './test-verdict.js';
28
29
  /** Default bounded retry budget for recognized harness crashes. */
29
30
  export const DEFAULT_HARNESS_RETRIES = 2;
30
31
  /** Resolves the mach test command a suite dispatches to. */
@@ -121,6 +122,15 @@ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
121
122
  objDir: ctx.objDir === undefined ? undefined : join(ctx.engineDir, ctx.objDir),
122
123
  pgidFile: ctx.pgidFile,
123
124
  });
125
+ const start = await sampleTestHost();
126
+ const warned = reportTestHost(start, 'suite start');
127
+ setVerdictRunAttribute('host-load', start.load.toFixed(2));
128
+ if (warned)
129
+ setVerdictRunAttribute('host-cpu-warning', 'true');
130
+ if (env && Object.keys(env).some((key) => key.endsWith('_PERF_SAMPLE_JSON'))) {
131
+ setVerdictRunAttribute('power-source', start.power);
132
+ env = { ...env, FIREFORGE_POWER_SOURCE: start.power };
133
+ }
124
134
  try {
125
135
  await teardown.prepare();
126
136
  return await runMachTestSuite(kind, {
@@ -134,6 +144,13 @@ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
134
144
  }
135
145
  finally {
136
146
  teardown.dispose();
147
+ const end = await sampleTestHost();
148
+ if (reportTestHost(end, 'suite end'))
149
+ setVerdictRunAttribute('host-cpu-warning', 'true');
150
+ if (await stampPerfHost(env, start, end)) {
151
+ ctx.powerChanged = true;
152
+ setVerdictRunAttribute('power-changed', 'true');
153
+ }
137
154
  }
138
155
  }
139
156
  /**
@@ -158,6 +175,7 @@ const SHARD_STATUS_LABEL = {
158
175
  'test-failures': 'FAIL',
159
176
  'harness-crash': 'CRASH',
160
177
  'no-tests': 'NO-TESTS',
178
+ 'harness-arguments': 'ARGUMENTS',
161
179
  };
162
180
  /**
163
181
  * Derives the aggregate classification for a sharded run: all-green is
@@ -168,6 +186,8 @@ const SHARD_STATUS_LABEL = {
168
186
  */
169
187
  export function deriveAggregateShardVerdict(shards) {
170
188
  const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
189
+ if (kinds.has('harness-arguments'))
190
+ return { kind: 'harness-arguments' };
171
191
  if (kinds.has('harness-crash'))
172
192
  return { kind: 'harness-crash' };
173
193
  if (kinds.has('no-tests'))
@@ -11,9 +11,9 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
11
11
  * and `killed` marks a run terminated by a signal before it could reach any
12
12
  * of the others.
13
13
  */
14
- export type FireforgeVerdictReason = 'crash' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
14
+ export type FireforgeVerdictReason = 'crash' | 'harness-arguments' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
15
  /** Additive keys a run can stamp on its verdict line. */
16
- export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped';
16
+ export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed';
17
17
  /** Records an additive attribute for this run's verdict line. */
18
18
  export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
19
19
  /**
@@ -3,29 +3,30 @@ 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';
6
- import { reportOrphanedHarnessProcesses } from '../core/harness-orphans.js';
7
6
  import { hasBuildArtifacts, hasRunnableBundle } from '../core/mach.js';
8
7
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
9
- import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
10
- import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
11
- import { ensureMochitestServerPortAvailable } from '../core/mochitest-server-port.js';
8
+ import { ensureLaunchableBrowserNotRunning } from '../core/marionette-port.js';
9
+ import { waitForPreflight } from '../core/preflight-wait.js';
12
10
  import { closeActiveRunLog, openRunLog, setActiveRunLog, writeToActiveRunLog, } from '../core/run-log.js';
13
11
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
14
12
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
13
+ import { cleanupProfileFiles, stageProfileFiles, } from '../core/test-profile-files.js';
15
14
  import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
16
15
  import { FireForgeError, GeneralError, PreflightRefusalError } from '../errors/base.js';
17
16
  import { BuildError } from '../errors/build.js';
18
17
  import { toError } from '../utils/errors.js';
19
18
  import { pathExists } from '../utils/fs.js';
20
- import { info, intro, notice, outro, verbose } from '../utils/logger.js';
19
+ import { info, intro, notice, warn } from '../utils/logger.js';
20
+ import { resolveWaitLockSeconds } from '../utils/options.js';
21
21
  import { stripEnginePrefix } from '../utils/paths.js';
22
+ import { appendMarionetteForwardingArgs, ensureTestBrowserEnvironment, runDoctorPreflight, } from './test-browser-preflight.js';
22
23
  import { runTestBuildPhase } from './test-build-phase.js';
23
24
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
24
- import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
25
+ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed, shuffleTestGroups, } from './test-harness-env.js';
25
26
  import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
26
27
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
27
28
  import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
28
- import { addVerdictRunCount, emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
29
+ import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
29
30
  async function assertTestPathsExist(engineDir, testPaths) {
30
31
  const missingPaths = [];
31
32
  for (const testPath of testPaths) {
@@ -102,171 +103,6 @@ function logTestSelection(scopes) {
102
103
  }
103
104
  info('');
104
105
  }
105
- /**
106
- * Runs the `--doctor` marionette handshake probe. With no test paths the
107
- * probe is the entire command (returns `'stop'` after reporting). With
108
- * paths it gates the mach invocation, where a FAIL throws before mach runs.
109
- */
110
- async function runDoctorPreflight(args) {
111
- const { engineDir, effectivePort, hasTestPaths, objDir, binaryName, launchablePath } = args;
112
- // Non-TTY captures need the banner even if clack's renderer defers output
113
- // in pipe mode. TTY users need the clack framing. Gated rather than
114
- // written twice: the unconditional pair printed the same line twice on a
115
- // terminal.
116
- if (process.stdout.isTTY) {
117
- info('Running marionette preflight...');
118
- }
119
- else {
120
- process.stdout.write('Running marionette preflight...\n');
121
- }
122
- const preflight = effectivePort !== undefined
123
- ? await runMarionettePreflight(engineDir, { port: effectivePort })
124
- : await runMarionettePreflight(engineDir);
125
- // The authoritative PASS/FAIL line is written with `process.stdout.write`
126
- // as the first output after the probe returns, because clack's renderer
127
- // can drop the summary under non-TTY capture.
128
- //
129
- // Gated on non-TTY: unconditional, it stacks with the two clack renderings
130
- // below and the same line appears three times on a terminal. Captured
131
- // streams are exactly where this branch still fires.
132
- const directLine = formatMarionettePreflightLine(preflight);
133
- if (!process.stdout.isTTY) {
134
- process.stdout.write(`${directLine}\n`);
135
- }
136
- process.stdout.write(`Marionette preflight environment: objdir=${objDir ?? '(none)'}; binary=${binaryName}; app=${launchablePath ? `engine/${launchablePath}` : '(unknown)'}; port=${effectivePort ?? 2828}; elapsed=${preflight.durationMs}ms\n`);
137
- reportMarionettePreflight(preflight);
138
- if (!hasTestPaths) {
139
- if (!preflight.ok) {
140
- emitFailVerdict('preflight');
141
- throw new GeneralError('Marionette preflight reported FAIL — see output above.');
142
- }
143
- // Doctor-only runs end here, so they carry their own verdict line
144
- // (reason=preflight on failure). With test paths the verdict comes
145
- // from the actual harness run downstream.
146
- emitPassVerdict();
147
- outro('Test completed');
148
- return 'stop';
149
- }
150
- if (!preflight.ok) {
151
- emitFailVerdict('preflight');
152
- throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
153
- }
154
- return 'continue';
155
- }
156
- /**
157
- * Auto-forwards `--marionette-port` to mach (`--setpref=marionette.port`
158
- * for the listener, `--marionette=127.0.0.1:<n>` for the mochitest
159
- * client), skipping each piece the operator already forwarded via
160
- * `--mach-arg` and the xpcshell flavor that ignores the pref entirely.
161
- * Mutates `extraArgs` in place.
162
- */
163
- function appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly = false) {
164
- // Auto-forward the Marionette port to mach when `--marionette-port` is set.
165
- // `--setpref=marionette.port=<n>` configures where the browser listener
166
- // binds. `--marionette=127.0.0.1:<n>` tells the mochitest harness client to
167
- // connect there (default client is 127.0.0.1:2828). xpcshell ignores both
168
- // for browser Marionette.
169
- //
170
- // Skip setpref forwarding when the operator already supplied an equivalent
171
- // arg via `--mach-arg`: duplicates would confuse without changing
172
- // semantics. Skip when mach args explicitly request `--flavor=xpcshell` (or
173
- // `xpcshell-tests`): the preflight still honours `--marionette-port`, but
174
- // mach does not use the marionette.port pref on that harness. Any other arg
175
- // shape still forwards so toolkit widget paths and mixed suites stay
176
- // aligned with the probe without duplicate `--mach-arg` flags.
177
- //
178
- // Skip auto `--marionette=...` when `--mach-arg` already includes a client
179
- // `--marionette=...` (or two-token `--marionette host:port`).
180
- if (options.marionettePort === undefined)
181
- return;
182
- if (xpcshellOnly) {
183
- // Manifest classification says every requested path is xpcshell.
184
- // xpcshell ignores the browser Marionette path entirely, and forwarding
185
- // the mochitest client flags here makes mach reject the dispatch.
186
- info(`--marionette-port=${options.marionettePort} applied to the preflight probe only: the requested paths are xpcshell-only, and xpcshell ignores the browser Marionette port. Not forwarding --setpref=marionette.port or --marionette to mach.`);
187
- return;
188
- }
189
- {
190
- const operatorAlreadyForwarded = forwardedPort !== undefined;
191
- const machArgs = options.machArg ?? [];
192
- if (operatorAlreadyForwarded) {
193
- info(`--marionette-port=${options.marionettePort} set, but the same port is already forwarded via --mach-arg; skipping auto-forward.`);
194
- }
195
- else if (shouldAutoForwardMarionettePortToMach(machArgs)) {
196
- extraArgs.push(`--setpref=marionette.port=${options.marionettePort}`);
197
- }
198
- else {
199
- info(`--marionette-port=${options.marionettePort} applied to the preflight probe, but --flavor=xpcshell is set — mach is not auto-configured with --setpref=marionette.port or --marionette (xpcshell ignores the browser Marionette path). Pass --mach-arg --setpref=marionette.port=${options.marionettePort} explicitly if you still need mach to see the port.`);
200
- }
201
- if (shouldAutoForwardMarionettePortToMach(machArgs) &&
202
- !forwardedMachArgsIncludeMarionetteClient(machArgs)) {
203
- extraArgs.push(`--marionette=127.0.0.1:${options.marionettePort}`);
204
- }
205
- }
206
- }
207
- async function ensureTestMarionettePortAvailable(port, binaryName, options, skip) {
208
- // Refuse a stale listener before mach surfaces a generic bind failure.
209
- // This also recognizes a fork-branded browser via binaryName.
210
- if (skip.xpcshellOnly && !skip.doctor) {
211
- // xpcshell does not bind the browser Marionette port, so a developer's
212
- // interactive browser holding 2828 must not kill an xpcshell run.
213
- // --doctor keeps the preflight: its probe launches a
214
- // Marionette browser regardless of the requested harness.
215
- const message = 'Skipping the Marionette stale-port preflight: all requested paths are xpcshell ' +
216
- '(xpcshell does not bind the browser Marionette port).';
217
- if (options.marionettePort !== undefined || options.killStaleMarionette === true) {
218
- info(message);
219
- }
220
- else {
221
- verbose(message);
222
- }
223
- return;
224
- }
225
- if (options.killStaleMarionette === true) {
226
- await ensureMarionettePortAvailable(port, { binaryName, killStaleBrowser: true });
227
- return;
228
- }
229
- await assertMarionettePortAvailable(port, { binaryName });
230
- }
231
- async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, projectConfig, options, objDir) {
232
- // A timed-out mochitest can leave the built app alive after its Marionette
233
- // listener has disappeared. The port probe cannot see that case, but the
234
- // survivor can still steal focus and wedge every later headed run.
235
- if (!xpcshellOnly && launchablePath) {
236
- await ensureLaunchableBrowserNotRunning(join(engineDir, launchablePath), {
237
- killStaleBrowser: options.killStaleMarionette === true,
238
- });
239
- }
240
- // A zombie mochitest httpd squatting the server port makes a fresh
241
- // browser connect to a server that cannot serve this run's manifest,
242
- // which surfaces as a 370s "Ran 0 checks" stall naming nothing. xpcshell
243
- // does not use the httpd, so an xpcshell-only run is never blocked by it.
244
- if (!xpcshellOnly) {
245
- await ensureMochitestServerPortAvailable(undefined, {
246
- engineDir,
247
- killStaleServer: options.killStaleMarionette === true,
248
- });
249
- }
250
- // Helpers that outlived an earlier run (httpd, pywebsocket, ssltunnel,
251
- // moz-http2) slow every later run without appearing in its output. Both
252
- // harnesses are affected, so this census is not gated on xpcshellOnly.
253
- // Reaping is opt-in per invocation (--reap-orphans) or per repo
254
- // (test.reapOrphans: "reap"); a reap is stamped on the verdict line so a
255
- // green after one is not mistaken for a green on a quiet machine.
256
- const census = await reportOrphanedHarnessProcesses(objDir, {
257
- reap: options.reapOrphans === true || projectConfig.test?.reapOrphans === 'reap',
258
- });
259
- addVerdictRunCount('orphans-reaped', census.reaped);
260
- const forwardedPort = options.machArg
261
- ? extractForwardedMarionettePort(options.machArg)
262
- : undefined;
263
- const effectivePort = options.marionettePort ?? forwardedPort;
264
- await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
265
- xpcshellOnly,
266
- doctor: options.doctor === true,
267
- });
268
- return { forwardedPort, effectivePort };
269
- }
270
106
  /** Build-artifact preflight wording for `fireforge test`. */
271
107
  const TEST_BUILD_PREFLIGHT = {
272
108
  label: 'Tests',
@@ -297,8 +133,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
297
133
  // `finally`, after the verdict line has already read the path.
298
134
  setActiveRunLog(await openRunLog(projectRoot, 'test'));
299
135
  setActivePgidFile(options.pgidFile);
136
+ let staged;
300
137
  try {
301
- await runTestCommandBody(projectRoot, testPaths, options);
138
+ if (options.profileFile?.length) {
139
+ staged = await stageProfileFiles(projectRoot, options.profileFile);
140
+ options = { ...options, machArg: [...(options.machArg ?? []), ...staged.args] };
141
+ }
142
+ await runTestCommandBody(projectRoot, testPaths, options, staged?.env);
302
143
  }
303
144
  catch (error) {
304
145
  if (!verdictEmitted()) {
@@ -310,8 +151,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
310
151
  finally {
311
152
  // A run that ends under FireForge's control owns no harness group any
312
153
  // more; the file only outlives FireForge when FireForge was killed.
313
- await removePgidFile(options.pgidFile);
314
- await closeActiveRunLog();
154
+ const stagedRoot = staged?.root;
155
+ const cleanups = [
156
+ ...(stagedRoot === undefined ? [] : [() => cleanupProfileFiles(stagedRoot)]),
157
+ () => removePgidFile(options.pgidFile),
158
+ () => closeActiveRunLog(),
159
+ ];
160
+ for (const cleanup of cleanups) {
161
+ try {
162
+ await cleanup();
163
+ }
164
+ catch (error) {
165
+ warn(`Test cleanup failed: ${toError(error).message}`);
166
+ }
167
+ }
315
168
  }
316
169
  }
317
170
  /** The refusal class a preflight gate named, when it named one. */
@@ -355,16 +208,56 @@ function renderPreflightRefusal(error) {
355
208
  * misdescribe a run whose harness already executed). It is emitted here so the
356
209
  * guard's throw can never leave a stale or missing verdict line behind.
357
210
  */
358
- async function verifyEngineGenerationOrEmitInconclusive(engineDir, before) {
211
+ async function verifyEngineGenerationOrEmitInconclusive(engineDir, before, powerChanged = false) {
359
212
  try {
360
213
  await assertEngineGenerationUnchanged(engineDir, before);
214
+ if (powerChanged)
215
+ throw new GeneralError('Perf power source changed during the run; repeat this sitting on a stable power source.');
361
216
  }
362
217
  catch (error) {
363
218
  emitFailVerdict('inconclusive');
364
219
  throw error;
365
220
  }
366
221
  }
367
- async function runTestCommandBody(projectRoot, testPaths, options = {}) {
222
+ async function guardBuildBrowser(engineDir, launchablePath, options) {
223
+ if ((options.build || options.buildOnly) && launchablePath) {
224
+ await waitForPreflight(() => ensureLaunchableBrowserNotRunning(join(engineDir, launchablePath), {
225
+ killStaleBrowser: options.killStaleMarionette === true,
226
+ }), options.waitBrowser === undefined ? undefined : resolveWaitLockSeconds(options.waitBrowser));
227
+ }
228
+ }
229
+ function prepareExtraMachArgs(options, shuffleSeed, canaryPath, projectConfig, forwardedMachArgs) {
230
+ const extraArgs = [];
231
+ if (options.headless) {
232
+ extraArgs.push('--headless');
233
+ }
234
+ if (shuffleSeed !== undefined) {
235
+ extraArgs.push('--shuffle');
236
+ }
237
+ if (options.auto === true) {
238
+ extraArgs.push('--auto');
239
+ }
240
+ if (canaryPath !== undefined)
241
+ extraArgs.push(`--timeout=${canaryTimeoutSeconds(projectConfig)}`);
242
+ // --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
243
+ // flags FireForge does not model directly (see the xpcshell appdir hint
244
+ // above for why). Appended after --headless so mach sees
245
+ // the FireForge-managed flags first and the escape-valve ones last, which
246
+ // keeps the override precedence predictable.
247
+ if (forwardedMachArgs.length > 0)
248
+ extraArgs.push(...forwardedMachArgs);
249
+ return extraArgs;
250
+ }
251
+ function prepareDispatchArguments(classification, options, canaryPath, projectConfig) {
252
+ const suite = resolveTestSuite(classification, options.genericMachTest === true);
253
+ const shuffleSeed = resolveShuffleSeed(options.shuffle, suite);
254
+ const forwardedMachArgs = options.machArg && options.machArg.length > 0
255
+ ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
256
+ : [];
257
+ const extraArgs = prepareExtraMachArgs(options, shuffleSeed, canaryPath, projectConfig, forwardedMachArgs);
258
+ return { suite, shuffleSeed, extraArgs };
259
+ }
260
+ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileEnv) {
368
261
  const paths = getProjectPaths(projectRoot);
369
262
  // Check if engine exists
370
263
  await assertEngineExists(paths.engine);
@@ -398,12 +291,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
398
291
  const requestedPaths = canaryPath !== undefined ? [canaryPath] : testPaths;
399
292
  const normalizedPaths = requestedPaths.map((p) => stripEnginePrefix(p).trim());
400
293
  const { classification, xpcshellOnly } = await classifyBeforeDispatch(paths.engine, normalizedPaths, { allowMixed: options.buildOnly === true });
401
- if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
402
- classification,
403
- normalizedPaths,
404
- })) {
405
- return;
406
- }
294
+ await guardBuildBrowser(paths.engine, launchablePath, options);
407
295
  // Resolve the effective Marionette port. Operator precedence:
408
296
  // 1. `--marionette-port` (first-class option, parsed at the CLI layer)
409
297
  // 2. forwarded `--mach-arg --marionette-port=NNNN` /
@@ -414,6 +302,12 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
414
302
  // documented `--mach-arg --marionette-port=NNNN` route still hits the
415
303
  // wrapper preflight refusing on 2828 before the forwarded arg reaches
416
304
  // mach.
305
+ if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
306
+ classification,
307
+ normalizedPaths,
308
+ })) {
309
+ return;
310
+ }
417
311
  const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options, buildCheck.objDir);
418
312
  if (options.doctor) {
419
313
  const doctorOutcome = await runDoctorPreflight({
@@ -428,30 +322,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
428
322
  return;
429
323
  }
430
324
  await assertTestPathsExist(paths.engine, normalizedPaths);
431
- const suite = resolveTestSuite(classification, options.genericMachTest === true);
432
- const shuffleSeed = resolveShuffleSeed(options.shuffle, suite);
433
- const forwardedMachArgs = options.machArg && options.machArg.length > 0
434
- ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
435
- : [];
436
- const extraArgs = [];
437
- if (options.headless) {
438
- extraArgs.push('--headless');
439
- }
440
- if (shuffleSeed !== undefined) {
441
- extraArgs.push('--shuffle');
442
- }
443
- if (options.auto === true) {
444
- extraArgs.push('--auto');
445
- }
446
- if (canaryPath !== undefined)
447
- extraArgs.push(`--timeout=${canaryTimeoutSeconds(projectConfig)}`);
448
- // --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
449
- // flags FireForge does not model directly (see the xpcshell appdir hint
450
- // above for why). Appended after --headless so mach sees
451
- // the FireForge-managed flags first and the escape-valve ones last, which
452
- // keeps the override precedence predictable.
453
- if (forwardedMachArgs.length > 0)
454
- extraArgs.push(...forwardedMachArgs);
325
+ const { suite, shuffleSeed, extraArgs } = prepareDispatchArguments(classification, options, canaryPath, projectConfig);
455
326
  appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
456
327
  // Directory arguments mean exactly that directory: mozbuild's test
457
328
  // resolver matches paths by string prefix, so a bare directory arg
@@ -478,7 +349,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
478
349
  // the manifest for each file individually. See src/core/xpcshell-appdir.ts
479
350
  // for the full motivation.
480
351
  logTestSelection(scopes);
481
- const harnessEnv = mergeHarnessEnv(buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples), shuffleSeed === undefined ? undefined : { FIREFORGE_SHUFFLE_SEED: String(shuffleSeed) });
352
+ const harnessEnv = mergeHarnessEnv(buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples), profileEnv, shuffleSeed === undefined ? undefined : { FIREFORGE_SHUFFLE_SEED: String(shuffleSeed) });
482
353
  const runCtx = {
483
354
  engineDir: paths.engine,
484
355
  objDir: buildCheck.objDir,
@@ -511,14 +382,15 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
511
382
  const generationBefore = await snapshotEngineGeneration(paths.engine);
512
383
  let summary;
513
384
  try {
514
- summary = await runShardedTests(runCtx, dispatchGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
385
+ const orderedGroups = shuffleSeed === undefined ? dispatchGroups : shuffleTestGroups(dispatchGroups, shuffleSeed);
386
+ summary = await runShardedTests(runCtx, orderedGroups, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, postRebuildContext));
515
387
  }
516
388
  finally {
517
389
  // Runs before the aggregate verdict below: a mutated engine/ emits
518
390
  // `FAIL reason=inconclusive` and throws, so an invalidated run can
519
391
  // never print `PASS shards=N/N` first. (A throw here masks an
520
392
  // in-flight shard error, as the plain assert always did.)
521
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore);
393
+ await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged);
522
394
  }
523
395
  finalizeShardedOutcome(summary);
524
396
  return;
@@ -533,7 +405,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
533
405
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
534
406
  }
535
407
  finally {
536
- await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore);
408
+ await verifyEngineGenerationOrEmitInconclusive(paths.engine, generationBefore, runCtx.powerChanged);
537
409
  }
538
410
  if (canaryPath !== undefined) {
539
411
  reportCanaryOutcome(outcome);
@@ -26,6 +26,11 @@ export interface TypecheckCommandOptions {
26
26
  * issues without waiting for the full set.
27
27
  */
28
28
  project?: string;
29
+ /**
30
+ * Build every project from scratch and write no incremental build info
31
+ * (`--no-cache`).
32
+ */
33
+ noCache?: boolean;
29
34
  }
30
35
  /**
31
36
  * Resolves the project list to type-check. `--project` wins over