@hominis/fireforge 0.15.7 → 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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +90 -10
  3. package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
  4. package/dist/src/commands/furnace/create-dry-run.js +7 -2
  5. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  6. package/dist/src/commands/furnace/create-features.js +56 -0
  7. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  8. package/dist/src/commands/furnace/create-templates.js +14 -6
  9. package/dist/src/commands/furnace/create.js +34 -39
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/run.d.ts +15 -1
  12. package/dist/src/commands/run.js +202 -7
  13. package/dist/src/commands/test.js +97 -2
  14. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  15. package/dist/src/core/furnace-apply-ftl.js +6 -2
  16. package/dist/src/core/furnace-apply-helpers.js +14 -4
  17. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  18. package/dist/src/core/furnace-config-custom.js +64 -0
  19. package/dist/src/core/furnace-config.js +2 -39
  20. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  21. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  22. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  23. package/dist/src/core/furnace-validate-helpers.js +19 -0
  24. package/dist/src/core/furnace-validate-structure.js +6 -2
  25. package/dist/src/core/furnace-validate.js +6 -3
  26. package/dist/src/core/mach.d.ts +26 -0
  27. package/dist/src/core/mach.js +25 -1
  28. package/dist/src/core/shared-ftl.d.ts +28 -0
  29. package/dist/src/core/shared-ftl.js +42 -0
  30. package/dist/src/core/smoke-patterns.d.ts +45 -0
  31. package/dist/src/core/smoke-patterns.js +100 -0
  32. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  33. package/dist/src/core/xpcshell-appdir.js +273 -0
  34. package/dist/src/errors/codes.d.ts +13 -0
  35. package/dist/src/errors/codes.js +13 -0
  36. package/dist/src/errors/run.d.ts +16 -0
  37. package/dist/src/errors/run.js +22 -0
  38. package/dist/src/types/commands/options.d.ts +48 -0
  39. package/dist/src/types/furnace.d.ts +39 -0
  40. package/dist/src/utils/process.d.ts +63 -0
  41. package/dist/src/utils/process.js +122 -0
  42. 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
@@ -5,6 +5,7 @@ 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
7
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
8
+ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
8
9
  import { GeneralError } from '../errors/base.js';
9
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
10
11
  import { pathExists } from '../utils/fs.js';
@@ -60,7 +61,47 @@ function hasStaleBuildArtifactsSignal(output) {
60
61
  /resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
61
62
  /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
62
63
  }
63
- 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) {
64
105
  if (result.exitCode === 0 || result.exitCode === 130)
65
106
  return;
66
107
  const combinedOutput = `${result.stdout}\n${result.stderr}`;
@@ -70,6 +111,12 @@ function handleNonZeroTestExit(result, normalizedPaths) {
70
111
  if (hasStaleBuildArtifactsSignal(combinedOutput)) {
71
112
  throw new GeneralError(buildStaleBuildMessage());
72
113
  }
114
+ if (hasMochitestHttp3ServerSignal(combinedOutput)) {
115
+ throw new GeneralError(buildMochitestHttp3ServerMessage());
116
+ }
117
+ if (hasXpcshellAppdirSignal(combinedOutput)) {
118
+ throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
119
+ }
73
120
  if (/invalid filename/i.test(combinedOutput) ||
74
121
  /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
75
122
  info('Hint: The test file may not be registered in browser.toml or jar.mn.');
@@ -159,6 +206,23 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
159
206
  if (options.headless) {
160
207
  extraArgs.push('--headless');
161
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);
162
226
  // Log what we're doing
163
227
  if (normalizedPaths.length > 0) {
164
228
  info(`Running tests: ${normalizedPaths.join(', ')}`);
@@ -174,7 +238,34 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
174
238
  catch (error) {
175
239
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
176
240
  }
177
- 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
+ }
178
269
  }
179
270
  /** Registers the test command on the CLI program. */
180
271
  export function registerTest(program, { getProjectRoot, withErrorHandling }) {
@@ -184,6 +275,10 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
184
275
  .option('--headless', 'Run tests in headless mode')
185
276
  .option('--build', 'Run incremental UI build before testing')
186
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
+ }, [])
187
282
  .action(withErrorHandling(async (paths, options) => {
188
283
  await testCommand(getProjectRoot(), paths, pickDefined(options));
189
284
  }));
@@ -8,7 +8,7 @@
8
8
  * aborting the whole command. Missing jar.mn on a fork without a locale
9
9
  * package should not block a working `.mjs`/`.css` from shipping.
10
10
  */
11
- import type { DryRunAction, StepError } from '../types/furnace.js';
11
+ import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
12
  import { type RollbackJournal } from './furnace-rollback.js';
13
13
  /**
14
14
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
@@ -28,6 +28,8 @@ export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir:
28
28
  /**
29
29
  * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
30
30
  * source workspace file has been deleted. Idempotent — absent entries are a
31
- * no-op.
31
+ * no-op. Early-returns for `sharedFtl` components: the shared bundle is
32
+ * owned elsewhere, and dropping its jar.mn line on our component's delete
33
+ * would orphan every other participant.
32
34
  */
33
- export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;
35
+ export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, config: CustomComponentConfig, rollbackJournal?: RollbackJournal): Promise<void>;
@@ -81,9 +81,13 @@ export function describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile) {
81
81
  /**
82
82
  * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
83
83
  * source workspace file has been deleted. Idempotent — absent entries are a
84
- * no-op.
84
+ * no-op. Early-returns for `sharedFtl` components: the shared bundle is
85
+ * owned elsewhere, and dropping its jar.mn line on our component's delete
86
+ * would orphan every other participant.
85
87
  */
86
- export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal) {
88
+ export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal) {
89
+ if (config.sharedFtl)
90
+ return;
87
91
  if (!fileName.endsWith('.ftl'))
88
92
  return;
89
93
  const tagName = fileName.slice(0, -'.ftl'.length);
@@ -139,10 +139,12 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
139
139
  await removeFile(enginePath);
140
140
  removed.push(relative(engineDir, enginePath));
141
141
  }
