@hominis/fireforge 0.15.6 → 0.15.8

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 (64) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +158 -15
  3. package/dist/src/commands/build.js +60 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
  6. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
  7. package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
  8. package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
  9. package/dist/src/commands/furnace/chrome-doc.js +37 -4
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +38 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +100 -0
  12. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  13. package/dist/src/commands/furnace/create-features.js +56 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  15. package/dist/src/commands/furnace/create-templates.js +28 -6
  16. package/dist/src/commands/furnace/create.js +62 -63
  17. package/dist/src/commands/furnace/index.js +4 -1
  18. package/dist/src/commands/lint.d.ts +17 -2
  19. package/dist/src/commands/lint.js +25 -2
  20. package/dist/src/commands/register.d.ts +1 -1
  21. package/dist/src/commands/register.js +30 -7
  22. package/dist/src/commands/run.d.ts +15 -1
  23. package/dist/src/commands/run.js +202 -7
  24. package/dist/src/commands/test.js +113 -3
  25. package/dist/src/core/build-audit-registration.d.ts +80 -0
  26. package/dist/src/core/build-audit-registration.js +187 -0
  27. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  28. package/dist/src/core/build-audit-transforms.js +94 -0
  29. package/dist/src/core/build-audit.js +107 -7
  30. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  31. package/dist/src/core/furnace-apply-ftl.js +6 -2
  32. package/dist/src/core/furnace-apply-helpers.js +14 -4
  33. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  34. package/dist/src/core/furnace-config-custom.js +64 -0
  35. package/dist/src/core/furnace-config.js +2 -39
  36. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  37. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  38. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  39. package/dist/src/core/furnace-validate-helpers.js +19 -0
  40. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  41. package/dist/src/core/furnace-validate-registration.js +66 -6
  42. package/dist/src/core/furnace-validate-structure.js +6 -2
  43. package/dist/src/core/furnace-validate.js +6 -3
  44. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  45. package/dist/src/core/mach-build-artifacts.js +104 -3
  46. package/dist/src/core/mach.d.ts +27 -1
  47. package/dist/src/core/mach.js +26 -2
  48. package/dist/src/core/shared-ftl.d.ts +28 -0
  49. package/dist/src/core/shared-ftl.js +42 -0
  50. package/dist/src/core/smoke-patterns.d.ts +45 -0
  51. package/dist/src/core/smoke-patterns.js +100 -0
  52. package/dist/src/core/test-stale-check.d.ts +42 -0
  53. package/dist/src/core/test-stale-check.js +114 -0
  54. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  55. package/dist/src/core/xpcshell-appdir.js +273 -0
  56. package/dist/src/errors/codes.d.ts +13 -0
  57. package/dist/src/errors/codes.js +13 -0
  58. package/dist/src/errors/run.d.ts +16 -0
  59. package/dist/src/errors/run.js +22 -0
  60. package/dist/src/types/commands/options.d.ts +64 -0
  61. package/dist/src/types/furnace.d.ts +39 -0
  62. package/dist/src/utils/process.d.ts +63 -0
  63. package/dist/src/utils/process.js +122 -0
  64. package/package.json +1 -1
