@hominis/fireforge 0.37.0 → 0.39.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 +33 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +13 -8
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +6 -2
- package/dist/src/commands/export-flow.js +17 -11
- package/dist/src/commands/export-placement-gate.js +4 -6
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export-shared.js +17 -2
- package/dist/src/commands/export.js +5 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +15 -5
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +74 -15
- package/dist/src/commands/patch/rename.js +4 -1
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +17 -6
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +17 -3
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +8 -2
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +52 -81
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +41 -0
- package/dist/src/core/build-baseline.d.ts +6 -1
- package/dist/src/core/build-baseline.js +48 -7
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +14 -1
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/license-headers.d.ts +24 -0
- package/dist/src/core/license-headers.js +103 -1
- package/dist/src/core/patch-export.d.ts +10 -1
- package/dist/src/core/patch-export.js +12 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
- package/dist/src/core/patch-lint-checkjs.js +30 -14
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +29 -8
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +56 -1
- package/dist/src/core/test-stale-check.js +114 -4
- package/dist/src/core/token-manager.js +39 -2
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/typecheck-shim.d.ts +13 -0
- package/dist/src/core/typecheck-shim.js +17 -2
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/types/commands/options.d.ts +69 -3
- package/dist/src/types/config.d.ts +22 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -4,7 +4,7 @@ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock
|
|
|
4
4
|
import { resolveArchive } from '../core/firefox-archive.js';
|
|
5
5
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
6
6
|
import { info, intro, outro, success } from '../utils/logger.js';
|
|
7
|
-
import { isValidFirefoxProduct } from '../utils/validation.js';
|
|
7
|
+
import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
|
|
8
8
|
const SOURCE_PRODUCTS = [
|
|
9
9
|
'firefox',
|
|
10
10
|
'firefox-esr',
|
|
@@ -27,6 +27,12 @@ function parseSourceProduct(product) {
|
|
|
27
27
|
}
|
|
28
28
|
throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
|
|
29
29
|
}
|
|
30
|
+
function parseSourceCandidate(candidate) {
|
|
31
|
+
if (isValidFirefoxCandidate(candidate)) {
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
throw new InvalidArgumentError(`--candidate must look like "buildN" (e.g. "build2"), got "${candidate}"`, '--candidate');
|
|
35
|
+
}
|
|
30
36
|
/**
|
|
31
37
|
* Atomically updates the Firefox source tuple in fireforge.json.
|
|
32
38
|
*/
|
|
@@ -38,6 +44,9 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
38
44
|
if (options.sha256 !== undefined && options.clearSha256 === true) {
|
|
39
45
|
throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
|
|
40
46
|
}
|
|
47
|
+
if (options.candidate !== undefined && options.clearCandidate === true) {
|
|
48
|
+
throw new InvalidArgumentError('--candidate cannot be combined with --clear-candidate', '--candidate');
|
|
49
|
+
}
|
|
41
50
|
const written = await withConfigFileLock(projectRoot, async () => {
|
|
42
51
|
const raw = await loadRawConfigDocument(projectRoot);
|
|
43
52
|
const updated = cloneRawConfig(raw);
|
|
@@ -50,6 +59,12 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
50
59
|
else if (options.sha256 !== undefined) {
|
|
51
60
|
firefox['sha256'] = options.sha256;
|
|
52
61
|
}
|
|
62
|
+
if (options.clearCandidate === true) {
|
|
63
|
+
delete firefox['candidate'];
|
|
64
|
+
}
|
|
65
|
+
else if (options.candidate !== undefined) {
|
|
66
|
+
firefox['candidate'] = options.candidate;
|
|
67
|
+
}
|
|
53
68
|
updated['firefox'] = firefox;
|
|
54
69
|
const validated = validateConfig(updated);
|
|
55
70
|
if (validated.firefox.sha256 !== undefined) {
|
|
@@ -58,7 +73,7 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
58
73
|
await writeConfigDocument(projectRoot, updated);
|
|
59
74
|
return validated.firefox;
|
|
60
75
|
});
|
|
61
|
-
const archive = resolveArchive(written.version, written.product);
|
|
76
|
+
const archive = resolveArchive(written.version, written.product, written.candidate);
|
|
62
77
|
success(`Set firefox.version = ${written.version}`);
|
|
63
78
|
success(`Set firefox.product = ${written.product}`);
|
|
64
79
|
success(`Resolved source URL: ${archive.url}`);
|
|
@@ -68,6 +83,12 @@ export async function sourceSetCommand(projectRoot, options) {
|
|
|
68
83
|
else if (options.clearSha256 === true) {
|
|
69
84
|
info('Cleared firefox.sha256');
|
|
70
85
|
}
|
|
86
|
+
if (written.candidate !== undefined) {
|
|
87
|
+
success(`Set firefox.candidate = ${written.candidate}`);
|
|
88
|
+
}
|
|
89
|
+
else if (options.clearCandidate === true) {
|
|
90
|
+
info('Cleared firefox.candidate');
|
|
91
|
+
}
|
|
71
92
|
outro('');
|
|
72
93
|
}
|
|
73
94
|
/** Registers the source command on the CLI program. */
|
|
@@ -82,13 +103,17 @@ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
|
|
|
82
103
|
.makeOptionMandatory())
|
|
83
104
|
.option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
|
|
84
105
|
.option('--clear-sha256', 'Clear any existing pinned SHA-256')
|
|
106
|
+
.option('--candidate <buildN>', 'Release-candidate build directory (e.g. "build2")', parseSourceCandidate)
|
|
107
|
+
.option('--clear-candidate', 'Clear any existing release-candidate build directory')
|
|
85
108
|
.action(withErrorHandling(async (options) => {
|
|
86
|
-
const { product, version, sha256, clearSha256 } = options;
|
|
109
|
+
const { product, version, sha256, clearSha256, candidate, clearCandidate } = options;
|
|
87
110
|
await sourceSetCommand(getProjectRoot(), {
|
|
88
111
|
version,
|
|
89
112
|
product: parseSourceProduct(product),
|
|
90
113
|
...(sha256 !== undefined ? { sha256 } : {}),
|
|
91
114
|
...(clearSha256 !== undefined ? { clearSha256 } : {}),
|
|
115
|
+
...(candidate !== undefined ? { candidate } : {}),
|
|
116
|
+
...(clearCandidate !== undefined ? { clearCandidate } : {}),
|
|
92
117
|
});
|
|
93
118
|
}));
|
|
94
119
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BuildBaseline } from '../core/build-baseline-types.js';
|
|
1
2
|
import type { ClassifiedFile } from '../core/status-classify.js';
|
|
2
3
|
export interface ClassifiedBuckets {
|
|
3
4
|
conflict: ClassifiedFile[];
|
|
@@ -11,3 +12,11 @@ export interface ClassifiedBuckets {
|
|
|
11
12
|
export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
|
|
12
13
|
/** Renders the default classified status buckets. */
|
|
13
14
|
export declare function renderDefaultStatus(totalModified: number, buckets: ClassifiedBuckets, projectRoot: string, binaryName: string): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
|
|
17
|
+
* of the last build baseline's test-packaging coverage. Before this
|
|
18
|
+
* existed, the only way to learn the recorded coverage scope — which
|
|
19
|
+
* concurrent sessions sharing one engine tree overwrite constantly — was
|
|
20
|
+
* to trip the out-of-coverage refusal on a real test run.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderTestCoverageStatus(baseline: BuildBaseline | undefined): void;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// SPDX-License-Identifier: EUPL-1.2
|
|
2
1
|
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
3
2
|
import { getPrimaryStatusCode } from '../core/status-classify.js';
|
|
4
3
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -182,3 +181,36 @@ export async function renderDefaultStatus(totalModified, buckets, projectRoot, b
|
|
|
182
181
|
.map((section) => `${section.files.length} ${section.label}`);
|
|
183
182
|
outro(parts.join(', '));
|
|
184
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Renders `fireforge status --test-coverage` (FORGE F11): a READ-ONLY view
|
|
186
|
+
* of the last build baseline's test-packaging coverage. Before this
|
|
187
|
+
* existed, the only way to learn the recorded coverage scope — which
|
|
188
|
+
* concurrent sessions sharing one engine tree overwrite constantly — was
|
|
189
|
+
* to trip the out-of-coverage refusal on a real test run.
|
|
190
|
+
*/
|
|
191
|
+
export function renderTestCoverageStatus(baseline) {
|
|
192
|
+
if (baseline === undefined) {
|
|
193
|
+
info('No build baseline recorded (.fireforge/last-build.json missing or unreadable).\n' +
|
|
194
|
+
'Run "fireforge build" to record one.');
|
|
195
|
+
outro('No test-packaging coverage recorded');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
info(`Last build: ${baseline.builtAt} (binary: ${baseline.binaryName}, ` +
|
|
199
|
+
`engine HEAD: ${baseline.engineHeadSha || '(unborn)'})`);
|
|
200
|
+
info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}`);
|
|
201
|
+
const coverage = baseline.testPackagingCoverage;
|
|
202
|
+
if (coverage === undefined) {
|
|
203
|
+
info('Test packaging coverage: full (implicit — baseline predates coverage recording)');
|
|
204
|
+
outro('Coverage: full');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (coverage === 'full') {
|
|
208
|
+
info('Test packaging coverage: full');
|
|
209
|
+
outro('Coverage: full');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const list = coverage.map((path) => ` - ${path}`).join('\n');
|
|
213
|
+
info(`Test packaging coverage: scoped to ${String(coverage.length)} path(s):\n${list}\n` +
|
|
214
|
+
'A "fireforge test" over paths outside this list will be refused as uncovered.');
|
|
215
|
+
outro(`Coverage: scoped (${String(coverage.length)} paths)`);
|
|
216
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { readBuildBaseline } from '../core/build-baseline.js';
|
|
3
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
5
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
5
6
|
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
@@ -12,7 +13,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
|
|
|
12
13
|
import { ExitCode } from '../errors/codes.js';
|
|
13
14
|
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
14
15
|
import { info, intro, isMachineOutputMode, outro, setMachineOutputMode, warn, } from '../utils/logger.js';
|
|
15
|
-
import { renderDefaultStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
16
|
+
import { renderDefaultStatus, renderTestCoverageStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
16
17
|
/**
|
|
17
18
|
* Renders raw worktree status as machine-parseable porcelain-style output.
|
|
18
19
|
* Each line is: STATUS<tab>FILE
|
|
@@ -170,13 +171,25 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
170
171
|
setMachineOutputMode(true);
|
|
171
172
|
}
|
|
172
173
|
try {
|
|
173
|
-
const modeCount = [
|
|
174
|
+
const modeCount = [
|
|
175
|
+
options.raw,
|
|
176
|
+
options.unmanaged,
|
|
177
|
+
options.ownership,
|
|
178
|
+
options.testCoverage,
|
|
179
|
+
options.json,
|
|
180
|
+
].filter((v) => v === true).length;
|
|
174
181
|
if (modeCount > 1) {
|
|
175
|
-
throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, and --json together. Pick at most one.');
|
|
182
|
+
throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
|
|
176
183
|
}
|
|
177
184
|
if (!options.raw && !options.json) {
|
|
178
185
|
intro('FireForge Status');
|
|
179
186
|
}
|
|
187
|
+
// Test-coverage mode needs only the project root (the baseline lives
|
|
188
|
+
// in .fireforge/), so it short-circuits before any engine/git guards.
|
|
189
|
+
if (options.testCoverage) {
|
|
190
|
+
renderTestCoverageStatus(await readBuildBaseline(projectRoot));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
180
193
|
const paths = getProjectPaths(projectRoot);
|
|
181
194
|
const config = await loadConfig(projectRoot);
|
|
182
195
|
const emitJsonError = (code, message) => {
|
|
@@ -309,6 +322,7 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
|
|
|
309
322
|
.option('--raw', 'Show raw worktree status without patch classification')
|
|
310
323
|
.option('--unmanaged', 'Show only unmanaged changes (not covered by patches or tools)')
|
|
311
324
|
.option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
|
|
325
|
+
.option('--test-coverage', 'Show what the last recorded build covers for test packaging (full or scoped paths)')
|
|
312
326
|
.option('--json', 'Output classified file status as JSON')
|
|
313
327
|
.action(withErrorHandling(async (options) => {
|
|
314
328
|
await statusCommand(getProjectRoot(), options);
|
|
@@ -14,7 +14,7 @@ import type { TestRunOutcome } from './test-run.js';
|
|
|
14
14
|
* harness problems with their own messages; everything else flows into
|
|
15
15
|
* the regular non-zero-exit diagnosis chain.
|
|
16
16
|
*/
|
|
17
|
-
export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined): void;
|
|
17
|
+
export declare function finalizeSingleRunOutcome(outcome: TestRunOutcome, normalizedPaths: string[], binaryName: string, postRebuildContext: PostRebuildFailureContext | undefined, headless?: boolean): void;
|
|
18
18
|
/**
|
|
19
19
|
* Shard-mode adapter over {@link handleNonZeroTestExit}: produces the
|
|
20
20
|
* diagnosis text as a string (to warn per shard) instead of throwing, so
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* `test-harness-crash.ts` for single and sharded invocations. Split out of
|
|
8
8
|
* `test.ts` to keep both files within the per-file line budget.
|
|
9
9
|
*/
|
|
10
|
-
import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, } from '../core/test-harness-crash.js';
|
|
10
|
+
import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
11
11
|
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { BuildError } from '../errors/build.js';
|
|
14
14
|
import { info } from '../utils/logger.js';
|
|
15
|
+
import { getPlatform } from '../utils/platform.js';
|
|
15
16
|
function buildUnknownTestMessage(testPaths) {
|
|
16
17
|
return (`mach could not discover the requested test path${testPaths.length === 1 ? '' : 's'}: ${testPaths.join(', ')}\n\n` +
|
|
17
18
|
'The file may exist, but Firefox does not currently resolve it as a runnable test.\n\n' +
|
|
@@ -179,9 +180,14 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
179
180
|
* harness problems with their own messages; everything else flows into
|
|
180
181
|
* the regular non-zero-exit diagnosis chain.
|
|
181
182
|
*/
|
|
182
|
-
export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext) {
|
|
183
|
+
export function finalizeSingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuildContext, headless = false) {
|
|
183
184
|
if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
|
|
184
|
-
|
|
185
|
+
const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
|
|
186
|
+
const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
|
|
187
|
+
headless,
|
|
188
|
+
platform: getPlatform(),
|
|
189
|
+
});
|
|
190
|
+
throw new GeneralError(hint ? `${base}\n\n${hint}` : base);
|
|
185
191
|
}
|
|
186
192
|
if (outcome.verdict.kind === 'tests-ran-ok') {
|
|
187
193
|
// The verdict is authoritative over the raw exit code: a completed
|
|
@@ -12,4 +12,25 @@ export declare function resolveCanaryPath(options: TestOptions, projectConfig: P
|
|
|
12
12
|
export declare function canaryTimeoutSeconds(projectConfig: ProjectConfig): number;
|
|
13
13
|
/** Emits or throws the one-word canary verdict. */
|
|
14
14
|
export declare function reportCanaryOutcome(outcome: TestRunOutcome): void;
|
|
15
|
+
/** Per-harness bucketing of the requested test paths. */
|
|
16
|
+
export interface HarnessClassification {
|
|
17
|
+
xpcshell: string[];
|
|
18
|
+
nonXpcshell: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Harness classification is a pure path→manifest lookup and must precede
|
|
22
|
+
* the build dispatch: a mixed xpcshell+mochitest request can never
|
|
23
|
+
* dispatch, so refusing it before spending minutes in a pre-test build
|
|
24
|
+
* is strictly better (FORGE F7). A nonexistent path classifies as
|
|
25
|
+
* nonXpcshell (findNearestXpcshellManifest returns null), so classifying
|
|
26
|
+
* before assertTestPathsExist is harmless — existence is still asserted
|
|
27
|
+
* after the stale/coverage gates, preserving their precedence over
|
|
28
|
+
* missing-path errors. `xpcshellOnly` (non-empty request, zero
|
|
29
|
+
* nonXpcshell paths) gates the Marionette preflight and the mochitest
|
|
30
|
+
* client flags (FORGE F10); pathless runs keep the full-suite behavior.
|
|
31
|
+
*/
|
|
32
|
+
export declare function classifyBeforeDispatch(engineDir: string, normalizedPaths: string[]): Promise<{
|
|
33
|
+
classification: HarnessClassification;
|
|
34
|
+
xpcshellOnly: boolean;
|
|
35
|
+
}>;
|
|
15
36
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { loadConfig } from '../core/config.js';
|
|
3
|
+
import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
|
|
3
4
|
import { GeneralError } from '../errors/base.js';
|
|
4
5
|
import { success } from '../utils/logger.js';
|
|
5
6
|
/** Rejects pathless `fireforge test` unless the operator selected a pathless mode. */
|
|
@@ -59,3 +60,44 @@ function buildPathlessTestMessage() {
|
|
|
59
60
|
' - `fireforge test --doctor` to run the Marionette preflight only\n' +
|
|
60
61
|
' - `fireforge test --canary` to run the configured short harness canary');
|
|
61
62
|
}
|
|
63
|
+
async function classifyTestHarnesses(engineDir, normalizedPaths) {
|
|
64
|
+
const result = { xpcshell: [], nonXpcshell: [] };
|
|
65
|
+
for (const testPath of normalizedPaths) {
|
|
66
|
+
const manifest = await findNearestXpcshellManifest(engineDir, testPath);
|
|
67
|
+
if (manifest) {
|
|
68
|
+
result.xpcshell.push(testPath);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result.nonXpcshell.push(testPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function buildMixedHarnessMessage(classification) {
|
|
77
|
+
return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
|
|
78
|
+
'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
|
|
79
|
+
` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
|
|
80
|
+
` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Harness classification is a pure path→manifest lookup and must precede
|
|
84
|
+
* the build dispatch: a mixed xpcshell+mochitest request can never
|
|
85
|
+
* dispatch, so refusing it before spending minutes in a pre-test build
|
|
86
|
+
* is strictly better (FORGE F7). A nonexistent path classifies as
|
|
87
|
+
* nonXpcshell (findNearestXpcshellManifest returns null), so classifying
|
|
88
|
+
* before assertTestPathsExist is harmless — existence is still asserted
|
|
89
|
+
* after the stale/coverage gates, preserving their precedence over
|
|
90
|
+
* missing-path errors. `xpcshellOnly` (non-empty request, zero
|
|
91
|
+
* nonXpcshell paths) gates the Marionette preflight and the mochitest
|
|
92
|
+
* client flags (FORGE F10); pathless runs keep the full-suite behavior.
|
|
93
|
+
*/
|
|
94
|
+
export async function classifyBeforeDispatch(engineDir, normalizedPaths) {
|
|
95
|
+
const classification = await classifyTestHarnesses(engineDir, normalizedPaths);
|
|
96
|
+
if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
|
|
97
|
+
throw new GeneralError(buildMixedHarnessMessage(classification));
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
classification,
|
|
101
|
+
xpcshellOnly: normalizedPaths.length > 0 && classification.nonXpcshell.length === 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
4
4
|
import { GeneralError } from '../errors/base.js';
|
|
5
|
-
import { commanderArgParser, pickDefined } from '../utils/options.js';
|
|
5
|
+
import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
|
|
6
6
|
import { testCommand } from './test.js';
|
|
7
7
|
import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
|
|
8
8
|
/** Registers the test command on the CLI program. */
|
|
9
9
|
export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
10
|
-
program
|
|
10
|
+
const test = program
|
|
11
11
|
.command('test [paths...]')
|
|
12
12
|
.description('Run tests via mach test')
|
|
13
13
|
.option('--headless', 'Run tests in headless mode')
|
|
14
14
|
.option('--build', 'Run incremental UI build before testing')
|
|
15
15
|
.option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
|
|
16
16
|
.option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
|
|
17
|
+
.option('--allow-stale-components', 'Run tests despite components.conf changes that only a full "fireforge build" compiles in (the packaged child process will resolve the OLD StaticComponents table)')
|
|
17
18
|
.option('--kill-stale-marionette', 'Terminate a recognized stale browser process holding the Marionette port before running tests')
|
|
18
19
|
.option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
|
|
19
20
|
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
@@ -56,9 +57,9 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
56
57
|
'runs (one browser instance per argument) by default, which does',
|
|
57
58
|
'not exercise cross-argument state; --no-shard restores the',
|
|
58
59
|
'combined single-instance invocation.',
|
|
59
|
-
].join('\n'))
|
|
60
|
-
|
|
60
|
+
].join('\n'));
|
|
61
|
+
addWaitLockOption(test).action(withErrorHandling(async (paths, options) => {
|
|
61
62
|
const projectRoot = getProjectRoot();
|
|
62
|
-
await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)));
|
|
63
|
+
await withEngineSessionLock(projectRoot, 'test', () => testCommand(projectRoot, paths, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
63
64
|
}));
|
|
64
65
|
}
|
|
@@ -36,6 +36,8 @@ export interface TestRunContext {
|
|
|
36
36
|
baseExtraArgs: readonly string[];
|
|
37
37
|
/** Bounded harness-crash retry budget (0 disables retries). */
|
|
38
38
|
harnessRetries: number;
|
|
39
|
+
/** Whether --headless was requested (crash-hint copy only). */
|
|
40
|
+
headless: boolean;
|
|
39
41
|
/** Extra environment variables for the mach process. */
|
|
40
42
|
env?: Record<string, string>;
|
|
41
43
|
}
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* combined invocation stays available via `--no-shard`.
|
|
13
13
|
*/
|
|
14
14
|
import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
|
|
15
|
-
import { buildHarnessCrashMessage, classifyHarnessRun, } from '../core/test-harness-crash.js';
|
|
15
|
+
import { buildHarnessCrashMessage, classifyHarnessRun, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
16
16
|
import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
|
|
17
17
|
import { BuildError } from '../errors/build.js';
|
|
18
18
|
import { info, note, warn } from '../utils/logger.js';
|
|
19
|
+
import { getPlatform } from '../utils/platform.js';
|
|
19
20
|
import { maybeInjectAppdirArg } from './test-appdir.js';
|
|
20
21
|
/** Default bounded retry budget for recognized harness crashes. */
|
|
21
22
|
export const DEFAULT_HARNESS_RETRIES = 2;
|
|
@@ -78,7 +79,12 @@ export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
|
|
|
78
79
|
const outcome = await runTestsWithRetries(ctx, group.paths);
|
|
79
80
|
shards.push({ label: group.label, outcome });
|
|
80
81
|
if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
|
|
81
|
-
|
|
82
|
+
const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
|
|
83
|
+
const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
|
|
84
|
+
headless: ctx.headless,
|
|
85
|
+
platform: getPlatform(),
|
|
86
|
+
});
|
|
87
|
+
warn(hint ? `${base}\n\n${hint}` : base);
|
|
82
88
|
}
|
|
83
89
|
else if (outcome.verdict.kind !== 'tests-ran-ok') {
|
|
84
90
|
const diagnosis = diagnoseShardFailure(outcome, group.label);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-dispatch gates for `fireforge test` that stand between the request
|
|
3
|
+
* and a run against a stale or under-packaged runtime: the packaging
|
|
4
|
+
* coverage refusal, the stale-content refusal, and the compiled
|
|
5
|
+
* StaticComponents refusal. Split out of `test.ts` so the command module
|
|
6
|
+
* stays wiring; the probes and refusal copy live in
|
|
7
|
+
* `src/core/test-stale-check.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import type { BuildBaseline } from '../core/build-baseline-types.js';
|
|
10
|
+
import type { TestOptions } from '../types/commands/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* Stale-build preflight — when `--build` was NOT requested, detect
|
|
13
|
+
* packageable engine edits since the last successful build and fail
|
|
14
|
+
* UP-FRONT unless the operator explicitly accepts the stale package risk.
|
|
15
|
+
*
|
|
16
|
+
* Packaging COVERAGE is checked first, on EVERY non-`--build` run and
|
|
17
|
+
* regardless of staleness or `--allow-stale-build`: a runtime packaged by
|
|
18
|
+
* a file-scoped `test --build` can lack support fixtures for OTHER
|
|
19
|
+
* manifests even when nothing changed since — dispatching such a run
|
|
20
|
+
* hangs on missing fixtures rather than failing, so the flag (which only
|
|
21
|
+
* accepts stale CONTENT) must not be the trigger. Field incident: a
|
|
22
|
+
* three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
|
|
23
|
+
* later run over different files timed out twice at 45s waiting on
|
|
24
|
+
* `DOMAudioPlaybackStarted`.
|
|
25
|
+
*
|
|
26
|
+
* Exception: a path-less `test --doctor` stops at the Marionette health
|
|
27
|
+
* check (`runDoctorPreflight` returns 'stop' when no test paths were
|
|
28
|
+
* given) and never dispatches a test, so it needs no packaging coverage —
|
|
29
|
+
* treating it as a full-suite request would refuse a probe that touches
|
|
30
|
+
* no fixtures. The stale-content refusal still applies to it unchanged.
|
|
31
|
+
*/
|
|
32
|
+
export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[]): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Compiled-StaticComponents gate — refuses runs whose child process would
|
|
35
|
+
* resolve a stale compiled component table. `components.conf` entries bake
|
|
36
|
+
* into compiled code that only a FULL build regenerates; a scoped
|
|
37
|
+
* `test --build` repackages the file but the failure surfaces as
|
|
38
|
+
* `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
|
|
39
|
+
* (after the coverage refusal) AND to scoped `test --build` runs (before
|
|
40
|
+
* the pre-test build — that build cannot fix the table). A path-less
|
|
41
|
+
* `test --build` is exempt: its full build refreshes the anchor itself.
|
|
42
|
+
* `--allow-stale-components` downgrades the refusal to a warning.
|
|
43
|
+
*/
|
|
44
|
+
export declare function enforceStaticComponentsGate(engineDir: string, baseline: BuildBaseline | undefined, options: TestOptions): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Pre-dispatch gates for `fireforge test` that stand between the request
|
|
4
|
+
* and a run against a stale or under-packaged runtime: the packaging
|
|
5
|
+
* coverage refusal, the stale-content refusal, and the compiled
|
|
6
|
+
* StaticComponents refusal. Split out of `test.ts` so the command module
|
|
7
|
+
* stays wiring; the probes and refusal copy live in
|
|
8
|
+
* `src/core/test-stale-check.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import { checkStaleBuildForTest, checkStaticComponentsStale, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
|
|
11
|
+
import { GeneralError } from '../errors/base.js';
|
|
12
|
+
import { warn } from '../utils/logger.js';
|
|
13
|
+
/**
|
|
14
|
+
* Stale-build preflight — when `--build` was NOT requested, detect
|
|
15
|
+
* packageable engine edits since the last successful build and fail
|
|
16
|
+
* UP-FRONT unless the operator explicitly accepts the stale package risk.
|
|
17
|
+
*
|
|
18
|
+
* Packaging COVERAGE is checked first, on EVERY non-`--build` run and
|
|
19
|
+
* regardless of staleness or `--allow-stale-build`: a runtime packaged by
|
|
20
|
+
* a file-scoped `test --build` can lack support fixtures for OTHER
|
|
21
|
+
* manifests even when nothing changed since — dispatching such a run
|
|
22
|
+
* hangs on missing fixtures rather than failing, so the flag (which only
|
|
23
|
+
* accepts stale CONTENT) must not be the trigger. Field incident: a
|
|
24
|
+
* three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
|
|
25
|
+
* later run over different files timed out twice at 45s waiting on
|
|
26
|
+
* `DOMAudioPlaybackStarted`.
|
|
27
|
+
*
|
|
28
|
+
* Exception: a path-less `test --doctor` stops at the Marionette health
|
|
29
|
+
* check (`runDoctorPreflight` returns 'stop' when no test paths were
|
|
30
|
+
* given) and never dispatches a test, so it needs no packaging coverage —
|
|
31
|
+
* treating it as a full-suite request would refuse a probe that touches
|
|
32
|
+
* no fixtures. The stale-content refusal still applies to it unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
|
|
35
|
+
const stale = await checkStaleBuildForTest(projectRoot, engineDir);
|
|
36
|
+
const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
|
|
37
|
+
if (!dispatchesNoTests) {
|
|
38
|
+
const recordedCoverage = stale.baseline?.testPackagingCoverage;
|
|
39
|
+
const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
|
|
40
|
+
if (uncovered.length > 0) {
|
|
41
|
+
throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await enforceStaticComponentsGate(engineDir, stale.baseline, options);
|
|
45
|
+
const staleMessage = stale.stale
|
|
46
|
+
? `${formatStaleBuildWarning(stale)}\n\n` +
|
|
47
|
+
'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
|
|
48
|
+
'`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
|
|
49
|
+
: undefined;
|
|
50
|
+
if (staleMessage !== undefined) {
|
|
51
|
+
if (options.allowStaleBuild === true) {
|
|
52
|
+
warn(staleMessage);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
throw new GeneralError(staleMessage);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compiled-StaticComponents gate — refuses runs whose child process would
|
|
61
|
+
* resolve a stale compiled component table. `components.conf` entries bake
|
|
62
|
+
* into compiled code that only a FULL build regenerates; a scoped
|
|
63
|
+
* `test --build` repackages the file but the failure surfaces as
|
|
64
|
+
* `NS_ERROR_MALFORMED_URI` inside the test. Applies to build-less runs
|
|
65
|
+
* (after the coverage refusal) AND to scoped `test --build` runs (before
|
|
66
|
+
* the pre-test build — that build cannot fix the table). A path-less
|
|
67
|
+
* `test --build` is exempt: its full build refreshes the anchor itself.
|
|
68
|
+
* `--allow-stale-components` downgrades the refusal to a warning.
|
|
69
|
+
*/
|
|
70
|
+
export async function enforceStaticComponentsGate(engineDir, baseline, options) {
|
|
71
|
+
const result = await checkStaticComponentsStale(engineDir, baseline);
|
|
72
|
+
if (!result.stale) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const message = formatStaticComponentsRefusal(result.changedManifests);
|
|
76
|
+
if (options.allowStaleComponents === true) {
|
|
77
|
+
warn(message);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
throw new GeneralError(message);
|
|
81
|
+
}
|