@hominis/fireforge 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +20 -1
  3. package/dist/src/commands/furnace/create-templates.d.ts +26 -0
  4. package/dist/src/commands/furnace/create-templates.js +86 -0
  5. package/dist/src/commands/furnace/create.js +64 -101
  6. package/dist/src/commands/furnace/deploy.js +3 -3
  7. package/dist/src/commands/test.js +20 -0
  8. package/dist/src/core/config-paths.d.ts +2 -2
  9. package/dist/src/core/config-paths.js +2 -0
  10. package/dist/src/core/config-validate.js +32 -0
  11. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  12. package/dist/src/core/furnace-apply-ftl.js +102 -0
  13. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  14. package/dist/src/core/furnace-apply-helpers.js +16 -12
  15. package/dist/src/core/furnace-apply.js +7 -4
  16. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  17. package/dist/src/core/furnace-config-tokens.js +28 -0
  18. package/dist/src/core/furnace-config.d.ts +6 -0
  19. package/dist/src/core/furnace-config.js +8 -1
  20. package/dist/src/core/furnace-constants.d.ts +20 -0
  21. package/dist/src/core/furnace-constants.js +32 -0
  22. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  23. package/dist/src/core/furnace-registration-ast.js +58 -25
  24. package/dist/src/core/furnace-registration.d.ts +27 -0
  25. package/dist/src/core/furnace-registration.js +96 -0
  26. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  27. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  28. package/dist/src/core/furnace-validate-helpers.js +81 -0
  29. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  30. package/dist/src/core/furnace-validate-registration.js +34 -9
  31. package/dist/src/core/furnace-validate.js +2 -2
  32. package/dist/src/core/marionette-preflight.d.ts +39 -0
  33. package/dist/src/core/marionette-preflight.js +210 -0
  34. package/dist/src/types/commands/options.d.ts +6 -0
  35. package/dist/src/types/config.d.ts +7 -0
  36. package/dist/src/types/furnace.d.ts +8 -0
  37. package/dist/src/utils/process.d.ts +15 -2
  38. package/dist/src/utils/process.js +73 -0
  39. package/package.json +1 -1
@@ -113,6 +113,102 @@ export async function addJarMnEntries(engineDir, tagName, files) {
113
113
  await writeText(filePath, content);
114
114
  return newFiles.length;
115
115
  }
