@hominis/fireforge 0.28.1 → 0.28.4
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 +5 -0
- package/README.md +18 -0
- package/dist/src/commands/build.js +33 -5
- package/dist/src/commands/re-export-bulk-scan.d.ts +5 -0
- package/dist/src/commands/re-export-bulk-scan.js +81 -0
- package/dist/src/commands/re-export-options.d.ts +3 -0
- package/dist/src/commands/re-export-options.js +39 -0
- package/dist/src/commands/re-export-scan.d.ts +2 -0
- package/dist/src/commands/re-export-scan.js +2 -1
- package/dist/src/commands/re-export.js +38 -42
- package/dist/src/commands/test-appdir.d.ts +6 -0
- package/dist/src/commands/test-appdir.js +30 -0
- package/dist/src/commands/test.js +7 -28
- package/dist/src/core/patch-artifact-normalize.d.ts +5 -4
- package/dist/src/core/patch-artifact-normalize.js +14 -5
- package/dist/src/core/test-harness-output.d.ts +9 -0
- package/dist/src/core/test-harness-output.js +50 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
## 0.28.0
|
|
4
4
|
|
|
5
5
|
- Added the product-resolved Firefox source archive URL to `source set` output so pinned checksums can be verified against the exact archive target before download.
|
|
6
|
+
- Added dry-run locking for `re-export` so parallel previews serialize engine git inspection instead of racing on `.git/index.lock`.
|
|
7
|
+
- Added `re-export --scan --scan-files <manifest>` for dry-runnable bulk generated-file assignment across owner patches, with ambiguity and ownership refusals.
|
|
8
|
+
- Improved `fireforge test` diagnostics for harness startup failures and zero selected tests run, including the actionable harness line before generic failure output.
|
|
9
|
+
- Improved build failure summaries so real make/mach failures and target context outrank trailing warning-only output.
|
|
10
|
+
- Normalized whitespace-only blank hunk payloads in generated patch artifacts while documenting `npm run whitespace:check` as the release-safe source whitespace gate.
|
|
6
11
|
- Fixed re-export serialization so blank context lines keep their unified-diff context marker, preventing FireForge-generated patches from producing false patch-owned drift warnings during `verify`.
|
|
7
12
|
- Fixed partial `re-export` manifest writes so legacy source metadata is preserved on unselected patch rows unless `--stamp` or another source metadata update explicitly targets them.
|
|
8
13
|
- Added regression coverage for targeted and full stamped re-export round-trips with blank context lines.
|
package/README.md
CHANGED
|
@@ -55,14 +55,32 @@ After setup you should have a `fireforge.json`, an `engine/` directory containin
|
|
|
55
55
|
npx fireforge status
|
|
56
56
|
npx fireforge export browser/base/content/browser.js --name custom-toolbar --category ui
|
|
57
57
|
npx fireforge re-export custom-toolbar
|
|
58
|
+
npx fireforge re-export --scan --scan-files generated-assets.json --dry-run
|
|
58
59
|
npx fireforge lint --per-patch
|
|
59
60
|
npx fireforge verify
|
|
61
|
+
npm run whitespace:check
|
|
60
62
|
npx fireforge build
|
|
61
63
|
npx fireforge test browser/base/content/test/browser/
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
Use `fireforge --help` for the full set of commands.
|
|
65
67
|
|
|
68
|
+
For large generated asset batches, `re-export --scan --scan-files <manifest>` assigns files to
|
|
69
|
+
their owner patches without broad directory scanning. The manifest is JSON:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"assignments": [
|
|
74
|
+
{ "patch": "002-branding-runtime-icons.patch", "files": ["browser/branding/hominis/icon.svg"] }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command is dry-runnable, refuses ambiguous ownership, and reports each file-to-patch
|
|
80
|
+
assignment before refreshing the patch. For release whitespace checks, use
|
|
81
|
+
`npm run whitespace:check`; it still checks source diffs while excluding generated
|
|
82
|
+
`patches/*.patch` diff syntax.
|
|
83
|
+
|
|
66
84
|
## Rebasing Firefox Source
|
|
67
85
|
|
|
68
86
|
When Mozilla publishes a new Firefox source release you need to update the configured version/product, download the new source code and reapply the patches:
|
|
@@ -77,27 +77,53 @@ function tailLines(text, maxLines) {
|
|
|
77
77
|
return lines.slice(-maxLines).join('\n');
|
|
78
78
|
}
|
|
79
79
|
function extractLastMakeError(captured) {
|
|
80
|
-
const lines = captured.split(/\r?\n/).filter((line) => /\
|
|
81
|
-
|
|
80
|
+
const lines = captured.split(/\r?\n/).filter((line) => /\bg?make(?:\[\d+\])?: \*\*\*/.test(line));
|
|
81
|
+
const actionable = lines.filter((line) => !/\[\s*(?:all|build|default)\s*\]\s+Error\s+\d+/i.test(line));
|
|
82
|
+
return (actionable.at(-1) ?? lines.at(-1))?.trim();
|
|
83
|
+
}
|
|
84
|
+
function isWarningOnlyLine(line) {
|
|
85
|
+
if (/\b(?:error|failed|fatal)\b/i.test(line))
|
|
86
|
+
return false;
|
|
87
|
+
return (/\bwarning\b/i.test(line) ||
|
|
88
|
+
/urllib3|LibreSSL|NotOpenSSLWarning|InsecurePlatformWarning/i.test(line));
|
|
89
|
+
}
|
|
90
|
+
function extractRecentMakeContext(captured) {
|
|
91
|
+
const lines = captured
|
|
92
|
+
.split(/\r?\n/)
|
|
93
|
+
.map((line) => line.trimEnd())
|
|
94
|
+
.filter((line) => line.trim().length > 0);
|
|
95
|
+
const makeLines = lines.filter((line) => /\b(?:g?make)(?:\[\d+\])?:/.test(line));
|
|
96
|
+
if (makeLines.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return makeLines.slice(-6).join('\n');
|
|
82
99
|
}
|
|
83
100
|
function extractLikelyFailingCommand(captured) {
|
|
84
101
|
const lines = captured
|
|
85
102
|
.split(/\r?\n/)
|
|
86
103
|
.map((line) => line.trim())
|
|
87
104
|
.filter((line) => line.length > 0);
|
|
105
|
+
let lastMakeErrorIndex = -1;
|
|
88
106
|
for (let index = lines.length - 1; index >= 0; index--) {
|
|
107
|
+
if (/\bg?make(?:\[\d+\])?: \*\*\*/.test(lines[index] ?? '')) {
|
|
108
|
+
lastMakeErrorIndex = index;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const startIndex = lastMakeErrorIndex > 0 ? lastMakeErrorIndex - 1 : lines.length - 1;
|
|
113
|
+
for (let index = startIndex; index >= 0; index--) {
|
|
89
114
|
const line = lines[index];
|
|
90
115
|
if (!line)
|
|
91
116
|
continue;
|
|
117
|
+
if (isWarningOnlyLine(line))
|
|
118
|
+
continue;
|
|
92
119
|
if (/^make(?:\[\d+\])?:/.test(line))
|
|
93
120
|
continue;
|
|
94
121
|
if (/^g?make(?:\[\d+\])?:/.test(line))
|
|
95
122
|
continue;
|
|
96
123
|
if (/^Error running mach:/.test(line))
|
|
97
124
|
continue;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (/\b(?:cp|clang|clang\+\+|rustc|python|node|make|install_name_tool)\b/.test(line)) {
|
|
125
|
+
const comparable = line.replace(/^\d+:\d+\.\d+\s+/, '');
|
|
126
|
+
if (/\b(?:cp|clang|clang\+\+|rustc|python|node|make|install_name_tool)\b/.test(comparable)) {
|
|
101
127
|
return line;
|
|
102
128
|
}
|
|
103
129
|
}
|
|
@@ -108,6 +134,7 @@ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
|
|
|
108
134
|
const stderrTail = tailLines(result.stderr, 20);
|
|
109
135
|
const combinedTail = tailLines(captured, 30);
|
|
110
136
|
const makeError = extractLastMakeError(captured);
|
|
137
|
+
const makeContext = extractRecentMakeContext(captured);
|
|
111
138
|
const failingCommand = extractLikelyFailingCommand(captured);
|
|
112
139
|
const logHint = objDir
|
|
113
140
|
? `engine/${objDir}/ (inspect build logs, warnings, and generated make targets under this objdir)`
|
|
@@ -119,6 +146,7 @@ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
|
|
|
119
146
|
`Build failed with exit code ${result.exitCode}.`,
|
|
120
147
|
`Mach phase: ${machCommand}`,
|
|
121
148
|
makeError ? `Last make error: ${makeError}` : undefined,
|
|
149
|
+
makeContext ? `Recent make context:\n${makeContext}` : undefined,
|
|
122
150
|
failingCommand ? `Final failing command/error line: ${failingCommand}` : undefined,
|
|
123
151
|
stderrTail ? `Captured stderr tail:\n${stderrTail}` : undefined,
|
|
124
152
|
`Captured output tail:\n${combinedTail}`,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PatchesManifest } from '../types/commands/index.js';
|
|
2
|
+
/** Loads and validates a `re-export --scan-files` assignment manifest. */
|
|
3
|
+
export declare function loadScanFilesAssignments(manifestPath: string, manifest: PatchesManifest): Promise<Map<string, string[]>>;
|
|
4
|
+
/** Serializes dry-run re-export git inspection against the same project tree. */
|
|
5
|
+
export declare function withDryRunReExportLock<T>(fireforgeDir: string, isDryRun: boolean, operation: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createSiblingLockPath, withFileLock } from '../core/file-lock.js';
|
|
4
|
+
import { resolvePatchIdentifier } from '../core/patch-manifest.js';
|
|
5
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
6
|
+
import { toError } from '../utils/errors.js';
|
|
7
|
+
import { readText } from '../utils/fs.js';
|
|
8
|
+
import { normalizeEngineRelativeInput } from './re-export-scan.js';
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function parseScanFilesManifest(raw, manifestPath) {
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new InvalidArgumentError(`--scan-files manifest is not valid JSON (${manifestPath}): ${toError(error).message}`, '--scan-files');
|
|
19
|
+
}
|
|
20
|
+
if (!isRecord(parsed) || !Array.isArray(parsed['assignments'])) {
|
|
21
|
+
throw new InvalidArgumentError('--scan-files manifest must contain an assignments array.', '--scan-files');
|
|
22
|
+
}
|
|
23
|
+
const assignments = [];
|
|
24
|
+
for (const [index, assignment] of parsed['assignments'].entries()) {
|
|
25
|
+
if (!isRecord(assignment)) {
|
|
26
|
+
throw new InvalidArgumentError(`--scan-files assignments[${index}] must be an object.`, '--scan-files');
|
|
27
|
+
}
|
|
28
|
+
const { patch, files } = assignment;
|
|
29
|
+
if (typeof patch !== 'string' || patch.trim().length === 0) {
|
|
30
|
+
throw new InvalidArgumentError(`--scan-files assignments[${index}].patch must be a non-empty string.`, '--scan-files');
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(files) ||
|
|
33
|
+
files.length === 0 ||
|
|
34
|
+
!files.every((file) => typeof file === 'string')) {
|
|
35
|
+
throw new InvalidArgumentError(`--scan-files assignments[${index}].files must be a non-empty string array.`, '--scan-files');
|
|
36
|
+
}
|
|
37
|
+
assignments.push({ patch, files });
|
|
38
|
+
}
|
|
39
|
+
if (assignments.length === 0) {
|
|
40
|
+
throw new InvalidArgumentError('--scan-files manifest must assign at least one file.', '--scan-files');
|
|
41
|
+
}
|
|
42
|
+
return { assignments };
|
|
43
|
+
}
|
|
44
|
+
/** Loads and validates a `re-export --scan-files` assignment manifest. */
|
|
45
|
+
export async function loadScanFilesAssignments(manifestPath, manifest) {
|
|
46
|
+
const parsed = parseScanFilesManifest(await readText(manifestPath), manifestPath);
|
|
47
|
+
const filesByPatch = new Map();
|
|
48
|
+
const ownerByFile = new Map();
|
|
49
|
+
for (const assignment of parsed.assignments) {
|
|
50
|
+
const patch = resolvePatchIdentifier(assignment.patch, manifest.patches);
|
|
51
|
+
if (!patch) {
|
|
52
|
+
const available = manifest.patches.map((entry) => entry.filename).join(', ');
|
|
53
|
+
throw new InvalidArgumentError(`--scan-files patch "${assignment.patch}" not found in manifest.\n\nAvailable patches: ${available}`, '--scan-files');
|
|
54
|
+
}
|
|
55
|
+
const patchFiles = filesByPatch.get(patch.filename) ?? new Set();
|
|
56
|
+
filesByPatch.set(patch.filename, patchFiles);
|
|
57
|
+
for (const rawFile of assignment.files) {
|
|
58
|
+
const file = normalizeEngineRelativeInput(rawFile, '--scan-files');
|
|
59
|
+
const previousOwner = ownerByFile.get(file);
|
|
60
|
+
if (previousOwner !== undefined && previousOwner !== patch.filename) {
|
|
61
|
+
throw new InvalidArgumentError(`--scan-files path is assigned to more than one patch: ${file} (${previousOwner}, ${patch.filename})`, '--scan-files');
|
|
62
|
+
}
|
|
63
|
+
ownerByFile.set(file, patch.filename);
|
|
64
|
+
patchFiles.add(file);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return new Map([...filesByPatch.entries()].map(([patchFilename, files]) => [patchFilename, [...files].sort()]));
|
|
68
|
+
}
|
|
69
|
+
/** Serializes dry-run re-export git inspection against the same project tree. */
|
|
70
|
+
export async function withDryRunReExportLock(fireforgeDir, isDryRun, operation) {
|
|
71
|
+
if (!isDryRun)
|
|
72
|
+
return operation();
|
|
73
|
+
const lockPath = createSiblingLockPath(join(fireforgeDir, 're-export-dry-run'), '.lock');
|
|
74
|
+
return withFileLock(lockPath, operation, {
|
|
75
|
+
timeoutMs: 24 * 60 * 60 * 1000,
|
|
76
|
+
onTimeoutMessage: `Timed out waiting for the FireForge re-export dry-run lock at ${lockPath}. ` +
|
|
77
|
+
'If no other `fireforge re-export --dry-run` is running, remove the lock directory and retry.',
|
|
78
|
+
onStaleLockMessage: (ageMs) => `Removing stale FireForge re-export dry-run lock (age: ${Math.round(ageMs / 1000)}s). A previous dry-run process may have crashed.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=re-export-bulk-scan.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
3
|
+
/** Validates mutually exclusive `re-export` targeting and metadata options. */
|
|
4
|
+
export function validateReExportOptionCombinations(patches, options) {
|
|
5
|
+
if (options.files !== undefined) {
|
|
6
|
+
if (options.all || options.scan) {
|
|
7
|
+
throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
|
|
8
|
+
}
|
|
9
|
+
if (options.scanFilesManifest !== undefined) {
|
|
10
|
+
throw new InvalidArgumentError('--files cannot be combined with --scan-files.', '--files');
|
|
11
|
+
}
|
|
12
|
+
if (patches.length !== 1) {
|
|
13
|
+
throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (options.scanFiles !== undefined) {
|
|
17
|
+
if (!options.scan)
|
|
18
|
+
throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
|
|
19
|
+
if (options.scanFilesManifest !== undefined) {
|
|
20
|
+
throw new InvalidArgumentError('--scan-file cannot be combined with --scan-files.', '--scan-file');
|
|
21
|
+
}
|
|
22
|
+
if (options.all || patches.length !== 1) {
|
|
23
|
+
throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (options.scanFilesManifest !== undefined) {
|
|
27
|
+
if (!options.scan)
|
|
28
|
+
throw new InvalidArgumentError('--scan-files requires --scan.', '--scan-files');
|
|
29
|
+
if (options.all || patches.length > 0) {
|
|
30
|
+
throw new InvalidArgumentError('--scan-files selects patches from its manifest and cannot be combined with positional patches or --all.', '--scan-files');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const usingTierFlag = options.tier !== undefined;
|
|
34
|
+
const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
|
|
35
|
+
if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
|
|
36
|
+
throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=re-export-options.js.map
|
|
@@ -6,6 +6,8 @@ export interface ScanResult {
|
|
|
6
6
|
}
|
|
7
7
|
/** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
|
|
8
8
|
export declare function normalizeScanFiles(scanFiles: readonly string[] | undefined): string[] | undefined;
|
|
9
|
+
/** Normalizes one CLI-provided path into a safe engine-relative path. */
|
|
10
|
+
export declare function normalizeEngineRelativeInput(rawPath: string, flagName: string): string;
|
|
9
11
|
/** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
|
|
10
12
|
export declare function scanPatchFilesForReExport(args: {
|
|
11
13
|
currentFilesAffected: string[];
|
|
@@ -17,7 +17,8 @@ export function normalizeScanFiles(scanFiles) {
|
|
|
17
17
|
.sort();
|
|
18
18
|
return normalized.length > 0 ? normalized : undefined;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
/** Normalizes one CLI-provided path into a safe engine-relative path. */
|
|
21
|
+
export function normalizeEngineRelativeInput(rawPath, flagName) {
|
|
21
22
|
const normalized = normalizePathSlashes(stripEnginePrefix(rawPath).trim());
|
|
22
23
|
if (normalized.length === 0) {
|
|
23
24
|
throw new InvalidArgumentError(`${flagName} requires a non-empty engine-relative path.`, flagName);
|
|
@@ -16,7 +16,9 @@ import { pathExists } from '../utils/fs.js';
|
|
|
16
16
|
import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
|
|
17
17
|
import { pickDefined } from '../utils/options.js';
|
|
18
18
|
import { runPatchLint } from './export-shared.js';
|
|
19
|
+
import { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
|
|
19
20
|
import { reExportFilesInPlace } from './re-export-files.js';
|
|
21
|
+
import { validateReExportOptionCombinations } from './re-export-options.js';
|
|
20
22
|
import { assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
|
|
21
23
|
async function findMissingFiles(engineDir, files) {
|
|
22
24
|
const missingFiles = [];
|
|
@@ -277,33 +279,7 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
277
279
|
}
|
|
278
280
|
const isDryRun = options.dryRun === true;
|
|
279
281
|
intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
|
|
280
|
-
|
|
281
|
-
// different scope contracts.
|
|
282
|
-
if (options.files !== undefined) {
|
|
283
|
-
if (options.all || options.scan) {
|
|
284
|
-
throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
|
|
285
|
-
}
|
|
286
|
-
if (patches.length !== 1) {
|
|
287
|
-
throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (options.scanFiles !== undefined) {
|
|
291
|
-
if (!options.scan) {
|
|
292
|
-
throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
|
|
293
|
-
}
|
|
294
|
-
if (options.all || patches.length !== 1) {
|
|
295
|
-
throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
// --tier and --lint-ignore are per-patch metadata edits; combining them
|
|
299
|
-
// with --all silently rewrites every patch's tier/ignore list, which is
|
|
300
|
-
// virtually always wrong (different patches have different shapes).
|
|
301
|
-
// Refuse the combination so the operator must enumerate the targets.
|
|
302
|
-
const usingTierFlag = options.tier !== undefined;
|
|
303
|
-
const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
|
|
304
|
-
if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
|
|
305
|
-
throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
|
|
306
|
-
}
|
|
282
|
+
validateReExportOptionCombinations(patches, options);
|
|
307
283
|
const paths = getProjectPaths(projectRoot);
|
|
308
284
|
// Check if engine exists
|
|
309
285
|
if (!(await pathExists(paths.engine))) {
|
|
@@ -318,8 +294,13 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
318
294
|
if (!manifest || manifest.patches.length === 0) {
|
|
319
295
|
throw new GeneralError('No patches found in manifest. Run "fireforge export" to create patches first.');
|
|
320
296
|
}
|
|
297
|
+
const scanFilesByPatch = options.scanFilesManifest !== undefined
|
|
298
|
+
? await loadScanFilesAssignments(options.scanFilesManifest, manifest)
|
|
299
|
+
: undefined;
|
|
321
300
|
// Resolve which patches to re-export
|
|
322
|
-
const selectedPatches =
|
|
301
|
+
const selectedPatches = scanFilesByPatch !== undefined
|
|
302
|
+
? manifest.patches.filter((patch) => scanFilesByPatch.has(patch.filename))
|
|
303
|
+
: await resolveSelectedPatches(patches, options, manifest);
|
|
323
304
|
if (!selectedPatches)
|
|
324
305
|
return;
|
|
325
306
|
if (selectedPatches.length === 0) {
|
|
@@ -327,6 +308,15 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
327
308
|
outro('Nothing to re-export');
|
|
328
309
|
return;
|
|
329
310
|
}
|
|
311
|
+
if (scanFilesByPatch !== undefined) {
|
|
312
|
+
info(`Bulk scan assignments from ${options.scanFilesManifest}`);
|
|
313
|
+
for (const patch of selectedPatches) {
|
|
314
|
+
const files = scanFilesByPatch.get(patch.filename) ?? [];
|
|
315
|
+
info(` ${patch.filename} <= ${files.length} file(s)`);
|
|
316
|
+
for (const file of files)
|
|
317
|
+
info(` + ${file}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
330
320
|
// --files path: handled end-to-end here so we can lint the *projected*
|
|
331
321
|
// shrunken state (not the current queue) and skip the generic re-export
|
|
332
322
|
// loop. The projection substitutes the target patch's diff and newFiles
|
|
@@ -335,7 +325,7 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
335
325
|
// we write anything.
|
|
336
326
|
if (options.files !== undefined) {
|
|
337
327
|
const filesConfig = await loadConfig(projectRoot);
|
|
338
|
-
await reExportFilesInPlace(paths, selectedPatches, options, filesConfig);
|
|
328
|
+
await withDryRunReExportLock(paths.fireforgeDir, isDryRun, () => reExportFilesInPlace(paths, selectedPatches, options, filesConfig));
|
|
339
329
|
return;
|
|
340
330
|
}
|
|
341
331
|
const config = await loadConfig(projectRoot);
|
|
@@ -343,20 +333,24 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
343
333
|
const reExportedFilenames = [];
|
|
344
334
|
const progress = spinner('Preparing re-export...');
|
|
345
335
|
const startedAt = Date.now();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
336
|
+
await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => {
|
|
337
|
+
for (const [index, patch] of selectedPatches.entries()) {
|
|
338
|
+
const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
|
|
339
|
+
const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
|
|
340
|
+
progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
|
|
341
|
+
try {
|
|
342
|
+
const exported = await reExportSinglePatch(patch, paths, manifest, patchOptions, isDryRun, config);
|
|
343
|
+
if (exported) {
|
|
344
|
+
reExported++;
|
|
345
|
+
reExportedFilenames.push(patch.filename);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
warn(`Failed to re-export ${patch.filename}`);
|
|
350
|
+
warn(toError(error).message);
|
|
353
351
|
}
|
|
354
352
|
}
|
|
355
|
-
|
|
356
|
-
warn(`Failed to re-export ${patch.filename}`);
|
|
357
|
-
warn(toError(error).message);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
353
|
+
});
|
|
360
354
|
if (reExported === 0 && selectedPatches.length > 0) {
|
|
361
355
|
progress.error('Re-export failed');
|
|
362
356
|
throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
|
|
@@ -402,6 +396,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
402
396
|
.option('-a, --all', 'Re-export all patches')
|
|
403
397
|
.option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
|
|
404
398
|
.option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
|
|
399
|
+
.option('--scan-files <manifest>', 'With --scan, bulk-assign generated files from a JSON manifest: {"assignments":[{"patch":"002-name.patch","files":["path"]}]}. Selects patches from the manifest.')
|
|
405
400
|
.option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
|
|
406
401
|
.split(',')
|
|
407
402
|
.map((v) => v.trim())
|
|
@@ -415,10 +410,11 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
415
410
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
416
411
|
.option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
|
|
417
412
|
.action(withErrorHandling(async (patches, options) => {
|
|
418
|
-
const { tier, lintIgnore, scanFile, ...rest } = options;
|
|
413
|
+
const { tier, lintIgnore, scanFile, scanFiles, ...rest } = options;
|
|
419
414
|
await reExportCommand(getProjectRoot(), patches, {
|
|
420
415
|
...pickDefined(rest),
|
|
421
416
|
...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
|
|
417
|
+
...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
|
|
422
418
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
423
419
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
424
420
|
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves and appends an xpcshell `--app-path=<abs>` argument when the
|
|
3
|
+
* selected manifest requests a browser appdir that rebranded forks otherwise
|
|
4
|
+
* fail to discover.
|
|
5
|
+
*/
|
|
6
|
+
export declare function maybeInjectAppdirArg(engineDir: string, normalizedPaths: readonly string[], objDir: string | undefined, extraArgs: string[]): Promise<boolean>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
3
|
+
import { info, warn } from '../utils/logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves and appends an xpcshell `--app-path=<abs>` argument when the
|
|
6
|
+
* selected manifest requests a browser appdir that rebranded forks otherwise
|
|
7
|
+
* fail to discover.
|
|
8
|
+
*/
|
|
9
|
+
export async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
|
|
10
|
+
if (!objDir)
|
|
11
|
+
return false;
|
|
12
|
+
if (operatorAlreadySetAppPath(extraArgs))
|
|
13
|
+
return false;
|
|
14
|
+
const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
|
|
15
|
+
switch (outcome.kind) {
|
|
16
|
+
case 'none':
|
|
17
|
+
return false;
|
|
18
|
+
case 'mismatch':
|
|
19
|
+
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.`);
|
|
20
|
+
return false;
|
|
21
|
+
case 'unresolved':
|
|
22
|
+
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.`);
|
|
23
|
+
return false;
|
|
24
|
+
case 'injected':
|
|
25
|
+
extraArgs.push(`--app-path=${outcome.result.appPath}`);
|
|
26
|
+
info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=test-appdir.js.map
|
|
@@ -5,15 +5,17 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
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
|
+
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, } from '../core/test-harness-output.js';
|
|
8
9
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
9
10
|
import { tryRepairStaleXpcshellTestSymlink } from '../core/test-stale-symlink.js';
|
|
10
|
-
import { findNearestXpcshellManifest
|
|
11
|
+
import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
|
|
11
12
|
import { GeneralError } from '../errors/base.js';
|
|
12
13
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
13
14
|
import { pathExists } from '../utils/fs.js';
|
|
14
15
|
import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
|
|
15
16
|
import { pickDefined } from '../utils/options.js';
|
|
16
17
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
18
|
+
import { maybeInjectAppdirArg } from './test-appdir.js';
|
|
17
19
|
async function assertTestPathsExist(engineDir, testPaths) {
|
|
18
20
|
const missingPaths = [];
|
|
19
21
|
for (const testPath of testPaths) {
|
|
@@ -193,6 +195,10 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
193
195
|
if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
|
|
194
196
|
throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
|
|
195
197
|
}
|
|
198
|
+
const earlyExit = classifyHarnessEarlyExit(combinedOutput, normalizedPaths);
|
|
199
|
+
if (earlyExit) {
|
|
200
|
+
throw new GeneralError(buildHarnessEarlyExitMessage(earlyExit, normalizedPaths));
|
|
201
|
+
}
|
|
196
202
|
// Fork-owned module load failures must beat the branding stale-build
|
|
197
203
|
// branch: 2026-04-21 eval (Finding #14) saw a fork's test fail with
|
|
198
204
|
// `Failed to load resource:///modules/mybrowser/MyBrowserStore.sys.mjs`
|
|
@@ -449,33 +455,6 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
449
455
|
}
|
|
450
456
|
handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
|
|
451
457
|
}
|
|
452
|
-
/**
|
|
453
|
-
* Resolves and (when applicable) appends an `--app-path=<abs>` arg to
|
|
454
|
-
* `extraArgs`. Returns true iff the arg was injected. The logging branches
|
|
455
|
-
* mirror the {@link XpcshellAppdirOutcome} variants so an operator can tell
|
|
456
|
-
* from the test output whether FireForge tried to help and what it found.
|
|
457
|
-
*/
|
|
458
|
-
async function maybeInjectAppdirArg(engineDir, normalizedPaths, objDir, extraArgs) {
|
|
459
|
-
if (!objDir)
|
|
460
|
-
return false;
|
|
461
|
-
if (operatorAlreadySetAppPath(extraArgs))
|
|
462
|
-
return false;
|
|
463
|
-
const outcome = await resolveXpcshellAppdirArg(engineDir, normalizedPaths, objDir);
|
|
464
|
-
switch (outcome.kind) {
|
|
465
|
-
case 'none':
|
|
466
|
-
return false;
|
|
467
|
-
case 'mismatch':
|
|
468
|
-
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.`);
|
|
469
|
-
return false;
|
|
470
|
-
case 'unresolved':
|
|
471
|
-
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.`);
|
|
472
|
-
return false;
|
|
473
|
-
case 'injected':
|
|
474
|
-
extraArgs.push(`--app-path=${outcome.result.appPath}`);
|
|
475
|
-
info(`xpcshell appdir auto-injected: --app-path=${outcome.result.appPath} (from ${outcome.result.manifestPath} firefox-appdir=${outcome.result.relativeAppdir}).`);
|
|
476
|
-
return true;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
458
|
/** Registers the test command on the CLI program. */
|
|
480
459
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
481
460
|
program
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Normalizes generated patch files before they are written to disk.
|
|
3
3
|
*
|
|
4
|
-
* Kept as a narrow chokepoint for future artifact-level fixes. It must
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Kept as a narrow chokepoint for future artifact-level fixes. It must keep
|
|
5
|
+
* unified-diff hunk markers intact, but marker-only blank payload lines should
|
|
6
|
+
* not carry extra trailing whitespace (`"+ "` / `"- "` / `" "`), because raw
|
|
7
|
+
* repository whitespace checks flag those generated patch artifact lines even
|
|
8
|
+
* when the engine diff is clean.
|
|
8
9
|
*/
|
|
9
10
|
export declare function normalizePatchArtifact(content: string): string;
|
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Normalizes generated patch files before they are written to disk.
|
|
4
4
|
*
|
|
5
|
-
* Kept as a narrow chokepoint for future artifact-level fixes. It must
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Kept as a narrow chokepoint for future artifact-level fixes. It must keep
|
|
6
|
+
* unified-diff hunk markers intact, but marker-only blank payload lines should
|
|
7
|
+
* not carry extra trailing whitespace (`"+ "` / `"- "` / `" "`), because raw
|
|
8
|
+
* repository whitespace checks flag those generated patch artifact lines even
|
|
9
|
+
* when the engine diff is clean.
|
|
9
10
|
*/
|
|
10
11
|
export function normalizePatchArtifact(content) {
|
|
11
|
-
return content
|
|
12
|
+
return content
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((line) => {
|
|
15
|
+
if (/^[ +-]\s+$/.test(line)) {
|
|
16
|
+
return line[0] ?? line;
|
|
17
|
+
}
|
|
18
|
+
return line;
|
|
19
|
+
})
|
|
20
|
+
.join('\n');
|
|
12
21
|
}
|
|
13
22
|
//# sourceMappingURL=patch-artifact-normalize.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type HarnessEarlyExitKind = 'startup' | 'zero-tests';
|
|
2
|
+
export interface HarnessEarlyExit {
|
|
3
|
+
kind: HarnessEarlyExitKind;
|
|
4
|
+
line: string;
|
|
5
|
+
}
|
|
6
|
+
/** Classifies mach output where no requested test actually began running. */
|
|
7
|
+
export declare function classifyHarnessEarlyExit(output: string, normalizedPaths: readonly string[]): HarnessEarlyExit | undefined;
|
|
8
|
+
/** Builds the user-facing message for a harness startup or zero-run failure. */
|
|
9
|
+
export declare function buildHarnessEarlyExitMessage(earlyExit: HarnessEarlyExit, normalizedPaths: readonly string[]): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
function getNonEmptyOutputLines(output) {
|
|
3
|
+
return output
|
|
4
|
+
.split(/\r?\n/)
|
|
5
|
+
.map((line) => line.trim())
|
|
6
|
+
.filter((line) => line.length > 0);
|
|
7
|
+
}
|
|
8
|
+
function findFirstMatchingLine(lines, patterns) {
|
|
9
|
+
return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
|
|
10
|
+
}
|
|
11
|
+
/** Classifies mach output where no requested test actually began running. */
|
|
12
|
+
export function classifyHarnessEarlyExit(output, normalizedPaths) {
|
|
13
|
+
const lines = getNonEmptyOutputLines(output);
|
|
14
|
+
const startupLine = findFirstMatchingLine(lines, [
|
|
15
|
+
/HominisBrowserUnavailableError/i,
|
|
16
|
+
/Marionette.*(?:session|startup|start).*fail/i,
|
|
17
|
+
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
18
|
+
/SessionNotCreatedException/i,
|
|
19
|
+
/Browser process exited during spawn/i,
|
|
20
|
+
]);
|
|
21
|
+
if (startupLine) {
|
|
22
|
+
return { kind: 'startup', line: startupLine };
|
|
23
|
+
}
|
|
24
|
+
if (normalizedPaths.length === 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
const zeroTestsLine = findFirstMatchingLine(lines, [
|
|
27
|
+
/\bRan 0 tests?\b/i,
|
|
28
|
+
/\b0 tests? ran\b/i,
|
|
29
|
+
/\b0 tests? selected\b/i,
|
|
30
|
+
/\b0 subtests\b/i,
|
|
31
|
+
/\bno tests (?:were )?(?:run|ran|selected|collected|found)\b/i,
|
|
32
|
+
]);
|
|
33
|
+
if (zeroTestsLine) {
|
|
34
|
+
return { kind: 'zero-tests', line: zeroTestsLine };
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Builds the user-facing message for a harness startup or zero-run failure. */
|
|
39
|
+
export function buildHarnessEarlyExitMessage(earlyExit, normalizedPaths) {
|
|
40
|
+
const reason = earlyExit.kind === 'startup'
|
|
41
|
+
? 'The test harness failed during browser/session startup before the selected tests began.'
|
|
42
|
+
: 'The test harness exited after reporting that zero selected tests ran.';
|
|
43
|
+
const paths = normalizedPaths.length > 0 ? normalizedPaths.join(', ') : '(all tests)';
|
|
44
|
+
return (`mach test did not run the selected tests.\n\n` +
|
|
45
|
+
`${reason}\n\n` +
|
|
46
|
+
`Actionable harness line: ${earlyExit.line}\n\n` +
|
|
47
|
+
`Requested paths: ${paths}\n\n` +
|
|
48
|
+
'Fix the harness startup/discovery issue above before interpreting this as a test failure.');
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=test-harness-output.js.map
|
|
@@ -183,6 +183,13 @@ export interface ReExportOptions {
|
|
|
183
183
|
* Requires `--scan` and exactly one target patch.
|
|
184
184
|
*/
|
|
185
185
|
scanFiles?: string[];
|
|
186
|
+
/**
|
|
187
|
+
* Path to a JSON manifest containing bulk scan assignments:
|
|
188
|
+
* `{ "assignments": [{ "patch": "<patch>", "files": ["path"] }] }`.
|
|
189
|
+
* Requires `--scan`; mutually exclusive with positional patches, `--all`,
|
|
190
|
+
* `--scan-file`, and `--files`.
|
|
191
|
+
*/
|
|
192
|
+
scanFilesManifest?: string;
|
|
186
193
|
/**
|
|
187
194
|
* Restrict the re-exported patch's filesAffected to this explicit list.
|
|
188
195
|
* Files currently in the patch but not in this list are dropped (shrink);
|