142
- // When an `.ftl` is deleted from the workspace, the corresponding locale
142
+ // When an `.ftl` is deleted from the workspace the corresponding locale
143
143
  // jar.mn entry must also be dropped — otherwise the chrome URI points at
144
144
  // a missing file and runtime Fluent resolution breaks silently.
145
- await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal);
145
+ // `removeCustomFtlJarMnEntry` early-returns for `sharedFtl` components
146
+ // (the shared bundle is owned elsewhere).
147
+ await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal);
146
148
  }
147
149
  return removed;
148
150
  }
@@ -306,7 +308,12 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
306
308
  description: `Copy ${entry.name} to ${config.targetPath}`,
307
309
  });
308
310
  }
309
- if (config.localized) {
311
+ // Per-component .ftl handling is skipped when the component opts into a
312
+ // shared feature-scoped bundle via `sharedFtl`. The shared file is
313
+ // registered (and copied) by whoever owns the feature bundle, so
314
+ // emitting a copy-ftl / register-jar action here would duplicate (or
315
+ // later orphan) the entry.
316
+ if (config.localized && !config.sharedFtl) {
310
317
  const ftlFile = `${name}.ftl`;
311
318
  const ftlSrc = join(componentDir, ftlFile);
312
319
  if (await pathExists(ftlSrc)) {
@@ -402,7 +409,10 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
402
409
  affectedPaths.push(relative(engineDir, dest));
403
410
  copiedFileNames.push(entry.name);
404
411
  }));
