@hominis/fireforge 0.13.2 → 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 (78) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +20 -1
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create-templates.d.ts +26 -0
  13. package/dist/src/commands/furnace/create-templates.js +86 -0
  14. package/dist/src/commands/furnace/create.js +77 -103
  15. package/dist/src/commands/furnace/deploy.js +20 -5
  16. package/dist/src/commands/furnace/diff.js +3 -1
  17. package/dist/src/commands/furnace/init.js +25 -7
  18. package/dist/src/commands/furnace/list.js +15 -7
  19. package/dist/src/commands/furnace/override.js +47 -15
  20. package/dist/src/commands/furnace/remove.js +68 -20
  21. package/dist/src/commands/furnace/rename.js +31 -3
  22. package/dist/src/commands/furnace/scan.js +8 -0
  23. package/dist/src/commands/furnace/validate.js +70 -7
  24. package/dist/src/commands/import.js +65 -11
  25. package/dist/src/commands/re-export.js +11 -4
  26. package/dist/src/commands/rebase/abort.js +26 -14
  27. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  28. package/dist/src/commands/rebase/confirm.js +2 -2
  29. package/dist/src/commands/rebase/continue.js +39 -15
  30. package/dist/src/commands/rebase/index.js +2 -1
  31. package/dist/src/commands/rebase/patch-loop.js +90 -33
  32. package/dist/src/commands/register.js +13 -0
  33. package/dist/src/commands/resolve.js +31 -10
  34. package/dist/src/commands/run.js +9 -44
  35. package/dist/src/commands/setup-support.js +25 -7
  36. package/dist/src/commands/status.js +59 -8
  37. package/dist/src/commands/test.js +33 -7
  38. package/dist/src/commands/token.js +11 -1
  39. package/dist/src/commands/watch.js +51 -1
  40. package/dist/src/commands/wire.js +23 -0
  41. package/dist/src/core/config-paths.d.ts +2 -2
  42. package/dist/src/core/config-paths.js +2 -0
  43. package/dist/src/core/config-validate.js +47 -1
  44. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  45. package/dist/src/core/furnace-apply-ftl.js +102 -0
  46. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  47. package/dist/src/core/furnace-apply-helpers.js +16 -12
  48. package/dist/src/core/furnace-apply.js +7 -4
  49. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  50. package/dist/src/core/furnace-config-tokens.js +28 -0
  51. package/dist/src/core/furnace-config.d.ts +6 -0
  52. package/dist/src/core/furnace-config.js +8 -1
  53. package/dist/src/core/furnace-constants.d.ts +20 -0
  54. package/dist/src/core/furnace-constants.js +32 -0
  55. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  56. package/dist/src/core/furnace-registration-ast.js +58 -25
  57. package/dist/src/core/furnace-registration.d.ts +28 -1
  58. package/dist/src/core/furnace-registration.js +98 -1
  59. package/dist/src/core/furnace-staleness.d.ts +17 -0
  60. package/dist/src/core/furnace-staleness.js +58 -0
  61. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  62. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  63. package/dist/src/core/furnace-validate-helpers.js +81 -0
  64. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  65. package/dist/src/core/furnace-validate-registration.js +34 -9
  66. package/dist/src/core/furnace-validate.js +2 -2
  67. package/dist/src/core/marionette-preflight.d.ts +39 -0
  68. package/dist/src/core/marionette-preflight.js +210 -0
  69. package/dist/src/core/signal-critical.d.ts +49 -0
  70. package/dist/src/core/signal-critical.js +80 -0
  71. package/dist/src/errors/download.d.ts +1 -1
  72. package/dist/src/errors/download.js +6 -3
  73. package/dist/src/types/commands/options.d.ts +6 -0
  74. package/dist/src/types/config.d.ts +7 -0
  75. package/dist/src/types/furnace.d.ts +8 -0
  76. package/dist/src/utils/process.d.ts +15 -2
  77. package/dist/src/utils/process.js +73 -0
  78. package/package.json +1 -1
