@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(`Patch lint found ${warnings.length} warning(s), exceeding --max-warnings ${options.maxWarnings}.`);
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(`Patch lint found ${warnings.length} warning(s) across ${linted} patch(es), exceeding --max-warnings ${options.maxWarnings}.`);
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
- // Additive-only changes still deserve a prompt because --files asserts
180
- // an authoritative file set.
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
- let launchablePath;
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 prepareBuildEnvironment(projectRoot, paths, projectConfig);
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 (options.machArg && options.machArg.length > 0) {
323
- extraArgs.push(...options.machArg);
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 generateNewFileDiff(repoDir, file);
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 generateNewFileDiff(repoDir, file);
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 (const arg of machArgs) {
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.22.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": "^62.9.0",
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",