@@ -1,14 +1,36 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { readdir } from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { readdir, readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { getProjectPaths } from '../core/config.js';
5
6
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
6
- import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
7
- import { GeneralError } from '../errors/base.js';
7
+ import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
8
+ import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
9
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
8
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
+ import { ExitCode } from '../errors/codes.js';
12
+ import { SmokeRunError } from '../errors/run.js';
9
13
  import { toError } from '../utils/errors.js';
10
14
  import { pathExists, removeDir, removeFile } from '../utils/fs.js';
11
- import { info, intro, verbose } from '../utils/logger.js';
15
+ import { info, intro, verbose, warn } from '../utils/logger.js';
16
+ import { pickDefined } from '../utils/options.js';
17
+ /**
18
+ * Exit code returned by smoke-run mode when the captured console stream
19
+ * produced one or more error lines that did NOT match the operator's
20
+ * allowlist.
21
+ */
22
+ export const SMOKE_EXIT_FAILURE = ExitCode.SMOKE_EXIT_FAILURE;
23
+ /**
24
+ * Exit code returned by smoke-run mode when the browser itself exited
25
+ * with a non-clean status before the smoke window elapsed — i.e. a
26
+ * launch-side failure we could NOT observe as a console error line
27
+ * (crash before console wiring, missing profile, etc.).
28
+ */
29
+ export const SMOKE_LAUNCH_FAILURE = ExitCode.SMOKE_LAUNCH_FAILURE;
30
+ /** Recommendation surfaced when the smoke window is shorter than a typical cold start. */
31
+ const SMOKE_COLD_START_THRESHOLD_MS = 30_000;
32
+ /** Maximum number of unallowed error lines to surface in the terminal summary. */
33
+ const SMOKE_UNALLOWED_PREVIEW_MAX = 10;
12
34
  /**
13
35
  * Cleans the dev profile to prevent stale-state startup failures.
14
36
  *
@@ -50,7 +72,7 @@ async function cleanDevProfile(engineDir) {
50
72
  * Runs the run command to launch the built browser.
51
73
  * @param projectRoot - Root directory of the project
52
74
  */
53
- export async function runCommand(projectRoot) {
75
+ export async function runCommand(projectRoot, options = {}) {
54
76
  intro('FireForge Run');
55
77
  const paths = getProjectPaths(projectRoot);
56
78
  // Check if engine exists
@@ -76,6 +98,10 @@ export async function runCommand(projectRoot) {
76
98
  await warnIfFurnaceStale(projectRoot);
77
99
  // Clean stale profile state to prevent silent startup failures
78
100
  await cleanDevProfile(paths.engine);
101
+ if (options.smokeExit !== undefined) {
102
+ await runSmokeExit(paths.engine, options);
103
+ return;
104
+ }
79
105
  info('Launching browser...\n');
80
106
  const exitCode = await run(paths.engine);
81
107
  // Exit-code whitelist:
@@ -89,13 +115,182 @@ export async function runCommand(projectRoot) {
89
115
  throw new BuildError(`Browser exited with code ${exitCode}`, 'mach run');
90
116
  }
91
117
  }
118
+ /**
119
+ * Drives the `--smoke-exit` launch path. Runs the browser under
120
+ * {@link runMachSmoke}, scans the merged console stream for error-class
121
+ * lines against the operator-supplied allowlist, and applies the smoke
122
+ * exit contract. The deadline-fires-SIGTERM path is treated as a clean
123
+ * window iff no unallowed errors were observed.
124
+ */
125
+ async function runSmokeExit(engineDir, options) {
126
+ // Windows lacks the POSIX process-group primitives --smoke-exit leans on to
127
+ // SIGTERM the whole mach → python → firefox tree. Running through anyway
128
+ // would only kill the top-level wrapper and orphan Firefox content
129
+ // processes, so reject the flag up front to match the documented contract
130
+ // in CHANGELOG.md / README.md.
131
+ if (process.platform === 'win32') {
132
+ throw new InvalidArgumentError('--smoke-exit is POSIX-only; process-group semantics do not map cleanly onto Windows.', 'smokeExit');
133
+ }
134
+ const smokeExit = options.smokeExit;
135
+ // The runCommand caller has already gated on `options.smokeExit !== undefined`,
136
+ // but commander can hand us `0` or negative values through the action
137
+ // layer if the parser in `registerRun` was bypassed (e.g. programmatic
138
+ // use in a test that skips the parser). Guard explicitly so the deadline
139
+ // timer cannot be scheduled at 0 ms and immediately kill the process.
140
+ if (smokeExit === undefined || smokeExit < 1 || !Number.isFinite(smokeExit)) {
141
+ throw new InvalidArgumentError('--smoke-exit expects a positive integer number of seconds.', 'smokeExit');
142
+ }
143
+ const smokeTimeoutMs = smokeExit * 1000;
144
+ if (smokeTimeoutMs < SMOKE_COLD_START_THRESHOLD_MS) {
145
+ // Not an error — cold starts just tend to exceed the window. Surfacing
146
+ // the hint here instead of failing lets agents run shorter windows
147
+ // intentionally (e.g. warm-cache smoke checks).
148
+ verbose(`Smoke window is ${String(smokeExit)}s; cold starts on slow machines often exceed 30s.`);
149
+ }
150
+ const allowlist = await buildAllowlist(options);
151
+ const captureStream = options.captureConsole
152
+ ? createWriteStream(options.captureConsole)
153
+ : undefined;
154
+ // createWriteStream opens the fd asynchronously, so ENOENT / EACCES /
155
+ // EISDIR / EROFS surface as an 'error' event *after* the constructor
156
+ // returns. Without a listener Node re-throws as uncaughtException and
157
+ // kills the CLI mid-smoke-run — orphaning the mach → python → firefox
158
+ // tree because the deadline timer never fires. Swallow the event into a
159
+ // warning so the smoke run still terminates cleanly; subsequent mirror
160
+ // writes on the errored stream are silent no-ops.
161
+ captureStream?.on('error', (err) => {
162
+ warn(`--capture-console stream error: ${err.message}`);
163
+ });
164
+ const findings = [];
165
+ let allowlistedHits = 0;
166
+ const handleLine = (stream, line) => {
167
+ // Mirror raw output to the terminal so operators watching the smoke
168
+ // run still see what the browser is printing. Stream selection on the
169
+ // mirror preserves stdout/stderr separation for downstream piping.
170
+ const sink = stream === 'stdout' ? process.stdout : process.stderr;
171
+ sink.write(`${line}\n`);
172
+ if (!matchesSmokeError(line))
173
+ return;
174
+ if (matchesAllowlist(line, allowlist)) {
175
+ allowlistedHits += 1;
176
+ return;
177
+ }
178
+ findings.push({ stream, line });
179
+ };
180
+ info(`Launching browser (smoke-exit after ${String(smokeExit)}s)...\n`);
181
+ const startedAt = Date.now();
182
+ let result;
183
+ try {
184
+ result = await runMachSmoke(['run'], engineDir, {
185
+ smokeTimeoutMs,
186
+ onStdoutLine: (line) => {
187
+ handleLine('stdout', line);
188
+ },
189
+ onStderrLine: (line) => {
190
+ handleLine('stderr', line);
191
+ },
192
+ ...(captureStream ? { mirror: { stdout: captureStream, stderr: captureStream } } : {}),
193
+ });
194
+ }
195
+ finally {
196
+ captureStream?.end();
197
+ }
198
+ const elapsedMs = Date.now() - startedAt;
199
+ reportSmokeSummary({
200
+ smokeTimeoutMs,
201
+ elapsedMs,
202
+ timedOut: result.timedOut,
203
+ allowlistedHits,
204
+ findings,
205
+ exitCode: result.exitCode,
206
+ });
207
+ // Exit contract (precedence: unallowed errors dominate timed-out).
208
+ if (findings.length > 0) {
209
+ throw new SmokeRunError(`Smoke run observed ${String(findings.length)} unallowed console error(s).`, SMOKE_EXIT_FAILURE);
210
+ }
211
+ if (result.timedOut) {
212
+ // Clean window — SIGTERM from us. Treat as success.
213
+ return;
214
+ }
215
+ if (result.exitCode === 0 || result.exitCode === 130 || result.exitCode === 143) {
216
+ return;
217
+ }
218
+ throw new SmokeRunError(`Browser exited with code ${String(result.exitCode)} before smoke-exit window elapsed.`, SMOKE_LAUNCH_FAILURE);
219
+ }
220
+ /**
221
+ * Compiles the active allowlist from `--console-allow` CLI values and
222
+ * the optional `--console-allow-file`. Fails fast on a bad regex —
223
+ * better to surface the typo at parse time than to silently let it
224
+ * match nothing and turn every allowed hit into a smoke failure.
225
+ */
226
+ async function buildAllowlist(options) {
227
+ const allow = [];
228
+ if (options.consoleAllow && options.consoleAllow.length > 0) {
229
+ try {
230
+ allow.push(...compileAllowlistFromStrings(options.consoleAllow));
231
+ }
232
+ catch (error) {
233
+ throw new InvalidArgumentError(toError(error).message, 'consoleAllow');
234
+ }
235
+ }
236
+ if (options.consoleAllowFile) {
237
+ try {
238
+ const body = await readFile(options.consoleAllowFile, 'utf8');
239
+ allow.push(...compileAllowlistFromFile(body, options.consoleAllowFile));
240
+ }
241
+ catch (error) {
242
+ throw new InvalidArgumentError(`Failed to read --console-allow-file: ${toError(error).message}`, 'consoleAllowFile');
243
+ }
244
+ }
245
+ return allow;
246
+ }
247
+ /**
248
+ * Prints the human-readable summary block that follows every smoke run.
249
+ * Called once, right before the exit-code decision. Keeps the reporting
250
+ * path separate from exit-contract logic so a test can render summaries
251
+ * without mocking the BuildError construction.
252
+ */
253
+ function reportSmokeSummary(args) {
254
+ const seconds = (args.elapsedMs / 1000).toFixed(1);
255
+ const windowSeconds = (args.smokeTimeoutMs / 1000).toFixed(0);
256
+ const suffix = args.timedOut ? ' (deadline fired — SIGTERM sent to process group)' : '';
257
+ info('');
258
+ info(`Smoke run complete: ${seconds}s elapsed of ${windowSeconds}s window${suffix}`);
259
+ info(` Unallowed errors: ${String(args.findings.length)}`);
260
+ info(` Allowlisted hits: ${String(args.allowlistedHits)}`);
261
+ info(` Child exit code: ${String(args.exitCode)}`);
262
+ if (args.findings.length === 0)
263
+ return;
264
+ warn('');
265
+ warn(`Unallowed console errors (first ${String(SMOKE_UNALLOWED_PREVIEW_MAX)}):`);
266
+ args.findings.slice(0, SMOKE_UNALLOWED_PREVIEW_MAX).forEach((finding, index) => {
267
+ warn(` ${String(index + 1)}. [${finding.stream}] ${finding.line}`);
268
+ });
269
+ if (args.findings.length > SMOKE_UNALLOWED_PREVIEW_MAX) {
270
+ const remaining = args.findings.length - SMOKE_UNALLOWED_PREVIEW_MAX;
271
+ warn(` …and ${String(remaining)} more.`);
272
+ }
273
+ }
92
274
  /** Registers the run command on the CLI program. */
93
275
  export function registerRun(program, { getProjectRoot, withErrorHandling }) {
94
276
  program
95
277
  .command('run')
96
278
  .description('Launch the built browser')
97
- .action(withErrorHandling(async () => {
98
- await runCommand(getProjectRoot());
279
+ .option('--smoke-exit <seconds>', 'Smoke-run mode (POSIX only): launch, capture console, SIGTERM the process group after <seconds>. Exit 0 on a clean window, 12 on unallowed errors, 13 on launch failure.', (value) => {
280
+ const parsed = Number.parseInt(value, 10);
281
+ if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value.trim()) {
282
+ throw new Error(`--smoke-exit expects a positive integer number of seconds (got "${value}").`);
283
+ }
284
+ return parsed;
285
+ })
286
+ .option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', (value, acc) => {
287
+ acc.push(value);
288
+ return acc;
289
+ }, [])
290
+ .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
291
+ .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
292
+ .action(withErrorHandling(async (options) => {
293
+ await runCommand(getProjectRoot(), pickDefined(options));
99
294
  }));
100
295
  }
101
296
  //# sourceMappingURL=run.js.map
@@ -4,10 +4,12 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
6
6
  import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
+ import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
8
+ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
7
9
  import { GeneralError } from '../errors/base.js';
8
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
9
11
  import { pathExists } from '../utils/fs.js';
10
- import { info, intro, spinner } from '../utils/logger.js';
12
+ import { info, intro, spinner, warn } from '../utils/logger.js';
11
13
  import { pickDefined } from '../utils/options.js';
12
14
  /**
13
15
  * Strips a leading "engine/" or "engine\\" prefix from a path if present.
@@ -59,7 +61,47 @@ function hasStaleBuildArtifactsSignal(output) {
59
61
  /resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
60
62
  /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
61
63
  }
62
- function handleNonZeroTestExit(result, normalizedPaths) {
64
+ // Detects the broader xpcshell symptom where every `resource:///modules/...`
65
+ // import fails — the signature of xpcshell running with the wrong app-dir on
66
+ // a manifest that sets `firefox-appdir = "browser"`. Checked AFTER the
67
+ // stale-build signal (which matches the narrower `distribution.sys.mjs`
68
+ // path) so the more specific diagnosis wins when both patterns apply.
69
+ function hasXpcshellAppdirSignal(output) {
70
+ return /Failed to load resource:\/\/\/modules\//i.test(output);
71
+ }
72
+ function buildXpcshellAppdirMessage(injectionAttempted) {
73
+ const triggerLines = injectionAttempted
74
+ ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or the harness was built against a layout FireForge cannot probe (omni.ja-packed tree, alternate `dist/` shape).\n\n'
75
+ : 'Likely triggers:\n' +
76
+ ' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
77
+ ' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
78
+ return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
79
+ 'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
80
+ triggerLines +
81
+ 'Options:\n' +
82
+ ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly.\n' +
83
+ ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection).\n' +
84
+ ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
85
+ ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
86
+ }
87
+ // Detects the `AttributeError: 'MochitestDesktop' object has no attribute
88
+ // 'http3Server'` teardown crash. The attribute is lazy-initialized inside
89
+ // harness code paths that presume chrome://branding resolves correctly; a
90
+ // missing or miswired branding registration short-circuits the setup and
91
+ // leaves the cleanup path looking up an attribute that was never assigned.
92
+ function hasMochitestHttp3ServerSignal(output) {
93
+ return /'MochitestDesktop' object has no attribute 'http3Server'/.test(output);
94
+ }
95
+ function buildMochitestHttp3ServerMessage() {
96
+ return ("Mochitest raised `AttributeError: 'MochitestDesktop' object has no attribute 'http3Server'`.\n\n" +
97
+ 'This is almost always a symptom of `chrome://branding` not registering correctly in your fork — the mochitest harness lazy-initializes `http3Server` only after branding resolves, and a missing branding registration short-circuits setup. The cleanup path then trips the AttributeError, masking the real error.\n\n' +
98
+ 'Check that:\n' +
99
+ " - Your fork's branding directory is listed in `browser/branding/moz.build` (or equivalent) and ships a `brand.properties` / `brand.ftl`.\n" +
100
+ ' - `chrome://branding/locale/brand.properties` resolves at runtime (try `fireforge run` and inspect the Browser Console).\n' +
101
+ " - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
102
+ 'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
103
+ }
104
+ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted) {
63
105
  if (result.exitCode === 0 || result.exitCode === 130)
64
106
  return;
65
107
  const combinedOutput = `${result.stdout}\n${result.stderr}`;
@@ -69,6 +111,12 @@ function handleNonZeroTestExit(result, normalizedPaths) {
69
111
  if (hasStaleBuildArtifactsSignal(combinedOutput)) {
70
112
  throw new GeneralError(buildStaleBuildMessage());
71
113
  }
114
+ if (hasMochitestHttp3ServerSignal(combinedOutput)) {
115
+ throw new GeneralError(buildMochitestHttp3ServerMessage());
116
+ }
117
+ if (hasXpcshellAppdirSignal(combinedOutput)) {
118
+ throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
119
+ }
72
120
  if (/invalid filename/i.test(combinedOutput) ||
73
121
  /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
74
122
  info('Hint: The test file may not be registered in browser.toml or jar.mn.');
@@ -118,6 +166,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
118
166
  s.stop('Build complete');
119
167
  info('');
120
168
  }
169
+ else {
170
+ // Stale-build preflight — when --build was NOT requested, detect
171
+ // packageable engine edits since the last successful `fireforge build`
172
+ // and warn UP-FRONT. Without this, edits to chrome / packaged resources
173
+ // surface only as a cryptic `NS_ERROR_FILE_NOT_FOUND` inside xpcshell
174
+ // after mach test has already launched (see motivating case in
175
+ // `core/test-stale-check.ts`). The check is warn-only so a fork that
176
+ // rebuilt out-of-band (no FireForge-recorded baseline update) is not
177
+ // blocked from running tests.
178
+ const stale = await checkStaleBuildForTest(projectRoot, paths.engine);
179
+ if (stale.stale) {
180
+ warn(formatStaleBuildWarning(stale));
181
+ }
182
+ }
121
183
  // `--doctor` runs a short marionette handshake probe. When test paths are
122
184
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
123
185
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -144,6 +206,23 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
144
206
  if (options.headless) {
145
207
  extraArgs.push('--headless');
146
208
  }
209
+ // --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
210
+ // flags FireForge does not model directly (see the xpcshell appdir hint
211
+ // above for the motivating case). Appended AFTER --headless so mach sees
212
+ // the FireForge-managed flags first and the escape-valve ones last, which
213
+ // keeps the override precedence predictable.
214
+ if (options.machArg && options.machArg.length > 0) {
215
+ extraArgs.push(...options.machArg);
216
+ }
217
+ // xpcshell appdir auto-injection — see src/core/xpcshell-appdir.ts for the
218
+ // full motivation. On rebranded forks (appname != "firefox") the upstream
219
+ // harness silently ignores `firefox-appdir = "browser"` directives in the
220
+ // xpcshell.toml, so every `resource:///modules/…` import throws. We probe
221
+ // the nearest manifest, compute the absolute appdir under obj-*/dist/, and
222
+ // inject `--app-path=<abs>` so the harness uses the right root. Operator
223
+ // overrides via `--mach-arg=--app-path=…` always win — we skip injection
224
+ // when the operator already passed one.
225
+ const appdirInjection = await maybeInjectAppdirArg(paths.engine, normalizedPaths, buildCheck.objDir, extraArgs);
147
226
  // Log what we're doing
148
227
  if (normalizedPaths.length > 0) {
149
228
  info(`Running tests: ${normalizedPaths.join(', ')}`);
@@ -159,7 +238,34 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
159
238
  catch (error) {
160
239
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
161
240
  }
162
- handleNonZeroTestExit(result, normalizedPaths);
241
+ handleNonZeroTestExit(result, normalizedPaths, appdirInjection);
242
+ }
243
+ /**
244
+ * Resolves and (when applicable) appends an `--app-path=<abs>` arg to
245
+ * `extraArgs`. Returns true iff the arg was injected. The logging branches
246
+ * mirror the {@link XpcshellAppdirOutcome} variants so an operator can tell
247
+ * from the test output whether FireForge tried to help and what it found.
248
+ */
249
+ async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
250
+ if (!objDir)
251
+ return false;
252
+ if (operatorAlreadySetAppPath(extraArgs))
253
+ return false;
254
+ const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
255
+ switch (outcome.kind) {
256
+ case 'none':
257
+ return false;
258
+ case 'mismatch':
259
+ warn(`xpcshell appdir auto-injection skipped — multiple test paths resolved to different app dirs (${outcome.values.join(', ')}). Pass --mach-arg=--app-path=<abs> to disambiguate.`);
260
+ return false;
261
+ case 'unresolved':
262
+ warn(`xpcshell appdir auto-injection skipped — manifest at ${outcome.manifestPath} requests appdir "${outcome.relativeAppdir}" but no matching directory exists under ${objDir}/dist/. Build artifacts may be stale.`);
263
+ return false;
264
+ case 'injected':
265
+ extraArgs.push(`--app-path=${outcome.result.appPath}`);
266
+ info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
267
+ return true;
268
+ }
163
269
  }
164
270
  /** Registers the test command on the CLI program. */
165
271
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
@@ -169,6 +275,10 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
169
275
  .option('--headless', 'Run tests in headless mode')
170
276
  .option('--build', 'Run incremental UI build before testing')
171
277
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
278
+ .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', (value, acc) => {
279
+ acc.push(value);
280
+ return acc;
281
+ }, [])
172
282
  .action(withErrorHandling(async (paths, options) => {
173
283
  await testCommand(getProjectRoot(), paths, pickDefined(options));
174
284
  }));
