@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
@@ -0,0 +1,273 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Auto-injects `--app-path=<abs>` into `mach test` invocations whose nearest
4
+ * xpcshell.toml sets `firefox-appdir = "browser"` (or `<appname>-appdir = …`)
5
+ * but whose `appname` is not `firefox`.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * The upstream xpcshell harness computes the manifest key for the appdir
10
+ * override as `mozInfo["appname"] + "-appdir"`. On a stock Firefox build the
11
+ * key is `firefox-appdir`, so the very common `firefox-appdir = "browser"`
12
+ * directive is honoured. On a rebranded fork (appname=`hominis`,
13
+ * `mybrowser`, …) the harness looks for `hominis-appdir` / `mybrowser-appdir`
14
+ * — the literal `firefox-appdir` line is silently ignored, `appPath` falls
15
+ * back to `xrePath`, and every `resource:///modules/…` import throws
16
+ * `Failed to load resource:///modules/<name>.sys.mjs` because xpcshell now
17
+ * resolves the `resource:///` prefix one level above the real app root.
18
+ *
19
+ * ## Strategy
20
+ *
21
+ * 1. For each test path the operator handed us, find the nearest
22
+ * `xpcshell.toml`. If none exists, the test is not an xpcshell test and
23
+ * nothing to inject.
24
+ * 2. Read the manifest's `[DEFAULT]` section. Look for `<appname>-appdir`
25
+ * first — if present, the harness already finds it and there's nothing to
26
+ * do. Fall back to `firefox-appdir`. This ordering matches upstream
27
+ * precedence and avoids overriding an operator who already migrated.
28
+ * 3. If only `firefox-appdir` is present and `appname != "firefox"`, compute
29
+ * the absolute app dir path against the active `obj-X/dist` tree
30
+ * (probing `dist/bin/<value>` first, then any `dist/<bundle>.app/Contents/
31
+ * Resources/<value>` for the macOS packaged layout) and return it as
32
+ * the value to pass to `--app-path`.
33
+ * 4. If multiple test paths disagree on the resolved value (e.g. one
34
+ * manifest sets `browser`, another sets `xulrunner`), refuse injection
35
+ * and return null — the operator can drop down to `--mach-arg`.
36
+ *
37
+ * Operator escape hatches: `--mach-arg=--app-path=…` always wins (handled in
38
+ * test.ts; we skip injection when `--app-path=` already appears in the
39
+ * forwarded args).
40
+ */
41
+ import { readdir } from 'node:fs/promises';
42
+ import { dirname, join, resolve, sep } from 'node:path';
43
+ import { pathExists, readJson, readText } from '../utils/fs.js';
44
+ import { isObject, isString } from '../utils/validation.js';
45
+ /**
46
+ * `[DEFAULT]` section parser shaped to the narrow case we need: pull a
47
+ * single key/value out without depending on a real TOML parser. Avoids
48
+ * pulling a TOML dep into the test path for a one-shot lookup.
49
+ *
50
+ * Accepts:
51
+ * - Single- or double-quoted values
52
+ * - Whitespace either side of `=`
53
+ * - Continuation comments (`#` or `;`) at the end of the line
54
+ * - Bare unquoted bareword values (e.g. `firefox-appdir = browser`) — some
55
+ * operators omit the quotes and the harness honours either form.
56
+ *
57
+ * Returns `undefined` when the key is absent or sits outside `[DEFAULT]`.
58
+ */
59
+ export function parseAppdirFromToml(tomlText, key) {
60
+ const lines = tomlText.split(/\r?\n/);
61
+ let inDefault = false;
62
+ let sectionSeen = false;
63
+ // Anchored on the start of the line so a `# firefox-appdir = "…"`-style
64
+ // comment further along the file is not mistaken for the directive.
65
+ const escapedKey = escapeRegex(key);
66
+ const keyPattern = new RegExp('^\\s*' + escapedKey + '\\s*=\\s*(.+?)\\s*(?:[#;].*)?$');
67
+ for (let i = 0; i < lines.length; i += 1) {
68
+ const line = lines[i] ?? '';
69
+ const sectionMatch = /^\s*\[([^\]]+)\]\s*$/.exec(line);
70
+ if (sectionMatch) {
71
+ sectionSeen = true;
72
+ inDefault = sectionMatch[1]?.trim().toUpperCase() === 'DEFAULT';
73
+ continue;
74
+ }
75
+ // The implicit pre-section region of an xpcshell.toml is treated as
76
+ // [DEFAULT] by the upstream parser, so we honour the same convention.
77
+ const inImplicitDefault = !sectionSeen;
78
+ if (!inDefault && !inImplicitDefault)
79
+ continue;
80
+ const match = keyPattern.exec(line);
81
+ if (!match)
82
+ continue;
83
+ const raw = (match[1] ?? '').trim();
84
+ const value = stripQuotes(raw);
85
+ if (value === undefined)
86
+ continue;
87
+ return { value, lineIndex: i };
88
+ }
89
+ return undefined;
90
+ }
91
+ function escapeRegex(input) {
92
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ }
94
+ function stripQuotes(raw) {
95
+ if (raw.length === 0)
96
+ return undefined;
97
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
98
+ return raw.slice(1, -1);
99
+ }
100
+ // Bareword: must not contain whitespace; otherwise we are looking at
101
+ // commentary that the regex's optional comment tail did not strip.
102
+ if (/\s/.test(raw))
103
+ return undefined;
104
+ return raw;
105
+ }
106
+ /**
107
+ * Walks up from `startPath` (a file or directory under `engineDir`) and
108
+ * returns the absolute path of the first sibling `xpcshell.toml` found.
109
+ * Stops at `engineDir` (inclusive) and returns null on miss.
110
+ *
111
+ * Special-cases `startPath` itself when it already ends with
112
+ * `xpcshell.toml` — operators sometimes pass a manifest path directly.
113
+ */
114
+ export async function findNearestXpcshellManifest(engineDir, startPath) {
115
+ const absStart = resolve(engineDir, startPath);
116
+ if (absStart.toLowerCase().endsWith(`${sep}xpcshell.toml`)) {
117
+ return (await pathExists(absStart)) ? absStart : null;
118
+ }
119
+ const engineAbs = resolve(engineDir);
120
+ let current = absStart;
121
+ // First iteration walks down to a directory; subsequent ones walk up.
122
+ // Cap iterations defensively — a pathological symlink loop would
123
+ // otherwise spin until the call stack overflows.
124
+ for (let i = 0; i < 64; i += 1) {
125
+ const dir = i === 0 ? dirname(absStart) : dirname(current);
126
+ const candidate = join(dir, 'xpcshell.toml');
127
+ if (await pathExists(candidate))
128
+ return candidate;
129
+ if (dir === engineAbs || dir === dirname(dir))
130
+ return null;
131
+ current = dir;
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Reads `<objDir>/mozinfo.json` for the active app name. Returns
137
+ * `"firefox"` when mozinfo cannot be read or the field is missing — that
138
+ * is the safe default because it matches stock Firefox behaviour and
139
+ * means the resolver will not inject anything (the manifest's
140
+ * `firefox-appdir` value WILL be honoured by the upstream harness when
141
+ * appname is firefox).
142
+ */
143
+ export async function readMozinfoAppname(objDirPath) {
144
+ const mozinfoPath = join(objDirPath, 'mozinfo.json');
145
+ if (!(await pathExists(mozinfoPath)))
146
+ return 'firefox';
147
+ try {
148
+ const data = await readJson(mozinfoPath);
149
+ if (isObject(data) && isString(data['appname'])) {
150
+ return data['appname'];
151
+ }
152
+ }
153
+ catch {
154
+ // Malformed mozinfo is a build-system problem out of scope for the
155
+ // appdir resolver; treat as if appname were missing.
156
+ }
157
+ return 'firefox';
158
+ }
159
+ /**
160
+ * Probes the obj-dir's `dist/` subtree for the absolute path that the
161
+ * harness would have computed if the manifest key had been honoured.
162
+ * Returns null when no candidate exists — better to skip injection
163
+ * silently than to point the harness at a path that doesn't exist
164
+ * (which fails with a different error than the original `firefox-appdir`
165
+ * symptom and confuses triage).
166
+ *
167
+ * Probe order matches the on-disk layouts FireForge supports today:
168
+ * 1. `<objDir>/dist/bin/<value>` — Linux primary, also macOS via the
169
+ * `dist/bin -> dist/<App>.app/Contents/MacOS/` symlink.
170
+ * 2. `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` — macOS
171
+ * packaged layout, where `dist/bin/` may not exist as a directory.
172
+ */
173
+ export async function resolveAbsoluteAppPath(objDirAbs, relativeAppdir) {
174
+ const distBinCandidate = join(objDirAbs, 'dist', 'bin', relativeAppdir);
175
+ if (await pathExists(distBinCandidate))
176
+ return distBinCandidate;
177
+ const distDir = join(objDirAbs, 'dist');
178
+ if (!(await pathExists(distDir)))
179
+ return null;
180
+ let entries;
181
+ try {
182
+ entries = await readdir(distDir);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ for (const entry of entries) {
188
+ if (!entry.endsWith('.app'))
189
+ continue;
190
+ const candidate = join(distDir, entry, 'Contents', 'Resources', relativeAppdir);
191
+ if (await pathExists(candidate))
192
+ return candidate;
193
+ }
194
+ return null;
195
+ }
196
+ /**
197
+ * Top-level resolver. Walks every test path, reads the nearest
198
+ * xpcshell.toml, and returns the single absolute path to inject (or a
199
+ * structured "no injection" outcome). Never throws — every fs / parse
200
+ * error is folded into a `none` outcome so the test command always falls
201
+ * through to the diagnostic hint instead of dying inside a helper.
202
+ */
203
+ export async function resolveXpcshellAppdirArg(engineDir, testPaths, objDirName) {
204
+ if (testPaths.length === 0)
205
+ return { kind: 'none' };
206
+ const objDirAbs = resolve(engineDir, objDirName);
207
+ const appname = await readMozinfoAppname(objDirAbs);
208
+ // When appname IS "firefox" the upstream harness reads `firefox-appdir`
209
+ // natively. Injecting in that case would be a no-op at best and an
210
+ // override at worst, so bail out before doing any IO per-path.
211
+ if (appname === 'firefox')
212
+ return { kind: 'none' };
213
+ const appnameKey = `${appname}-appdir`;
214
+ const seenInjections = new Map();
215
+ for (const testPath of testPaths) {
216
+ const manifestPath = await findNearestXpcshellManifest(engineDir, testPath);
217
+ if (!manifestPath)
218
+ continue;
219
+ let body;
220
+ try {
221
+ body = await readText(manifestPath);
222
+ }
223
+ catch {
224
+ continue;
225
+ }
226
+ // Operator already migrated — harness will read the appname-keyed
227
+ // value directly. Nothing to do.
228
+ if (parseAppdirFromToml(body, appnameKey) !== undefined)
229
+ continue;
230
+ const fallback = parseAppdirFromToml(body, 'firefox-appdir');
231
+ if (fallback === undefined)
232
+ continue;
233
+ const absolute = await resolveAbsoluteAppPath(objDirAbs, fallback.value);
234
+ if (!absolute) {
235
+ return {
236
+ kind: 'unresolved',
237
+ relativeAppdir: fallback.value,
238
+ manifestPath,
239
+ };
240
+ }
241
+ seenInjections.set(absolute, {
242
+ appPath: absolute,
243
+ manifestPath,
244
+ key: 'firefox-appdir',
245
+ relativeAppdir: fallback.value,
246
+ });
247
+ }
248
+ if (seenInjections.size === 0)
249
+ return { kind: 'none' };
250
+ if (seenInjections.size > 1) {
251
+ return { kind: 'mismatch', values: Array.from(seenInjections.keys()) };
252
+ }
253
+ const [result] = seenInjections.values();
254
+ // Map.size === 1 was just checked, so result is defined.
255
+ return { kind: 'injected', result: result };
256
+ }
257
+ /**
258
+ * Returns true when the operator already passed `--app-path=` (or its
259
+ * `--app-path <value>` two-token form) through `--mach-arg`. Used by the
260
+ * test command to skip auto-injection so the operator override always
261
+ * wins.
262
+ */
263
+ export function operatorAlreadySetAppPath(extraArgs) {
264
+ for (let i = 0; i < extraArgs.length; i += 1) {
265
+ const arg = extraArgs[i] ?? '';
266
+ if (arg === '--app-path' && i + 1 < extraArgs.length)
267
+ return true;
268
+ if (arg.startsWith('--app-path='))
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+ //# sourceMappingURL=xpcshell-appdir.js.map
@@ -25,5 +25,18 @@ export declare const ExitCode: {
25
25
  readonly FURNACE_ERROR: 9;
26
26
  /** Patch conflict resolution error */
27
27
  readonly RESOLUTION_ERROR: 10;
28
+ /**
29
+ * `fireforge run --smoke-exit` observed one or more unallowed console
30
+ * error lines inside the smoke window. Distinct from BUILD_ERROR so CI
31
+ * can route smoke regressions separately from compile/config failures.
32
+ */
33
+ readonly SMOKE_EXIT_FAILURE: 12;
34
+ /**
35
+ * `fireforge run --smoke-exit` saw the browser exit with a non-clean
36
+ * status before the smoke window elapsed — a launch-side failure that
37
+ * did NOT surface as a console error line (crash before console wiring,
38
+ * missing profile, etc.).
39
+ */
40
+ readonly SMOKE_LAUNCH_FAILURE: 13;
28
41
  };
29
42
  export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
@@ -26,5 +26,18 @@ export const ExitCode = {
26
26
  FURNACE_ERROR: 9,
27
27
  /** Patch conflict resolution error */
28
28
  RESOLUTION_ERROR: 10,
29
+ /**
30
+ * `fireforge run --smoke-exit` observed one or more unallowed console
31
+ * error lines inside the smoke window. Distinct from BUILD_ERROR so CI
32
+ * can route smoke regressions separately from compile/config failures.
33
+ */
34
+ SMOKE_EXIT_FAILURE: 12,
35
+ /**
36
+ * `fireforge run --smoke-exit` saw the browser exit with a non-clean
37
+ * status before the smoke window elapsed — a launch-side failure that
38
+ * did NOT surface as a console error line (crash before console wiring,
39
+ * missing profile, etc.).
40
+ */
41
+ SMOKE_LAUNCH_FAILURE: 13,
29
42
  };
30
43
  //# sourceMappingURL=codes.js.map
@@ -0,0 +1,16 @@
1
+ import { FireForgeError } from './base.js';
2
+ import { ExitCode } from './codes.js';
3
+ /**
4
+ * Error raised by `fireforge run --smoke-exit` when the captured console
5
+ * stream produced one or more error lines that did NOT match the
6
+ * configured allowlist.
7
+ *
8
+ * Distinct from `BuildError` so CI pipelines can route smoke failures
9
+ * differently from build failures and so the exit code is the smoke-run
10
+ * contract's `SMOKE_EXIT_FAILURE` rather than the generic `BUILD_ERROR`.
11
+ */
12
+ export declare class SmokeRunError extends FireForgeError {
13
+ readonly code: ExitCode;
14
+ constructor(message: string, exitCode: ExitCode, cause?: Error);
15
+ get userMessage(): string;
16
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { FireForgeError } from './base.js';
3
+ /**
4
+ * Error raised by `fireforge run --smoke-exit` when the captured console
5
+ * stream produced one or more error lines that did NOT match the
6
+ * configured allowlist.
7
+ *
8
+ * Distinct from `BuildError` so CI pipelines can route smoke failures
9
+ * differently from build failures and so the exit code is the smoke-run
10
+ * contract's `SMOKE_EXIT_FAILURE` rather than the generic `BUILD_ERROR`.
11
+ */
12
+ export class SmokeRunError extends FireForgeError {
13
+ code;
14
+ constructor(message, exitCode, cause) {
15
+ super(message, cause);
16
+ this.code = exitCode;
17
+ }
18
+ get userMessage() {
19
+ return `Smoke run failed: ${this.message}`;
20
+ }
21
+ }
22
+ //# sourceMappingURL=run.js.map
@@ -41,6 +41,14 @@ export interface BuildOptions {
41
41
  jobs?: number;
42
42
  /** Brand to build (stable, esr, etc.) */
43
43
  brand?: string;
44
+ /**
45
+ * When a mozinfo mismatch is detected that looks like a safe path
46
+ * relocation (same structure, different prefix), patch mozinfo paths
47
+ * in place and run `mach configure` rather than aborting with a
48
+ * full-rebuild instruction. Falls back to the original abort message
49
+ * for any mismatch the rewriter cannot prove safe.
50
+ */
51
+ rewriteMozinfo?: boolean;
44
52
  }
45
53
  /**
46
54
  * Options for the export command.
@@ -169,6 +177,39 @@ export interface RebaseOptions {
169
177
  export interface RunOptions {
170
178
  /** Additional arguments to pass to the browser */
171
179
  args?: string[];
180
+ /**
181
+ * Enable smoke-run mode. Launches the browser, streams the console,
182
+ * sends SIGTERM to the whole process group after `smokeExit` seconds,
183
+ * and applies the smoke exit contract:
184
+ * - `0` — clean window (no unallowed error lines).
185
+ * - `ExitCode.SMOKE_EXIT_FAILURE` (12) — one or more console lines
186
+ * matched the error heuristic and were not covered by the allowlist.
187
+ * - `ExitCode.SMOKE_LAUNCH_FAILURE` (13) — the browser exited with a
188
+ * non-clean status before the smoke window elapsed (launch-side
189
+ * failure we cannot observe as a console line — crash before console
190
+ * wiring, missing profile, etc.).
191
+ *
192
+ * POSIX only (process-group semantics do not map cleanly onto Windows);
193
+ * `runSmokeExit` rejects the flag up front on `win32`.
194
+ */
195
+ smokeExit?: number;
196
+ /**
197
+ * Repeatable regex patterns that mark a matching console line as
198
+ * benign. Matches are still counted for the summary but do not drive
199
+ * the smoke-run exit code.
200
+ */
201
+ consoleAllow?: string[];
202
+ /**
203
+ * Path to a newline-delimited allowlist regex file. Blank lines and
204
+ * `#` comments are ignored; each remaining line is compiled as a
205
+ * regex and appended to the active allowlist.
206
+ */
207
+ consoleAllowFile?: string;
208
+ /**
209
+ * Mirror the captured console output to this file path so agents can
210
+ * inspect the raw stream after smoke-exit returns.
211
+ */
212
+ captureConsole?: string;
172
213
  }
173
214
  /**
174
215
  * Options for the test command.
@@ -184,6 +225,13 @@ export interface TestOptions {
184
225
  * spawned. When no paths are supplied, runs the preflight only and exits.
185
226
  */
186
227
  doctor?: boolean;
228
+ /**
229
+ * Extra arguments forwarded verbatim to `mach test` (repeatable). Escape
230
+ * valve for upstream xpcshell/mochitest flags that FireForge does not
231
+ * model directly. Order relative to other flags is preserved; passthrough
232
+ * values appear after `--headless` if both are set.
233
+ */
234
+ machArg?: string[];
187
235
  }
188
236
  /**
189
237
  * Options for the furnace apply command.
@@ -298,6 +346,22 @@ export interface FurnaceCreateOptions {
298
346
  testStyle?: 'mochikit' | 'browser-chrome' | 'xpcshell';
299
347
  /** Stock component tag names composed internally by this component */
300
348
  compose?: string[];
349
+ /**
350
+ * Participate in a pre-existing feature-scoped Fluent bundle at this
351
+ * path (as used by `insertFTLIfNeeded`, e.g. `browser/hominis-dock.ftl`)
352
+ * instead of scaffolding a per-component `.ftl`. Implies `localized`.
353
+ * Persists onto the furnace.json entry so validation and apply skip the
354
+ * per-component paths.
355
+ */
356
+ sharedFtl?: string;
357
+ /**
358
+ * Show the planned file set and furnace.json changes without writing
359
+ * anything. All validation that does not require disk writes (tag name
360
+ * shape, name conflicts, engine pre-existence, `--compose` targets, cycle
361
+ * detection, prefix warning) runs before the plan is emitted, so a
362
+ * dry-run faithfully previews the real command's outcome.
363
+ */
364
+ dryRun?: boolean;
301
365
  }
302
366
  /**
303
367
  * Options for the wire command.
@@ -50,6 +50,45 @@ export interface CustomComponentConfig {
50
50
  localized: boolean;
51
51
  /** Stock component tag names composed internally by this component */
52
52
  composes?: string[];
53
+ /**
54
+ * Opts the component out of the `no-keyboard-handler` accessibility check
55
+ * when it wraps a native-interactive inner element that is not tracked in
56
+ * `composes` (for example a hand-authored `<button>` or a non-stock
57
+ * `moz-*` widget). When `true`, the check is skipped even if the template
58
+ * appears to attach `@click` to synthetic markup.
59
+ *
60
+ * Leave unset for the default behavior: the validator still silences the
61
+ * check automatically when any entry in `composes` matches its native-
62
+ * interactive allowlist (e.g. `moz-button`, `moz-toggle`). This flag is
63
+ * only needed when `composes` does not capture the inner element.
64
+ *
65
+ * Operator-asserted: setting this to `true` does not re-check the
66
+ * component, so it can be used to silence genuine findings. Prefer adding
67
+ * the wrapped element to `composes` when that field applies.
68
+ */
69
+ keyboardCovered?: boolean;
70
+ /**
71
+ * Path of a pre-existing feature-scoped Fluent bundle this component
72
+ * participates in, in the same form used by `insertFTLIfNeeded` (for
73
+ * example `browser/hominis-dock.ftl`). When set:
74
+ *
75
+ * - `furnace create --localized` does NOT scaffold a per-component
76
+ * `.ftl` stub — the component shares the feature bundle.
77
+ * - The generated `.mjs` calls `insertFTLIfNeeded("<sharedFtl>")` at
78
+ * the shared path instead of the per-component one.
79
+ * - `furnace validate`'s `missing-ftl` structural rule is skipped for
80
+ * the component (there is no `<tag>.ftl` to require).
81
+ * - `furnace apply` does NOT copy a per-component `.ftl` into the FTL
82
+ * tree nor register a new entry in the locale `jar.mn` — the shared
83
+ * file is owned by whoever authored the feature bundle.
84
+ *
85
+ * Requires `localized: true`. Mutually exclusive with the per-component
86
+ * `.ftl` scaffold. Does NOT auto-migrate previous per-component FTL
87
+ * state: flipping an existing component onto `sharedFtl` leaves the
88
+ * prior per-component entry in the engine tree and the locale `jar.mn`
89
+ * until explicitly cleaned up.
90
+ */
91
+ sharedFtl?: string;
53
92
  }
54
93
  /**
55
94
  * The furnace.json schema.
@@ -79,6 +79,69 @@ export declare function execInherit(command: string, args: string[], options?: E
79
79
  export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions & {
80
80
  shutdownGraceMs?: number;
81
81
  }): Promise<ExecResult>;
82
+ /** Per-line callback for smoke-run stream dispatch. */
83
+ export type SmokeLineCallback = (line: string) => void;
84
+ /** Options for {@link execSmokeRun}. */
85
+ export interface SmokeRunOptions extends ExecOptions {
86
+ /**
87
+ * Hard deadline in milliseconds. When it elapses the child process
88
+ * group is sent SIGTERM and, after `killGraceMs`, SIGKILL. The returned
89
+ * {@link SmokeRunResult.timedOut} is `true` when the deadline fires —
90
+ * callers treat that as a clean smoke window (no child-driven error),
91
+ * not a failure.
92
+ */
93
+ smokeTimeoutMs: number;
94
+ /**
95
+ * Grace period between SIGTERM and SIGKILL when the deadline fires.
96
+ * Defaults to 10000 ms because Firefox's AsyncShutdown and
97
+ * profileBeforeChange blockers can take ~5–10 s to flush in-memory
98
+ * state. A shorter grace risks corrupting the dev profile mid-quit.
99
+ */
100
+ killGraceMs?: number;
101
+ /** Invoked once per complete line of stdout. Final partial line is flushed on close. */
102
+ onStdoutLine?: SmokeLineCallback;
103
+ /** Invoked once per complete line of stderr. Final partial line is flushed on close. */
104
+ onStderrLine?: SmokeLineCallback;
105
+ /**
106
+ * Optional writable stream to mirror captured output to (e.g. an
107
+ * operator-supplied `--capture-console` file). Writes happen inline
108
+ * with line dispatch and the stream is NOT closed here — the caller
109
+ * owns its lifecycle.
110
+ */
111
+ mirror?: {
112
+ stdout?: NodeJS.WritableStream;
113
+ stderr?: NodeJS.WritableStream;
114
+ };
115
+ }
116
+ /** Result of {@link execSmokeRun}. */
117
+ export interface SmokeRunResult extends ExecResult {
118
+ /**
119
+ * `true` when the smoke deadline fired and we SIGTERMed the child
120
+ * ourselves. Callers that want to distinguish "smoke window elapsed
121
+ * cleanly" from "child exited on its own" check this flag — the
122
+ * `exitCode` in the timedOut path is almost always 143 (SIGTERM) and
123
+ * should NOT be treated as a child-driven failure.
124
+ */
125
+ timedOut: boolean;
126
+ }
127
+ /**
128
+ * Spawns `command` with `args` in its own process group (POSIX), streams
129
+ * stdout/stderr line-by-line to the caller, enforces a deadline by
130
+ * SIGTERMing the whole group when it elapses, and returns the captured
131
+ * output alongside a `timedOut` flag.
132
+ *
133
+ * Process-group semantics matter here because `mach run` execs a Python
134
+ * wrapper that then forks Firefox, which itself spawns content processes.
135
+ * Sending SIGTERM only to the Python PID leaves an orphan Firefox tree
136
+ * behind. Running the child as a process-group leader (`detached: true`
137
+ * on POSIX) and signalling `-pid` routes the kill to every descendant
138
+ * that inherited the group.
139
+ *
140
+ * Windows fallback: `detached: true` does not create an equivalent group
141
+ * there, so we degrade to `child.kill()` and log a best-effort warning
142
+ * via the `onStderrLine` callback if the caller wired one.
143
+ */
144
+ export declare function execSmokeRun(command: string, args: string[], options: SmokeRunOptions): Promise<SmokeRunResult>;
82
145
  /**
83
146
  * Finds an executable in the system PATH.
84
147
  * @param name - Name of the executable