@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.
- package/CHANGELOG.md +31 -0
- package/README.md +20 -1
- package/dist/src/commands/furnace/create-templates.d.ts +26 -0
- package/dist/src/commands/furnace/create-templates.js +86 -0
- package/dist/src/commands/furnace/create.js +64 -101
- package/dist/src/commands/furnace/deploy.js +3 -3
- package/dist/src/commands/test.js +20 -0
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate.js +32 -0
- package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
- package/dist/src/core/furnace-apply-ftl.js +102 -0
- package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
- package/dist/src/core/furnace-apply-helpers.js +16 -12
- package/dist/src/core/furnace-apply.js +7 -4
- package/dist/src/core/furnace-config-tokens.d.ts +11 -0
- package/dist/src/core/furnace-config-tokens.js +28 -0
- package/dist/src/core/furnace-config.d.ts +6 -0
- package/dist/src/core/furnace-config.js +8 -1
- package/dist/src/core/furnace-constants.d.ts +20 -0
- package/dist/src/core/furnace-constants.js +32 -0
- package/dist/src/core/furnace-registration-ast.d.ts +13 -1
- package/dist/src/core/furnace-registration-ast.js +58 -25
- package/dist/src/core/furnace-registration.d.ts +27 -0
- package/dist/src/core/furnace-registration.js +96 -0
- package/dist/src/core/furnace-validate-accessibility.js +8 -2
- package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
- package/dist/src/core/furnace-validate-helpers.js +81 -0
- package/dist/src/core/furnace-validate-registration.d.ts +8 -2
- package/dist/src/core/furnace-validate-registration.js +34 -9
- package/dist/src/core/furnace-validate.js +2 -2
- package/dist/src/core/marionette-preflight.d.ts +39 -0
- package/dist/src/core/marionette-preflight.js +210 -0
- package/dist/src/types/commands/options.d.ts +6 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +8 -0
- package/dist/src/utils/process.d.ts +15 -2
- package/dist/src/utils/process.js +73 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|