@hominis/fireforge 0.46.2 → 0.47.1
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 +39 -0
- package/dist/bin/fireforge.js +6 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/config.js +1 -2
- package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
- package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
- package/dist/src/commands/furnace/deploy.js +36 -3
- package/dist/src/commands/furnace/validation-output.d.ts +5 -0
- package/dist/src/commands/furnace/validation-output.js +35 -9
- package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
- package/dist/src/commands/patch/delete-gap-notice.js +46 -0
- package/dist/src/commands/patch/delete.js +11 -0
- package/dist/src/commands/re-export-lint.d.ts +26 -1
- package/dist/src/commands/re-export-lint.js +60 -20
- package/dist/src/commands/re-export-scan.d.ts +8 -0
- package/dist/src/commands/re-export-scan.js +12 -2
- package/dist/src/commands/re-export-single.js +24 -5
- package/dist/src/commands/run.js +42 -17
- package/dist/src/commands/test-harness-env.d.ts +24 -0
- package/dist/src/commands/test-harness-env.js +61 -0
- package/dist/src/commands/test-harness-teardown.d.ts +62 -0
- package/dist/src/commands/test-harness-teardown.js +128 -0
- package/dist/src/commands/test-register.js +8 -3
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +36 -7
- package/dist/src/commands/test-verdict.d.ts +21 -4
- package/dist/src/commands/test-verdict.js +37 -4
- package/dist/src/commands/test.js +27 -26
- package/dist/src/core/build-jar-escalation.d.ts +1 -4
- package/dist/src/core/build-jar-escalation.js +1 -4
- package/dist/src/core/build-prepare.js +72 -55
- 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-test-toolchains.js +7 -0
- package/dist/src/core/file-lock.js +16 -9
- package/dist/src/core/firefox-cache.js +1 -2
- package/dist/src/core/furnace-apply-output.d.ts +10 -0
- package/dist/src/core/furnace-apply-output.js +19 -4
- package/dist/src/core/furnace-css-fragments.d.ts +7 -0
- package/dist/src/core/furnace-css-fragments.js +12 -3
- package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
- package/dist/src/core/furnace-fragment-includers.js +122 -0
- package/dist/src/core/git-diff-new-file.js +1 -2
- package/dist/src/core/git-file-ops.js +1 -2
- package/dist/src/core/git.js +1 -2
- package/dist/src/core/harness-helper-pids.d.ts +98 -0
- package/dist/src/core/harness-helper-pids.js +240 -0
- package/dist/src/core/harness-orphans.d.ts +53 -8
- package/dist/src/core/harness-orphans.js +125 -44
- package/dist/src/core/mach-build-artifacts.js +1 -2
- package/dist/src/core/mach.d.ts +16 -0
- package/dist/src/core/mach.js +6 -1
- package/dist/src/core/marionette-preflight.js +9 -3
- package/dist/src/core/mochitest-server-port.d.ts +43 -4
- package/dist/src/core/mochitest-server-port.js +64 -11
- package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
- package/dist/src/core/parent-exit-watchdog.js +69 -0
- package/dist/src/core/patch-lint-imports.js +4 -4
- package/dist/src/core/smoke-patterns.d.ts +24 -0
- package/dist/src/core/smoke-patterns.js +50 -5
- package/dist/src/types/commands/options.d.ts +17 -1
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/utils/process-group.d.ts +5 -1
- package/dist/src/utils/process-group.js +7 -3
- package/dist/src/utils/process.d.ts +18 -0
- package/dist/src/utils/process.js +14 -2
- package/docs/configuration.md +15 -1
- package/docs/exit-codes.md +19 -19
- package/docs/lifecycle-invariants.md +17 -1
- package/docs/machine-output.md +44 -19
- package/docs/testing.md +80 -4
- package/package.json +5 -5
|
@@ -20,15 +20,18 @@
|
|
|
20
20
|
* (`refreshQueueCtxEntry`), so later iterations of an `--all` run lint
|
|
21
21
|
* against the just-refreshed body instead of the stale one.
|
|
22
22
|
*/
|
|
23
|
-
import { buildPatchQueueContext, countNonBinaryDiffLines, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
23
|
+
import { buildPatchQueueContext, countNonBinaryDiffLines, detectNewFilesInDiff, formatPatchLintIssue, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
24
24
|
import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/patch-lint-cache.js';
|
|
25
25
|
import { invalidateNewFileCreatorsCache } from '../core/patch-lint-cross.js';
|
|
26
|
-
import { extractAddedLinesPerFile } from '../core/patch-lint-diff.js';
|
|
27
26
|
import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
|
|
27
|
+
import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
|
|
28
|
+
import { GeneralError } from '../errors/base.js';
|
|
28
29
|
import { pathExists } from '../utils/fs.js';
|
|
29
|
-
import { info } from '../utils/logger.js';
|
|
30
|
+
import { info, warn } from '../utils/logger.js';
|
|
30
31
|
import { reportPatchLintOutcome } from './export-shared.js';
|
|
31
32
|
import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
|
|
33
|
+
import { projectEntryBody } from './patch/entry-projection.js';
|
|
34
|
+
import { formatForwardImportRemedy } from './re-export-scan.js';
|
|
32
35
|
/** Builds the once-per-invocation lint context. */
|
|
33
36
|
export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
|
|
34
37
|
const patchQueueCtx = (await pathExists(paths.patches))
|
|
@@ -50,6 +53,7 @@ export async function buildReExportLintContext(projectRoot, paths, config, noCac
|
|
|
50
53
|
cacheDirty: false,
|
|
51
54
|
reusedCacheEntries: 0,
|
|
52
55
|
globalsEmitted: false,
|
|
56
|
+
queueBaselineWarned: false,
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
/** The checkJs-relevant files this fresh diff creates. */
|
|
@@ -179,10 +183,61 @@ export async function storeReExportLintResult(lintCtx, patchFilename, projectedM
|
|
|
179
183
|
});
|
|
180
184
|
lintCtx.cacheDirty = true;
|
|
181
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Projects the rewritten body into the hoisted queue context and refuses
|
|
188
|
+
* when the projection introduces error-severity cross-patch findings the
|
|
189
|
+
* current queue does not have (a forward import an earlier owner gained,
|
|
190
|
+
* say). Mirrors `re-export --files` (its projected-state conflict) and the
|
|
191
|
+
* `--scan` adoption gate, which only covered files a scan adopted: a plain
|
|
192
|
+
* re-export of an unchanged file set with new CONTENT used to pass, and the
|
|
193
|
+
* queue carried the error until the next `export` placement refused it.
|
|
194
|
+
*
|
|
195
|
+
* Runs in dry-run. `--force-unsafe` downgrades the refusal to warnings;
|
|
196
|
+
* `--skip-lint` does not bypass it, same as `--files`. The baseline is the
|
|
197
|
+
* hoisted context, which {@link refreshQueueCtxEntry} keeps equal to the
|
|
198
|
+
* on-disk queue across an `--all` loop, so patch N+1 is projected against
|
|
199
|
+
* the queue as it will exist.
|
|
200
|
+
*/
|
|
201
|
+
export function assertRefreshedBodyHasNoNewQueueErrors(args) {
|
|
202
|
+
const { lintCtx, patchFilename, diffContent, forceUnsafe } = args;
|
|
203
|
+
const ctx = lintCtx.patchQueueCtx;
|
|
204
|
+
if (!ctx)
|
|
205
|
+
return;
|
|
206
|
+
const projection = projectEntryBody(diffContent);
|
|
207
|
+
const projectedEntries = ctx.entries.map((entry) => entry.filename === patchFilename ? { ...entry, ...projection } : entry);
|
|
208
|
+
const isError = (issue) => issue.severity === 'error';
|
|
209
|
+
const baseline = lintPatchQueue(ctx).filter(isError);
|
|
210
|
+
const projected = lintPatchQueue({
|
|
211
|
+
entries: projectedEntries,
|
|
212
|
+
...(ctx.patchPolicy ? { patchPolicy: ctx.patchPolicy } : {}),
|
|
213
|
+
}).filter(isError);
|
|
214
|
+
const regressions = computeProjectedLintRegressions(baseline, projected);
|
|
215
|
+
if (regressions.length === 0) {
|
|
216
|
+
if (baseline.length > 0 && !lintCtx.queueBaselineWarned) {
|
|
217
|
+
lintCtx.queueBaselineWarned = true;
|
|
218
|
+
warn(`Note: projected queue still has ${baseline.length} pre-existing ` +
|
|
219
|
+
'cross-patch error(s) unrelated to this re-export. Run "fireforge verify" to list them.');
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const details = regressions.map((issue) => ` ${formatPatchLintIssue(issue)}`).join('\n');
|
|
224
|
+
const hasForwardImport = regressions.some((issue) => issue.check === 'forward-import');
|
|
225
|
+
const message = `Refusing to re-export ${patchFilename} because the refreshed body introduces ` +
|
|
226
|
+
`${regressions.length} new cross-patch lint error${regressions.length === 1 ? '' : 's'}:\n${details}` +
|
|
227
|
+
(hasForwardImport ? `\n${formatForwardImportRemedy('re-running the re-export')}` : '');
|
|
228
|
+
if (forceUnsafe) {
|
|
229
|
+
warn(message);
|
|
230
|
+
warn('Proceeding because --force-unsafe was provided.');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
throw new GeneralError(message);
|
|
234
|
+
}
|
|
182
235
|
/**
|
|
183
236
|
* Refreshes the in-memory queue entry for a just-rewritten patch so later
|
|
184
237
|
* iterations (and later cache keys) lint against the new body instead of
|
|
185
|
-
* the stale one. The on-disk write is the authority. This mirrors it
|
|
238
|
+
* the stale one. The on-disk write is the authority. This mirrors it, via
|
|
239
|
+
* the same projection `buildPatchQueueContext` derives an entry from, so
|
|
240
|
+
* `createdFiles` and `modifiedFileAdditions` stay in step with a rebuild.
|
|
186
241
|
*/
|
|
187
242
|
export function refreshQueueCtxEntry(lintCtx, patchFilename, diffContent) {
|
|
188
243
|
const ctx = lintCtx.patchQueueCtx;
|
|
@@ -191,22 +246,7 @@ export function refreshQueueCtxEntry(lintCtx, patchFilename, diffContent) {
|
|
|
191
246
|
const entry = ctx.entries.find((e) => e.filename === patchFilename);
|
|
192
247
|
if (!entry)
|
|
193
248
|
return;
|
|
194
|
-
entry
|
|
195
|
-
const newFilePaths = detectNewFilesInDiff(diffContent);
|
|
196
|
-
const addedLinesByFile = extractAddedLinesPerFile(diffContent);
|
|
197
|
-
const newFiles = new Map();
|
|
198
|
-
const modifiedFileAdditions = new Map();
|
|
199
|
-
for (const [file, lines] of addedLinesByFile) {
|
|
200
|
-
if (newFilePaths.has(file)) {
|
|
201
|
-
// A created file's content is its added lines.
|
|
202
|
-
newFiles.set(file, lines.join('\n'));
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
modifiedFileAdditions.set(file, lines.join('\n'));
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
entry.newFiles = newFiles;
|
|
209
|
-
entry.modifiedFileAdditions = modifiedFileAdditions;
|
|
249
|
+
Object.assign(entry, projectEntryBody(diffContent));
|
|
210
250
|
invalidateNewFileCreatorsCache(ctx);
|
|
211
251
|
}
|
|
212
252
|
/** Persists cache writes and reports reuse, once after the loop. */
|
|
@@ -45,6 +45,14 @@ export declare function assertScanAdoptionsHaveNoForwardImports(args: {
|
|
|
45
45
|
patchFilename: string;
|
|
46
46
|
added: readonly string[];
|
|
47
47
|
}): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* The one remedy sentence for a forward import a patch body cannot carry.
|
|
50
|
+
* Shared by the `--scan` adoption gate and the plain re-export projection
|
|
51
|
+
* gate so both refusals point at the same commands.
|
|
52
|
+
*
|
|
53
|
+
* @param reRunHint - What to do after the fix ("re-running the scan")
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatForwardImportRemedy(reRunHint: string): string;
|
|
48
56
|
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
49
57
|
export declare function assertScanFileAdditionsHaveDiffHunks(args: {
|
|
50
58
|
diffContent: string;
|
|
@@ -201,9 +201,19 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
|
|
|
201
201
|
const details = offending.map((issue) => ` - ${issue.file}: ${issue.message}`).join('\n');
|
|
202
202
|
throw new GeneralError(`Refusing to adopt ${offending.length} scanned file${offending.length === 1 ? '' : 's'} into ${patchFilename} ` +
|
|
203
203
|
`because they import modules created by later patches:\n${details}\n` +
|
|
204
|
-
'
|
|
204
|
+
formatForwardImportRemedy('re-running the scan'));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The one remedy sentence for a forward import a patch body cannot carry.
|
|
208
|
+
* Shared by the `--scan` adoption gate and the plain re-export projection
|
|
209
|
+
* gate so both refusals point at the same commands.
|
|
210
|
+
*
|
|
211
|
+
* @param reRunHint - What to do after the fix ("re-running the scan")
|
|
212
|
+
*/
|
|
213
|
+
export function formatForwardImportRemedy(reRunHint) {
|
|
214
|
+
return ('Export those files as their own later patch ("fireforge export --order <n>" / ' +
|
|
205
215
|
'"fireforge patch split --order <n>") or declare the intentional dependency with ' +
|
|
206
|
-
|
|
216
|
+
`"fireforge patch staged-dependency --add" before ${reRunHint}.`);
|
|
207
217
|
}
|
|
208
218
|
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
209
219
|
export function assertScanFileAdditionsHaveDiffHunks(args) {
|
|
@@ -16,7 +16,7 @@ import { info, success, warn } from '../utils/logger.js';
|
|
|
16
16
|
import { sleep } from '../utils/sleep.js';
|
|
17
17
|
import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
|
|
18
18
|
import { reportForeignDrift } from './re-export-drift.js';
|
|
19
|
-
import { lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
|
|
19
|
+
import { assertRefreshedBodyHasNoNewQueueErrors, lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
|
|
20
20
|
import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, scanPatchFilesForReExport, } from './re-export-scan.js';
|
|
21
21
|
const GIT_INDEX_LOCK_RETRY_MS = 300;
|
|
22
22
|
/**
|
|
@@ -161,10 +161,16 @@ async function reExportSinglePatch(args) {
|
|
|
161
161
|
return false;
|
|
162
162
|
}
|
|
163
163
|
const { updates, ignoreChecks, effectiveTier, existingIgnoreSet, flagIgnoreSet } = computeReExportUpdates(patch, options, currentFilesAffected);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
enforceProjectedManifestPolicy({ config, manifest, patch, updates, options });
|
|
165
|
+
// Cross-patch projection gate (runs in dry-run too): the refreshed body
|
|
166
|
+
// must not introduce queue-level errors the current queue lacks, e.g. an
|
|
167
|
+
// earlier owner gaining an import of a later patch's module. Cheap
|
|
168
|
+
// (in-memory) and more actionable than the per-patch lint, so it fires
|
|
169
|
+
// first.
|
|
170
|
+
assertRefreshedBodyHasNoNewQueueErrors({
|
|
171
|
+
lintCtx,
|
|
172
|
+
patchFilename: patch.filename,
|
|
173
|
+
diffContent,
|
|
168
174
|
forceUnsafe: options.forceUnsafe === true,
|
|
169
175
|
});
|
|
170
176
|
// Lint against the once-per-invocation hoisted context (queue context +
|
|
@@ -190,6 +196,9 @@ async function reExportSinglePatch(args) {
|
|
|
190
196
|
if (addedIgnores.length > 0) {
|
|
191
197
|
info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
|
|
192
198
|
}
|
|
199
|
+
// Memory-only: later `--all --dry-run` iterations project against this
|
|
200
|
+
// patch's would-be body, exactly as the real run does after its write.
|
|
201
|
+
refreshQueueCtxEntry(lintCtx, patch.filename, diffContent);
|
|
193
202
|
}
|
|
194
203
|
else {
|
|
195
204
|
await commitRefreshedPatch({
|
|
@@ -208,6 +217,16 @@ async function reExportSinglePatch(args) {
|
|
|
208
217
|
}
|
|
209
218
|
return true;
|
|
210
219
|
}
|
|
220
|
+
/** Manifest-level policy gate over the projected row (ranges, gaps, collisions). */
|
|
221
|
+
function enforceProjectedManifestPolicy(args) {
|
|
222
|
+
const { config, manifest, patch, updates, options } = args;
|
|
223
|
+
enforcePatchPolicy({
|
|
224
|
+
config,
|
|
225
|
+
manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
|
|
226
|
+
command: 're-export',
|
|
227
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
211
230
|
/**
|
|
212
231
|
* Writes the refreshed body + manifest row atomically under the patch
|
|
213
232
|
* directory lock (a split write would let a concurrent queue mutation
|
package/dist/src/commands/run.js
CHANGED
|
@@ -8,7 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
|
|
|
8
8
|
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
9
9
|
import { hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke } from '../core/mach.js';
|
|
10
10
|
import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
|
|
11
|
-
import { compileAllowlistFromFile, compileAllowlistFromStrings, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
|
|
11
|
+
import { compileAllowlistFromFile, compileAllowlistFromStrings, findOverCapAllowlistEntries, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
|
|
12
12
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
13
13
|
import { BuildError } from '../errors/build.js';
|
|
14
14
|
import { ExitCode } from '../errors/codes.js';
|
|
@@ -192,17 +192,7 @@ async function runSmokeExit(engineDir, options) {
|
|
|
192
192
|
}
|
|
193
193
|
findings.push({ stream, line });
|
|
194
194
|
};
|
|
195
|
-
|
|
196
|
-
// interacting with the window mid-run can trigger console errors (a
|
|
197
|
-
// password-manager import scan probing an unreadable profile dir, say)
|
|
198
|
-
// that fail the smoke run looking like a product regression. CI hosts (CI
|
|
199
|
-
// env var set) are assumed display-free and unattended, so the notice
|
|
200
|
-
// stays quiet there.
|
|
201
|
-
if (!options.headless && !process.env['CI']) {
|
|
202
|
-
warn('Headed smoke window: keyboard/mouse input during the window will contaminate the ' +
|
|
203
|
-
'console capture and can fail the run. Pass --headless (or run on an unattended host) ' +
|
|
204
|
-
'for reliable smoke checks.');
|
|
205
|
-
}
|
|
195
|
+
warnIfHeadedOnDesktop(options.headless === true);
|
|
206
196
|
info(`Launching browser (smoke-exit after ${smokeExit}s)...\n`);
|
|
207
197
|
const startedAt = Date.now();
|
|
208
198
|
let result;
|
|
@@ -222,6 +212,7 @@ async function runSmokeExit(engineDir, options) {
|
|
|
222
212
|
captureStream?.end();
|
|
223
213
|
}
|
|
224
214
|
const elapsedMs = Date.now() - startedAt;
|
|
215
|
+
const overCap = findOverCapAllowlistEntries(allowlist, allowlistHits);
|
|
225
216
|
reportSmokeSummary({
|
|
226
217
|
smokeTimeoutMs,
|
|
227
218
|
elapsedMs,
|
|
@@ -230,12 +221,14 @@ async function runSmokeExit(engineDir, options) {
|
|
|
230
221
|
allowlistedTotalHits,
|
|
231
222
|
allowlist,
|
|
232
223
|
allowlistHits,
|
|
224
|
+
overCap,
|
|
233
225
|
findings,
|
|
234
226
|
exitCode: result.exitCode,
|
|
235
227
|
});
|
|
236
|
-
// Exit contract (precedence: unallowed errors
|
|
237
|
-
|
|
238
|
-
|
|
228
|
+
// Exit contract (precedence: unallowed errors and over-ceiling floods
|
|
229
|
+
// dominate timed-out).
|
|
230
|
+
if (findings.length > 0 || overCap.length > 0) {
|
|
231
|
+
throw new SmokeRunError(describeSmokeExitFailure(findings.length, overCap.length), ExitCode.SMOKE_EXIT_FAILURE);
|
|
239
232
|
}
|
|
240
233
|
if (result.timedOut) {
|
|
241
234
|
// Clean window: SIGTERM came from us. Treat as success.
|
|
@@ -273,6 +266,30 @@ async function buildAllowlist(options) {
|
|
|
273
266
|
}
|
|
274
267
|
return allow;
|
|
275
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* A headed smoke window on a developer desktop absorbs live input: a human
|
|
271
|
+
* interacting with the window mid-run can trigger console errors (a
|
|
272
|
+
* password-manager import scan probing an unreadable profile dir, say)
|
|
273
|
+
* that fail the smoke run looking like a product regression. CI hosts (CI
|
|
274
|
+
* env var set) are assumed display-free and unattended, so the notice
|
|
275
|
+
* stays quiet there.
|
|
276
|
+
*/
|
|
277
|
+
function warnIfHeadedOnDesktop(headless) {
|
|
278
|
+
if (headless || process.env['CI'])
|
|
279
|
+
return;
|
|
280
|
+
warn('Headed smoke window: keyboard/mouse input during the window will contaminate the ' +
|
|
281
|
+
'console capture and can fail the run. Pass --headless (or run on an unattended host) ' +
|
|
282
|
+
'for reliable smoke checks.');
|
|
283
|
+
}
|
|
284
|
+
/** The SMOKE_EXIT_FAILURE message, naming whichever of the two causes fired. */
|
|
285
|
+
function describeSmokeExitFailure(unallowed, overCap) {
|
|
286
|
+
const parts = [];
|
|
287
|
+
if (unallowed > 0)
|
|
288
|
+
parts.push(`${unallowed} unallowed console error(s)`);
|
|
289
|
+
if (overCap > 0)
|
|
290
|
+
parts.push(`${overCap} allowlist entr${overCap === 1 ? 'y' : 'ies'} over its max-hits ceiling`);
|
|
291
|
+
return `Smoke run observed ${parts.join(' and ')}.`;
|
|
292
|
+
}
|
|
276
293
|
/**
|
|
277
294
|
* Prints the human-readable summary block that follows every smoke run.
|
|
278
295
|
* Called once, right before the exit-code decision. Keeps the reporting
|
|
@@ -302,9 +319,17 @@ function reportSmokeSummary(args) {
|
|
|
302
319
|
args.allowlist.forEach((entry, index) => {
|
|
303
320
|
const hits = args.allowlistHits[index] ?? 0;
|
|
304
321
|
const zeroSuffix = hits === 0 ? ' (never matched — candidate for removal)' : '';
|
|
305
|
-
|
|
322
|
+
const cap = entry.maxHits === undefined ? '' : `/${entry.maxHits}`;
|
|
323
|
+
info(` ${hits}×${cap} ${entry.origin} ${entry.source}${zeroSuffix}`);
|
|
306
324
|
});
|
|
307
325
|
}
|
|
326
|
+
// An allowlisted shape firing past its `# max-hits` ceiling is a flood the
|
|
327
|
+
// allowlist was never meant to absorb. It is reported next to the
|
|
328
|
+
// attribution it came from and counts toward the exit code below.
|
|
329
|
+
for (const over of args.overCap) {
|
|
330
|
+
warn(` allowlisted shape "${over.entry.source}" (${over.entry.origin}) fired ${over.hits}× ` +
|
|
331
|
+
`over its ceiling of ${over.maxHits}`);
|
|
332
|
+
}
|
|
308
333
|
if (args.findings.length === 0)
|
|
309
334
|
return;
|
|
310
335
|
warn('');
|
|
@@ -330,7 +355,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
|
|
|
330
355
|
return parsed;
|
|
331
356
|
}))
|
|
332
357
|
.option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', ...stringListOption())
|
|
333
|
-
.option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
|
|
358
|
+
.option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored. A "# max-hits: N" comment directly above an entry caps how many lines it may match in one run; more than that fails the run with exit 12.')
|
|
334
359
|
.option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
|
|
335
360
|
.option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
|
|
336
361
|
.action(withErrorHandling(
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TestSuite } from './test-run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the perf-sample env contract for the harness run:
|
|
4
|
+
* `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
|
|
5
|
+
* artifact file a budget checker consumes after the run.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildPerfSampleEnv(projectRoot: string, binaryName: string, perfSamples: string | undefined): Record<string, string> | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Merges the per-feature harness env contracts into one map, or `undefined`
|
|
10
|
+
* when no feature exported anything (so `TestRunContext.env` stays absent).
|
|
11
|
+
*/
|
|
12
|
+
export declare function mergeHarnessEnv(...parts: (Record<string, string> | undefined)[]): Record<string, string> | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the `--shuffle` seed: a fresh one when the flag was bare, the
|
|
15
|
+
* operator's when it carried one. Mach's `--shuffle` is an UNSEEDED
|
|
16
|
+
* Fisher–Yates over `Math.random` (SimpleTest/setup.js), so a red found by
|
|
17
|
+
* shuffling could not be reproduced. FireForge draws the seed, exports it
|
|
18
|
+
* as `FIREFORGE_SHUFFLE_SEED` for harness code that reorders inside a file,
|
|
19
|
+
* prints it, and stamps it on the verdict line.
|
|
20
|
+
*
|
|
21
|
+
* Mochitest-only: the xpcshell harness has no shuffle, and generic `mach
|
|
22
|
+
* test` dispatch would forward the flag to every suite it fans out to.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveShuffleSeed(shuffle: number | boolean | undefined, suite: TestSuite): number | undefined;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Per-feature environment and argument contracts `fireforge test` exports to
|
|
4
|
+
* the harness process: the perf-sample artifact path and the seeded
|
|
5
|
+
* test-order shuffle. Split out of `test.ts` to keep the command body inside
|
|
6
|
+
* its file budget.
|
|
7
|
+
*/
|
|
8
|
+
import { randomInt } from 'node:crypto';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { GeneralError } from '../errors/base.js';
|
|
11
|
+
import { info } from '../utils/logger.js';
|
|
12
|
+
import { setVerdictRunAttribute } from './test-verdict.js';
|
|
13
|
+
/**
|
|
14
|
+
* Builds the perf-sample env contract for the harness run:
|
|
15
|
+
* `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
|
|
16
|
+
* artifact file a budget checker consumes after the run.
|
|
17
|
+
*/
|
|
18
|
+
export function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
|
|
19
|
+
if (!perfSamples)
|
|
20
|
+
return undefined;
|
|
21
|
+
const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
|
|
22
|
+
const artifactPath = resolve(projectRoot, perfSamples);
|
|
23
|
+
info(`Perf sample contract: ${envName}=${artifactPath}`);
|
|
24
|
+
return { [envName]: artifactPath };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Merges the per-feature harness env contracts into one map, or `undefined`
|
|
28
|
+
* when no feature exported anything (so `TestRunContext.env` stays absent).
|
|
29
|
+
*/
|
|
30
|
+
export function mergeHarnessEnv(...parts) {
|
|
31
|
+
const merged = {};
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
if (part)
|
|
34
|
+
Object.assign(merged, part);
|
|
35
|
+
}
|
|
36
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the `--shuffle` seed: a fresh one when the flag was bare, the
|
|
40
|
+
* operator's when it carried one. Mach's `--shuffle` is an UNSEEDED
|
|
41
|
+
* Fisher–Yates over `Math.random` (SimpleTest/setup.js), so a red found by
|
|
42
|
+
* shuffling could not be reproduced. FireForge draws the seed, exports it
|
|
43
|
+
* as `FIREFORGE_SHUFFLE_SEED` for harness code that reorders inside a file,
|
|
44
|
+
* prints it, and stamps it on the verdict line.
|
|
45
|
+
*
|
|
46
|
+
* Mochitest-only: the xpcshell harness has no shuffle, and generic `mach
|
|
47
|
+
* test` dispatch would forward the flag to every suite it fans out to.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveShuffleSeed(shuffle, suite) {
|
|
50
|
+
if (shuffle === undefined || shuffle === false)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (suite !== 'mochitest') {
|
|
53
|
+
throw new GeneralError("--shuffle forwards the mochitest harness's file-order shuffle; the xpcshell harness has " +
|
|
54
|
+
'none, and generic `mach test` dispatch would hand it to every suite. Narrow the paths to ' +
|
|
55
|
+
'browser-chrome/mochitest files (and drop --generic-mach-test).');
|
|
56
|
+
}
|
|
57
|
+
const seed = shuffle === true ? randomInt(1, 2 ** 31 - 1) : shuffle;
|
|
58
|
+
setVerdictRunAttribute('shuffle', String(seed));
|
|
59
|
+
info(`Test order shuffle: seed=${seed} (reproduce with "fireforge test --shuffle=${seed}")`);
|
|
60
|
+
return seed;
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the kill path of one mach test dispatch for `fireforge test`.
|
|
3
|
+
*
|
|
4
|
+
* Three things the run must do around every `runMachTestSuite` call, kept
|
|
5
|
+
* together because each one exists for the same incident (a supervisor
|
|
6
|
+
* killed the process above FireForge at its bound and the harness httpd it
|
|
7
|
+
* had launched spun for hours):
|
|
8
|
+
*
|
|
9
|
+
* 1. Track the helper pids the harness announces, and after mach has
|
|
10
|
+
* exited terminate any that outlived it (`harness-helper-pids.ts`).
|
|
11
|
+
* 2. Publish the harness process group (`--pgid-file`), so a supervisor
|
|
12
|
+
* that can only SIGKILL FireForge can still take the whole tree with
|
|
13
|
+
* one `kill -- -<pgid>` afterwards.
|
|
14
|
+
* 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
|
|
15
|
+
* vanishes mid-run, write the killed verdict, reap the group, and let
|
|
16
|
+
* the dispatch loop end the run instead of retrying.
|
|
17
|
+
*
|
|
18
|
+
* Every reap adds to the `orphans-reaped=` verdict attribute.
|
|
19
|
+
*/
|
|
20
|
+
import type { MachTestSuiteOptions } from '../core/mach.js';
|
|
21
|
+
/** Inputs for {@link createHarnessTeardown}. */
|
|
22
|
+
export interface HarnessTeardownInputs {
|
|
23
|
+
/** Absolute objdir of this project, when known: the anchor for every kill. */
|
|
24
|
+
objDir: string | undefined;
|
|
25
|
+
/** `--pgid-file`: where to publish the harness process group id. */
|
|
26
|
+
pgidFile: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** One dispatch's teardown: the hooks to spread into `runMachTestSuite`, and its lifecycle. */
|
|
29
|
+
export interface HarnessTeardown {
|
|
30
|
+
/** Hooks for {@link MachTestSuiteOptions.teardown}. */
|
|
31
|
+
hooks: NonNullable<MachTestSuiteOptions['teardown']>;
|
|
32
|
+
/**
|
|
33
|
+
* Records the same-objdir helpers alive right now, before the dispatch
|
|
34
|
+
* spawns anything, so the post-close reap can tell this dispatch's
|
|
35
|
+
* unannounced helpers from an earlier run's survivors. Await it before
|
|
36
|
+
* `runMachTestSuite`.
|
|
37
|
+
*/
|
|
38
|
+
prepare(): Promise<void>;
|
|
39
|
+
/** Stops the parent watchdog. Call after the dispatch resolved or threw. */
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the teardown for one dispatch (one retry attempt of one shard).
|
|
44
|
+
* The pid tracker and the group id are per dispatch; the parent watchdog
|
|
45
|
+
* runs only while that dispatch is in flight.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createHarnessTeardown(inputs: HarnessTeardownInputs): HarnessTeardown;
|
|
48
|
+
/** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
|
|
49
|
+
export declare function setActivePgidFile(path: string | undefined): void;
|
|
50
|
+
/**
|
|
51
|
+
* Removes the pgid file at the end of a run that ended under FireForge's
|
|
52
|
+
* control. A file left behind after a normal exit would name a group id
|
|
53
|
+
* the kernel may have reused; the file must only outlive FireForge when
|
|
54
|
+
* FireForge itself was killed without notice, which is what it is for.
|
|
55
|
+
*/
|
|
56
|
+
export declare function removePgidFile(path: string | undefined): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
|
|
59
|
+
* file, if one is registered. Called by the bin entry point after the child
|
|
60
|
+
* shutdown wait, so the group the file names has already been signalled.
|
|
61
|
+
*/
|
|
62
|
+
export declare function removeActivePgidFile(): Promise<void>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Owns the kill path of one mach test dispatch for `fireforge test`.
|
|
4
|
+
*
|
|
5
|
+
* Three things the run must do around every `runMachTestSuite` call, kept
|
|
6
|
+
* together because each one exists for the same incident (a supervisor
|
|
7
|
+
* killed the process above FireForge at its bound and the harness httpd it
|
|
8
|
+
* had launched spun for hours):
|
|
9
|
+
*
|
|
10
|
+
* 1. Track the helper pids the harness announces, and after mach has
|
|
11
|
+
* exited terminate any that outlived it (`harness-helper-pids.ts`).
|
|
12
|
+
* 2. Publish the harness process group (`--pgid-file`), so a supervisor
|
|
13
|
+
* that can only SIGKILL FireForge can still take the whole tree with
|
|
14
|
+
* one `kill -- -<pgid>` afterwards.
|
|
15
|
+
* 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
|
|
16
|
+
* vanishes mid-run, write the killed verdict, reap the group, and let
|
|
17
|
+
* the dispatch loop end the run instead of retrying.
|
|
18
|
+
*
|
|
19
|
+
* Every reap adds to the `orphans-reaped=` verdict attribute.
|
|
20
|
+
*/
|
|
21
|
+
import { unlink, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { createHelperPidTracker, reapTrackedHarnessPids, snapshotObjdirHelpers, } from '../core/harness-helper-pids.js';
|
|
23
|
+
import { startParentExitWatchdog } from '../core/parent-exit-watchdog.js';
|
|
24
|
+
import { toError } from '../utils/errors.js';
|
|
25
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
26
|
+
import { sweepProcessGroup } from '../utils/process-group.js';
|
|
27
|
+
import { addVerdictRunCount, emitKilledVerdict } from './test-verdict.js';
|
|
28
|
+
/**
|
|
29
|
+
* Builds the teardown for one dispatch (one retry attempt of one shard).
|
|
30
|
+
* The pid tracker and the group id are per dispatch; the parent watchdog
|
|
31
|
+
* runs only while that dispatch is in flight.
|
|
32
|
+
*/
|
|
33
|
+
export function createHarnessTeardown(inputs) {
|
|
34
|
+
const tracker = createHelperPidTracker();
|
|
35
|
+
let pgid;
|
|
36
|
+
let baseline;
|
|
37
|
+
const watchdog = startParentExitWatchdog({
|
|
38
|
+
onParentExit: ({ originalPpid, currentPpid }) => {
|
|
39
|
+
// Verdict first, before anything that can stall: the parent that would
|
|
40
|
+
// have read it is gone, but the run log and any tee of stdout are not.
|
|
41
|
+
emitKilledVerdict('parent-exit');
|
|
42
|
+
warn(`Parent process ${originalPpid} exited while the harness was running (this process now ` +
|
|
43
|
+
`reports parent ${currentPpid}); ending the run and terminating the harness tree so it ` +
|
|
44
|
+
'cannot run on unsupervised.');
|
|
45
|
+
if (pgid !== undefined) {
|
|
46
|
+
void sweepProcessGroup(pgid, undefined, 'after the parent process exited').catch((error) => {
|
|
47
|
+
warn(`Reaping harness group ${pgid} after parent exit failed: ${toError(error).message}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
hooks: {
|
|
54
|
+
onOutputChunk: (stream, chunk) => {
|
|
55
|
+
tracker.feed(stream, chunk);
|
|
56
|
+
},
|
|
57
|
+
onProcessGroup: (id) => {
|
|
58
|
+
pgid = id;
|
|
59
|
+
verbose(`Harness process group: ${id} (mach is the group leader).`);
|
|
60
|
+
if (inputs.pgidFile !== undefined)
|
|
61
|
+
void publishPgid(inputs.pgidFile, id);
|
|
62
|
+
},
|
|
63
|
+
postCloseSweep: async () => {
|
|
64
|
+
const reaped = await reapTrackedHarnessPids(tracker.tracked(), inputs.objDir, baseline);
|
|
65
|
+
addVerdictRunCount('orphans-reaped', reaped);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
prepare: async () => {
|
|
69
|
+
baseline = await snapshotObjdirHelpers(inputs.objDir);
|
|
70
|
+
},
|
|
71
|
+
dispose: () => {
|
|
72
|
+
watchdog.stop();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Writes `<pgid>\n` to the pgid file. Best-effort: a supervisor that asked
|
|
78
|
+
* for the file and cannot get it is warned, and the run proceeds, because
|
|
79
|
+
* the file is a courtesy to the supervisor and not a precondition of the
|
|
80
|
+
* tests.
|
|
81
|
+
*/
|
|
82
|
+
async function publishPgid(path, pgid) {
|
|
83
|
+
try {
|
|
84
|
+
await writeFile(path, `${pgid}\n`, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
warn(`Could not write --pgid-file ${path}: ${toError(error).message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The pgid file of the run in flight, for the signal pipeline. `testCommand`'s
|
|
92
|
+
* own `finally` removes the file on every path it controls, but on SIGTERM
|
|
93
|
+
* that `finally` races the bin handler's `process.exit` and loses, and a
|
|
94
|
+
* SIGTERM is notice: the file must not outlive a run FireForge itself
|
|
95
|
+
* ended. Set when the run starts, cleared by {@link removePgidFile}.
|
|
96
|
+
*/
|
|
97
|
+
let activePgidFile;
|
|
98
|
+
/** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
|
|
99
|
+
export function setActivePgidFile(path) {
|
|
100
|
+
activePgidFile = path;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Removes the pgid file at the end of a run that ended under FireForge's
|
|
104
|
+
* control. A file left behind after a normal exit would name a group id
|
|
105
|
+
* the kernel may have reused; the file must only outlive FireForge when
|
|
106
|
+
* FireForge itself was killed without notice, which is what it is for.
|
|
107
|
+
*/
|
|
108
|
+
export async function removePgidFile(path) {
|
|
109
|
+
if (path === undefined)
|
|
110
|
+
return;
|
|
111
|
+
if (activePgidFile === path)
|
|
112
|
+
activePgidFile = undefined;
|
|
113
|
+
try {
|
|
114
|
+
await unlink(path);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// ENOENT is the usual case when no dispatch ever spawned.
|
|
118
|
+
verbose(`--pgid-file ${path} not removed: ${toError(error).message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
|
|
123
|
+
* file, if one is registered. Called by the bin entry point after the child
|
|
124
|
+
* shutdown wait, so the group the file names has already been signalled.
|
|
125
|
+
*/
|
|
126
|
+
export async function removeActivePgidFile() {
|
|
127
|
+
await removePgidFile(activePgidFile);
|
|
128
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
4
4
|
import { GeneralError, LockContentionError } from '../errors/base.js';
|
|
5
5
|
import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
|
|
6
|
+
import { parsePositiveIntegerFlag } from '../utils/validation.js';
|
|
6
7
|
import { testCommand } from './test.js';
|
|
7
8
|
import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
|
|
8
9
|
import { emitFailVerdict, resetVerdictEmission, verdictEmitted } from './test-verdict.js';
|
|
@@ -29,7 +30,9 @@ const TEST_HELP_TEXT = [
|
|
|
29
30
|
'--doctor) ends with exactly one raw stdout line',
|
|
30
31
|
' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
|
|
31
32
|
' no-tests|preflight|inconclusive|lock-timeout|killed] [checks=<n>]',
|
|
32
|
-
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [
|
|
33
|
+
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [shuffle=<seed>]',
|
|
34
|
+
' [log=<path>]',
|
|
35
|
+
'shuffle=<seed> is the seed a --shuffle run exported to the harness.',
|
|
33
36
|
"log=<path> names this run's complete output, written to",
|
|
34
37
|
'.fireforge/logs/ as the run streams, so a piped or truncated run',
|
|
35
38
|
'still leaves a re-readable artifact.',
|
|
@@ -68,11 +71,13 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
68
71
|
.option('--auto', 'Forward mach test --auto. Valid only when no explicit paths are provided.')
|
|
69
72
|
.option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
|
|
70
73
|
.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)')
|
|
71
|
-
.option('--kill-stale-marionette',
|
|
72
|
-
.option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag opts into the kill.')
|
|
74
|
+
.option('--kill-stale-marionette', "Terminate a recognized stale browser from this objdir, one holding the Marionette port, or this checkout's own leftover mochitest httpd before running tests. Never touches another checkout's live harness.")
|
|
75
|
+
.option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag (or test.reapOrphans: "reap" in fireforge.json) opts into the kill. Reaps are stamped on the verdict line as orphans-reaped=<n>.')
|
|
76
|
+
.option('--pgid-file <path>', 'Write the harness process-group id (mach is the group leader) to this file on every spawn, so a supervisor that had to SIGKILL fireforge can still take the whole harness tree with "kill -- -$(cat <path>)". Removed when the run ends normally.')
|
|
73
77
|
.option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
|
|
74
78
|
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
75
79
|
.option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
|
|
80
|
+
.option('--shuffle [seed]', 'Run the mochitest files in a seeded random order: forwards mach --shuffle and exports FIREFORGE_SHUFFLE_SEED=<seed> to the harness (a fresh seed when omitted). The seed rides the FIREFORGE-VERDICT line as shuffle=<seed>; replay a red with --shuffle=<seed>. Mochitest-only. Write it as --shuffle=<seed> or after the paths, since a bare "--shuffle <path>" reads the path as the seed.', commanderArgParser((raw) => parsePositiveIntegerFlag('--shuffle', raw)))
|
|
76
81
|
.option('--harness-retries <n>', `Retry budget for recognized harness crashes (resource-monitor tracebacks, pre-test hangs, post-green shutdown re-entry). 0 disables retries. Default: ${DEFAULT_HARNESS_RETRIES}.`, commanderArgParser((raw) => {
|
|
77
82
|
const n = Number.parseInt(raw, 10);
|
|
78
83
|
if (!Number.isFinite(n) || n < 0 || n > 10) {
|
|
@@ -45,6 +45,8 @@ export interface TestRunContext {
|
|
|
45
45
|
* the coding-agent markers that quiet it. Off by default.
|
|
46
46
|
*/
|
|
47
47
|
fullOutput?: boolean;
|
|
48
|
+
/** `--pgid-file`: where each dispatch publishes its harness process group id. */
|
|
49
|
+
pgidFile?: string | undefined;
|
|
48
50
|
}
|
|
49
51
|
/** Outcome of one (possibly retried) harness invocation. */
|
|
50
52
|
export interface TestRunOutcome {
|