@@ -69,7 +69,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
69
69
  // check so that "moz-card.css" does not match "moz-card-group.css".
70
70
  const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
71
71
  if (newFiles.length === 0)
72
- return;
72
+ return 0;
73
73
  // Build new entry lines using the indent detected from existing entries.
74
74
  const indent = detectJarMnIndent(lines);
75
75
  const newEntries = newFiles.map((f) => `${indent}content/global/elements/${f} (widgets/${tagName}/${f})`);
@@ -111,6 +111,103 @@ export async function addJarMnEntries(engineDir, tagName, files) {
111
111
  lines.splice(insertIndex, 0, ...newEntries);
112
112
  content = lines.join('\n');
113
113
  await writeText(filePath, content);
114
+ return newFiles.length;
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'));
114
211
  }
115
212
  /**
116
213
  * Removes all jar.mn entries for a given tag name.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Furnace staleness advisory — shared between `fireforge run` and
3
+ * `fireforge watch`. Both commands launch the built browser without
4
+ * first running `furnace apply`, so this helper surfaces a warning when
5
+ * component files have drifted from the last-applied checksums and the
6
+ * user is about to run with stale engine state.
7
+ *
8
+ * The check is advisory only: errors (broken furnace config, partial
9
+ * state, transient filesystem failure) must never block the caller.
10
+ */
11
+ /**
12
+ * Emits a warning when any tracked override or custom component has
13
+ * changed on disk since the last apply. Safe to call from any build-time
14
+ * command that does not auto-apply — a failure inside the probe is
15
+ * downgraded to a verbose log and the caller continues.
16
+ */
17
+ export declare function warnIfFurnaceStale(projectRoot: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Furnace staleness advisory — shared between `fireforge run` and
4
+ * `fireforge watch`. Both commands launch the built browser without
5
+ * first running `furnace apply`, so this helper surfaces a warning when
6
+ * component files have drifted from the last-applied checksums and the
7
+ * user is about to run with stale engine state.
8
+ *
9
+ * The check is advisory only: errors (broken furnace config, partial
10
+ * state, transient filesystem failure) must never block the caller.
11
+ */
12
+ import { pathExists } from '../utils/fs.js';
13
+ import { verbose, warn } from '../utils/logger.js';
14
+ import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
15
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
16
+ /**
17
+ * Emits a warning when any tracked override or custom component has
18
+ * changed on disk since the last apply. Safe to call from any build-time
19
+ * command that does not auto-apply — a failure inside the probe is
20
+ * downgraded to a verbose log and the caller continues.
21
+ */
22
+ export async function warnIfFurnaceStale(projectRoot) {
23
+ try {
24
+ if (!(await furnaceConfigExists(projectRoot)))
25
+ return;
26
+ const config = await loadFurnaceConfig(projectRoot);
27
+ const state = await loadFurnaceState(projectRoot);
28
+ const furnacePaths = getFurnacePaths(projectRoot);
29
+ if (!state.appliedChecksums)
30
+ return;
31
+ const stale = [];
32
+ for (const name of Object.keys(config.overrides)) {
33
+ const dir = `${furnacePaths.overridesDir}/${name}`;
34
+ if (!(await pathExists(dir)))
35
+ continue;
36
+ const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
37
+ if (await hasComponentChanged(dir, prev))
38
+ stale.push(name);
39
+ }
40
+ for (const name of Object.keys(config.custom)) {
41
+ const dir = `${furnacePaths.customDir}/${name}`;
42
+ if (!(await pathExists(dir)))
43
+ continue;
44
+ const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
45
+ if (await hasComponentChanged(dir, prev))
46
+ stale.push(name);
47
+ }
48
+ if (stale.length > 0) {
49
+ warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
50
+ 'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
51
+ }
52
+ }
53
+ catch {
54
+ // Non-fatal: a broken furnace config should not block the caller.
55
+ verbose('Furnace staleness check skipped due to an error.');
56
+ }
57
+ }
58
+ //# sourceMappingURL=furnace-staleness.js.map
@@ -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