@hominis/fireforge 0.22.0 → 0.23.0
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
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
- Improved xpcshell test argument filtering and mixed-harness diagnostics.
|
|
6
|
+
- Locked pre-test build phases and improved stale harness diagnostics.
|
|
7
|
+
- Fixed binary-safe re-export for new untracked files.
|
|
8
|
+
- Improved additive `re-export --files` and lint warning guidance.
|
|
9
|
+
|
|
3
10
|
## 0.22.0
|
|
4
11
|
|
|
5
12
|
- Added `doctor --clear-resolution` with verify-backed safety checks.
|
|
@@ -161,6 +161,12 @@ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
|
|
|
161
161
|
}
|
|
162
162
|
return diff;
|
|
163
163
|
}
|
|
164
|
+
function buildMaxWarningsMessage(count, maxWarnings, scope) {
|
|
165
|
+
const scoped = scope ? ` ${scope}` : '';
|
|
166
|
+
const base = `Patch lint found ${count} warning(s)${scoped}, exceeding --max-warnings ${maxWarnings}.`;
|
|
167
|
+
return (base +
|
|
168
|
+
' If this is a release gate and the warnings are historical patch-size advisories, run with --per-patch to identify the owning patch and split/re-export that patch, or add a scoped lintIgnore entry only after review.');
|
|
169
|
+
}
|
|
164
170
|
/**
|
|
165
171
|
* Filters aggregate-mode lint issues against per-patch `lintIgnore`
|
|
166
172
|
* lists drawn from the manifest. An issue is dropped when at least one
|
|
@@ -373,7 +379,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
373
379
|
}
|
|
374
380
|
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
375
381
|
outro('Lint failed');
|
|
376
|
-
throw new GeneralError(
|
|
382
|
+
throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings));
|
|
377
383
|
}
|
|
378
384
|
// Notices are advisory and don't count as warnings — emitting "passed
|
|
379
385
|
// with warnings" when only notices fired contradicts the preceding
|
|
@@ -493,7 +499,7 @@ async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
|
493
499
|
}
|
|
494
500
|
if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
|
|
495
501
|
outro('Lint failed');
|
|
496
|
-
throw new GeneralError(
|
|
502
|
+
throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings, `across ${linted} patch(es)`));
|
|
497
503
|
}
|
|
498
504
|
if (warnings.length > 0) {
|
|
499
505
|
outro('Lint passed with warnings');
|
|
@@ -175,9 +175,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
175
175
|
forceUnsafe: options.forceUnsafe === true,
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
|
-
// Shrinks are destructive (previously-owned files become unmanaged)
|
|
179
|
-
//
|
|
180
|
-
//
|
|
178
|
+
// Shrinks are destructive (previously-owned files become unmanaged), so
|
|
179
|
+
// they keep the explicit confirmation gate. Additive-only scopes are safe
|
|
180
|
+
// to run non-interactively after lint/policy projection because no existing
|
|
181
|
+
// patch ownership is being dropped.
|
|
181
182
|
const summary = [
|
|
182
183
|
`re-export ${target.filename} with --files scope`,
|
|
183
184
|
`current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
|
|
@@ -196,7 +197,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
196
197
|
operation: 're-export-files',
|
|
197
198
|
title: `Re-export ${target.filename} with --files`,
|
|
198
199
|
summary,
|
|
199
|
-
yes: options.yes === true,
|
|
200
|
+
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
200
201
|
dryRun: isDryRun,
|
|
201
202
|
unsafeOverride: options.forceUnsafe === true,
|
|
202
203
|
conflicts,
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
|
-
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
|
|
5
|
+
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
|
|
6
6
|
import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
|
|
7
7
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
8
8
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
9
|
-
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
9
|
+
import { findNearestXpcshellManifest, operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
10
10
|
import { GeneralError } from '../errors/base.js';
|
|
11
11
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
12
12
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -36,6 +36,43 @@ function buildStaleBuildMessage() {
|
|
|
36
36
|
'The failing output referenced missing branding or distribution resources, which usually means the current obj-* build does not match recent engine or branding changes.\n\n' +
|
|
37
37
|
'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
|
|
38
38
|
}
|
|
39
|
+
async function classifyTestHarnesses(engineDir, normalizedPaths) {
|
|
40
|
+
const result = { xpcshell: [], nonXpcshell: [] };
|
|
41
|
+
for (const testPath of normalizedPaths) {
|
|
42
|
+
const manifest = await findNearestXpcshellManifest(engineDir, testPath);
|
|
43
|
+
if (manifest) {
|
|
44
|
+
result.xpcshell.push(testPath);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
result.nonXpcshell.push(testPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
function buildMixedHarnessMessage(classification) {
|
|
53
|
+
return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
|
|
54
|
+
'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
|
|
55
|
+
` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
|
|
56
|
+
` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
|
|
59
|
+
if (classification.xpcshell.length === 0 || classification.nonXpcshell.length > 0) {
|
|
60
|
+
return [...machArgs];
|
|
61
|
+
}
|
|
62
|
+
const filtered = [];
|
|
63
|
+
for (let i = 0; i < machArgs.length; i += 1) {
|
|
64
|
+
const arg = machArgs[i] ?? '';
|
|
65
|
+
if (/^--flavor=xpcshell(?:-tests)?$/.test(arg)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg === '--flavor' && /^xpcshell(?:-tests)?$/.test(machArgs[i + 1] ?? '')) {
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
filtered.push(arg);
|
|
73
|
+
}
|
|
74
|
+
return filtered;
|
|
75
|
+
}
|
|
39
76
|
function hasStaleBuildArtifactsSignal(output) {
|
|
40
77
|
// Deliberately narrow: only fire on branding-specific resource paths
|
|
41
78
|
// that are always a stale-artifact symptom. The earlier pattern also
|
|
@@ -101,6 +138,36 @@ function buildXpcshellAppdirMessage(injectionAttempted) {
|
|
|
101
138
|
' - 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' +
|
|
102
139
|
' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
|
|
103
140
|
}
|
|
141
|
+
function buildMochitestSymlinkMessage() {
|
|
142
|
+
return ('mach failed while preparing mochitest harness symlinks before the requested tests ran.\n\n' +
|
|
143
|
+
'This usually means the objdir contains stale harness setup from an earlier run. Re-run with `fireforge test --build` to refresh the harness state, or remove the stale mochitest symlink in the active obj-* directory before retrying.');
|
|
144
|
+
}
|
|
145
|
+
async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
|
|
146
|
+
if (!objDir)
|
|
147
|
+
return undefined;
|
|
148
|
+
const bundleCheck = await hasRunnableBundle(engineDir, binaryName, objDir);
|
|
149
|
+
if (!bundleCheck.runnable) {
|
|
150
|
+
const expectedSuffix = bundleCheck.expectedPath
|
|
151
|
+
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
152
|
+
: '';
|
|
153
|
+
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
154
|
+
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
155
|
+
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
156
|
+
}
|
|
157
|
+
return bundleCheck.expectedPath;
|
|
158
|
+
}
|
|
159
|
+
async function runPreTestBuild(projectRoot, paths, projectConfig) {
|
|
160
|
+
await withBuildLock(projectRoot, async () => {
|
|
161
|
+
await prepareBuildEnvironment(projectRoot, paths, projectConfig);
|
|
162
|
+
const s = spinner('Running incremental build...');
|
|
163
|
+
const buildResult = await buildUI(paths.engine);
|
|
164
|
+
if (buildResult.exitCode !== 0) {
|
|
165
|
+
s.error('Pre-test build failed');
|
|
166
|
+
throw new BuildError('Pre-test build failed', 'mach build faster');
|
|
167
|
+
}
|
|
168
|
+
s.stop('Build complete');
|
|
169
|
+
});
|
|
170
|
+
}
|
|
104
171
|
// Detects the `AttributeError: 'MochitestDesktop' object has no attribute
|
|
105
172
|
// 'http3Server'` teardown crash. The attribute is lazy-initialized inside
|
|
106
173
|
// harness code paths that presume chrome://branding resolves correctly; a
|
|
@@ -153,6 +220,9 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
153
220
|
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
154
221
|
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
155
222
|
}
|
|
223
|
+
if (/FileExistsError/i.test(combinedOutput) && /mochitest/i.test(combinedOutput)) {
|
|
224
|
+
throw new GeneralError(buildMochitestSymlinkMessage());
|
|
225
|
+
}
|
|
156
226
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
157
227
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
158
228
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -201,29 +271,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
201
271
|
// here so `test --doctor` against an incomplete build surfaces the
|
|
202
272
|
// missing-bundle path instead of a cryptic `Browser process exited
|
|
203
273
|
// during spawn (exit code 1, signal none). stderr tail: (empty)`.
|
|
204
|
-
|
|
205
|
-
if (buildCheck.objDir) {
|
|
206
|
-
const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
207
|
-
launchablePath = bundleCheck.expectedPath;
|
|
208
|
-
if (!bundleCheck.runnable) {
|
|
209
|
-
const expectedSuffix = bundleCheck.expectedPath
|
|
210
|
-
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
211
|
-
: '';
|
|
212
|
-
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
213
|
-
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
214
|
-
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
215
|
-
}
|
|
216
|
-
}
|
|
274
|
+
const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
217
275
|
// Run incremental build if requested
|
|
218
276
|
if (options.build) {
|
|
219
|
-
await
|
|
220
|
-
const s = spinner('Running incremental build...');
|
|
221
|
-
const buildResult = await buildUI(paths.engine);
|
|
222
|
-
if (buildResult.exitCode !== 0) {
|
|
223
|
-
s.error('Pre-test build failed');
|
|
224
|
-
throw new BuildError('Pre-test build failed', 'mach build faster');
|
|
225
|
-
}
|
|
226
|
-
s.stop('Build complete');
|
|
277
|
+
await runPreTestBuild(projectRoot, paths, projectConfig);
|
|
227
278
|
info('');
|
|
228
279
|
}
|
|
229
280
|
else {
|
|
@@ -309,6 +360,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
309
360
|
// previous case-insensitive + leading-whitespace-tolerant contract.
|
|
310
361
|
const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
|
|
311
362
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
363
|
+
const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
|
|
364
|
+
if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
|
|
365
|
+
throw new GeneralError(buildMixedHarnessMessage(classification));
|
|
366
|
+
}
|
|
367
|
+
const forwardedMachArgs = options.machArg && options.machArg.length > 0
|
|
368
|
+
? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
|
|
369
|
+
: [];
|
|
312
370
|
// Build extra args
|
|
313
371
|
const extraArgs = [];
|
|
314
372
|
if (options.headless) {
|
|
@@ -319,8 +377,8 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
319
377
|
// above for the motivating case). Appended AFTER --headless so mach sees
|
|
320
378
|
// the FireForge-managed flags first and the escape-valve ones last, which
|
|
321
379
|
// keeps the override precedence predictable.
|
|
322
|
-
if (
|
|
323
|
-
extraArgs.push(...
|
|
380
|
+
if (forwardedMachArgs.length > 0) {
|
|
381
|
+
extraArgs.push(...forwardedMachArgs);
|
|
324
382
|
}
|
|
325
383
|
// Auto-forward the Marionette port to mach when `--marionette-port` is
|
|
326
384
|
// set. `--setpref=marionette.port=<n>` configures where the browser
|
|
@@ -8,7 +8,7 @@ import { pathExists, readText } from '../utils/fs.js';
|
|
|
8
8
|
import { verbose } from '../utils/logger.js';
|
|
9
9
|
import { exec } from '../utils/process.js';
|
|
10
10
|
import { ensureGit, git } from './git-base.js';
|
|
11
|
-
import { fileExistsInHead } from './git-file-ops.js';
|
|
11
|
+
import { fileExistsInHead, isBinaryFile } from './git-file-ops.js';
|
|
12
12
|
import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
|
|
13
13
|
async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
|
|
14
14
|
const result = await exec('git', args, { cwd: repoDir });
|
|
@@ -174,7 +174,9 @@ export async function getAllDiff(repoDir) {
|
|
|
174
174
|
// Generate diffs for untracked files
|
|
175
175
|
const untrackedDiffs = [];
|
|
176
176
|
for (const file of untrackedFiles) {
|
|
177
|
-
const diff = await
|
|
177
|
+
const diff = (await isBinaryFile(repoDir, file))
|
|
178
|
+
? await generateBinaryFilePatch(repoDir, file)
|
|
179
|
+
: await generateNewFileDiff(repoDir, file);
|
|
178
180
|
untrackedDiffs.push(diff);
|
|
179
181
|
}
|
|
180
182
|
// Combine all diffs — each already ends with \n, so concatenate directly
|
|
@@ -247,7 +249,9 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
|
|
|
247
249
|
}
|
|
248
250
|
continue;
|
|
249
251
|
}
|
|
250
|
-
const diff = await
|
|
252
|
+
const diff = (await isBinaryFile(repoDir, file))
|
|
253
|
+
? await generateBinaryFilePatch(repoDir, file)
|
|
254
|
+
: await generateNewFileDiff(repoDir, file);
|
|
251
255
|
if (diff.trim()) {
|
|
252
256
|
diffs.push(diff);
|
|
253
257
|
}
|
|
@@ -291,9 +291,12 @@ export function forwardedMachArgsIncludeMarionetteClient(machArgs) {
|
|
|
291
291
|
* for runs where the pref is ignored anyway.
|
|
292
292
|
*/
|
|
293
293
|
export function hasExplicitXpcshellFlavor(machArgs) {
|
|
294
|
-
for (
|
|
294
|
+
for (let i = 0; i < machArgs.length; i += 1) {
|
|
295
|
+
const arg = machArgs[i] ?? '';
|
|
295
296
|
if (/^--flavor=xpcshell\b/.test(arg) || arg === '--flavor=xpcshell-tests')
|
|
296
297
|
return true;
|
|
298
|
+
if (arg === '--flavor' && /^xpcshell(?:-tests)?$/.test(machArgs[i + 1] ?? ''))
|
|
299
|
+
return true;
|
|
297
300
|
}
|
|
298
301
|
return false;
|
|
299
302
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hominis/fireforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "FireForge — a build tool for customizing Firefox",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.2",
|
|
67
67
|
"eslint": "^10.0.0",
|
|
68
68
|
"eslint-config-prettier": "^10.1.8",
|
|
69
|
-
"eslint-plugin-jsdoc": "^
|
|
69
|
+
"eslint-plugin-jsdoc": "^63.0.0",
|
|
70
70
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
71
71
|
"fast-check": "^4.6.0",
|
|
72
72
|
"husky": "^9.1.7",
|