@hominis/fireforge 0.15.6 → 0.15.8
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 +78 -0
- package/README.md +158 -15
- package/dist/src/commands/build.js +60 -3
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
- package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
- package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
- package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
- package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
- package/dist/src/commands/furnace/chrome-doc.js +37 -4
- package/dist/src/commands/furnace/create-dry-run.d.ts +38 -0
- package/dist/src/commands/furnace/create-dry-run.js +100 -0
- 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 +28 -6
- package/dist/src/commands/furnace/create.js +62 -63
- package/dist/src/commands/furnace/index.js +4 -1
- package/dist/src/commands/lint.d.ts +17 -2
- package/dist/src/commands/lint.js +25 -2
- package/dist/src/commands/register.d.ts +1 -1
- package/dist/src/commands/register.js +30 -7
- package/dist/src/commands/run.d.ts +15 -1
- package/dist/src/commands/run.js +202 -7
- package/dist/src/commands/test.js +113 -3
- package/dist/src/core/build-audit-registration.d.ts +80 -0
- package/dist/src/core/build-audit-registration.js +187 -0
- package/dist/src/core/build-audit-transforms.d.ts +23 -0
- package/dist/src/core/build-audit-transforms.js +94 -0
- package/dist/src/core/build-audit.js +107 -7
- 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-registration.d.ts +6 -4
- package/dist/src/core/furnace-validate-registration.js +66 -6
- package/dist/src/core/furnace-validate-structure.js +6 -2
- package/dist/src/core/furnace-validate.js +6 -3
- package/dist/src/core/mach-build-artifacts.d.ts +44 -0
- package/dist/src/core/mach-build-artifacts.js +104 -3
- package/dist/src/core/mach.d.ts +27 -1
- package/dist/src/core/mach.js +26 -2
- 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/test-stale-check.d.ts +42 -0
- package/dist/src/core/test-stale-check.js +114 -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 +64 -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
|
|
@@ -4,10 +4,12 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
|
4
4
|
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
|
+
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
8
|
+
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
7
9
|
import { GeneralError } from '../errors/base.js';
|
|
8
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
9
11
|
import { pathExists } from '../utils/fs.js';
|
|
10
|
-
import { info, intro, spinner } from '../utils/logger.js';
|
|
12
|
+
import { info, intro, spinner, warn } from '../utils/logger.js';
|
|
11
13
|
import { pickDefined } from '../utils/options.js';
|
|
12
14
|
/**
|
|
13
15
|
* Strips a leading "engine/" or "engine\\" prefix from a path if present.
|
|
@@ -59,7 +61,47 @@ function hasStaleBuildArtifactsSignal(output) {
|
|
|
59
61
|
/resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
|
|
60
62
|
/browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
|
|
61
63
|
}
|
|
62
|
-
|
|
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) {
|
|
63
105
|
if (result.exitCode === 0 || result.exitCode === 130)
|
|
64
106
|
return;
|
|
65
107
|
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
@@ -69,6 +111,12 @@ function handleNonZeroTestExit(result, normalizedPaths) {
|
|
|
69
111
|
if (hasStaleBuildArtifactsSignal(combinedOutput)) {
|
|
70
112
|
throw new GeneralError(buildStaleBuildMessage());
|
|
71
113
|
}
|
|
114
|
+
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
115
|
+
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
116
|
+
}
|
|
117
|
+
if (hasXpcshellAppdirSignal(combinedOutput)) {
|
|
118
|
+
throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
|
|
119
|
+
}
|
|
72
120
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
73
121
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
74
122
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -118,6 +166,20 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
118
166
|
s.stop('Build complete');
|
|
119
167
|
info('');
|
|
120
168
|
}
|
|
169
|
+
else {
|
|
170
|
+
// Stale-build preflight — when --build was NOT requested, detect
|
|
171
|
+
// packageable engine edits since the last successful `fireforge build`
|
|
172
|
+
// and warn UP-FRONT. Without this, edits to chrome / packaged resources
|
|
173
|
+
// surface only as a cryptic `NS_ERROR_FILE_NOT_FOUND` inside xpcshell
|
|
174
|
+
// after mach test has already launched (see motivating case in
|
|
175
|
+
// `core/test-stale-check.ts`). The check is warn-only so a fork that
|
|
176
|
+
// rebuilt out-of-band (no FireForge-recorded baseline update) is not
|
|
177
|
+
// blocked from running tests.
|
|
178
|
+
const stale = await checkStaleBuildForTest(projectRoot, paths.engine);
|
|
179
|
+
if (stale.stale) {
|
|
180
|
+
warn(formatStaleBuildWarning(stale));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
121
183
|
// `--doctor` runs a short marionette handshake probe. When test paths are
|
|
122
184
|
// supplied the probe gates the mach test invocation (a FAIL bails out). When
|
|
123
185
|
// no paths are supplied this is the only step — it's the fastest way to tell
|
|
@@ -144,6 +206,23 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
144
206
|
if (options.headless) {
|
|
145
207
|
extraArgs.push('--headless');
|
|
146
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);
|
|
147
226
|
// Log what we're doing
|
|
148
227
|
if (normalizedPaths.length > 0) {
|
|
149
228
|
info(`Running tests: ${normalizedPaths.join(', ')}`);
|
|
@@ -159,7 +238,34 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
159
238
|
catch (error) {
|
|
160
239
|
throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
|
|
161
240
|
}
|
|
162
|
-
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
|
+
}
|
|
163
269
|
}
|
|
164
270
|
/** Registers the test command on the CLI program. */
|
|
165
271
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -169,6 +275,10 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
169
275
|
.option('--headless', 'Run tests in headless mode')
|
|
170
276
|
.option('--build', 'Run incremental UI build before testing')
|
|
171
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
|
+
}, [])
|
|
172
282
|
.action(withErrorHandling(async (paths, options) => {
|
|
173
283
|
await testCommand(getProjectRoot(), paths, pickDefined(options));
|
|
174
284
|
}));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Parsed jar.mn registration anchored to a specific engine source path. */
|
|
2
|
+
export interface RegistrationHit {
|
|
3
|
+
/** Target path extracted from the entry (POSIX). */
|
|
4
|
+
target: string;
|
|
5
|
+
/** Source path from the entry (POSIX, relative to the jar.mn directory). */
|
|
6
|
+
source: string;
|
|
7
|
+
/** Absolute path of the jar.mn that owns the registration. */
|
|
8
|
+
jarManifest: string;
|
|
9
|
+
}
|
|
10
|
+
/** Result of a registration-aware dist probe. */
|
|
11
|
+
export interface RegistrationProbeResult {
|
|
12
|
+
/** Absolute path of the packaged artifact matching the registration target. */
|
|
13
|
+
artifact: string;
|
|
14
|
+
/** The registration entry that anchored the match. */
|
|
15
|
+
hit: RegistrationHit;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parses a single jar.mn line into `{ target, source }` when the line is a
|
|
19
|
+
* content entry with an explicit `(source)` reference. Returns undefined
|
|
20
|
+
* for comments, headers (`browser.jar:`), `%` manifest directives, blank
|
|
21
|
+
* lines, and entries without a source reference.
|
|
22
|
+
*
|
|
23
|
+
* Accepted entry shapes:
|
|
24
|
+
* ` content/browser/foo.js (content/foo.js)` bare
|
|
25
|
+
* `* content/browser/foo.js (content/foo.js)` `*` = preprocessed
|
|
26
|
+
* `en-US.jar: content/foo.js (content/foo.js)` locale-prefixed
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseJarMnEntry(line: string): {
|
|
29
|
+
target: string;
|
|
30
|
+
source: string;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Scans a jar.mn file's contents for an entry whose source reference
|
|
34
|
+
* matches `relativeSource` (POSIX, relative to the jar.mn directory).
|
|
35
|
+
* Returns the first match; jar.mn enforces uniqueness of `(source)` in
|
|
36
|
+
* practice, so a first-match wins behaviour is adequate.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findJarMnEntryForSource(content: string, relativeSource: string): {
|
|
39
|
+
target: string;
|
|
40
|
+
source: string;
|
|
41
|
+
} | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Walks from the source's directory upward to the engine root, returning
|
|
44
|
+
* the first jar.mn entry that registers the given source. Returns undefined
|
|
45
|
+
* when no ancestor jar.mn claims the source.
|
|
46
|
+
*
|
|
47
|
+
* @param engineDir Absolute engine root; walk halts here.
|
|
48
|
+
* @param source Engine-relative POSIX source path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function findRegisteredTarget(engineDir: string, source: string): Promise<RegistrationHit | undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* Probes the dist tree for the artifact registered against the given
|
|
53
|
+
* source. Returns the matched candidate and the registration hit that
|
|
54
|
+
* anchored it; undefined when the source has no owning jar.mn or when
|
|
55
|
+
* no same-basename candidate under the search roots ends with the
|
|
56
|
+
* registered target path.
|
|
57
|
+
*
|
|
58
|
+
* Suffix-matching against the target path is intentional: jar.mn targets
|
|
59
|
+
* are relative to the jar root (`browser.jar:`, `toolkit.jar:`), but the
|
|
60
|
+
* dist tree prefixes every entry with a jar-specific directory
|
|
61
|
+
* (`.../chrome/browser/content/browser/…`). The source basename plus the
|
|
62
|
+
* target suffix are unambiguous across every packaging convention we
|
|
63
|
+
* care about.
|
|
64
|
+
*
|
|
65
|
+
* @param engineDir Absolute engine root.
|
|
66
|
+
* @param source Engine-relative POSIX source path.
|
|
67
|
+
* @param searchRoots Absolute roots to probe (dist/, _tests/).
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveArtifactByRegistration(engineDir: string, source: string, searchRoots: readonly string[]): Promise<RegistrationProbeResult | undefined>;
|
|
70
|
+
/**
|
|
71
|
+
* Returns the absolute paths of every same-basename candidate under the
|
|
72
|
+
* given search roots. Used by the audit to enumerate ALL false-match
|
|
73
|
+
* candidates when the heuristic fallback downgrades to "missing" — the
|
|
74
|
+
* operator needs to see the full set, not just the scorer's pick, to
|
|
75
|
+
* distinguish a registration bug from a genuine packaging drop.
|
|
76
|
+
*
|
|
77
|
+
* @param source Engine-relative POSIX source path.
|
|
78
|
+
* @param searchRoots Absolute roots to scan.
|
|
79
|
+
*/
|
|
80
|
+
export declare function collectSameBasenameCandidates(source: string, searchRoots: readonly string[]): Promise<string[]>;
|