@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +18 -110
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/furnace/apply.js +2 -2
  6. package/dist/src/commands/furnace/deploy.js +2 -2
  7. package/dist/src/commands/furnace/index.js +12 -6
  8. package/dist/src/commands/furnace/init.js +5 -2
  9. package/dist/src/commands/furnace/sync.js +2 -2
  10. package/dist/src/commands/re-export-files.js +63 -23
  11. package/dist/src/commands/re-export-scan.js +18 -2
  12. package/dist/src/commands/re-export-single.js +19 -7
  13. package/dist/src/commands/test-register.js +5 -1
  14. package/dist/src/commands/test-run.d.ts +5 -0
  15. package/dist/src/commands/test-run.js +11 -4
  16. package/dist/src/commands/test-verdict.js +10 -1
  17. package/dist/src/commands/test.js +9 -0
  18. package/dist/src/commands/token.js +36 -12
  19. package/dist/src/core/build-audit.js +6 -2
  20. package/dist/src/core/furnace-jsconfig.js +5 -3
  21. package/dist/src/core/furnace-operation.d.ts +25 -2
  22. package/dist/src/core/furnace-operation.js +27 -2
  23. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  24. package/dist/src/core/mach-known-noise-filter.js +19 -0
  25. package/dist/src/core/mach.d.ts +8 -3
  26. package/dist/src/core/mach.js +47 -3
  27. package/dist/src/core/run-log.d.ts +60 -0
  28. package/dist/src/core/run-log.js +183 -0
  29. package/dist/src/core/test-harness-output.d.ts +24 -1
  30. package/dist/src/core/test-harness-output.js +46 -15
  31. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  32. package/dist/src/core/test-xpcshell-retry.js +6 -1
  33. package/dist/src/core/token-manager.d.ts +7 -2
  34. package/dist/src/core/token-manager.js +74 -20
  35. package/dist/src/core/token-variant.d.ts +46 -2
  36. package/dist/src/core/token-variant.js +190 -33
  37. package/dist/src/types/commands/options.d.ts +30 -1
  38. package/dist/src/utils/child-env.d.ts +30 -0
  39. package/dist/src/utils/child-env.js +30 -0
  40. package/dist/src/utils/options.d.ts +28 -0
  41. package/dist/src/utils/options.js +32 -1
  42. package/dist/src/utils/process.d.ts +22 -0
  43. package/dist/src/utils/process.js +8 -7
  44. package/docs/README.md +35 -0
  45. package/docs/configuration.md +116 -0
  46. package/docs/exit-codes.md +63 -0
  47. package/docs/furnace.md +57 -0
  48. package/docs/lifecycle-invariants.md +309 -0
  49. package/docs/machine-output.md +94 -0
  50. package/docs/patch-workflow.md +119 -0
  51. package/docs/run-logs.md +60 -0
  52. package/docs/testing.md +92 -0
  53. package/docs/verification-trees.md +48 -0
  54. package/package.json +2 -1
@@ -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
- /** Finds the first high-signal failure line from captured mach test output. */
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
- /** Finds the first high-signal failure line from captured mach test output. */
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
- const matched = findFirstMatchingLine(lines, [
15
- /\bTEST-UNEXPECTED-[A-Z-]+\b/,
16
- /\bPROCESS-CRASH\b/i,
17
- /\bTIMEOUT\b/i,
18
- /timed out/i,
19
- /HominisBrowserUnavailableError/i,
20
- /Marionette.*(?:session|startup|start).*fail/i,
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>) => Promise<MachCommandResult>;
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
- /** Token category matching section headers in the CSS file */
23
- category: string;
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 */