116
+ /**
117
+ * Adds a locale jar.mn entry mapping `<chromeSubPath>/<tagName>.ftl` to the
118
+ * on-disk `.ftl` that `furnace apply` just copied under the FTL tree. Without
119
+ * this entry the chrome URI passed to `window.MozXULElement.insertFTLIfNeeded`
120
+ * does not resolve at runtime, so the generated `--localized` component
121
+ * silently ships broken l10n.
122
+ *
123
+ * Degrades gracefully — if the locale jar.mn (e.g. `toolkit/locales/jar.mn`)
124
+ * does not exist, returns 0 rather than throwing, so a custom fork without a
125
+ * standard locales package can still apply a localized component.
126
+ *
127
+ * The written entry mirrors the Mozilla convention for toolkit widgets:
128
+ *
129
+ * locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl (%<chromeSubPath>/<tagName>.ftl)
130
+ *
131
+ * @param engineDir - Path to the Firefox engine source root
132
+ * @param jarMnRelPath - Engine-relative path to the locale jar.mn
133
+ * @param tagName - Custom element tag name (base of the `.ftl` file)
134
+ * @param chromeSubPath - Chrome sub-path (e.g. `toolkit/global`)
135
+ * @returns Number of entries inserted (0 when already present, or jar.mn missing)
136
+ */
137
+ export async function addLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
138
+ const filePath = join(engineDir, jarMnRelPath);
139
+ if (!(await pathExists(filePath))) {
140
+ return 0;
141
+ }
142
+ const content = await readText(filePath);
143
+ const lines = content.split('\n');
144
+ const ftlFile = `${tagName}.ftl`;
145
+ const escapedTag = escapeForRegex(tagName);
146
+ const escapedChrome = escapeForRegex(chromeSubPath);
147
+ const presencePattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/${escapedTag}\\.ftl`, 'm');
148
+ if (presencePattern.test(content)) {
149
+ return 0;
150
+ }
151
+ const indent = detectLocaleJarMnIndent(lines, chromeSubPath);
152
+ const newEntry = `${indent}locale/@AB_CD@/${chromeSubPath}/${ftlFile} (%${chromeSubPath}/${ftlFile})`;
153
+ const sectionPattern = new RegExp(`^(\\s+)locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/([^.\\s]+)\\.ftl`);
154
+ let insertIndex = -1;
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const line = lines[i];
157
+ if (line === undefined)
158
+ continue;
159
+ const match = sectionPattern.exec(line);
160
+ if (match) {
161
+ const existingTag = match[2] ?? '';
162
+ if (existingTag > tagName) {
163
+ insertIndex = i;
164
+ break;
165
+ }
166
+ insertIndex = i + 1;
167
+ }
168
+ }
169
+ if (insertIndex === -1) {
170
+ // No existing entries under this chrome sub-path. Fall back to end-of-file
171
+ // placement so the operator can reorder manually if desired.
172
+ insertIndex = lines.length;
173
+ }
174
+ lines.splice(insertIndex, 0, newEntry);
175
+ await writeText(filePath, lines.join('\n'));
176
+ return 1;
177
+ }
178
+ /** Detects locale jar.mn indentation by sampling an existing matching entry. */
179
+ function detectLocaleJarMnIndent(lines, chromeSubPath) {
180
+ const escapedChrome = escapeForRegex(chromeSubPath);
181
+ const pattern = new RegExp(`^(\\s+)locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/`);
182
+ for (const line of lines) {
183
+ const match = pattern.exec(line);
184
+ if (match?.[1])
185
+ return match[1];
186
+ }
187
+ // Fall back to detecting any existing `locale/...` indent before giving up.
188
+ for (const line of lines) {
189
+ const match = /^(\s+)locale\//.exec(line);
190
+ if (match?.[1])
191
+ return match[1];
192
+ }
193
+ return ' ';
194
+ }
195
+ /**
196
+ * Removes a locale jar.mn entry previously written by `addLocaleFtlJarMnEntry`.
197
+ * Idempotent — if the entry is absent or the file is missing, nothing happens.
198
+ */
199
+ export async function removeLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
200
+ const filePath = join(engineDir, jarMnRelPath);
201
+ if (!(await pathExists(filePath))) {
202
+ return;
203
+ }
204
+ const content = await readText(filePath);
205
+ const lines = content.split('\n');
206
+ const pattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeForRegex(chromeSubPath)}\\/${escapeForRegex(tagName)}\\.ftl`);
207
+ const filtered = lines.filter((line) => !pattern.test(line));
208
+ if (filtered.length === lines.length)
209
+ return;
210
+ await writeText(filePath, filtered.join('\n'));
211
+ }
116
212
  /**
117
213
  * Removes all jar.mn entries for a given tag name.
118
214
  *
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateKeyboardHandler, hasUnlabelledFormInput, } from './furnace-validate-helpers.js';
4
+ import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateClickOnSyntheticInteractive, hasTemplateKeyboardHandler, hasUnlabelledFormInput, } from './furnace-validate-helpers.js';
5
5
  /**
6
6
  * Validates accessibility patterns in a component's .mjs file.
7
7
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
@@ -17,7 +17,13 @@ export async function validateAccessibility(componentDir, tagName) {
17
17
  }
18
18
  const hasClick = hasTemplateClickHandler(content);
19
19
  const hasKeyboardHandler = hasTemplateKeyboardHandler(content);
20
- if (hasClick && !hasKeyboardHandler) {
20
+ // Native interactive elements (<button>, <a href>, form controls,
21
+ // moz-button/moz-toggle/etc.) dispatch click on Enter/Space via the
22
+ // platform, so a duplicate keyboard handler would usually double-fire.
23
+ // Only flag synthetic markup (e.g. `<div @click>`) where the activation
24
+ // path has to be wired manually.
25
+ const hasClickOnSynthetic = hasTemplateClickOnSyntheticInteractive(content);
26
+ if (hasClickOnSynthetic && !hasKeyboardHandler) {
21
27
  issues.push(createIssue(tagName, 'warning', 'no-keyboard-handler', 'Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup).'));
22
28
  }
23
29
  if (containsHardcodedTemplateText(content)) {
@@ -13,6 +13,14 @@ export declare function hasUnlabelledFormInput(content: string): boolean;
13
13
  export declare function hasTemplateClickHandler(content: string): boolean;
14
14
  /** Detects Lit-style template keyboard handlers. */
