@hominis/fireforge 0.14.0 → 0.15.2

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 (50) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +41 -3
  3. package/dist/src/commands/build.js +12 -1
  4. package/dist/src/commands/furnace/create-templates.d.ts +47 -0
  5. package/dist/src/commands/furnace/create-templates.js +135 -0
  6. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  7. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  8. package/dist/src/commands/furnace/create.js +81 -109
  9. package/dist/src/commands/furnace/deploy.js +3 -3
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/setup.d.ts +1 -1
  12. package/dist/src/commands/setup.js +3 -2
  13. package/dist/src/commands/test.js +20 -0
  14. package/dist/src/core/build-prepare.js +6 -1
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +2 -0
  17. package/dist/src/core/config-validate.js +32 -0
  18. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  19. package/dist/src/core/furnace-apply-ftl.js +102 -0
  20. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  21. package/dist/src/core/furnace-apply-helpers.js +16 -12
  22. package/dist/src/core/furnace-apply.js +7 -4
  23. package/dist/src/core/furnace-config-tokens.d.ts +17 -0
  24. package/dist/src/core/furnace-config-tokens.js +43 -0
  25. package/dist/src/core/furnace-config.d.ts +6 -0
  26. package/dist/src/core/furnace-config.js +16 -3
  27. package/dist/src/core/furnace-constants.d.ts +20 -0
  28. package/dist/src/core/furnace-constants.js +32 -0
  29. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  30. package/dist/src/core/furnace-registration-ast.js +58 -25
  31. package/dist/src/core/furnace-registration.d.ts +27 -0
  32. package/dist/src/core/furnace-registration.js +96 -0
  33. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  34. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  35. package/dist/src/core/furnace-validate-helpers.d.ts +39 -1
  36. package/dist/src/core/furnace-validate-helpers.js +182 -18
  37. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  38. package/dist/src/core/furnace-validate-registration.js +34 -9
  39. package/dist/src/core/furnace-validate.js +2 -2
  40. package/dist/src/core/marionette-preflight.d.ts +46 -0
  41. package/dist/src/core/marionette-preflight.js +260 -0
  42. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  43. package/dist/src/core/patch-lint-cross.js +1 -1
  44. package/dist/src/core/patch-lint.js +29 -9
  45. package/dist/src/types/commands/options.d.ts +16 -0
  46. package/dist/src/types/config.d.ts +7 -0
  47. package/dist/src/types/furnace.d.ts +19 -0
  48. package/dist/src/utils/process.d.ts +15 -2
  49. package/dist/src/utils/process.js +73 -0
  50. package/package.json +1 -1
