@hominis/fireforge 0.21.4 → 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 +73 -749
- package/README.md +52 -775
- package/dist/src/commands/doctor-furnace-manifest-sync.js +42 -4
- package/dist/src/commands/doctor.js +17 -3
- package/dist/src/commands/lint.js +8 -2
- package/dist/src/commands/patch/compact.js +18 -1
- package/dist/src/commands/re-export-files.js +5 -4
- package/dist/src/commands/test.js +83 -25
- package/dist/src/commands/verify.d.ts +19 -0
- package/dist/src/commands/verify.js +90 -61
- package/dist/src/core/git-diff.js +7 -3
- package/dist/src/core/marionette-port.js +4 -1
- package/dist/src/types/commands/options.d.ts +8 -0
- package/package.json +2 -2
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Lives in a sibling module to keep `doctor-furnace.ts` under the
|
|
16
16
|
* per-file LOC budget.
|
|
17
17
|
*/
|
|
18
|
-
import { readdir } from 'node:fs/promises';
|
|
18
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig } from '../core/furnace-config.js';
|
|
21
21
|
import { toError } from '../utils/errors.js';
|
|
@@ -120,6 +120,37 @@ async function repairOrphanOverrides(projectRoot, orphans) {
|
|
|
120
120
|
}
|
|
121
121
|
return { restored, unrecoverable };
|
|
122
122
|
}
|
|
123
|
+
async function repairCustomOrphans(projectRoot, customNames) {
|
|
124
|
+
const deleted = [];
|
|
125
|
+
const retained = [];
|
|
126
|
+
const errors = [];
|
|
127
|
+
if (customNames.length === 0)
|
|
128
|
+
return { deleted, retained, errors };
|
|
129
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
130
|
+
for (const name of customNames) {
|
|
131
|
+
const dir = join(furnacePaths.customDir, name);
|
|
132
|
+
let entries;
|
|
133
|
+
try {
|
|
134
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
errors.push(`${name}: ${toError(err).message}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (entries.length > 0) {
|
|
141
|
+
retained.push(name);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await rm(dir);
|
|
146
|
+
deleted.push(name);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
errors.push(`${name}: ${toError(err).message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { deleted, retained, errors };
|
|
153
|
+
}
|
|
123
154
|
export const furnaceManifestSyncCheck = {
|
|
124
155
|
name: 'Furnace manifest sync',
|
|
125
156
|
dependsOn: ['Furnace configuration'],
|
|
@@ -142,6 +173,7 @@ export const furnaceManifestSyncCheck = {
|
|
|
142
173
|
if (repairResult.writeError) {
|
|
143
174
|
return failure('Furnace manifest sync', `Repair failed while writing furnace.json: ${repairResult.writeError}`, 'Fix the underlying filesystem error and retry the doctor command.');
|
|
144
175
|
}
|
|
176
|
+
const customRepair = await repairCustomOrphans(ctx.projectRoot, orphans.customNames);
|
|
145
177
|
const { restored, unrecoverable } = repairResult;
|
|
146
178
|
const restoreDetail = restored.length > 0
|
|
147
179
|
? `Re-registered ${restored.length} override${restored.length === 1 ? '' : 's'} (${restored.join(', ')}) from their override.json sidecars.`
|
|
@@ -149,10 +181,16 @@ export const furnaceManifestSyncCheck = {
|
|
|
149
181
|
const unrecoverableDetail = unrecoverable.length > 0
|
|
150
182
|
? ` Could not recover ${unrecoverable.length} override${unrecoverable.length === 1 ? '' : 's'} without a valid override.json (${unrecoverable.join(', ')}) — delete components/overrides/<name> or re-run "fireforge furnace override" to restore the entry.`
|
|
151
183
|
: '';
|
|
152
|
-
const customDetail =
|
|
153
|
-
? ` ${
|
|
184
|
+
const customDetail = customRepair.deleted.length > 0
|
|
185
|
+
? ` Deleted ${customRepair.deleted.length} empty custom orphan ${customRepair.deleted.length === 1 ? 'directory' : 'directories'} (${customRepair.deleted.join(', ')}).`
|
|
186
|
+
: '';
|
|
187
|
+
const retainedCustomDetail = customRepair.retained.length > 0
|
|
188
|
+
? ` ${customRepair.retained.length} non-empty custom orphan ${customRepair.retained.length === 1 ? 'directory requires' : 'directories require'} manual action (${customRepair.retained.join(', ')}): re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
|
|
189
|
+
: '';
|
|
190
|
+
const customErrorDetail = customRepair.errors.length > 0
|
|
191
|
+
? ` Could not inspect or delete ${customRepair.errors.length} custom orphan ${customRepair.errors.length === 1 ? 'directory' : 'directories'} (${customRepair.errors.join('; ')}).`
|
|
154
192
|
: '';
|
|
155
|
-
return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}`.trim() ||
|
|
193
|
+
return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}${retainedCustomDetail}${customErrorDetail}`.trim() ||
|
|
156
194
|
'Nothing to repair (orphans surfaced but all were already recoverable).');
|
|
157
195
|
},
|
|
158
196
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configExists, getProjectPaths, loadConfig, loadState } from '../core/config.js';
|
|
1
|
+
import { configExists, getProjectPaths, loadConfig, loadState, updateState, } from '../core/config.js';
|
|
2
2
|
import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
|
|
3
3
|
import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
4
4
|
import { ensureGit } from '../core/git-base.js';
|
|
@@ -13,6 +13,7 @@ import { findExecutable } from '../utils/process.js';
|
|
|
13
13
|
import { failure, ok, warning } from './doctor-check-core.js';
|
|
14
14
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
15
15
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
16
|
+
import { collectPatchQueueHealth } from './verify.js';
|
|
16
17
|
/**
|
|
17
18
|
* Runs a single check definition, converting thrown errors into
|
|
18
19
|
* DoctorCheck failure rows. Always returns an array so the caller can
|
|
@@ -184,9 +185,21 @@ const DOCTOR_CHECKS = [
|
|
|
184
185
|
{
|
|
185
186
|
name: 'Pending Resolution',
|
|
186
187
|
skipIf: (ctx) => !ctx.state.pendingResolution,
|
|
187
|
-
run: (ctx) => {
|
|
188
|
+
run: async (ctx) => {
|
|
188
189
|
const patchFilename = ctx.state.pendingResolution?.patchFilename ?? 'unknown';
|
|
189
|
-
|
|
190
|
+
if (ctx.options.clearResolution) {
|
|
191
|
+
const health = await collectPatchQueueHealth(ctx.projectRoot);
|
|
192
|
+
if (health.errorCount > 0) {
|
|
193
|
+
return failure('Pending Resolution', `Refusing to clear pending resolution for ${patchFilename}: patch queue health check found ${health.errorCount} error(s).`, 'Run "fireforge verify" for details, fix the queue, then retry "fireforge doctor --clear-resolution".');
|
|
194
|
+
}
|
|
195
|
+
await updateState(ctx.projectRoot, (current) => {
|
|
196
|
+
const next = { ...current };
|
|
197
|
+
delete next.pendingResolution;
|
|
198
|
+
return next;
|
|
199
|
+
});
|
|
200
|
+
return ok('Pending Resolution');
|
|
201
|
+
}
|
|
202
|
+
return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed. If the queue now verifies cleanly, run "fireforge doctor --clear-resolution" to discard the stale marker.');
|
|
190
203
|
},
|
|
191
204
|
},
|
|
192
205
|
{
|
|
@@ -452,6 +465,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
|
|
|
452
465
|
.description('Diagnose project issues')
|
|
453
466
|
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
|
|
454
467
|
.option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
|
|
468
|
+
.option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
|
|
455
469
|
.action(withErrorHandling(async (options) => {
|
|
456
470
|
const result = await doctorCommand(getProjectRoot(), options);
|
|
457
471
|
if (result.exitCode !== 0) {
|
|
@@ -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');
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* This command renumbers all patches to sequential ordinals (1, 2, 3, …)
|
|
7
7
|
* in a single atomic operation, preserving relative order.
|
|
8
8
|
*/
|
|
9
|
-
import { getProjectPaths } from '../../core/config.js';
|
|
9
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
10
10
|
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
11
11
|
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
12
12
|
import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
|
|
13
|
+
import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
|
|
13
14
|
import { GeneralError } from '../../errors/base.js';
|
|
14
15
|
import { toError } from '../../utils/errors.js';
|
|
15
16
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -44,6 +45,7 @@ function computeCompactRenameMap(patches) {
|
|
|
44
45
|
export async function patchCompactCommand(projectRoot, options = {}) {
|
|
45
46
|
intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
|
|
46
47
|
const paths = getProjectPaths(projectRoot);
|
|
48
|
+
const config = await loadConfig(projectRoot);
|
|
47
49
|
if (!(await pathExists(paths.patches))) {
|
|
48
50
|
throw new GeneralError('Patches directory not found.');
|
|
49
51
|
}
|
|
@@ -62,12 +64,19 @@ export async function patchCompactCommand(projectRoot, options = {}) {
|
|
|
62
64
|
for (const [oldFilename, entry] of sorted) {
|
|
63
65
|
summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
|
|
64
66
|
}
|
|
67
|
+
enforcePatchPolicy({
|
|
68
|
+
config,
|
|
69
|
+
manifest: applyRenameMapToManifest(manifest, renameMap),
|
|
70
|
+
command: 'patch compact',
|
|
71
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
72
|
+
});
|
|
65
73
|
const decision = await confirmDestructive({
|
|
66
74
|
operation: 'patch-compact',
|
|
67
75
|
title: `Compact ${manifest.patches.length} patches (${renameMap.size} rename(s))`,
|
|
68
76
|
summary,
|
|
69
77
|
yes: options.yes === true,
|
|
70
78
|
dryRun: options.dryRun === true,
|
|
79
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
71
80
|
});
|
|
72
81
|
if (decision === 'dry-run') {
|
|
73
82
|
outro('Dry run complete — no changes made');
|
|
@@ -87,6 +96,12 @@ export async function patchCompactCommand(projectRoot, options = {}) {
|
|
|
87
96
|
info('Patch queue was compacted by another process. Nothing to do.');
|
|
88
97
|
return;
|
|
89
98
|
}
|
|
99
|
+
enforcePatchPolicy({
|
|
100
|
+
config,
|
|
101
|
+
manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
|
|
102
|
+
command: 'patch compact',
|
|
103
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
104
|
+
});
|
|
90
105
|
await renumberPatchesInManifest(paths.patches, currentRenameMap);
|
|
91
106
|
const historyEntry = {
|
|
92
107
|
operation: 'patch-compact',
|
|
@@ -100,6 +115,7 @@ export async function patchCompactCommand(projectRoot, options = {}) {
|
|
|
100
115
|
})),
|
|
101
116
|
},
|
|
102
117
|
...(options.yes === true ? { yes: true } : {}),
|
|
118
|
+
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
103
119
|
result: 'ok',
|
|
104
120
|
};
|
|
105
121
|
try {
|
|
@@ -125,6 +141,7 @@ export function registerPatchCompact(parent, context) {
|
|
|
125
141
|
.description('Close ordinal gaps in the patch queue (renumber sequentially)')
|
|
126
142
|
.option('--dry-run', 'Show what would happen without writing')
|
|
127
143
|
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
144
|
+
.option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
|
|
128
145
|
.action(withErrorHandling(async (options) => {
|
|
129
146
|
await patchCompactCommand(getProjectRoot(), pickDefined(options));
|
|
130
147
|
}));
|
|
@@ -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
|
|
@@ -15,6 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from 'commander';
|
|
17
17
|
import type { CommandContext } from '../types/cli.js';
|
|
18
|
+
interface VerifyIssueGroup {
|
|
19
|
+
title: string;
|
|
20
|
+
issues: string[];
|
|
21
|
+
errorCount: number;
|
|
22
|
+
warningCount: number;
|
|
23
|
+
}
|
|
24
|
+
export interface PatchQueueHealth {
|
|
25
|
+
hasPatchesDirectory: boolean;
|
|
26
|
+
groups: VerifyIssueGroup[];
|
|
27
|
+
errorCount: number;
|
|
28
|
+
warningCount: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Collects the same queue-health findings reported by `fireforge verify`
|
|
32
|
+
* without printing. Used by doctor recovery paths that need a read-only
|
|
33
|
+
* "is this queue healthy?" decision before clearing stale state.
|
|
34
|
+
*/
|
|
35
|
+
export declare function collectPatchQueueHealth(projectRoot: string): Promise<PatchQueueHealth>;
|
|
18
36
|
/**
|
|
19
37
|
* Runs the `verify` command: manifest consistency + cross-patch lint.
|
|
20
38
|
* Read-only; exits non-zero on any error-severity finding.
|
|
@@ -29,3 +47,4 @@ export declare function verifyCommand(projectRoot: string): Promise<void>;
|
|
|
29
47
|
* @param context - Shared CLI registration context
|
|
30
48
|
*/
|
|
31
49
|
export declare function registerVerify(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
|
|
50
|
+
export {};
|
|
@@ -101,100 +101,129 @@ function detectCrossPatchFileClaims(manifestPatches) {
|
|
|
101
101
|
return results;
|
|
102
102
|
}
|
|
103
103
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* @param projectRoot - Project root directory
|
|
104
|
+
* Collects the same queue-health findings reported by `fireforge verify`
|
|
105
|
+
* without printing. Used by doctor recovery paths that need a read-only
|
|
106
|
+
* "is this queue healthy?" decision before clearing stale state.
|
|
108
107
|
*/
|
|
109
|
-
export async function
|
|
110
|
-
intro('FireForge Verify');
|
|
108
|
+
export async function collectPatchQueueHealth(projectRoot) {
|
|
111
109
|
const paths = getProjectPaths(projectRoot);
|
|
112
110
|
const config = await loadConfig(projectRoot);
|
|
113
111
|
if (!(await pathExists(paths.patches))) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
return {
|
|
113
|
+
hasPatchesDirectory: false,
|
|
114
|
+
groups: [],
|
|
115
|
+
errorCount: 0,
|
|
116
|
+
warningCount: 0,
|
|
117
|
+
};
|
|
117
118
|
}
|
|
119
|
+
const groups = [];
|
|
118
120
|
let errorCount = 0;
|
|
119
121
|
let warningCount = 0;
|
|
120
|
-
// 1. Manifest consistency: orphan patch files, missing entries,
|
|
121
|
-
// files-affected mismatch, duplicate entries, unparseable manifest.
|
|
122
122
|
const consistencyIssues = await validatePatchesManifestConsistency(paths.patches);
|
|
123
123
|
if (consistencyIssues.length > 0) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const issues = consistencyIssues.map((issue) => `[${issue.code}] ${issue.message}`);
|
|
125
|
+
groups.push({
|
|
126
|
+
title: `Manifest consistency issues (${consistencyIssues.length})`,
|
|
127
|
+
issues,
|
|
128
|
+
errorCount: consistencyIssues.length,
|
|
129
|
+
warningCount: 0,
|
|
130
|
+
});
|
|
131
|
+
errorCount += consistencyIssues.length;
|
|
129
132
|
}
|
|
130
|
-
// 2. Cross-patch file claims: two or more manifest entries listing the
|
|
131
|
-
// same path in filesAffected. Not caught by per-patch consistency.
|
|
132
133
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
133
134
|
if (manifest) {
|
|
134
135
|
const policyIssues = evaluatePatchPolicy(config, manifest);
|
|
135
136
|
if (policyIssues.length > 0) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
const policyErrors = policyIssues.filter((issue) => issue.severity === 'error').length;
|
|
138
|
+
const policyWarnings = policyIssues.length - policyErrors;
|
|
139
|
+
groups.push({
|
|
140
|
+
title: `Patch policy issues (${policyIssues.length})`,
|
|
141
|
+
issues: policyIssues.map((issue) => {
|
|
142
|
+
const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
|
|
143
|
+
return `${label} [${issue.code}] ${issue.filename}: ${issue.message}`;
|
|
144
|
+
}),
|
|
145
|
+
errorCount: policyErrors,
|
|
146
|
+
warningCount: policyWarnings,
|
|
147
|
+
});
|
|
148
|
+
errorCount += policyErrors;
|
|
149
|
+
warningCount += policyWarnings;
|
|
145
150
|
}
|
|
146
151
|
const crossClaims = detectCrossPatchFileClaims(manifest.patches);
|
|
147
152
|
if (crossClaims.length > 0) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
errorCount
|
|
152
|
-
|
|
153
|
+
groups.push({
|
|
154
|
+
title: `Cross-patch filesAffected conflicts (${crossClaims.length})`,
|
|
155
|
+
issues: crossClaims.map((claim) => `${claim.path} claimed by: ${claim.filenames.join(', ')}`),
|
|
156
|
+
errorCount: crossClaims.length,
|
|
157
|
+
warningCount: 0,
|
|
158
|
+
});
|
|
159
|
+
errorCount += crossClaims.length;
|
|
153
160
|
}
|
|
154
161
|
}
|
|
155
|
-
// 3. Cross-patch lint: duplicate /dev/null creation + forward imports.
|
|
156
162
|
const ctx = await buildPatchQueueContext(paths.patches);
|
|
157
163
|
const lintIssues = lintPatchQueue(ctx);
|
|
158
164
|
if (lintIssues.length > 0) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
const lintErrors = lintIssues.filter((issue) => issue.severity === 'error').length;
|
|
166
|
+
const lintWarnings = lintIssues.filter((issue) => issue.severity === 'warning').length;
|
|
167
|
+
groups.push({
|
|
168
|
+
title: `Cross-patch lint issues (${lintIssues.length})`,
|
|
169
|
+
issues: lintIssues.map((issue) => {
|
|
170
|
+
const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARN' : 'NOTICE';
|
|
171
|
+
return `${label} [${issue.check}] ${issue.file}: ${issue.message}`;
|
|
172
|
+
}),
|
|
173
|
+
errorCount: lintErrors,
|
|
174
|
+
warningCount: lintWarnings,
|
|
175
|
+
});
|
|
176
|
+
errorCount += lintErrors;
|
|
177
|
+
warningCount += lintWarnings;
|
|
168
178
|
}
|
|
169
|
-
// 4. Registration-consequence consistency: walk each patch body and
|
|
170
|
-
// confirm that every widget / locale registration it adds has a
|
|
171
|
-
// corresponding file body covered by the patch queue OR present in
|
|
172
|
-
// the engine working tree. 2026-04-24 eval Finding 1: a patch
|
|
173
|
-
// produced by `export-all --exclude-furnace` referenced
|
|
174
|
-
// `toolkit/content/widgets/moz-qa-panel/*.mjs` via jar.mn /
|
|
175
|
-
// customElements.js edits, but the source files themselves were
|
|
176
|
-
// excluded from the patch. `verify` used to report "clean"; it now
|
|
177
|
-
// flags each dangling reference as a `dangling-registration` error
|
|
178
|
-
// naming the specific patch and target path.
|
|
179
179
|
if (manifest) {
|
|
180
180
|
const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
|
|
181
181
|
if (registrationIssues.length > 0) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
errorCount
|
|
186
|
-
|
|
182
|
+
groups.push({
|
|
183
|
+
title: `Dangling registration references (${registrationIssues.length})`,
|
|
184
|
+
issues: registrationIssues.map((issue) => `${issue.patchFilename}: registers ${issue.targetPath} via ${issue.source}, but no patch body or engine file supplies it`),
|
|
185
|
+
errorCount: registrationIssues.length,
|
|
186
|
+
warningCount: 0,
|
|
187
|
+
});
|
|
188
|
+
errorCount += registrationIssues.length;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
hasPatchesDirectory: true,
|
|
193
|
+
groups,
|
|
194
|
+
errorCount,
|
|
195
|
+
warningCount,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Runs the `verify` command: manifest consistency + cross-patch lint.
|
|
200
|
+
* Read-only; exits non-zero on any error-severity finding.
|
|
201
|
+
*
|
|
202
|
+
* @param projectRoot - Project root directory
|
|
203
|
+
*/
|
|
204
|
+
export async function verifyCommand(projectRoot) {
|
|
205
|
+
intro('FireForge Verify');
|
|
206
|
+
const health = await collectPatchQueueHealth(projectRoot);
|
|
207
|
+
if (!health.hasPatchesDirectory) {
|
|
208
|
+
info('No patches directory. Nothing to verify.');
|
|
209
|
+
outro('Verify clean');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const group of health.groups) {
|
|
213
|
+
warn(`${group.title}:`);
|
|
214
|
+
for (const issue of group.issues) {
|
|
215
|
+
warn(` ${issue}`);
|
|
187
216
|
}
|
|
188
217
|
}
|
|
189
|
-
if (errorCount === 0 && warningCount === 0) {
|
|
218
|
+
if (health.errorCount === 0 && health.warningCount === 0) {
|
|
190
219
|
success('Patch queue is consistent.');
|
|
191
220
|
outro('Verify clean');
|
|
192
221
|
return;
|
|
193
222
|
}
|
|
194
|
-
info(`\nVerify: ${errorCount} error(s), ${warningCount} warning(s)`);
|
|
195
|
-
if (errorCount > 0) {
|
|
223
|
+
info(`\nVerify: ${health.errorCount} error(s), ${health.warningCount} warning(s)`);
|
|
224
|
+
if (health.errorCount > 0) {
|
|
196
225
|
outro('Verify failed');
|
|
197
|
-
throw new GeneralError(`fireforge verify found ${errorCount} error(s). Fix these before running export/import/rebase.`);
|
|
226
|
+
throw new GeneralError(`fireforge verify found ${health.errorCount} error(s). Fix these before running export/import/rebase.`);
|
|
198
227
|
}
|
|
199
228
|
outro('Verify passed with warnings');
|
|
200
229
|
}
|