15
15
  export declare function hasTemplateKeyboardHandler(content: string): boolean;
16
+ /**
17
+ * Returns true when `content` has at least one `@click=${...}` handler on a
18
+ * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
19
+ * keyboard activation and therefore needs an explicit key handler for
20
+ * Enter/Space. Returns false when every `@click` handler sits on a native
21
+ * interactive element — those already fire `click` on keyboard activation.
22
+ */
23
+ export declare function hasTemplateClickOnSyntheticInteractive(content: string): boolean;
16
24
  /** Detects hardcoded user-visible template text that should usually be localized. */
17
25
  export declare function containsHardcodedTemplateText(content: string): boolean;
18
26
  /** Detects whether a component opts into shadow-root focus delegation. */
@@ -52,6 +52,87 @@ export function hasTemplateClickHandler(content) {
52
52
  export function hasTemplateKeyboardHandler(content) {
53
53
  return /@key(down|press|up)\s*=\s*\$\{/.test(content);
54
54
  }
55
+ /**
56
+ * Native HTML elements that dispatch `click` on Enter and Space via the
57
+ * platform. Attaching `@click` to these is NOT a keyboard-a11y bug because
58
+ * the browser already handles the keyboard activation path — a duplicate
59
+ * `@keydown`/`@keypress` handler would usually double-fire the action.
60
+ *
61
+ * `<a>` is accepted only when an `href` attribute is present; bare `<a>` is
62
+ * non-interactive and is treated as synthetic.
63
+ */
64
+ const NATIVE_CLICK_INTERACTIVE_TAGS = new Set([
65
+ 'button',
66
+ 'input',
67
+ 'select',
68
+ 'textarea',
69
+ 'summary',
70
+ 'details',
71
+ // Mozilla widgets that extend the native pattern and keep Enter/Space activation.
72
+ 'moz-button',
73
+ 'moz-toggle',
74
+ 'moz-checkbox',
75
+ 'moz-radio',
76
+ 'moz-radio-group',
77
+ 'moz-menulist',
78
+ ]);
79
+ /**
80
+ * Returns true when `content` has at least one `@click=${...}` handler on a
81
+ * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
82
+ * keyboard activation and therefore needs an explicit key handler for
83
+ * Enter/Space. Returns false when every `@click` handler sits on a native
84
+ * interactive element — those already fire `click` on keyboard activation.
85
+ */
86
+ export function hasTemplateClickOnSyntheticInteractive(content) {
87
+ const pattern = /@click\s*=\s*\$\{/g;
88
+ let match;
89
+ while ((match = pattern.exec(content)) !== null) {
90
+ if (isClickOnSyntheticInteractive(content, match.index)) {
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+ /**
97
+ * Given the offset of an `@click=${...}` occurrence, walks backwards to find
98
+ * the opening `<tag` that owns it and decides whether that tag is a native
99
+ * interactive element (no warning needed) or a synthetic one (warning needed).
100
+ */
101
+ function isClickOnSyntheticInteractive(content, clickIndex) {
102
+ // Find the nearest preceding `<` that starts a tag (skip `</` closers).
103
+ let i = clickIndex - 1;
104
+ let tagOpenIndex = -1;
105
+ while (i >= 0) {
106
+ if (content[i] === '<' && content[i + 1] !== '/') {
107
+ tagOpenIndex = i;
108
+ break;
109
+ }
110
+ // A `>` before an unclosed `<` means we're outside the attribute list,
111
+ // which shouldn't happen for a well-formed template but we defensively
112
+ // treat it as synthetic to preserve the prior-behaviour warning.
113
+ if (content[i] === '>') {
114
+ return true;
115
+ }
116
+ i--;
117
+ }
118
+ if (tagOpenIndex < 0)
119
+ return true;
120
+ const tagMatch = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(content.slice(tagOpenIndex));
121
+ if (!tagMatch?.[1])
122
+ return true;
123
+ const tagName = tagMatch[1].toLowerCase();
124
+ if (tagName === 'a') {
125
+ // Bare <a> (no href) is non-interactive; require a keyboard handler.
126
+ // Look forward from the tag open for the closing `>` and scan the
127
+ // attribute text in between for an href attribute.
128
+ const tagEnd = content.indexOf('>', tagOpenIndex);
129
+ if (tagEnd < 0)
130
+ return true;
131
+ const attrs = content.slice(tagOpenIndex, tagEnd);
132
+ return !/\shref\s*=/.test(attrs);
133
+ }
134
+ return !NATIVE_CLICK_INTERACTIVE_TAGS.has(tagName);
135
+ }
55
136
  function isSymbolOnlyText(text) {
56
137
  return Array.from(text).every((character) => {
57
138
  const code = character.codePointAt(0) ?? 0;
@@ -32,9 +32,15 @@ export declare function checkRegistrationConsistency(root: string, name: string,
32
32
  export declare function validateJarMnEntries(root: string, config: FurnaceConfig): Promise<ValidationIssue[]>;
33
33
  /**
34
34
  * Validates that components using design tokens have the tokens CSS
35
- * linked in browser.xhtml. Without the link, tokens silently resolve to nothing.
35
+ * linked in at least one chrome host document. Without the link, tokens
36
+ * silently resolve to nothing at runtime.
37
+ *
38
+ * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
39
+ * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
40
+ * furnace.json; the warning fires only when NONE of the configured
41
+ * documents link the tokens CSS.
36
42
  */
37
- export declare function validateTokenLink(componentDir: string, tagName: string, root: string, tokenPrefix?: string): Promise<ValidationIssue[]>;
43
+ export declare function validateTokenLink(componentDir: string, tagName: string, root: string, tokenPrefix?: string, tokenHostDocuments?: string[]): Promise<ValidationIssue[]>;
38
44
  /**
39
45
  * Post-apply registration consistency check for custom components.
40
46
  *
@@ -206,11 +206,22 @@ export async function validateJarMnEntries(root, config) {
206
206
  }
207
207
  return issues;
208
208
  }
209
+ /**
210
+ * Default chrome host document scanned by `validateTokenLink` when
211
+ * `tokenHostDocuments` is not configured in furnace.json.
212
+ */
213
+ const DEFAULT_TOKEN_HOST_DOCUMENTS = ['browser/base/content/browser.xhtml'];
209
214
  /**
210
215
  * Validates that components using design tokens have the tokens CSS
211
- * linked in browser.xhtml. Without the link, tokens silently resolve to nothing.
216
+ * linked in at least one chrome host document. Without the link, tokens
217
+ * silently resolve to nothing at runtime.
218
+ *
219
+ * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
220
+ * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
221
+ * furnace.json; the warning fires only when NONE of the configured
222
+ * documents link the tokens CSS.
212
223
  */
213
- export async function validateTokenLink(componentDir, tagName, root, tokenPrefix) {
224
+ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix, tokenHostDocuments) {
214
225
  const issues = [];
215
226
  const cssPath = join(componentDir, `${tagName}.css`);
216
227
  if (!(await pathExists(cssPath)))
@@ -221,11 +232,10 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
221
232
  // Check if the component CSS references any tokens with the configured prefix
222
233
  if (!cssContent.includes(tokenPrefix))
223
234
  return issues;
224
- // Check if browser.xhtml links the token CSS file
225
235
  const { engine: engineDir } = getProjectPaths(root);
226
- const browserXhtmlPath = join(engineDir, 'browser/base/content/browser.xhtml');
227
- if (!(await pathExists(browserXhtmlPath)))
228
- return issues;
236
+ const hostDocuments = tokenHostDocuments && tokenHostDocuments.length > 0
237
+ ? tokenHostDocuments
238
+ : DEFAULT_TOKEN_HOST_DOCUMENTS;
229
239
  let tokensCssFile;
230
240
  try {
231
241
  const forgeConfig = await loadConfig(root);
@@ -237,13 +247,28 @@ export async function validateTokenLink(componentDir, tagName, root, tokenPrefix
237
247
  warn(`Could not resolve token CSS link target for ${tagName} during validation: ${reason}`);
238
248
  return issues;
239
249
  }
240
- const xhtmlContent = await readText(browserXhtmlPath);
241
- if (!xhtmlContent.includes(tokensCssFile)) {
250
+ const checkedDocuments = [];
251
+ let anyLinks = false;
252
+ for (const relDocPath of hostDocuments) {
253
+ const absPath = join(engineDir, relDocPath);
254
+ if (!(await pathExists(absPath)))
255
+ continue;
256
+ checkedDocuments.push(relDocPath);
257
+ const xhtmlContent = await readText(absPath);
258
+ if (xhtmlContent.includes(tokensCssFile)) {
259
+ anyLinks = true;
260
+ break;
261
+ }
262
+ }
263
+ if (checkedDocuments.length === 0)
264
+ return issues;
265
+ if (!anyLinks) {
266
+ const docsList = checkedDocuments.join(', ');
242
267
  issues.push({
243
268
  component: tagName,
244
269
  severity: 'warning',
245
270
  check: 'missing-token-link',
246
- message: `Component uses ${tokenPrefix}* tokens but browser.xhtml does not link ${tokensCssFile}. Tokens will silently resolve to nothing.`,
271
+ message: `Component uses ${tokenPrefix}* tokens but none of the configured chrome host documents (${docsList}) link ${tokensCssFile}. Tokens will silently resolve to nothing. Configure additional hosts via furnace.json "tokenHostDocuments" if needed.`,
247
272
  });
248
273
  }
249
274
  return issues;
@@ -46,9 +46,9 @@ export async function validateComponent(componentDir, tagName, type, config, roo
46
46
  const forgeConfig = await loadConfig(root);
47
47
  issues.push(...buildOverrideVersionDriftIssues(config, forgeConfig.firefox.version, tagName));
48
48
  }
49
- // Check for missing token link in browser.xhtml
49
+ // Check for missing token link across configured chrome host documents.
50
50
  if (root) {
51
- issues.push(...(await validateTokenLink(componentDir, tagName, root, config?.tokenPrefix)));
51
+ issues.push(...(await validateTokenLink(componentDir, tagName, root, config?.tokenPrefix, config?.tokenHostDocuments)));
52
52
  }
53
53
  // When root is provided and this is a custom component with registration,
54
54
  // also run registration pattern and jar.mn validation for this component.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Marionette handshake preflight for `fireforge test --doctor`.
3
+ *
4
+ * Answers a single question before tests run: "does marionette come up?" A
5
+ * silent 360-second mach-test hang is indistinguishable from "tests failed
6
+ * to discover"; this helper surfaces the failure in under a minute with a
7
+ * clear PASS/FAIL line and the tail of the browser's stderr.
8
+ *
9
+ * The probe is intentionally narrow — it does not replace mach test or try
10
+ * to execute anything via marionette. It spawns `mach run --marionette
11
+ * --headless` (plus a throwaway profile) and waits for the marionette server
12
+ * to accept a TCP connection on the conventional port. Any byte read from
13
+ * the socket proves a handshake payload is being produced.
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import net from 'node:net';
17
+ export interface MarionettePreflightResult {
18
+ ok: boolean;
19
+ durationMs: number;
20
+ /** Human-readable summary. */
21
+ detail: string;
22
+ }
23
+ export interface MarionettePreflightOptions {
24
+ /** Total budget in ms. Defaults to 30 seconds. */
25
+ timeoutMs?: number;
26
+ /** Overrides marionette TCP port — primarily used in tests. */
27
+ port?: number;
28
+ /** Test seam: spawn and socket connect factories. */
29
+ spawner?: typeof spawn;
30
+ connect?: typeof net.createConnection;
31
+ }
32
+ /**
33
+ * Runs the marionette preflight. Returns PASS on first byte read from the
34
+ * marionette socket within the budget; FAIL otherwise. Always tears down the
35
+ * spawned browser before returning.
36
+ */
37
+ export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
38
+ /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
39
+ export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;
@@ -0,0 +1,210 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Marionette handshake preflight for `fireforge test --doctor`.
4
+ *
5
+ * Answers a single question before tests run: "does marionette come up?" A
6
+ * silent 360-second mach-test hang is indistinguishable from "tests failed
7
+ * to discover"; this helper surfaces the failure in under a minute with a
8
+ * clear PASS/FAIL line and the tail of the browser's stderr.
9
+ *
10
+ * The probe is intentionally narrow — it does not replace mach test or try
11
+ * to execute anything via marionette. It spawns `mach run --marionette
12
+ * --headless` (plus a throwaway profile) and waits for the marionette server
13
+ * to accept a TCP connection on the conventional port. Any byte read from
14
+ * the socket proves a handshake payload is being produced.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { mkdtemp, rm } from 'node:fs/promises';
18
+ import net from 'node:net';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { pathExists } from '../utils/fs.js';
22
+ import { info, warn } from '../utils/logger.js';
23
+ import { ensureMach } from './mach.js';
24
+ import { getPython } from './mach-python.js';
25
+ /** Marionette's default TCP port when the browser is launched with `--marionette`. */
26
+ const MARIONETTE_PORT = 2828;
27
+ /** Overall budget for the preflight (browser boot + socket handshake). */
28
+ const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
29
+ /** Per-attempt socket connect timeout. Polling continues until the overall budget expires. */
30
+ const SOCKET_ATTEMPT_TIMEOUT_MS = 2_000;
31
+ /** Tail of stderr preserved for FAIL diagnostics. */
32
+ const STDERR_TAIL_LIMIT = 8 * 1024;
33
+ /**
34
+ * Runs the marionette preflight. Returns PASS on first byte read from the
35
+ * marionette socket within the budget; FAIL otherwise. Always tears down the
36
+ * spawned browser before returning.
37
+ */
38
+ export async function runMarionettePreflight(engineDir, options = {}) {
39
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
40
+ const port = options.port ?? MARIONETTE_PORT;
41
+ const spawnerFn = options.spawner ?? spawn;
42
+ const connectFn = options.connect ?? net.createConnection;
43
+ const startedAt = Date.now();
44
+ const elapsed = () => Date.now() - startedAt;
45
+ if (!(await pathExists(engineDir))) {
46
+ return {
47
+ ok: false,
48
+ durationMs: elapsed(),
49
+ detail: 'Engine directory not found — run "fireforge download" first.',
50
+ };
51
+ }
52
+ try {
53
+ await ensureMach(engineDir);
54
+ }
55
+ catch (error) {
56
+ return {
57
+ ok: false,
58
+ durationMs: elapsed(),
59
+ detail: `mach not available in engine: ${error.message}`,
60
+ };
61
+ }
62
+ const python = await getPython(engineDir);
63
+ const profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
64
+ let child;
65
+ let stderrTail = '';
66
+ try {
67
+ child = spawnerFn(python, [
68
+ join(engineDir, 'mach'),
69
+ 'run',
70
+ '--marionette',
71
+ '--headless',
72
+ '--no-remote',
73
+ '-profile',
74
+ profileDir,
75
+ ], {
76
+ cwd: engineDir,
77
+ env: { ...process.env, MOZ_HEADLESS: '1' },
78
+ stdio: ['ignore', 'ignore', 'pipe'],
79
+ });
80
+ child.stderr?.on('data', (data) => {
81
+ const chunk = data.toString();
82
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT);
83
+ });
84
+ const spawnedChild = child;
85
+ const socketResult = await waitForMarionetteSocket(port, connectFn, () => {
86
+ return elapsed() < timeoutMs && !hasChildExited(spawnedChild);
87
+ });
88
+ if (socketResult.ok) {
89
+ return {
90
+ ok: true,
91
+ durationMs: elapsed(),
92
+ detail: `Marionette handshake received on 127.0.0.1:${port} in ${Date.now() - startedAt}ms.`,
93
+ };
94
+ }
95
+ // Child may have exited before the socket was ever ready — surface that
96
+ // distinctly from "socket never answered" so the operator has a lead.
97
+ if (hasChildExited(spawnedChild)) {
98
+ return {
99
+ ok: false,
100
+ durationMs: elapsed(),
101
+ detail: `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
102
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
103
+ };
104
+ }
105
+ return {
106
+ ok: false,
107
+ durationMs: elapsed(),
108
+ detail: `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
109
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
110
+ };
111
+ }
112
+ finally {
113
+ if (child && !hasChildExited(child)) {
114
+ try {
115
+ child.kill('SIGTERM');
116
+ }
117
+ catch {
118
+ // Already exited — nothing to do.
119
+ }
120
+ // Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
121
+ // so we don't leave a ghost mach process around after a failed probe.
122
+ await delay(500);
123
+ if (!hasChildExited(child)) {
124
+ try {
125
+ child.kill('SIGKILL');
126
+ }
127
+ catch {
128
+ // Already gone.
129
+ }
130
+ }
131
+ }
132
+ try {
133
+ await rm(profileDir, { recursive: true, force: true });
134
+ }
135
+ catch (error) {
136
+ warn(`Could not clean up marionette preflight profile: ${error.message}`);
137
+ }
138
+ }
139
+ }
140
+ /** Returns true when the child process has exited (normal or signaled). */
141
+ function hasChildExited(child) {
142
+ return child.exitCode !== null || child.signalCode !== null;
143
+ }
144
+ async function waitForMarionetteSocket(port, connectFn, keepTrying) {
145
+ while (keepTrying()) {
146
+ const result = await attemptMarionetteConnect(port, connectFn);
147
+ if (result.ok) {
148
+ return { ok: true };
149
+ }
150
+ await delay(400);
151
+ }
152
+ return { ok: false };
153
+ }
154
+ function attemptMarionetteConnect(port, connectFn) {
155
+ return new Promise((resolve) => {
156
+ const socket = connectFn({ host: '127.0.0.1', port });
157
+ let settled = false;
158
+ const finish = (ok) => {
159
+ if (settled)
160
+ return;
161
+ settled = true;
162
+ try {
163
+ socket.destroy();
164
+ }
165
+ catch {
166
+ // Ignore — already closed.
167
+ }
168
+ resolve({ ok });
169
+ };
170
+ const attemptTimer = setTimeout(() => {
171
+ finish(false);
172
+ }, SOCKET_ATTEMPT_TIMEOUT_MS);
173
+ attemptTimer.unref();
174
+ socket.once('connect', () => {
175
+ // Connect alone is insufficient — the marionette server performs a
176
+ // handshake send as soon as the socket opens, so wait for at least one
177
+ // byte to confirm the server is actually speaking marionette.
178
+ const readTimer = setTimeout(() => {
179
+ finish(false);
180
+ }, SOCKET_ATTEMPT_TIMEOUT_MS);
181
+ readTimer.unref();
182
+ socket.once('data', () => {
183
+ clearTimeout(readTimer);
184
+ finish(true);
185
+ });
186
+ });
187
+ socket.once('error', () => {
188
+ finish(false);
189
+ });
190
+ socket.once('close', () => {
191
+ finish(false);
192
+ });
193
+ });
194
+ }
195
+ function delay(ms) {
196
+ return new Promise((resolve) => {
197
+ const timer = setTimeout(resolve, ms);
198
+ timer.unref();
199
+ });
200
+ }
201
+ /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
202
+ export function reportMarionettePreflight(result) {
203
+ if (result.ok) {
204
+ info(`Marionette preflight: PASS (${result.durationMs}ms) — ${result.detail}`);
205
+ }
206
+ else {
207
+ warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
208
+ }
209
+ }
210
+ //# sourceMappingURL=marionette-preflight.js.map
@@ -178,6 +178,12 @@ export interface TestOptions {
178
178
  headless?: boolean;
179
179
  /** Run incremental UI build before testing */
180
180
  build?: boolean;
181
+ /**
182
+ * Run a marionette preflight before tests. Reports PASS/FAIL in under a
183
+ * minute. When test paths are supplied, a FAIL aborts before mach test is
184
+ * spawned. When no paths are supplied, runs the preflight only and exits.
185
+ */
186
+ doctor?: boolean;
181
187
  }
182
188
  /**
183
189
  * Options for the furnace apply command.
@@ -44,6 +44,13 @@ export interface FireForgeConfig {
44
44
  wire?: WireConfig;
45
45
  /** Patch lint configuration */
46
46
  patchLint?: PatchLintConfig;
47
+ /**
48
+ * Project marker prefix appended to lines FireForge writes into
49
+ * upstream Firefox source files (e.g. the `customElements.js` tag list).
50
+ * `"HOMINIS"` emits a trailing ` // HOMINIS:` on each inserted line.
51
+ * Keeps modifications discoverable and re-applies idempotent.
52
+ */
53
+ markerComment?: string;
47
54
  }
48
55
  /**
49
56
  * Wire command configuration.
@@ -63,6 +63,14 @@ export interface FurnaceConfig {
63
63
  tokenPrefix?: string;
64
64
  /** Custom properties allowed even though they don't match tokenPrefix (e.g. ["--background-color-box"]) */
65
65
  tokenAllowlist?: string[];
66
+ /**
67
+ * Chrome documents scanned by the `missing-token-link` validator to confirm
68
+ * the tokens CSS file is `<link>`ed. Forks with multiple chrome host
69
+ * documents (e.g. `hominis.xhtml` beside the stock `browser.xhtml`) should
70
+ * list every document that may own the link. When omitted, defaults to
71
+ * `['browser/base/content/browser.xhtml']` — the upstream Firefox path.
72
+ */
73
+ tokenHostDocuments?: string[];
66
74
  /**
67
75
  * Override the default Fluent (.ftl) base path within the engine.
68
76
  * Defaults to `toolkit/locales/en-US/toolkit/global` when not set.
@@ -51,12 +51,23 @@ export interface StreamOptions extends ExecOptions {
51
51
  export declare function execStream(command: string, args: string[], options?: StreamOptions): Promise<number>;
52
52
  /**
53
53
  * Executes a command and inherits stdio (shows output directly).
54
+ *
55
+ * Graceful shutdown: when the FireForge process receives SIGINT/SIGTERM, the
56
+ * signal is forwarded to the child as SIGTERM and a short grace timer (default
57
+ * 1500ms) runs before escalating to SIGKILL. A second matching signal during
58
+ * the grace period triggers an immediate SIGKILL — matching the usual
59
+ * "hit Ctrl-C again to force-quit" UX. Without this, Firefox's AsyncShutdown
60
+ * / profileBeforeChange blockers (which flush in-memory state to disk) can be
61
+ * racing the OS child-exit path, losing the last few seconds of edits.
62
+ *
54
63
  * @param command - Command to execute
55
64
  * @param args - Command arguments
56
65
  * @param options - Execution options
57
66
  * @returns Exit code of the process
58
67
  */
59
- export declare function execInherit(command: string, args: string[], options?: ExecOptions): Promise<number>;
68
+ export declare function execInherit(command: string, args: string[], options?: ExecOptions & {
69
+ shutdownGraceMs?: number;
70
+ }): Promise<number>;
60
71
  /**
61
72
  * Executes a command while inheriting stdin, streaming stdout/stderr live,
62
73
  * and capturing the emitted output for diagnostics.
@@ -65,7 +76,9 @@ export declare function execInherit(command: string, args: string[], options?: E
65
76
  * @param options - Execution options
66
77
  * @returns Execution result with stdout, stderr, and exit code
67
78
  */
68
- export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
79
+ export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions & {
80
+ shutdownGraceMs?: number;
81
+ }): Promise<ExecResult>;
69
82
  /**
70
83
  * Finds an executable in the system PATH.
71
84
  * @param name - Name of the executable