@@ -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)) {
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { hasRawCssColors } from '../utils/regex.js';
5
- import { classExtendsMozLitElement, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
5
+ import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
6
6
  async function validateMjsCompatibility(mjsPath, tagName) {
7
7
  if (!(await pathExists(mjsPath)))
8
8
  return [];
@@ -29,13 +29,24 @@ async function validateCssCompatibility(cssPath, tagName, type, config, root) {
29
29
  issues.push(createIssue(tagName, 'error', 'raw-color-value', 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.'));
30
30
  }
31
31
  if (config?.tokenPrefix) {
32
- const { allowlist, inheritedOverrideVars } = await getTokenPrefixContext(tagName, type, config, root);
32
+ const { allowlist, inheritedOverrideVars, runtimeVariables } = await getTokenPrefixContext(tagName, type, config, root);
33
+ // Auto-exempt component-local runtime channels: a CSS custom property
34
+ // both declared and consumed in the same file is a runtime state
35
+ // channel (e.g. `--cam-x`), not a design-token reference. See
36
+ // `runtimeVariables` in furnace.json for cross-component cases.
37
+ const localDeclarations = collectCssVariableDeclarations(cssContent);
33
38
  for (const prop of collectCssVariableReferences(cssContent)) {
34
- if (!prop.startsWith(config.tokenPrefix) &&
35
- !allowlist.has(prop) &&
36
- !inheritedOverrideVars.has(prop)) {
37
- issues.push(createIssue(tagName, 'error', 'token-prefix-violation', `CSS references var(${prop}) which does not match the required token prefix "${config.tokenPrefix}". Use a design token or add to tokenAllowlist.`));
38
- }
39
+ if (prop.startsWith(config.tokenPrefix))
40
+ continue;
41
+ if (allowlist.has(prop))
42
+ continue;
43
+ if (inheritedOverrideVars.has(prop))
44
+ continue;
45
+ if (runtimeVariables.has(prop))
46
+ continue;
47
+ if (localDeclarations.has(prop))
48
+ continue;
49
+ issues.push(createIssue(tagName, 'error', 'token-prefix-violation', `CSS references var(${prop}) which does not match the required token prefix "${config.tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`));
39
50
  }
40
51
  }
41
52
  // Flag excessive !important usage
@@ -13,7 +13,33 @@ 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
- /** Detects hardcoded user-visible template text that should usually be localized. */
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;
24
+ /**
25
+ * Detects hardcoded user-visible template text that should usually be
26
+ * localized.
27
+ *
28
+ * Scoped to three positive contexts rather than scanning the whole file,
29
+ * because a bare `>…<` regex catches JS comparisons (`if (x > 0 && y <
30
+ * 100)`), diagnostic strings (`console.error("Failed <id> lookup")`), and
31
+ * identifier literals that are never shown to a user. Only matches that
32
+ * actually enter a UI render path count:
33
+ *
34
+ * 1. Content inside a Lit `` html`…` `` tagged template literal.
35
+ * 2. The string literal on the RHS of `.textContent = "…"` or
36
+ * `.innerHTML = "…"`.
37
+ * 3. The string literal assigned to an XUL-widget `label=`,
38
+ * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
39
+ *
40
+ * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
41
+ * findings (matches the pre-existing escape hatch).
42
+ */
17
43
  export declare function containsHardcodedTemplateText(content: string): boolean;
18
44
  /** Detects whether a component opts into shadow-root focus delegation. */
19
45
  export declare function hasDelegatesFocusEnabled(content: string): boolean;
@@ -27,8 +53,20 @@ export declare function hasCustomElementDefineCall(mjsContent: string): boolean;
27
53
  export declare function classExtendsMozLitElement(mjsContent: string): boolean;
28
54
  /** Collects CSS custom property references used via var(--token-name). */
29
55
  export declare function collectCssVariableReferences(cssContent: string): string[];
56
+ /**
57
+ * Collects CSS custom property *declarations* — names appearing on the
58
+ * left-hand side of a `--name:` declaration. Used to auto-exempt
59
+ * component-local runtime variables from the token-prefix check: if the
60
+ * component both declares and consumes a variable in its own CSS file, it
61
+ * is a local runtime channel, not a design-token reference.
62
+ *
63
+ * The anchor `(?:^|[{;,\s])` rules out `var(--name)` occurrences (which are
64
+ * always preceded by `(`), so references are not mistaken for declarations.
65
+ */
66
+ export declare function collectCssVariableDeclarations(cssContent: string): Set<string>;
30
67
  /** Builds token-validation context from the config allowlist and inherited override CSS. */
31
68
  export declare function getTokenPrefixContext(tagName: string, type: ComponentType, config: FurnaceConfig, root: string | undefined): Promise<{
32
69
  allowlist: Set<string>;
33
70
  inheritedOverrideVars: Set<string>;
71
+ runtimeVariables: Set<string>;
34
72
  }>;
@@ -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;
@@ -67,28 +148,88 @@ function isWithinLocalizedElement(content, matchIndex) {
67
148
  const tagContent = contentBefore.slice(lastTagOpen, matchIndex + 1);
68
149
  return /data-l10n-id\s*=/.test(tagContent);
69
150
  }
70
- /** Detects hardcoded user-visible template text that should usually be localized. */
151
+ /**
152
+ * Detects hardcoded user-visible template text that should usually be
153
+ * localized.
154
+ *
155
+ * Scoped to three positive contexts rather than scanning the whole file,
156
+ * because a bare `>…<` regex catches JS comparisons (`if (x > 0 && y <
157
+ * 100)`), diagnostic strings (`console.error("Failed <id> lookup")`), and
158
+ * identifier literals that are never shown to a user. Only matches that
159
+ * actually enter a UI render path count:
160
+ *
161
+ * 1. Content inside a Lit `` html`…` `` tagged template literal.
162
+ * 2. The string literal on the RHS of `.textContent = "…"` or
163
+ * `.innerHTML = "…"`.
164
+ * 3. The string literal assigned to an XUL-widget `label=`,
165
+ * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
166
+ *
167
+ * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
168
+ * findings (matches the pre-existing escape hatch).
169
+ */
71
170
  export function containsHardcodedTemplateText(content) {
72
171
  if (/furnace-ignore:\s*hardcoded-text/.test(content)) {
73
172
  return false;
74
173
  }
75
- const textPattern = />([^<$\s][^<$]*)</g;
76
- let textMatch;
77
- while ((textMatch = textPattern.exec(content)) !== null) {
78
- const text = textMatch[1]?.trim() ?? '';
79
- if (/\$\{/.test(text)) {
80
- continue;
81
- }
82
- if (Array.from(text).length <= 1) {
83
- continue;
84
- }
85
- if (isSymbolOnlyText(text)) {
86
- continue;
87
- }
88
- if (isWithinLocalizedElement(content, textMatch.index)) {
89
- continue;
174
+ return (hasFlaggedTextInLitTemplates(content) ||
175
+ hasFlaggedTextInDomAssignment(content) ||
176
+ hasFlaggedTextInXulAttribute(content));
177
+ }
178
+ function isFlaggableText(text) {
179
+ const trimmed = text.trim();
180
+ if (!trimmed)
181
+ return false;
182
+ if (/\$\{/.test(trimmed))
183
+ return false;
184
+ if (Array.from(trimmed).length <= 1)
185
+ return false;
186
+ if (isSymbolOnlyText(trimmed))
187
+ return false;
188
+ return true;
189
+ }
190
+ function hasFlaggedTextInLitTemplates(content) {
191
+ // Match `html\`…\`` regions, anchored on a non-identifier char before `html`
192
+ // so substrings like `otherhtml` do not spuriously open a template.
193
+ const htmlPattern = /(?:^|[^a-zA-Z0-9_$])html`([\s\S]*?)`/g;
194
+ let litMatch;
195
+ while ((litMatch = htmlPattern.exec(content)) !== null) {
196
+ const region = litMatch[1] ?? '';
197
+ const textPattern = />([^<$\s][^<$]*)</g;
198
+ let textMatch;
199
+ while ((textMatch = textPattern.exec(region)) !== null) {
200
+ const text = textMatch[1] ?? '';
201
+ if (!isFlaggableText(text))
202
+ continue;
203
+ if (isWithinLocalizedElement(region, textMatch.index))
204
+ continue;
205
+ return true;
90
206
  }
91
- return true;
207
+ }
208
+ return false;
209
+ }
210
+ function hasFlaggedTextInDomAssignment(content) {
211
+ // `<expr>.textContent = "abc"` and `<expr>.innerHTML = "abc"` — these are
212
+ // user-visible render paths. Template-literal RHS is excluded (usually
213
+ // dynamic), matching the `${` guard used elsewhere in this helper.
214
+ const assignPattern = /\.(?:textContent|innerHTML)\s*=\s*(["'])((?:\\.|(?!\1).)*)\1/g;
215
+ let match;
216
+ while ((match = assignPattern.exec(content)) !== null) {
217
+ const text = match[2] ?? '';
218
+ if (isFlaggableText(text))
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ function hasFlaggedTextInXulAttribute(content) {
224
+ // Assignments like `node.setAttribute("label", "Save")` or JS-built XUL
225
+ // attributes `label="…"` / `title="…"` / `tooltiptext="…"` in template
226
+ // literals outside Lit blocks. Covers DOM built via createXULElement.
227
+ const setAttrPattern = /setAttribute\s*\(\s*["'](?:label|title|tooltiptext)["']\s*,\s*(["'])((?:\\.|(?!\1).)*)\1/g;
228
+ let setAttrMatch;
229
+ while ((setAttrMatch = setAttrPattern.exec(content)) !== null) {
230
+ const text = setAttrMatch[2] ?? '';
231
+ if (isFlaggableText(text))
232
+ return true;
92
233
  }
93
234
  return false;
94
235
  }
@@ -133,6 +274,27 @@ export function collectCssVariableReferences(cssContent) {
133
274
  }
134
275
  return referencedVariables;
135
276
  }
277
+ /**
278
+ * Collects CSS custom property *declarations* — names appearing on the
279
+ * left-hand side of a `--name:` declaration. Used to auto-exempt
280
+ * component-local runtime variables from the token-prefix check: if the
281
+ * component both declares and consumes a variable in its own CSS file, it
282
+ * is a local runtime channel, not a design-token reference.
283
+ *
284
+ * The anchor `(?:^|[{;,\s])` rules out `var(--name)` occurrences (which are
285
+ * always preceded by `(`), so references are not mistaken for declarations.
286
+ */
287
+ export function collectCssVariableDeclarations(cssContent) {
288
+ const declared = new Set();
289
+ const pattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
290
+ let match;
291
+ while ((match = pattern.exec(cssContent)) !== null) {
292
+ const name = match[1];
293
+ if (name)
294
+ declared.add(name);
295
+ }
296
+ return declared;
297
+ }
136
298
  async function collectInheritedOverrideVariables(tagName, config, root) {
137
299
  const inheritedVariables = new Set();
138
300
  const basePath = config.overrides[tagName]?.basePath;
@@ -153,12 +315,14 @@ async function collectInheritedOverrideVariables(tagName, config, root) {
153
315
  /** Builds token-validation context from the config allowlist and inherited override CSS. */
154
316
  export async function getTokenPrefixContext(tagName, type, config, root) {
155
317
  const allowlist = new Set(config.tokenAllowlist ?? []);
318
+ const runtimeVariables = new Set(config.runtimeVariables ?? []);
156
319
  if (type !== 'override' || !root) {
157
- return { allowlist, inheritedOverrideVars: new Set() };
320
+ return { allowlist, inheritedOverrideVars: new Set(), runtimeVariables };
158
321
  }
159
322
  return {
160
323
  allowlist,
161
324
  inheritedOverrideVars: await collectInheritedOverrideVariables(tagName, config, root),
325
+ runtimeVariables,
162
326
  };
163
327
  }
164
328
  //# sourceMappingURL=furnace-validate-helpers.js.map
@@ -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. `mybrowser.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. `mybrowser.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,46 @@
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 a cascade of layered checks (engine → mach → python →
10
+ * profile → spawn → handshake). Each layer has a tight per-attempt budget
11
+ * so a broken earlier layer fails fast with a specific diagnosis rather
12
+ * than blocking on the final socket poll for the full overall budget.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import net from 'node:net';
16
+ export interface MarionettePreflightResult {
17
+ ok: boolean;
18
+ durationMs: number;
19
+ /** Human-readable summary. On FAIL, prefixed with `[layer N/6: <name>]`. */
20
+ detail: string;
21
+ }
22
+ export interface MarionettePreflightOptions {
23
+ /** Total budget in ms. Defaults to 30 seconds. */
24
+ timeoutMs?: number;
25
+ /** Overrides marionette TCP port — primarily used in tests. */
26
+ port?: number;
27
+ /**
28
+ * Grace window after spawn() before the browser is considered "running
29
+ * OK." Catches immediate crashes (missing dylib, wrong CPU arch, corrupt
30
+ * profile) at the spawn layer rather than the handshake layer. Default:
31
+ * {@link SPAWN_SETTLE_MS}. Tests may set this to 0 to skip the settle.
32
+ */
33
+ spawnSettleMs?: number;
34
+ /** Test seam: spawn and socket connect factories. */
35
+ spawner?: typeof spawn;
36
+ connect?: typeof net.createConnection;
37
+ }
38
+ /**
39
+ * Runs the marionette preflight. Returns PASS on first byte read from the
40
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
41
+ * identifying which layer of the cascade broke. Always tears down the
42
+ * spawned browser before returning.
43
+ */
44
+ export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
45
+ /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
46
+ export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;