@hominis/fireforge 0.15.7 → 0.15.9
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 +44 -0
- package/README.md +103 -12
- package/dist/src/commands/export-shared.d.ts +6 -1
- package/dist/src/commands/export-shared.js +7 -2
- package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
- package/dist/src/commands/furnace/create-dry-run.js +7 -2
- package/dist/src/commands/furnace/create-features.d.ts +24 -0
- package/dist/src/commands/furnace/create-features.js +56 -0
- package/dist/src/commands/furnace/create-templates.d.ts +9 -5
- package/dist/src/commands/furnace/create-templates.js +14 -6
- package/dist/src/commands/furnace/create.js +34 -39
- package/dist/src/commands/furnace/index.js +1 -0
- package/dist/src/commands/lint.d.ts +20 -0
- package/dist/src/commands/lint.js +157 -44
- package/dist/src/commands/re-export-files.js +6 -2
- package/dist/src/commands/re-export.js +37 -4
- package/dist/src/commands/run.d.ts +15 -1
- package/dist/src/commands/run.js +202 -7
- package/dist/src/commands/test.js +97 -2
- package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
- package/dist/src/core/furnace-apply-ftl.js +6 -2
- package/dist/src/core/furnace-apply-helpers.js +14 -4
- package/dist/src/core/furnace-config-custom.d.ts +14 -0
- package/dist/src/core/furnace-config-custom.js +64 -0
- package/dist/src/core/furnace-config.js +2 -39
- package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
- package/dist/src/core/furnace-validate-accessibility.js +17 -3
- package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
- package/dist/src/core/furnace-validate-helpers.js +19 -0
- package/dist/src/core/furnace-validate-structure.js +6 -2
- package/dist/src/core/furnace-validate.js +6 -3
- package/dist/src/core/mach.d.ts +26 -0
- package/dist/src/core/mach.js +25 -1
- package/dist/src/core/patch-lint.d.ts +6 -1
- package/dist/src/core/patch-lint.js +14 -1
- package/dist/src/core/shared-ftl.d.ts +28 -0
- package/dist/src/core/shared-ftl.js +42 -0
- package/dist/src/core/smoke-patterns.d.ts +45 -0
- package/dist/src/core/smoke-patterns.js +100 -0
- package/dist/src/core/xpcshell-appdir.d.ts +143 -0
- package/dist/src/core/xpcshell-appdir.js +273 -0
- package/dist/src/errors/codes.d.ts +13 -0
- package/dist/src/errors/codes.js +13 -0
- package/dist/src/errors/run.d.ts +16 -0
- package/dist/src/errors/run.js +22 -0
- package/dist/src/types/commands/options.d.ts +58 -0
- package/dist/src/types/commands/patches.d.ts +22 -0
- package/dist/src/types/furnace.d.ts +39 -0
- package/dist/src/utils/process.d.ts +63 -0
- package/dist/src/utils/process.js +122 -0
- package/package.json +1 -1
package/dist/src/commands/run.js
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { getProjectPaths } from '../core/config.js';
|
|
5
6
|
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
6
|
-
import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
|
|
7
|
-
import {
|
|
7
|
+
import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
|
|
8
|
+
import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
|
|
9
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
8
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
11
|
+
import { ExitCode } from '../errors/codes.js';
|
|
12
|
+
import { SmokeRunError } from '../errors/run.js';
|
|
9
13
|
import { toError } from '../utils/errors.js';
|
|
10
14
|
import { pathExists, removeDir, removeFile } from '../utils/fs.js';
|
|
11
|
-
import { info, intro, verbose } from '../utils/logger.js';
|
|
15
|
+
import { info, intro, verbose, warn } from '../utils/logger.js';
|
|
16
|
+
import { pickDefined } from '../utils/options.js';
|
|
17
|
+
/**
|
|
18
|
+
* Exit code returned by smoke-run mode when the captured console stream
|
|
19
|
+
* produced one or more error lines that did NOT match the operator's
|
|
20
|
+
* allowlist.
|
|
21
|
+
*/
|
|
22
|
+
export const SMOKE_EXIT_FAILURE = ExitCode.SMOKE_EXIT_FAILURE;
|
|
23
|
+
/**
|
|
24
|
+
* Exit code returned by smoke-run mode when the browser itself exited
|
|
25
|
+
* with a non-clean status before the smoke window elapsed — i.e. a
|
|
26
|
+
* launch-side failure we could NOT observe as a console error line
|
|
27
|
+
* (crash before console wiring, missing profile, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export const SMOKE_LAUNCH_FAILURE = ExitCode.SMOKE_LAUNCH_FAILURE;
|
|
30
|
+
/** Recommendation surfaced when the smoke window is shorter than a typical cold start. */
|
|
31
|
+
const SMOKE_COLD_START_THRESHOLD_MS = 30_000;
|
|
32
|
+
/** Maximum number of unallowed error lines to surface in the terminal summary. */
|
|
33
|
+
const SMOKE_UNALLOWED_PREVIEW_MAX = 10;
|
|
12
34
|
/**
|
|
13
35
|
* Cleans the dev profile to prevent stale-state startup failures.
|
|
14
36
|
*
|
|
@@ -50,7 +72,7 @@ async function cleanDevProfile(engineDir) {
|
|
|
50
72
|
* Runs the run command to launch the built browser.
|
|
51
73
|
* @param projectRoot - Root directory of the project
|
|
52
74
|
*/
|
|
53
|
-
export async function runCommand(projectRoot) {
|
|
75
|
+
export async function runCommand(projectRoot, options = {}) {
|
|
54
76
|
intro('FireForge Run');
|
|
55
77
|
const paths = getProjectPaths(projectRoot);
|
|
56
78
|
// Check if engine exists
|
|
@@ -76,6 +98,10 @@ export async function runCommand(projectRoot) {
|
|
|
76
98
|
await warnIfFurnaceStale(projectRoot);
|
|
77
99
|
// Clean stale profile state to prevent silent startup failures
|
|
78
100
|
await cleanDevProfile(paths.engine);
|
|
101
|
+
if (options.smokeExit !== undefined) {
|
|
102
|
+
await runSmokeExit(paths.engine, options);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
79
105
|
info('Launching browser...\n');
|
|
80
106
|
const exitCode = await run(paths.engine);
|
|
81
107
|
// Exit-code whitelist:
|
|
@@ -89,13 +115,182 @@ export async function runCommand(projectRoot) {
|
|
|
89
115
|
throw new BuildError(`Browser exited with code ${exitCode}`, 'mach run');
|
|
90
116
|
}
|
|
91
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Drives the `--smoke-exit` launch path. Runs the browser under
|
|
120
|
+
* {@link runMachSmoke}, scans the merged console stream for error-class
|
|
121
|
+
* lines against the operator-supplied allowlist, and applies the smoke
|
|
122
|
+
* exit contract. The deadline-fires-SIGTERM path is treated as a clean
|
|
123
|
+
* window iff no unallowed errors were observed.
|
|
124
|
+
*/
|
|
125
|
+
async function runSmokeExit(engineDir, options) {
|
|
126
|
+
// Windows lacks the POSIX process-group primitives --smoke-exit leans on to
|
|
127
|
+
// SIGTERM the whole mach → python → firefox tree. Running through anyway
|
|
128
|
+
// would only kill the top-level wrapper and orphan Firefox content
|
|
129
|
+
// processes, so reject the flag up front to match the documented contract
|
|
130
|
+
// in CHANGELOG.md / README.md.
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
throw new InvalidArgumentError('--smoke-exit is POSIX-only; process-group semantics do not map cleanly onto Windows.', 'smokeExit');
|
|
133
|
+
}
|
|
134
|
+
const smokeExit = options.smokeExit;
|
|
135
|
+
// The runCommand caller has already gated on `options.smokeExit !== undefined`,
|
|
136
|
+
// but commander can hand us `0` or negative values through the action
|
|
137
|
+
// layer if the parser in `registerRun` was bypassed (e.g. programmatic
|
|
138
|
+
// use in a test that skips the parser). Guard explicitly so the deadline
|
|
139
|
+
// timer cannot be scheduled at 0 ms and immediately kill the process.
|
|
140
|
+
if (smokeExit === undefined || smokeExit < 1 || !Number.isFinite(smokeExit)) {
|
|
141
|
+
throw new InvalidArgumentError('--smoke-exit expects a positive integer number of seconds.', 'smokeExit');
|
|
142
|
+
}
|
|
143
|
+
const smokeTimeoutMs = smokeExit * 1000;
|
|
144
|
+
if (smokeTimeoutMs < SMOKE_COLD_START_THRESHOLD_MS) {
|
|
145
|
+
// Not an error — cold starts just tend to exceed the window. Surfacing
|
|
146
|
+
// the hint here instead of failing lets agents run shorter windows
|
|
147
|
+
// intentionally (e.g. warm-cache smoke checks).
|
|
148
|
+
verbose(`Smoke window is ${String(smokeExit)}s; cold starts on slow machines often exceed 30s.`);
|
|
149
|
+
}
|
|
150
|
+
const allowlist = await buildAllowlist(options);
|
|
151
|
+
const captureStream = options.captureConsole
|
|
152
|
+
? createWriteStream(options.captureConsole)
|
|
153
|
+
: undefined;
|
|
154
|
+
// createWriteStream opens the fd asynchronously, so ENOENT / EACCES /
|
|
155
|
+
// EISDIR / EROFS surface as an 'error' event *after* the constructor
|
|
156
|
+
// returns. Without a listener Node re-throws as uncaughtException and
|
|
157
|
+
// kills the CLI mid-smoke-run — orphaning the mach → python → firefox
|
|
158
|
+
// tree because the deadline timer never fires. Swallow the event into a
|
|
159
|
+
// warning so the smoke run still terminates cleanly; subsequent mirror
|
|
160
|
+
// writes on the errored stream are silent no-ops.
|
|
161
|
+
captureStream?.on('error', (err) => {
|
|
162
|
+
warn(`--capture-console stream error: ${err.message}`);
|
|
163
|
+
});
|
|
164
|
+
const findings = [];
|
|
165
|
+
let allowlistedHits = 0;
|
|
166
|
+
const handleLine = (stream, line) => {
|
|
167
|
+
// Mirror raw output to the terminal so operators watching the smoke
|
|
168
|
+
// run still see what the browser is printing. Stream selection on the
|
|
169
|
+
// mirror preserves stdout/stderr separation for downstream piping.
|
|
170
|
+
const sink = stream === 'stdout' ? process.stdout : process.stderr;
|
|
171
|
+
sink.write(`${line}\n`);
|
|
172
|
+
if (!matchesSmokeError(line))
|
|
173
|
+
return;
|
|
174
|
+
if (matchesAllowlist(line, allowlist)) {
|
|
175
|
+
allowlistedHits += 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
findings.push({ stream, line });
|
|
179
|
+
};
|
|
180
|
+
info(`Launching browser (smoke-exit after ${String(smokeExit)}s)...\n`);
|
|
181
|
+
const startedAt = Date.now();
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await runMachSmoke(['run'], engineDir, {
|
|
185
|
+
smokeTimeoutMs,
|
|
186
|
+
onStdoutLine: (line) => {
|
|
187
|
+
handleLine('stdout', line);
|
|
188
|
+
},
|
|
189
|
+
onStderrLine: (line) => {
|
|
190
|
+
handleLine('stderr', line);
|
|
191
|
+
},
|
|
192
|
+
...(captureStream ? { mirror: { stdout: captureStream, stderr: captureStream } } : {}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
captureStream?.end();
|
|
197
|
+
}
|
|
198
|
+
const elapsedMs = Date.now() - startedAt;
|
|
199
|
+
reportSmokeSummary({
|
|
200
|
+
smokeTimeoutMs,
|
|
201
|
+
elapsedMs,
|
|
202
|
+
timedOut: result.timedOut,
|
|
203
|
+
allowlistedHits,
|
|
204
|
+
findings,
|
|
205
|
+
exitCode: result.exitCode,
|
|
206
|
+
});
|
|
207
|
+
// Exit contract (precedence: unallowed errors dominate timed-out).
|
|
208
|
+
if (findings.length > 0) {
|
|
209
|
+
throw new SmokeRunError(`Smoke run observed ${String(findings.length)} unallowed console error(s).`, SMOKE_EXIT_FAILURE);
|
|
210
|
+
}
|
|
211
|
+
if (result.timedOut) {
|
|
212
|
+
// Clean window — SIGTERM from us. Treat as success.
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (result.exitCode === 0 || result.exitCode === 130 || result.exitCode === 143) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
throw new SmokeRunError(`Browser exited with code ${String(result.exitCode)} before smoke-exit window elapsed.`, SMOKE_LAUNCH_FAILURE);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Compiles the active allowlist from `--console-allow` CLI values and
|
|
222
|
+
* the optional `--console-allow-file`. Fails fast on a bad regex —
|
|
223
|
+
* better to surface the typo at parse time than to silently let it
|
|
224
|
+
* match nothing and turn every allowed hit into a smoke failure.
|
|
225
|
+
*/
|
|
226
|
+
async function buildAllowlist(options) {
|
|
227
|
+
const allow = [];
|
|
228
|
+
if (options.consoleAllow && options.consoleAllow.length > 0) {
|
|
229
|
+
try {
|
|
230
|
+
allow.push(...compileAllowlistFromStrings(options.consoleAllow));
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
throw new InvalidArgumentError(toError(error).message, 'consoleAllow');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (options.consoleAllowFile) {
|
|
237
|
+
try {
|
|
238
|
+
const body = await readFile(options.consoleAllowFile, 'utf8');
|
|
239
|
+
allow.push(...compileAllowlistFromFile(body, options.consoleAllowFile));
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
throw new InvalidArgumentError(`Failed to read --console-allow-file: ${toError(error).message}`, 'consoleAllowFile');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return allow;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Prints the human-readable summary block that follows every smoke run.
|
|
249
|
+
* Called once, right before the exit-code decision. Keeps the reporting
|
|
250
|
+
* path separate from exit-contract logic so a test can render summaries
|
|
251
|
+
* without mocking the BuildError construction.
|
|
252
|
+
*/
|
|
253
|
+
function reportSmokeSummary(args) {
|
|
254
|
+
const seconds = (args.elapsedMs / 1000).toFixed(1);
|
|
255
|
+
const windowSeconds = (args.smokeTimeoutMs / 1000).toFixed(0);
|
|
256
|
+
const suffix = args.timedOut ? ' (deadline fired — SIGTERM sent to process group)' : '';
|
|
257
|
+
info('');
|
|
258
|
+
info(`Smoke run complete: ${seconds}s elapsed of ${windowSeconds}s window${suffix}`);
|
|
259
|
+
info(` Unallowed errors: ${String(args.findings.length)}`);
|
|
260
|
+
info(` Allowlisted hits: ${String(args.allowlistedHits)}`);
|
|
261
|
+
info(` Child exit code: ${String(args.exitCode)}`);
|
|
262
|
+
if (args.findings.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
warn('');
|
|
265
|
+
warn(`Unallowed console errors (first ${String(SMOKE_UNALLOWED_PREVIEW_MAX)}):`);
|
|
266
|
+
args.findings.slice(0, SMOKE_UNALLOWED_PREVIEW_MAX).forEach((finding, index) => {
|
|
267
|
+
warn(` ${String(index + 1)}. [${finding.stream}] ${finding.line}`);
|
|
268
|
+
});
|
|
269
|
+
if (args.findings.length > SMOKE_UNALLOWED_PREVIEW_MAX) {
|
|
270
|
+
const remaining = args.findings.length - SMOKE_UNALLOWED_PREVIEW_MAX;
|
|
271
|
+
warn(` …and ${String(remaining)} more.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
92
274
|
/** Registers the run command on the CLI program. */
|
|
93
275
|
export function registerRun(program, { getProjectRoot, withErrorHandling }) {
|
|
94
276
|
program
|
|
95
277
|
.command('run')
|
|
96
278
|
.description('Launch the built browser')
|
|
97
|
-
.
|
|
98
|
-
|
|
279
|
+
.option('--smoke-exit <seconds>', 'Smoke-run mode (POSIX only): launch, capture console, SIGTERM the process group after <seconds>. Exit 0 on a clean window, 12 on unallowed errors, 13 on launch failure.', (value) => {
|
|
280
|
+
const parsed = Number.parseInt(value, 10);
|
|
281
|
+
if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value.trim()) {
|
|
282
|
+
throw new Error(`--smoke-exit expects a positive integer number of seconds (got "${value}").`);
|
|
283
|
+
}
|
|
284
|
+
return parsed;
|
|
285
|
+
})
|
|
286
|
+
.option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', (value, acc) => {
|
|
287
|
+
acc.push(value);
|
|
288
|
+
return acc;
|
|
289
|
+
}, [])
|
|
290
|
+
.option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
|
|
291
|
+
.option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
|
|
292
|
+
.action(withErrorHandling(async (options) => {
|
|
293
|
+
await runCommand(getProjectRoot(), pickDefined(options));
|
|
99
294
|
}));
|
|
100
295
|
}
|
|
101
296
|
//# sourceMappingURL=run.js.map
|
|
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
5
|
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
6
6
|
import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
|
|
7
7
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
8
|
+
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
8
9
|
import { GeneralError } from '../errors/base.js';
|
|
9
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
10
11
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -60,7 +61,47 @@ function hasStaleBuildArtifactsSignal(output) {
|
|
|
60
61
|
/resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
|
|
61
62
|
/browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
// Detects the broader xpcshell symptom where every `resource:///modules/...`
|
|
65
|
+
// import fails — the signature of xpcshell running with the wrong app-dir on
|
|
66
|
+
// a manifest that sets `firefox-appdir = "browser"`. Checked AFTER the
|
|
67
|
+
// stale-build signal (which matches the narrower `distribution.sys.mjs`
|
|
68
|
+
// path) so the more specific diagnosis wins when both patterns apply.
|
|
69
|
+
function hasXpcshellAppdirSignal(output) {
|
|
70
|
+
return /Failed to load resource:\/\/\/modules\//i.test(output);
|
|
71
|
+
}
|
|
72
|
+
function buildXpcshellAppdirMessage(injectionAttempted) {
|
|
73
|
+
const triggerLines = injectionAttempted
|
|
74
|
+
? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or the harness was built against a layout FireForge cannot probe (omni.ja-packed tree, alternate `dist/` shape).\n\n'
|
|
75
|
+
: 'Likely triggers:\n' +
|
|
76
|
+
' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
|
|
77
|
+
' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
|
|
78
|
+
return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
|
|
79
|
+
'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
|
|
80
|
+
triggerLines +
|
|
81
|
+
'Options:\n' +
|
|
82
|
+
' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly.\n' +
|
|
83
|
+
' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection).\n' +
|
|
84
|
+
' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
|
|
85
|
+
' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
|
|
86
|
+
}
|
|
87
|
+
// Detects the `AttributeError: 'MochitestDesktop' object has no attribute
|
|
88
|
+
// 'http3Server'` teardown crash. The attribute is lazy-initialized inside
|
|
89
|
+
// harness code paths that presume chrome://branding resolves correctly; a
|
|
90
|
+
// missing or miswired branding registration short-circuits the setup and
|
|
91
|
+
// leaves the cleanup path looking up an attribute that was never assigned.
|
|
92
|
+
function hasMochitestHttp3ServerSignal(output) {
|
|
93
|
+
return /'MochitestDesktop' object has no attribute 'http3Server'/.test(output);
|
|
94
|
+
}
|
|
95
|
+
function buildMochitestHttp3ServerMessage() {
|
|
96
|
+
return ("Mochitest raised `AttributeError: 'MochitestDesktop' object has no attribute 'http3Server'`.\n\n" +
|
|
97
|
+
'This is almost always a symptom of `chrome://branding` not registering correctly in your fork — the mochitest harness lazy-initializes `http3Server` only after branding resolves, and a missing branding registration short-circuits setup. The cleanup path then trips the AttributeError, masking the real error.\n\n' +
|
|
98
|
+
'Check that:\n' +
|
|
99
|
+
" - Your fork's branding directory is listed in `browser/branding/moz.build` (or equivalent) and ships a `brand.properties` / `brand.ftl`.\n" +
|
|
100
|
+
' - `chrome://branding/locale/brand.properties` resolves at runtime (try `fireforge run` and inspect the Browser Console).\n' +
|
|
101
|
+
" - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
|
|
102
|
+
'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
|
|
103
|
+
}
|
|
104
|
+
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted) {
|
|
64
105
|
if (result.exitCode === 0 || result.exitCode === 130)
|
|
65
106
|
return;
|
|
66
107
|
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
@@ -70,6 +111,12 @@ function handleNonZeroTestExit(result, normalizedPaths) {
|
|
|
70
111
|
if (hasStaleBuildArtifactsSignal(combinedOutput)) {
|
|
71
112
|
throw new GeneralError(buildStaleBuildMessage());
|
|
72
113
|
}
|
|
114
|
+
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
115
|
+
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
116
|
+
}
|
|
117
|
+
if (hasXpcshellAppdirSignal(combinedOutput)) {
|
|
118
|
+
throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
|
|
119
|
+
}
|
|
73
120
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
74
121
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
75
122
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -159,6 +206,23 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
159
206
|
if (options.headless) {
|
|
160
207
|
extraArgs.push('--headless');
|
|
161
208
|
}
|
|
209
|
+
// --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
|
|
210
|
+
// flags FireForge does not model directly (see the xpcshell appdir hint
|
|
211
|
+
// above for the motivating case). Appended AFTER --headless so mach sees
|
|
212
|
+
// the FireForge-managed flags first and the escape-valve ones last, which
|
|
213
|
+
// keeps the override precedence predictable.
|
|
214
|
+
if (options.machArg && options.machArg.length > 0) {
|
|
215
|
+
extraArgs.push(...options.machArg);
|
|
216
|
+
}
|
|
217
|
+
// xpcshell appdir auto-injection — see src/core/xpcshell-appdir.ts for the
|
|
218
|
+
// full motivation. On rebranded forks (appname != "firefox") the upstream
|
|
219
|
+
// harness silently ignores `firefox-appdir = "browser"` directives in the
|
|
220
|
+
// xpcshell.toml, so every `resource:///modules/…` import throws. We probe
|
|
221
|
+
// the nearest manifest, compute the absolute appdir under obj-*/dist/, and
|
|
222
|
+
// inject `--app-path=<abs>` so the harness uses the right root. Operator
|
|
223
|
+
// overrides via `--mach-arg=--app-path=…` always win — we skip injection
|
|
224
|
+
// when the operator already passed one.
|
|
225
|
+
const appdirInjection = await maybeInjectAppdirArg(paths.engine, normalizedPaths, buildCheck.objDir, extraArgs);
|
|
162
226
|
// Log what we're doing
|
|
163
227
|
if (normalizedPaths.length > 0) {
|
|
164
228
|
info(`Running tests: ${normalizedPaths.join(', ')}`);
|
|
@@ -174,7 +238,34 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
174
238
|
catch (error) {
|
|
175
239
|
throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
|
|
176
240
|
}
|
|
177
|
-
handleNonZeroTestExit(result, normalizedPaths);
|
|
241
|
+
handleNonZeroTestExit(result, normalizedPaths, appdirInjection);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Resolves and (when applicable) appends an `--app-path=<abs>` arg to
|
|
245
|
+
* `extraArgs`. Returns true iff the arg was injected. The logging branches
|
|
246
|
+
* mirror the {@link XpcshellAppdirOutcome} variants so an operator can tell
|
|
247
|
+
* from the test output whether FireForge tried to help and what it found.
|
|
248
|
+
*/
|
|
249
|
+
async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
|
|
250
|
+
if (!objDir)
|
|
251
|
+
return false;
|
|
252
|
+
if (operatorAlreadySetAppPath(extraArgs))
|
|
253
|
+
return false;
|
|
254
|
+
const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
|
|
255
|
+
switch (outcome.kind) {
|
|
256
|
+
case 'none':
|
|
257
|
+
return false;
|
|
258
|
+
case 'mismatch':
|
|
259
|
+
warn(`xpcshell appdir auto-injection skipped — multiple test paths resolved to different app dirs (${outcome.values.join(', ')}). Pass --mach-arg=--app-path=<abs> to disambiguate.`);
|
|
260
|
+
return false;
|
|
261
|
+
case 'unresolved':
|
|
262
|
+
warn(`xpcshell appdir auto-injection skipped — manifest at ${outcome.manifestPath} requests appdir "${outcome.relativeAppdir}" but no matching directory exists under ${objDir}/dist/. Build artifacts may be stale.`);
|
|
263
|
+
return false;
|
|
264
|
+
case 'injected':
|
|
265
|
+
extraArgs.push(`--app-path=${outcome.result.appPath}`);
|
|
266
|
+
info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
178
269
|
}
|
|
179
270
|
/** Registers the test command on the CLI program. */
|
|
180
271
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -184,6 +275,10 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
184
275
|
.option('--headless', 'Run tests in headless mode')
|
|
185
276
|
.option('--build', 'Run incremental UI build before testing')
|
|
186
277
|
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
278
|
+
.option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', (value, acc) => {
|
|
279
|
+
acc.push(value);
|
|
280
|
+
return acc;
|
|
281
|
+
}, [])
|
|
187
282
|
.action(withErrorHandling(async (paths, options) => {
|
|
188
283
|
await testCommand(getProjectRoot(), paths, pickDefined(options));
|
|
189
284
|
}));
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* aborting the whole command. Missing jar.mn on a fork without a locale
|
|
9
9
|
* package should not block a working `.mjs`/`.css` from shipping.
|
|
10
10
|
*/
|
|
11
|
-
import type { DryRunAction, StepError } from '../types/furnace.js';
|
|
11
|
+
import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
|
|
12
12
|
import { type RollbackJournal } from './furnace-rollback.js';
|
|
13
13
|
/**
|
|
14
14
|
* Copies a component's `.ftl` into the FTL tree and registers the chrome URI
|
|
@@ -28,6 +28,8 @@ export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir:
|
|
|
28
28
|
/**
|
|
29
29
|
* Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
|
|
30
30
|
* source workspace file has been deleted. Idempotent — absent entries are a
|
|
31
|
-
* no-op.
|
|
31
|
+
* no-op. Early-returns for `sharedFtl` components: the shared bundle is
|
|
32
|
+
* owned elsewhere, and dropping its jar.mn line on our component's delete
|
|
33
|
+
* would orphan every other participant.
|
|
32
34
|
*/
|
|
33
|
-
export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;
|
|
35
|
+
export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, config: CustomComponentConfig, rollbackJournal?: RollbackJournal): Promise<void>;
|
|
@@ -81,9 +81,13 @@ export function describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile) {
|
|
|
81
81
|
/**
|
|
82
82
|
* Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
|
|
83
83
|
* source workspace file has been deleted. Idempotent — absent entries are a
|
|
84
|
-
* no-op.
|
|
84
|
+
* no-op. Early-returns for `sharedFtl` components: the shared bundle is
|
|
85
|
+
* owned elsewhere, and dropping its jar.mn line on our component's delete
|
|
86
|
+
* would orphan every other participant.
|
|
85
87
|
*/
|
|
86
|
-
export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal) {
|
|
88
|
+
export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal) {
|
|
89
|
+
if (config.sharedFtl)
|
|
90
|
+
return;
|
|
87
91
|
if (!fileName.endsWith('.ftl'))
|
|
88
92
|
return;
|
|
89
93
|
const tagName = fileName.slice(0, -'.ftl'.length);
|
|
@@ -139,10 +139,12 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
|
|
|
139
139
|
await removeFile(enginePath);
|
|
140
140
|
removed.push(relative(engineDir, enginePath));
|
|
141
141
|
}
|
|
142
|
-
// When an `.ftl` is deleted from the workspace
|
|
142
|
+
// When an `.ftl` is deleted from the workspace the corresponding locale
|
|
143
143
|
// jar.mn entry must also be dropped — otherwise the chrome URI points at
|
|
144
144
|
// a missing file and runtime Fluent resolution breaks silently.
|
|
145
|
-
|
|
145
|
+
// `removeCustomFtlJarMnEntry` early-returns for `sharedFtl` components
|
|
146
|
+
// (the shared bundle is owned elsewhere).
|
|
147
|
+
await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, config, rollbackJournal);
|
|
146
148
|
}
|
|
147
149
|
return removed;
|
|
148
150
|
}
|
|
@@ -306,7 +308,12 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
|
|
|
306
308
|
description: `Copy ${entry.name} to ${config.targetPath}`,
|
|
307
309
|
});
|
|
308
310
|
}
|
|
309
|
-
|
|
311
|
+
// Per-component .ftl handling is skipped when the component opts into a
|
|
312
|
+
// shared feature-scoped bundle via `sharedFtl`. The shared file is
|
|
313
|
+
// registered (and copied) by whoever owns the feature bundle, so
|
|
314
|
+
// emitting a copy-ftl / register-jar action here would duplicate (or
|
|
315
|
+
// later orphan) the entry.
|
|
316
|
+
if (config.localized && !config.sharedFtl) {
|
|
310
317
|
const ftlFile = `${name}.ftl`;
|
|
311
318
|
const ftlSrc = join(componentDir, ftlFile);
|
|
312
319
|
if (await pathExists(ftlSrc)) {
|
|
@@ -402,7 +409,10 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
|
|
|
402
409
|
affectedPaths.push(relative(engineDir, dest));
|
|
403
410
|
copiedFileNames.push(entry.name);
|
|
404
411
|
}));
|
|
405
|
-
|
|
412
|
+
// See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
|
|
413
|
+
// the shared bundle is owned elsewhere and FireForge must not copy or
|
|
414
|
+
// register a per-component `.ftl` on its behalf.
|
|
415
|
+
if (config.localized && !config.sharedFtl) {
|
|
406
416
|
await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
|
|
407
417
|
}
|
|
408
418
|
if (config.register) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the `custom` entries in furnace.json. Extracted from
|
|
3
|
+
* `furnace-config.ts` so the main config module stays under the
|
|
4
|
+
* per-file LOC budget — the custom-component schema has grown to
|
|
5
|
+
* carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
|
|
6
|
+
* each add their own validation branch.
|
|
7
|
+
*/
|
|
8
|
+
import type { CustomComponentConfig } from '../types/furnace.js';
|
|
9
|
+
/**
|
|
10
|
+
* Validates a custom component config object.
|
|
11
|
+
* @param data - Raw data to validate
|
|
12
|
+
* @param name - Component name for error messages
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseCustomConfig(data: Record<string, unknown>, name: string): CustomComponentConfig;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Parser for the `custom` entries in furnace.json. Extracted from
|
|
4
|
+
* `furnace-config.ts` so the main config module stays under the
|
|
5
|
+
* per-file LOC budget — the custom-component schema has grown to
|
|
6
|
+
* carry opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`) that
|
|
7
|
+
* each add their own validation branch.
|
|
8
|
+
*/
|
|
9
|
+
import { FurnaceError } from '../errors/furnace.js';
|
|
10
|
+
import { isExplicitAbsolutePath } from '../utils/paths.js';
|
|
11
|
+
import { isBoolean, isString } from '../utils/validation.js';
|
|
12
|
+
import { parseStringArray } from './furnace-config.js';
|
|
13
|
+
import { validateSharedFtl } from './shared-ftl.js';
|
|
14
|
+
/**
|
|
15
|
+
* Validates a custom component config object.
|
|
16
|
+
* @param data - Raw data to validate
|
|
17
|
+
* @param name - Component name for error messages
|
|
18
|
+
*/
|
|
19
|
+
export function parseCustomConfig(data, name) {
|
|
20
|
+
if (!isString(data['description'])) {
|
|
21
|
+
throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
|
|
22
|
+
}
|
|
23
|
+
if (!isString(data['targetPath'])) {
|
|
24
|
+
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
|
|
25
|
+
}
|
|
26
|
+
if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
|
|
27
|
+
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
|
|
28
|
+
}
|
|
29
|
+
if (isExplicitAbsolutePath(data['targetPath'])) {
|
|
30
|
+
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
|
|
31
|
+
}
|
|
32
|
+
if (!isBoolean(data['register'])) {
|
|
33
|
+
throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
|
|
34
|
+
}
|
|
35
|
+
if (!isBoolean(data['localized'])) {
|
|
36
|
+
throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
|
|
37
|
+
}
|
|
38
|
+
if (data['composes'] !== undefined) {
|
|
39
|
+
parseStringArray(data['composes'], `${name}.composes`);
|
|
40
|
+
}
|
|
41
|
+
if (data['keyboardCovered'] !== undefined && !isBoolean(data['keyboardCovered'])) {
|
|
42
|
+
throw new FurnaceError(`Furnace config: custom "${name}.keyboardCovered" must be a boolean when set`);
|
|
43
|
+
}
|
|
44
|
+
let sharedFtl;
|
|
45
|
+
if (data['sharedFtl'] !== undefined) {
|
|
46
|
+
const result = validateSharedFtl(data['sharedFtl'], { localized: data['localized'] });
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
throw new FurnaceError(`Furnace config: custom "${name}.sharedFtl" ${result.reason}`);
|
|
49
|
+
}
|
|
50
|
+
sharedFtl = result.value;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
description: data['description'],
|
|
54
|
+
targetPath: data['targetPath'],
|
|
55
|
+
register: data['register'],
|
|
56
|
+
localized: data['localized'],
|
|
57
|
+
...(data['composes'] !== undefined
|
|
58
|
+
? { composes: parseStringArray(data['composes'], `${name}.composes`) }
|
|
59
|
+
: {}),
|
|
60
|
+
...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
|
|
61
|
+
...(sharedFtl !== undefined ? { sharedFtl } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=furnace-config-custom.js.map
|
|
@@ -4,9 +4,9 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
4
4
|
import { toError } from '../utils/errors.js';
|
|
5
5
|
import { pathExists, readJson, writeJson } from '../utils/fs.js';
|
|
6
6
|
import { warn } from '../utils/logger.js';
|
|
7
|
-
import {
|
|
8
|
-
import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
|
|
7
|
+
import { isArray, isObject, isString } from '../utils/validation.js';
|
|
9
8
|
import { FIREFORGE_DIR } from './config.js';
|
|
9
|
+
import { parseCustomConfig } from './furnace-config-custom.js';
|
|
10
10
|
import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
|
|
11
11
|
import { resolveFtlDir } from './furnace-constants.js';
|
|
12
12
|
import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
|
|
@@ -89,43 +89,6 @@ function parseOverrideConfig(data, name) {
|
|
|
89
89
|
...(isString(data['baseCommit']) ? { baseCommit: data['baseCommit'] } : {}),
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Validates a custom component config object.
|
|
94
|
-
* @param data - Raw data to validate
|
|
95
|
-
* @param name - Component name for error messages
|
|
96
|
-
*/
|
|
97
|
-
function parseCustomConfig(data, name) {
|
|
98
|
-
if (!isString(data['description'])) {
|
|
99
|
-
throw new FurnaceError(`Furnace config: custom "${name}.description" must be a string`);
|
|
100
|
-
}
|
|
101
|
-
if (!isString(data['targetPath'])) {
|
|
102
|
-
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must be a string`);
|
|
103
|
-
}
|
|
104
|
-
if (data['targetPath'].includes('..') || data['targetPath'].includes('\0')) {
|
|
105
|
-
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not contain ".." or null bytes (path traversal)`);
|
|
106
|
-
}
|
|
107
|
-
if (isExplicitAbsolutePath(data['targetPath'])) {
|
|
108
|
-
throw new FurnaceError(`Furnace config: custom "${name}.targetPath" must not be an absolute path`);
|
|
109
|
-
}
|
|
110
|
-
if (!isBoolean(data['register'])) {
|
|
111
|
-
throw new FurnaceError(`Furnace config: custom "${name}.register" must be a boolean`);
|
|
112
|
-
}
|
|
113
|
-
if (!isBoolean(data['localized'])) {
|
|
114
|
-
throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
|
|
115
|
-
}
|
|
116
|
-
if (data['composes'] !== undefined) {
|
|
117
|
-
parseStringArray(data['composes'], `${name}.composes`);
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
description: data['description'],
|
|
121
|
-
targetPath: data['targetPath'],
|
|
122
|
-
register: data['register'],
|
|
123
|
-
localized: data['localized'],
|
|
124
|
-
...(data['composes'] !== undefined
|
|
125
|
-
? { composes: parseStringArray(data['composes'], `${name}.composes`) }
|
|
126
|
-
: {}),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
92
|
/** The current (and only) config schema version. */
|
|
130
93
|
const CURRENT_CONFIG_VERSION = 1;
|
|
131
94
|
/**
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { ValidationIssue } from '../types/furnace.js';
|
|
1
|
+
import type { CustomComponentConfig, ValidationIssue } from '../types/furnace.js';
|
|
2
2
|
/**
|
|
3
3
|
* Validates accessibility patterns in a component's .mjs file.
|
|
4
4
|
* Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
|
|
5
|
+
*
|
|
6
|
+
* @param customConfig - When the component is custom, its matching entry
|
|
7
|
+
* from `furnace.json`. Used to skip the `no-keyboard-handler` warning
|
|
8
|
+
* when the component declares keyboard coverage either explicitly
|
|
9
|
+
* (`keyboardCovered: true`) or via `composes` naming a native-interactive
|
|
10
|
+
* inner element. Optional so stock/override callers and test fixtures
|
|
11
|
+
* without config in scope can continue to call without changes.
|
|
5
12
|
*/
|
|
6
|
-
export declare function validateAccessibility(componentDir: string, tagName: string): Promise<ValidationIssue[]>;
|
|
13
|
+
export declare function validateAccessibility(componentDir: string, tagName: string, customConfig?: CustomComponentConfig): Promise<ValidationIssue[]>;
|