@hominis/fireforge 0.44.1 → 0.44.3
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 +42 -0
- package/README.md +18 -110
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +36 -12
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +46 -2
- package/dist/src/core/token-variant.js +190 -33
- package/dist/src/types/commands/options.d.ts +30 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/docs/README.md +35 -0
- package/docs/configuration.md +116 -0
- package/docs/exit-codes.md +63 -0
- package/docs/furnace.md +57 -0
- package/docs/lifecycle-invariants.md +309 -0
- package/docs/machine-output.md +94 -0
- package/docs/patch-workflow.md +119 -0
- package/docs/run-logs.md +60 -0
- package/docs/testing.md +92 -0
- package/docs/verification-trees.md +48 -0
- package/package.json +2 -1
package/dist/src/core/mach.js
CHANGED
|
@@ -10,6 +10,7 @@ import { explainMachError } from './mach-error-hints.js';
|
|
|
10
10
|
import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, } from './mach-known-noise-filter.js';
|
|
11
11
|
import { getPython } from './mach-python.js';
|
|
12
12
|
import { installMachResourceGuard } from './mach-resource-shim.js';
|
|
13
|
+
import { teeToRunLog, writeToActiveRunLog } from './run-log.js';
|
|
13
14
|
import { detectHarnessCrashSignature } from './test-harness-crash.js';
|
|
14
15
|
// Re-export sub-modules so existing `from './mach.js'` imports keep working.
|
|
15
16
|
// This block is a frozen compatibility list, not a barrel to grow: every name
|
|
@@ -90,6 +91,7 @@ export async function runMachCapture(args, engineDir, options = {}) {
|
|
|
90
91
|
const exitCode = await execStream(python, [machPath, ...args], {
|
|
91
92
|
cwd: engineDir,
|
|
92
93
|
...(options.env ? { env: options.env } : {}),
|
|
94
|
+
...(options.envUnset ? { envUnset: options.envUnset } : {}),
|
|
93
95
|
// Every capture dispatch (test suites, protected builds, package,
|
|
94
96
|
// storybook) runs as a process-group leader and is group-reaped on
|
|
95
97
|
// exit/abort — see ExecOptions.processGroup.
|
|
@@ -99,6 +101,12 @@ export async function runMachCapture(args, engineDir, options = {}) {
|
|
|
99
101
|
if (stdout.length > CAPTURE_TAIL_LIMIT) {
|
|
100
102
|
stdout = stdout.slice(-CAPTURE_TAIL_LIMIT);
|
|
101
103
|
}
|
|
104
|
+
// RAW into the log, filtered into the echo. The log exists to be
|
|
105
|
+
// re-read after the fact by whoever is diagnosing the run, and the
|
|
106
|
+
// echo filter's whole purpose is to shorten what a HUMAN scrolls
|
|
107
|
+
// past — collapsing a traceback in the artifact would reintroduce
|
|
108
|
+
// the loss this log exists to prevent.
|
|
109
|
+
writeToActiveRunLog(data);
|
|
102
110
|
process.stdout.write(stdoutFilter ? stdoutFilter.transform(data) : data);
|
|
103
111
|
},
|
|
104
112
|
onStderr: (data) => {
|
|
@@ -106,6 +114,7 @@ export async function runMachCapture(args, engineDir, options = {}) {
|
|
|
106
114
|
if (stderr.length > CAPTURE_TAIL_LIMIT) {
|
|
107
115
|
stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
|
|
108
116
|
}
|
|
117
|
+
writeToActiveRunLog(data);
|
|
109
118
|
process.stderr.write(stderrFilter ? stderrFilter.transform(data) : data);
|
|
110
119
|
},
|
|
111
120
|
});
|
|
@@ -133,6 +142,10 @@ export async function runMachInheritCapture(args, engineDir, options = {}) {
|
|
|
133
142
|
return execInheritCapture(python, [machPath, ...args], {
|
|
134
143
|
cwd: engineDir,
|
|
135
144
|
...(options.env ? { env: options.env } : {}),
|
|
145
|
+
...(options.envUnset ? { envUnset: options.envUnset } : {}),
|
|
146
|
+
// This path streams through the collectors' mirror rather than per-chunk
|
|
147
|
+
// callbacks, so the run-log tee rides the mirror. `mach build` is here.
|
|
148
|
+
mirror: { stdout: teeToRunLog(process.stdout), stderr: teeToRunLog(process.stderr) },
|
|
136
149
|
processGroup: true,
|
|
137
150
|
});
|
|
138
151
|
}
|
|
@@ -378,6 +391,31 @@ export async function watchWithOutput(engineDir, options = {}) {
|
|
|
378
391
|
export async function test(engineDir, testPaths = [], args = []) {
|
|
379
392
|
return runMach(['test', ...testPaths, ...args], engineDir, { inherit: true });
|
|
380
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Environment markers mozbuild reads to decide it is running under a coding
|
|
396
|
+
* agent (`mozbuild.util.is_running_under_coding_agent`), which quiets
|
|
397
|
+
* terminal output to warnings and errors.
|
|
398
|
+
*
|
|
399
|
+
* The quieting is genuinely useful for BUILDS — that is most of what an
|
|
400
|
+
* agent session prints — but it applies to test runs too, where it removes
|
|
401
|
+
* `TEST_START` and console INFO: exactly the lines a hang or stall
|
|
402
|
+
* diagnosis needs, and the ones FireForge's own classifier reads
|
|
403
|
+
* (`Ran N checks`, `Unexpected results:`, `TEST-UNEXPECTED-*`). Unsetting
|
|
404
|
+
* the variable globally would give up the build half as well, so the
|
|
405
|
+
* dispatchers below unset it per TEST dispatch and leave the build path
|
|
406
|
+
* alone.
|
|
407
|
+
*/
|
|
408
|
+
const CODING_AGENT_ENV_MARKERS = ['CLAUDECODE'];
|
|
409
|
+
/**
|
|
410
|
+
* The `envUnset` list for a test dispatch: the coding-agent markers when
|
|
411
|
+
* full verbosity was requested, nothing otherwise.
|
|
412
|
+
*
|
|
413
|
+
* Opt-in rather than unconditional: this changes how much a third party
|
|
414
|
+
* prints, and an operator who wants mozbuild's quieting should keep it.
|
|
415
|
+
*/
|
|
416
|
+
function testVerbosityEnvUnset(fullOutput) {
|
|
417
|
+
return fullOutput === true ? CODING_AGENT_ENV_MARKERS : undefined;
|
|
418
|
+
}
|
|
381
419
|
/**
|
|
382
420
|
* Runs mach test while capturing streamed output for better diagnostics.
|
|
383
421
|
*
|
|
@@ -388,10 +426,12 @@ export async function test(engineDir, testPaths = [], args = []) {
|
|
|
388
426
|
* (merged over `process.env` by the exec layer). Used by
|
|
389
427
|
* `fireforge test --perf-samples` to publish the artifact-path contract.
|
|
390
428
|
*/
|
|
391
|
-
export async function testWithOutput(engineDir, testPaths = [], args = [], env) {
|
|
429
|
+
export async function testWithOutput(engineDir, testPaths = [], args = [], env, fullOutput) {
|
|
392
430
|
const guard = await installMachResourceGuard(engineDir);
|
|
431
|
+
const envUnset = testVerbosityEnvUnset(fullOutput);
|
|
393
432
|
return runMachCapture(['test', ...testPaths, ...args], engineDir, {
|
|
394
433
|
env: { ...guard.env, ...env },
|
|
434
|
+
...(envUnset ? { envUnset } : {}),
|
|
395
435
|
annotateKnownTeardownNoise: true,
|
|
396
436
|
});
|
|
397
437
|
}
|
|
@@ -405,10 +445,12 @@ export async function testWithOutput(engineDir, testPaths = [], args = [], env)
|
|
|
405
445
|
* Signature mirrors {@link testWithOutput} so the two are interchangeable in
|
|
406
446
|
* the dispatch path.
|
|
407
447
|
*/
|
|
408
|
-
export async function xpcshellTestWithOutput(engineDir, testPaths = [], args = [], env) {
|
|
448
|
+
export async function xpcshellTestWithOutput(engineDir, testPaths = [], args = [], env, fullOutput) {
|
|
409
449
|
const guard = await installMachResourceGuard(engineDir);
|
|
450
|
+
const envUnset = testVerbosityEnvUnset(fullOutput);
|
|
410
451
|
return runMachCapture(['xpcshell-test', ...testPaths, ...args], engineDir, {
|
|
411
452
|
env: { ...guard.env, ...env },
|
|
453
|
+
...(envUnset ? { envUnset } : {}),
|
|
412
454
|
annotateKnownTeardownNoise: true,
|
|
413
455
|
});
|
|
414
456
|
}
|
|
@@ -417,10 +459,12 @@ export async function xpcshellTestWithOutput(engineDir, testPaths = [], args = [
|
|
|
417
459
|
* capturing output. The suite-specific counterpart to {@link testWithOutput}
|
|
418
460
|
* for non-xpcshell single-suite runs — see {@link xpcshellTestWithOutput}.
|
|
419
461
|
*/
|
|
420
|
-
export async function mochitestWithOutput(engineDir, testPaths = [], args = [], env) {
|
|
462
|
+
export async function mochitestWithOutput(engineDir, testPaths = [], args = [], env, fullOutput) {
|
|
421
463
|
const guard = await installMachResourceGuard(engineDir);
|
|
464
|
+
const envUnset = testVerbosityEnvUnset(fullOutput);
|
|
422
465
|
return runMachCapture(['mochitest', ...testPaths, ...args], engineDir, {
|
|
423
466
|
env: { ...guard.env, ...env },
|
|
467
|
+
...(envUnset ? { envUnset } : {}),
|
|
424
468
|
annotateKnownTeardownNoise: true,
|
|
425
469
|
});
|
|
426
470
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run output logs under `.fireforge/logs/`.
|
|
3
|
+
*
|
|
4
|
+
* A run's diagnosis used to exist in exactly one place: the terminal. Piping
|
|
5
|
+
* through `tail`/`grep` — the ergonomic default when output is long — keeps
|
|
6
|
+
* the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
|
|
7
|
+
* broke, and it launders the exit code besides. The operator rule ("never
|
|
8
|
+
* pipe a run") was written down three times downstream and broken after each
|
|
9
|
+
* writing, which is the tell that it is not an operator problem: the tool is
|
|
10
|
+
* the only party that can make the log survive the mistake.
|
|
11
|
+
*
|
|
12
|
+
* So `test` and `build` write their own complete copy as they stream, and the
|
|
13
|
+
* `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
|
|
14
|
+
* backgrounded run then still leaves a re-readable artifact.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
|
|
17
|
+
* a run must never fail because one could not be opened, written, or pruned.
|
|
18
|
+
* Every entry point swallows its errors and degrades to "no log", which is
|
|
19
|
+
* exactly the behaviour that existed before this module.
|
|
20
|
+
*/
|
|
21
|
+
/** A run log sink. `write` never throws. */
|
|
22
|
+
export interface RunLog {
|
|
23
|
+
/** Absolute path to the log file. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Appends raw output. Silently drops on any stream error. */
|
|
26
|
+
write: (chunk: string) => void;
|
|
27
|
+
/** Flushes and closes. Resolves even when the stream already failed. */
|
|
28
|
+
close: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** `.fireforge/logs` under `projectRoot`. */
|
|
31
|
+
export declare function getRunLogDir(projectRoot: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
|
|
34
|
+
* logs of the same kind first.
|
|
35
|
+
*
|
|
36
|
+
* @param projectRoot - Project root directory
|
|
37
|
+
* @param command - Command kind, e.g. `test` or `build`
|
|
38
|
+
* @param now - Clock injection point for deterministic tests
|
|
39
|
+
* @returns The sink, or undefined when no log could be opened
|
|
40
|
+
*/
|
|
41
|
+
export declare function openRunLog(projectRoot: string, command: string, now?: Date): Promise<RunLog | undefined>;
|
|
42
|
+
/** Installs (or clears, with `undefined`) the run log for this process. */
|
|
43
|
+
export declare function setActiveRunLog(log: RunLog | undefined): void;
|
|
44
|
+
/** Absolute path of the active run log, or undefined when none is open. */
|
|
45
|
+
export declare function getActiveRunLogPath(): string | undefined;
|
|
46
|
+
/** Appends to the active run log. A no-op when none is open. */
|
|
47
|
+
export declare function writeToActiveRunLog(chunk: string): void;
|
|
48
|
+
/** Closes and clears the active run log. Resolves even when none is open. */
|
|
49
|
+
export declare function closeActiveRunLog(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* A writable that forwards to `base` AND to the active run log.
|
|
52
|
+
*
|
|
53
|
+
* Used by the stdio-inheriting capture path, whose collectors take a mirror
|
|
54
|
+
* stream rather than per-chunk callbacks — so the tee reuses the mirror the
|
|
55
|
+
* exec layer already supports instead of growing a second hook.
|
|
56
|
+
*
|
|
57
|
+
* @param base - The stream to forward to (normally `process.stdout`)
|
|
58
|
+
* @returns A writable teeing into `base` and the run log
|
|
59
|
+
*/
|
|
60
|
+
export declare function teeToRunLog(base: NodeJS.WritableStream): NodeJS.WritableStream;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Per-run output logs under `.fireforge/logs/`.
|
|
4
|
+
*
|
|
5
|
+
* A run's diagnosis used to exist in exactly one place: the terminal. Piping
|
|
6
|
+
* through `tail`/`grep` — the ergonomic default when output is long — keeps
|
|
7
|
+
* the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
|
|
8
|
+
* broke, and it launders the exit code besides. The operator rule ("never
|
|
9
|
+
* pipe a run") was written down three times downstream and broken after each
|
|
10
|
+
* writing, which is the tell that it is not an operator problem: the tool is
|
|
11
|
+
* the only party that can make the log survive the mistake.
|
|
12
|
+
*
|
|
13
|
+
* So `test` and `build` write their own complete copy as they stream, and the
|
|
14
|
+
* `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
|
|
15
|
+
* backgrounded run then still leaves a re-readable artifact.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
|
|
18
|
+
* a run must never fail because one could not be opened, written, or pruned.
|
|
19
|
+
* Every entry point swallows its errors and degrades to "no log", which is
|
|
20
|
+
* exactly the behaviour that existed before this module.
|
|
21
|
+
*/
|
|
22
|
+
import { createWriteStream } from 'node:fs';
|
|
23
|
+
import { mkdir, readdir, unlink } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { Writable } from 'node:stream';
|
|
26
|
+
import { verbose } from '../utils/logger.js';
|
|
27
|
+
import { FIREFORGE_DIR } from './config-paths.js';
|
|
28
|
+
/** Directory under `.fireforge/` holding run logs. */
|
|
29
|
+
const LOGS_DIRNAME = 'logs';
|
|
30
|
+
/**
|
|
31
|
+
* How many logs to keep per command kind. Retention is per-kind so a busy
|
|
32
|
+
* `test` loop cannot evict every `build` log, which is the pair an operator
|
|
33
|
+
* most often needs to read together.
|
|
34
|
+
*/
|
|
35
|
+
const RETAINED_LOGS_PER_COMMAND = 20;
|
|
36
|
+
/** `.fireforge/logs` under `projectRoot`. */
|
|
37
|
+
export function getRunLogDir(projectRoot) {
|
|
38
|
+
return join(projectRoot, FIREFORGE_DIR, LOGS_DIRNAME);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A filesystem-safe, sortable timestamp: `2026-08-28T14-32-05-123Z`.
|
|
42
|
+
*
|
|
43
|
+
* Colons are not portable in filenames (Windows rejects them outright), so
|
|
44
|
+
* the ISO form is punctuated with dashes only. Lexical order still equals
|
|
45
|
+
* chronological order, which is what the pruning below relies on.
|
|
46
|
+
*/
|
|
47
|
+
function logTimestamp(now) {
|
|
48
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Makes room for one new log by deleting the oldest, so that at most
|
|
52
|
+
* {@link RETAINED_LOGS_PER_COMMAND} logs for `command` exist once the new
|
|
53
|
+
* one is created. Pruning runs BEFORE the file is opened — hence the
|
|
54
|
+
* `- 1`, without which the directory settles at N + 1.
|
|
55
|
+
*
|
|
56
|
+
* Best-effort: a failure here must not stop the run that is about to be
|
|
57
|
+
* logged.
|
|
58
|
+
*/
|
|
59
|
+
async function pruneRunLogs(dir, command) {
|
|
60
|
+
const keep = RETAINED_LOGS_PER_COMMAND - 1;
|
|
61
|
+
try {
|
|
62
|
+
const entries = await readdir(dir);
|
|
63
|
+
const mine = entries.filter((name) => name.startsWith(`${command}-`) && name.endsWith('.log'));
|
|
64
|
+
if (mine.length <= keep)
|
|
65
|
+
return;
|
|
66
|
+
// Names embed a lexically-sortable timestamp, so sorting is ordering by
|
|
67
|
+
// age without stat-ing every file.
|
|
68
|
+
const stale = mine.sort().slice(0, mine.length - keep);
|
|
69
|
+
await Promise.all(stale.map(async (name) => {
|
|
70
|
+
try {
|
|
71
|
+
await unlink(join(dir, name));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A concurrent run may have pruned it already.
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
verbose(`Run-log pruning skipped: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
|
|
84
|
+
* logs of the same kind first.
|
|
85
|
+
*
|
|
86
|
+
* @param projectRoot - Project root directory
|
|
87
|
+
* @param command - Command kind, e.g. `test` or `build`
|
|
88
|
+
* @param now - Clock injection point for deterministic tests
|
|
89
|
+
* @returns The sink, or undefined when no log could be opened
|
|
90
|
+
*/
|
|
91
|
+
export async function openRunLog(projectRoot, command, now = new Date()) {
|
|
92
|
+
const dir = getRunLogDir(projectRoot);
|
|
93
|
+
let stream;
|
|
94
|
+
const path = join(dir, `${command}-${logTimestamp(now)}.log`);
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
await pruneRunLogs(dir, command);
|
|
98
|
+
stream = createWriteStream(path, { flags: 'a' });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
verbose(`Run log unavailable (${path}): ${String(error)}`);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// A stream error after open (disk full, a removed directory) must not
|
|
105
|
+
// become an unhandled 'error' event and kill the run the log exists to
|
|
106
|
+
// describe. Latch it and drop every later write.
|
|
107
|
+
let broken = false;
|
|
108
|
+
stream.on('error', (error) => {
|
|
109
|
+
broken = true;
|
|
110
|
+
verbose(`Run log write failed (${path}): ${error.message}`);
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
path,
|
|
114
|
+
write: (chunk) => {
|
|
115
|
+
if (broken)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
stream.write(chunk);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
broken = true;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
close: async () => {
|
|
125
|
+
await new Promise((resolve) => {
|
|
126
|
+
stream.end(() => {
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The run log for the command currently executing, if any.
|
|
135
|
+
*
|
|
136
|
+
* Module state rather than a threaded parameter, for the same reason
|
|
137
|
+
* `test-verdict.ts` keeps its emitted-once flag here: the sink has to be
|
|
138
|
+
* reachable from `mach.ts`'s stream callbacks, which sit five frames below
|
|
139
|
+
* the command that opened it, behind dispatch signatures that exist to
|
|
140
|
+
* describe TESTS rather than logging. Threading it would have touched every
|
|
141
|
+
* dispatcher, the retry wrapper and the shard loop to deliver one pointer.
|
|
142
|
+
*
|
|
143
|
+
* One command runs per process, so a single slot is the whole lifetime.
|
|
144
|
+
*/
|
|
145
|
+
let activeRunLog;
|
|
146
|
+
/** Installs (or clears, with `undefined`) the run log for this process. */
|
|
147
|
+
export function setActiveRunLog(log) {
|
|
148
|
+
activeRunLog = log;
|
|
149
|
+
}
|
|
150
|
+
/** Absolute path of the active run log, or undefined when none is open. */
|
|
151
|
+
export function getActiveRunLogPath() {
|
|
152
|
+
return activeRunLog?.path;
|
|
153
|
+
}
|
|
154
|
+
/** Appends to the active run log. A no-op when none is open. */
|
|
155
|
+
export function writeToActiveRunLog(chunk) {
|
|
156
|
+
activeRunLog?.write(chunk);
|
|
157
|
+
}
|
|
158
|
+
/** Closes and clears the active run log. Resolves even when none is open. */
|
|
159
|
+
export async function closeActiveRunLog() {
|
|
160
|
+
const log = activeRunLog;
|
|
161
|
+
activeRunLog = undefined;
|
|
162
|
+
await log?.close();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A writable that forwards to `base` AND to the active run log.
|
|
166
|
+
*
|
|
167
|
+
* Used by the stdio-inheriting capture path, whose collectors take a mirror
|
|
168
|
+
* stream rather than per-chunk callbacks — so the tee reuses the mirror the
|
|
169
|
+
* exec layer already supports instead of growing a second hook.
|
|
170
|
+
*
|
|
171
|
+
* @param base - The stream to forward to (normally `process.stdout`)
|
|
172
|
+
* @returns A writable teeing into `base` and the run log
|
|
173
|
+
*/
|
|
174
|
+
export function teeToRunLog(base) {
|
|
175
|
+
return new Writable({
|
|
176
|
+
write(chunk, _encoding, callback) {
|
|
177
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
178
|
+
base.write(text);
|
|
179
|
+
writeToActiveRunLog(text);
|
|
180
|
+
callback();
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -8,7 +8,30 @@ export interface PostRebuildFailureContext {
|
|
|
8
8
|
requestedPaths: readonly string[];
|
|
9
9
|
firstFailureLine?: string;
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Finds the first high-signal failure line from captured mach test output.
|
|
13
|
+
*
|
|
14
|
+
* Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
|
|
15
|
+
* `AttributeError:` arm above has exactly the same standing as
|
|
16
|
+
* `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
|
|
17
|
+
* is how a run whose real defect was an export shard's file-count assertion
|
|
18
|
+
* got diagnosed as the recognized mozsystemmonitor teardown crash: the
|
|
19
|
+
* information was present and the SELECTION was wrong, which is the
|
|
20
|
+
* expensive kind of wrong, because the named cause is a real, documented,
|
|
21
|
+
* unrelated upstream defect and therefore reads as an answer.
|
|
22
|
+
*
|
|
23
|
+
* So a capture carrying the recognized teardown incident gets two passes:
|
|
24
|
+
* candidates excluding its `AttributeError` header first, and only if that
|
|
25
|
+
* finds nothing does the full set apply. The narrowness the rest of the
|
|
26
|
+
* codebase maintains is preserved exactly — the exclusion is gated on
|
|
27
|
+
* `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
|
|
28
|
+
* closed attribute allowlist and a `resourcemonitor.py` frame. A novel
|
|
29
|
+
* attribute, or a traceback from anywhere else, stays a real failure and is
|
|
30
|
+
* still eligible to be reported.
|
|
31
|
+
*
|
|
32
|
+
* @param output - Raw captured stdout/stderr from the run
|
|
33
|
+
* @returns The chosen failure line, or the first non-empty line
|
|
34
|
+
*/
|
|
12
35
|
export declare function findFirstUsefulFailureLine(output: string): string | undefined;
|
|
13
36
|
/** Starts a post-rebuild context block for a focused test failure. */
|
|
14
37
|
export declare function createPostRebuildFailureContext(rebuildCommand: string, requestedPaths: readonly string[]): PostRebuildFailureContext;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { hasKnownTeardownNoise, isKnownTeardownNoiseLine } from './mach-known-noise-filter.js';
|
|
2
3
|
function getNonEmptyOutputLines(output) {
|
|
3
4
|
return output
|
|
4
5
|
.split(/\r?\n/)
|
|
@@ -8,23 +9,53 @@ function getNonEmptyOutputLines(output) {
|
|
|
8
9
|
function findFirstMatchingLine(lines, patterns) {
|
|
9
10
|
return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
const FAILURE_LINE_PATTERNS = [
|
|
13
|
+
/\bTEST-UNEXPECTED-[A-Z-]+\b/,
|
|
14
|
+
/\bPROCESS-CRASH\b/i,
|
|
15
|
+
/\bTIMEOUT\b/i,
|
|
16
|
+
/timed out/i,
|
|
17
|
+
/HominisBrowserUnavailableError/i,
|
|
18
|
+
/Marionette.*(?:session|startup|start).*fail/i,
|
|
19
|
+
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
20
|
+
/SessionNotCreatedException/i,
|
|
21
|
+
/Browser process exited during spawn/i,
|
|
22
|
+
/Failed to load (?:resource|chrome):\/\//i,
|
|
23
|
+
/\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
|
|
24
|
+
/AttributeError:\s+/,
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Finds the first high-signal failure line from captured mach test output.
|
|
28
|
+
*
|
|
29
|
+
* Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
|
|
30
|
+
* `AttributeError:` arm above has exactly the same standing as
|
|
31
|
+
* `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
|
|
32
|
+
* is how a run whose real defect was an export shard's file-count assertion
|
|
33
|
+
* got diagnosed as the recognized mozsystemmonitor teardown crash: the
|
|
34
|
+
* information was present and the SELECTION was wrong, which is the
|
|
35
|
+
* expensive kind of wrong, because the named cause is a real, documented,
|
|
36
|
+
* unrelated upstream defect and therefore reads as an answer.
|
|
37
|
+
*
|
|
38
|
+
* So a capture carrying the recognized teardown incident gets two passes:
|
|
39
|
+
* candidates excluding its `AttributeError` header first, and only if that
|
|
40
|
+
* finds nothing does the full set apply. The narrowness the rest of the
|
|
41
|
+
* codebase maintains is preserved exactly — the exclusion is gated on
|
|
42
|
+
* `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
|
|
43
|
+
* closed attribute allowlist and a `resourcemonitor.py` frame. A novel
|
|
44
|
+
* attribute, or a traceback from anywhere else, stays a real failure and is
|
|
45
|
+
* still eligible to be reported.
|
|
46
|
+
*
|
|
47
|
+
* @param output - Raw captured stdout/stderr from the run
|
|
48
|
+
* @returns The chosen failure line, or the first non-empty line
|
|
49
|
+
*/
|
|
12
50
|
export function findFirstUsefulFailureLine(output) {
|
|
13
51
|
const lines = getNonEmptyOutputLines(output);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
22
|
-
/SessionNotCreatedException/i,
|
|
23
|
-
/Browser process exited during spawn/i,
|
|
24
|
-
/Failed to load (?:resource|chrome):\/\//i,
|
|
25
|
-
/\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
|
|
26
|
-
/AttributeError:\s+/,
|
|
27
|
-
]);
|
|
52
|
+
if (hasKnownTeardownNoise(output)) {
|
|
53
|
+
const withoutNoise = lines.filter((line) => !isKnownTeardownNoiseLine(line));
|
|
54
|
+
const preferred = findFirstMatchingLine(withoutNoise, FAILURE_LINE_PATTERNS);
|
|
55
|
+
if (preferred !== undefined)
|
|
56
|
+
return preferred;
|
|
57
|
+
}
|
|
58
|
+
const matched = findFirstMatchingLine(lines, FAILURE_LINE_PATTERNS);
|
|
28
59
|
return matched ?? lines[0];
|
|
29
60
|
}
|
|
30
61
|
/** Starts a post-rebuild context block for a focused test failure. */
|
|
@@ -4,11 +4,11 @@ export interface XpcshellRetryClassification {
|
|
|
4
4
|
nonXpcshell: readonly string[];
|
|
5
5
|
}
|
|
6
6
|
/** Dispatches a (possibly suite-specific) mach test run, mirroring `testWithOutput`. */
|
|
7
|
-
export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string
|
|
7
|
+
export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string>, fullOutput?: boolean) => Promise<MachCommandResult>;
|
|
8
8
|
/**
|
|
9
9
|
* Removes a stale xpcshell install symlink and retries the focused mach test
|
|
10
10
|
* once. The retry uses the same `dispatch` (suite-specific or generic) the
|
|
11
11
|
* caller is already running on, so an xpcshell-suite run repairs and re-runs
|
|
12
12
|
* via `mach xpcshell-test` rather than falling back to the generic command.
|
|
13
13
|
*/
|
|
14
|
-
export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch): Promise<MachCommandResult>;
|
|
14
|
+
export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch, fullOutput?: boolean): Promise<MachCommandResult>;
|
|
@@ -7,12 +7,17 @@ import { tryRepairStaleXpcshellTestSymlink } from './test-stale-symlink.js';
|
|
|
7
7
|
* caller is already running on, so an xpcshell-suite run repairs and re-runs
|
|
8
8
|
* via `mach xpcshell-test` rather than falling back to the generic command.
|
|
9
9
|
*/
|
|
10
|
-
export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput) {
|
|
10
|
+
export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput, fullOutput) {
|
|
11
11
|
if (result.exitCode !== 0 &&
|
|
12
12
|
classification.xpcshell.length > 0 &&
|
|
13
13
|
classification.nonXpcshell.length === 0) {
|
|
14
14
|
const repaired = await tryRepairStaleXpcshellTestSymlink(engineDir, objDir, `${result.stdout}\n${result.stderr}`);
|
|
15
15
|
if (repaired) {
|
|
16
|
+
// The repaired re-run is the same logical invocation, so it keeps
|
|
17
|
+
// the verbosity the run was started with.
|
|
18
|
+
if (fullOutput === true) {
|
|
19
|
+
return dispatch(engineDir, normalizedPaths, extraArgs, env, true);
|
|
20
|
+
}
|
|
16
21
|
return env
|
|
17
22
|
? dispatch(engineDir, normalizedPaths, extraArgs, env)
|
|
18
23
|
: dispatch(engineDir, normalizedPaths, extraArgs);
|
|
@@ -19,8 +19,13 @@ export interface AddTokenOptions {
|
|
|
19
19
|
tokenName: string;
|
|
20
20
|
/** CSS value (e.g., "1px", "var(--space-small)", "light-dark(#fff, #000)") */
|
|
21
21
|
value: string;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Token category matching section headers in the CSS file. Required for a
|
|
24
|
+
* base declaration; ignored — and no longer required — under
|
|
25
|
+
* {@link AddTokenOptions.variant}, which routes into a `:root<selector>`
|
|
26
|
+
* block and never touches a category section.
|
|
27
|
+
*/
|
|
28
|
+
category?: string | undefined;
|
|
24
29
|
/** Dark mode behavior */
|
|
25
30
|
mode: TokenMode;
|
|
26
31
|
/** Comment description for the CSS file */
|