@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.
- package/CHANGELOG.md +85 -0
- package/README.md +20 -1
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- 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 +77 -103
- package/dist/src/commands/furnace/deploy.js +20 -5
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +33 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -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 +47 -1
- 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 +28 -1
- package/dist/src/core/furnace-registration.js +98 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -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/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- 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
|
@@ -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
|
-
|
|
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
|