405
- if (config.localized) {
412
+ // See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
413
+ // the shared bundle is owned elsewhere and FireForge must not copy or
414
+ // register a per-component `.ftl` on its behalf.
415
+ if (config.localized && !config.sharedFtl) {
406
416
  await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
407
417
  }
408
418
  if (config.register) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parser for the `custom` entries in furnace.json. Extracted from
3
+ * `furnace-config.ts` so the main config module stays under the
4
+ * per-file LOC budget — the custom-component schema has grown to
5
+ * carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
6
+ * each add their own validation branch.
7
+ */
8
+ import type { CustomComponentConfig } from '../types/furnace.js';
9
+ /**
10
+ * Validates a custom component config object.
11
+ * @param data - Raw data to validate
12
+ * @param name - Component name for error messages
13
+ */
14
+ export declare function parseCustomConfig(data: Record<string, unknown>, name: string): CustomComponentConfig;
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Parser for the `custom` entries in furnace.json. Extracted from
4
+ * `furnace-config.ts` so the main config module stays under the
5
+ * per-file LOC budget — the custom-component schema has grown to
6
+ * carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
7
+ * each add their own validation branch.
8
+ */
9
+ import { FurnaceError } from '../errors/furnace.js';
10
+ import { isExplicitAbsolutePath } from '../utils/paths.js';
11
+ import { isBoolean, isString } from '../utils/validation.js';
12
+ import { parseStringArray } from './furnace-config.js';
13
+ import { validateSharedFtl } from './shared-ftl.js';
14
+ /**
15
+ * Validates a custom component config object.
16
+ * @param data - Raw data to validate
17
+ * @param name - Component name for error messages
18
+ */
19
+ export function parseCustomConfig(data, name) {
20
+ if (!isString(data['description'])) {
21
+ throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
22
+ }
23
+ if (!isString(data['targetPath'])) {
24
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
25
+ }
26
+ if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
27
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
28
+ }
29
+ if (isExplicitAbsolutePath(data['targetPath'])) {
30
+ throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
31
+ }
32
+ if (!isBoolean(data['register'])) {
33
+ throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
34
+ }
35
+ if (!isBoolean(data['localized'])) {
36
+ throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
37
+ }
38
+ if (data['composes'] !== undefined) {
39
+ parseStringArray(data['composes'], `${name}.composes`);
40
+ }
41
+ if (data['keyboardCovered'] !== undefined && !isBoolean(data['keyboardCovered'])) {
42
+ throw new FurnaceError(`Furnace config: custom "${name}.keyboardCovered" must be a boolean when set`);
43
+ }
44
+ let sharedFtl;
45
+ if (data['sharedFtl'] !== undefined) {
46
+ const result = validateSharedFtl(data['sharedFtl'], { localized: data['localized'] });
47
+ if (!result.ok) {
48
+ throw new FurnaceError(`Furnace config: custom "${name}.sharedFtl" ${result.reason}`);
49
+ }
50
+ sharedFtl = result.value;
51
+ }
52
+ return {
53
+ description: data['description'],
54
+ targetPath: data['targetPath'],
55
+ register: data['register'],
56
+ localized: data['localized'],
57
+ ...(data['composes'] !== undefined
58
+ ? { composes: parseStringArray(data['composes'], `${name}.composes`) }
59
+ : {}),
60
+ ...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
61
+ ...(sharedFtl !== undefined ? { sharedFtl } : {}),
62
+ };
63
+ }
64
+ //# sourceMappingURL=furnace-config-custom.js.map
@@ -4,9 +4,9 @@ import { FurnaceError } from '../errors/furnace.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
- import { isExplicitAbsolutePath } from '../utils/paths.js';
8
- import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
7
+ import { isArray, isObject, isString } from '../utils/validation.js';
9
8
  import { FIREFORGE_DIR } from './config.js';
9
+ import { parseCustomConfig } from './furnace-config-custom.js';
10
10
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
11
11
  import { resolveFtlDir } from './furnace-constants.js';
12
12
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
@@ -89,43 +89,6 @@ function parseOverrideConfig(data, name) {
89
89
  ...(isString(data['baseCommit']) ? { baseCommit: data['baseCommit'] } : {}),
90
90
  };
91
91
  }
92
- /**
93
- * Validates a custom component config object.
94
- * @param data - Raw data to validate
95
- * @param name - Component name for error messages
96
- */
97
- function parseCustomConfig(data, name) {
98
- if (!isString(data['description'])) {
99
- throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
100
- }
101
- if (!isString(data['targetPath'])) {
102
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
103
- }
104
- if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
105
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
106
- }
107
- if (isExplicitAbsolutePath(data['targetPath'])) {
108
- throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
109
- }
110
- if (!isBoolean(data['register'])) {
111
- throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
112
- }
113
- if (!isBoolean(data['localized'])) {
114
- throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
115
- }
116
- if (data['composes'] !== undefined) {
117
- parseStringArray(data['composes'], `${name}.composes`);
118
- }
119
- return {
120
- description: data['description'],
121
- targetPath: data['targetPath'],
122
- register: data['register'],
123
- localized: data['localized'],
124
- ...(data['composes'] !== undefined
125
- ? { composes: parseStringArray(data['composes'], `${name}.composes`) }
126
- : {}),
127
- };
128
- }
129
92
  /** The current (and only) config schema version. */
130
93
  const CURRENT_CONFIG_VERSION = 1;
131
94
  /**
@@ -1,6 +1,13 @@
1
- import type { ValidationIssue } from '../types/furnace.js';
1
+ import type { CustomComponentConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
3
  * Validates accessibility patterns in a component's .mjs file.
4
4
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
5
+ *
6
+ * @param customConfig - When the component is custom, its matching entry
7
+ * from `furnace.json`. Used to skip the `no-keyboard-handler` warning
8
+ * when the component declares keyboard coverage either explicitly
9
+ * (`keyboardCovered: true`) or via `composes` naming a native-interactive
10
+ * inner element. Optional so stock/override callers and test fixtures
11
+ * without config in scope can continue to call without changes.
5
12
  */
6
- export declare function validateAccessibility(componentDir: string, tagName: string): Promise<ValidationIssue[]>;
13
+ export declare function validateAccessibility(componentDir: string, tagName: string, customConfig?: CustomComponentConfig): Promise<ValidationIssue[]>;