@@ -0,0 +1,80 @@
1
+ /** Parsed jar.mn registration anchored to a specific engine source path. */
2
+ export interface RegistrationHit {
3
+ /** Target path extracted from the entry (POSIX). */
4
+ target: string;
5
+ /** Source path from the entry (POSIX, relative to the jar.mn directory). */
6
+ source: string;
7
+ /** Absolute path of the jar.mn that owns the registration. */
8
+ jarManifest: string;
9
+ }
10
+ /** Result of a registration-aware dist probe. */
11
+ export interface RegistrationProbeResult {
12
+ /** Absolute path of the packaged artifact matching the registration target. */
13
+ artifact: string;
14
+ /** The registration entry that anchored the match. */
15
+ hit: RegistrationHit;
16
+ }
17
+ /**
18
+ * Parses a single jar.mn line into `{ target, source }` when the line is a
19
+ * content entry with an explicit `(source)` reference. Returns undefined
20
+ * for comments, headers (`browser.jar:`), `%` manifest directives, blank
21
+ * lines, and entries without a source reference.
22
+ *
23
+ * Accepted entry shapes:
24
+ * ` content/browser/foo.js (content/foo.js)` bare
25
+ * `* content/browser/foo.js (content/foo.js)` `*` = preprocessed
26
+ * `en-US.jar: content/foo.js (content/foo.js)` locale-prefixed
27
+ */
28
+ export declare function parseJarMnEntry(line: string): {
29
+ target: string;
30
+ source: string;
31
+ } | undefined;
32
+ /**
33
+ * Scans a jar.mn file's contents for an entry whose source reference
34
+ * matches `relativeSource` (POSIX, relative to the jar.mn directory).
35
+ * Returns the first match; jar.mn enforces uniqueness of `(source)` in
36
+ * practice, so a first-match wins behaviour is adequate.
37
+ */
38
+ export declare function findJarMnEntryForSource(content: string, relativeSource: string): {
39
+ target: string;
40
+ source: string;
41
+ } | undefined;
42
+ /**
43
+ * Walks from the source's directory upward to the engine root, returning
44
+ * the first jar.mn entry that registers the given source. Returns undefined
45
+ * when no ancestor jar.mn claims the source.
46
+ *
47
+ * @param engineDir Absolute engine root; walk halts here.
48
+ * @param source Engine-relative POSIX source path.
49
+ */
50
+ export declare function findRegisteredTarget(engineDir: string, source: string): Promise<RegistrationHit | undefined>;
51
+ /**
52
+ * Probes the dist tree for the artifact registered against the given
53
+ * source. Returns the matched candidate and the registration hit that
54
+ * anchored it; undefined when the source has no owning jar.mn or when
55
+ * no same-basename candidate under the search roots ends with the
56
+ * registered target path.
57
+ *
58
+ * Suffix-matching against the target path is intentional: jar.mn targets
59
+ * are relative to the jar root (`browser.jar:`, `toolkit.jar:`), but the
60
+ * dist tree prefixes every entry with a jar-specific directory
61
+ * (`.../chrome/browser/content/browser/…`). The source basename plus the
62
+ * target suffix are unambiguous across every packaging convention we
63
+ * care about.
64
+ *
65
+ * @param engineDir Absolute engine root.
66
+ * @param source Engine-relative POSIX source path.
67
+ * @param searchRoots Absolute roots to probe (dist/, _tests/).
68
+ */
69
+ export declare function resolveArtifactByRegistration(engineDir: string, source: string, searchRoots: readonly string[]): Promise<RegistrationProbeResult | undefined>;
70
+ /**
71
+ * Returns the absolute paths of every same-basename candidate under the
72
+ * given search roots. Used by the audit to enumerate ALL false-match
73
+ * candidates when the heuristic fallback downgrades to "missing" — the
74
+ * operator needs to see the full set, not just the scorer's pick, to
75
+ * distinguish a registration bug from a genuine packaging drop.
76
+ *
77
+ * @param source Engine-relative POSIX source path.
78
+ * @param searchRoots Absolute roots to scan.
79
+ */
80
+ export declare function collectSameBasenameCandidates(source: string, searchRoots: readonly string[]): Promise<string[]>;