@hominis/fireforge 0.33.0 → 0.34.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/src/commands/build.js +9 -1
  3. package/dist/src/commands/doctor-furnace-jar.d.ts +16 -0
  4. package/dist/src/commands/doctor-furnace-jar.js +47 -0
  5. package/dist/src/commands/doctor-furnace.js +2 -0
  6. package/dist/src/commands/export-flow.js +4 -1
  7. package/dist/src/commands/export-placement-gate.js +4 -1
  8. package/dist/src/commands/export.js +61 -4
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +20 -0
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +52 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +10 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +31 -4
  13. package/dist/src/commands/furnace/create-browser-test.d.ts +30 -0
  14. package/dist/src/commands/furnace/create-browser-test.js +180 -0
  15. package/dist/src/commands/furnace/create-mochikit.js +11 -4
  16. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  17. package/dist/src/commands/furnace/create-xpcshell.js +38 -12
  18. package/dist/src/commands/furnace/create.js +7 -114
  19. package/dist/src/commands/furnace/index.js +6 -1
  20. package/dist/src/commands/furnace/scan.d.ts +1 -0
  21. package/dist/src/commands/furnace/scan.js +57 -27
  22. package/dist/src/commands/furnace/validate.js +17 -1
  23. package/dist/src/commands/re-export-options.d.ts +26 -0
  24. package/dist/src/commands/re-export-options.js +48 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +10 -1
  27. package/dist/src/commands/test-diagnose.js +12 -0
  28. package/dist/src/commands/test.js +29 -22
  29. package/dist/src/core/furnace-registration.d.ts +24 -0
  30. package/dist/src/core/furnace-registration.js +56 -3
  31. package/dist/src/core/furnace-validate-registration.js +18 -0
  32. package/dist/src/core/mach-resource-shim.d.ts +70 -16
  33. package/dist/src/core/mach-resource-shim.js +310 -52
  34. package/dist/src/core/mach.d.ts +51 -10
  35. package/dist/src/core/mach.js +76 -32
  36. package/dist/src/core/manifest-helpers.d.ts +13 -0
  37. package/dist/src/core/manifest-helpers.js +1 -1
  38. package/dist/src/core/manifest-rules.d.ts +13 -3
  39. package/dist/src/core/manifest-rules.js +44 -17
  40. package/dist/src/core/patch-export.d.ts +10 -0
  41. package/dist/src/core/patch-export.js +23 -2
  42. package/dist/src/core/register-module.d.ts +1 -1
  43. package/dist/src/core/register-module.js +15 -2
  44. package/dist/src/core/register-result.d.ts +9 -0
  45. package/dist/src/core/register-scaffold.d.ts +55 -0
  46. package/dist/src/core/register-scaffold.js +146 -0
  47. package/dist/src/core/register-test-manifest.js +3 -1
  48. package/dist/src/core/register-xpcshell-test.d.ts +30 -0
  49. package/dist/src/core/register-xpcshell-test.js +96 -0
  50. package/dist/src/core/test-harness-crash.d.ts +26 -1
  51. package/dist/src/core/test-harness-crash.js +149 -11
  52. package/dist/src/core/xpcshell-appdir.d.ts +7 -0
  53. package/dist/src/core/xpcshell-appdir.js +12 -3
  54. package/dist/src/types/commands/options.d.ts +12 -0
  55. package/package.json +3 -3
@@ -32,7 +32,32 @@ export interface HarnessCrashSignature {
32
32
  export interface HarnessRunVerdict {
33
33
  kind: HarnessRunClassification;
34
34
  signature?: HarnessCrashSignature;
35
+ /**
36
+ * Set when a non-zero mach exit code was overridden because the output
37
+ * embeds a completed green summary — the exit code followed harness
38
+ * noise, not a test result. Callers should surface a note.
39
+ */
40
+ greenSummaryOverride?: boolean;
35
41
  }
42
+ /**
43
+ * Strips non-signal noise from captured output before crash-signature
44
+ * matching: resource-monitor degradation warnings, and traceback blocks
45
+ * that mach caught itself (telemetry submission tracebacks are printed but
46
+ * never abort the run). The stripped text is used ONLY as crash evidence —
47
+ * classification of test results still reads the full output.
48
+ */
49
+ export declare function stripNonSignalNoise(output: string): string;
50
+ /**
51
+ * True when the output embeds a COMPLETED, GREEN suite summary: an
52
+ * execution signal, `Unexpected results: 0` (and no non-zero unexpected
53
+ * count), a `SUITE_END` marker, and no real `TEST-UNEXPECTED-*` lines.
54
+ * Such a run finished its suite; any startup-traceback-shaped noise in the
55
+ * same output is by definition non-fatal, so this vetoes signature-based
56
+ * crash classification (field report: fully green sharded runs reported
57
+ * `CRASH (N attempts)` because degradation warnings matched the psutil
58
+ * signals). Exported for direct unit testing.
59
+ */
60
+ export declare function hasCompletedGreenSummary(output: string): boolean;
36
61
  /**
37
62
  * Detects the known harness-crash shapes in captured mach output.
38
63
  * Returns undefined for anything that looks like a genuine test result.
@@ -55,7 +80,7 @@ export declare function detectHarnessCrashSignature(output: string): HarnessCras
55
80
  */
56
81
  export declare function classifyHarnessRun(exitCode: number, output: string, requestedPaths: readonly string[]): HarnessRunVerdict;
57
82
  /** Builds the operator-facing failure message after retries are exhausted. */
58
- export declare function buildHarnessCrashMessage(signature: HarnessCrashSignature, attempts: number): string;
83
+ export declare function buildHarnessCrashMessage(signature: HarnessCrashSignature, attempts: number, commandLabel?: string): string;
59
84
  /**
60
85
  * Builds the message for a run that produced no `TEST-START` despite
61
86
  * requesting paths — including exit-code-zero runs whose `Passed: 0`
@@ -23,6 +23,15 @@
23
23
  * of reporting phantom test failures (or phantom passes).
24
24
  */
25
25
  const TEST_START_PATTERN = /\bTEST-START\b/;
26
+ /**
27
+ * Structured-log execution marker (`mach xpcshell-test` mozlog output uses
28
+ * `TEST_START`/`SUITE_END` with underscores, not the hyphenated
29
+ * `TEST-START` the browser-chrome dispatch prints).
30
+ */
31
+ const STRUCTURED_TEST_START_PATTERN = /\bTEST_START\b/;
32
+ const SUITE_END_PATTERN = /\bSUITE_END\b/;
33
+ const GREEN_UNEXPECTED_SUMMARY_PATTERN = /\bUnexpected results:\s*0\b/;
34
+ const NONZERO_UNEXPECTED_SUMMARY_PATTERN = /\bUnexpected results:\s*[1-9]/;
26
35
  /**
27
36
  * Execution signals emitted by the suite-specific xpcshell dispatch
28
37
  * (`mach xpcshell-test`), which does NOT print `TEST-START` lines the way
@@ -47,6 +56,17 @@ const NO_OUTPUT_TIMEOUT_PATTERN = /timed out after \d+ seconds with no output/i;
47
56
  * on macOS. Each is matched per-line so the evidence line in the report is
48
57
  * the concrete failure, not the whole traceback.
49
58
  */
59
+ // Downstream report (0.34.0 cycle): a pre-fix _DegradedReading fallback that
60
+ // wasn't a real namedtuple duck type crashed mozsystemmonitor on the fallback
61
+ // itself (`_build_meta` subscripts the reading; `_collect` unpacks it); a
62
+ // startup abort with zero TEST-START lines from this family is a crash, not
63
+ // a test failure. The `_collect failed` variant is caught-and-logged, so it
64
+ // carries no Traceback header — it gets its own zero-TEST-START check in
65
+ // `detectHarnessCrashSignature` besides joining the traceback cluster.
66
+ const DEGRADED_READING_CRASH_SIGNALS = [
67
+ /'_DegradedReading' object is not subscriptable/,
68
+ /'_DegradedReading' object is not iterable/,
69
+ ];
50
70
  const STARTUP_TRACEBACK_SIGNALS = [
51
71
  /AttributeError:.*SystemResourceMonitor/,
52
72
  /'SystemResourceMonitor' object has no attribute/,
@@ -55,6 +75,7 @@ const STARTUP_TRACEBACK_SIGNALS = [
55
75
  /HOST_VM_INFO64/,
56
76
  /\(ipc\/mig\) array not large enough/,
57
77
  /psutil\.[A-Za-z]*Error/,
78
+ ...DEGRADED_READING_CRASH_SIGNALS,
58
79
  ];
59
80
  function findLine(output, patterns) {
60
81
  for (const line of output.split(/\r?\n/)) {
@@ -63,6 +84,91 @@ function findLine(output, patterns) {
63
84
  }
64
85
  return undefined;
65
86
  }
87
+ /**
88
+ * Non-signal noise lines: the resource-monitor degrade path (FireForge's
89
+ * own guard plus mozlog's `_collect failed`) prints warnings on runs that
90
+ * then complete green. Field report (0.34.0 cycle): every multi-file suite
91
+ * was reported CRASH because these lines matched the startup-traceback
92
+ * signals even though the embedded summary was fully green. They are
93
+ * excluded from crash evidence entirely.
94
+ */
95
+ const NOISE_LINE_PATTERNS = [
96
+ /\bUserWarning\b/,
97
+ /psutil failed to run/i,
98
+ // `_collect failed` chatter is noise on green runs, EXCEPT the
99
+ // `_DegradedReading` variant: that is the crash evidence for the
100
+ // "object is not iterable" startup-abort signature above and must
101
+ // survive stripNonSignalNoise.
102
+ /_collect failed(?!.*_DegradedReading)/i,
103
+ /FireForge: host resource monitor degraded/i,
104
+ /warnings\.warn\(/,
105
+ ];
106
+ /** Matches the caught/telemetry context that marks a traceback as benign. */
107
+ const BENIGN_TRACEBACK_CONTEXT = /telemetry|glean/i;
108
+ /**
109
+ * Strips non-signal noise from captured output before crash-signature
110
+ * matching: resource-monitor degradation warnings, and traceback blocks
111
+ * that mach caught itself (telemetry submission tracebacks are printed but
112
+ * never abort the run). The stripped text is used ONLY as crash evidence —
113
+ * classification of test results still reads the full output.
114
+ */
115
+ export function stripNonSignalNoise(output) {
116
+ const lines = output.split(/\r?\n/);
117
+ const kept = [];
118
+ for (let i = 0; i < lines.length; i += 1) {
119
+ const line = lines[i] ?? '';
120
+ if (NOISE_LINE_PATTERNS.some((p) => p.test(line)))
121
+ continue;
122
+ if (TRACEBACK_PATTERN.test(line)) {
123
+ // Collect the whole traceback block: the header, indented frame/code
124
+ // lines, and the trailing unindented exception line.
125
+ const block = [line];
126
+ let j = i + 1;
127
+ for (; j < lines.length; j += 1) {
128
+ const blockLine = lines[j] ?? '';
129
+ block.push(blockLine);
130
+ const isIndented = /^\s/.test(blockLine) || blockLine.trim().length === 0;
131
+ if (!isIndented)
132
+ break; // unindented exception line terminates the block
133
+ }
134
+ if (BENIGN_TRACEBACK_CONTEXT.test(block.join('\n'))) {
135
+ i = j; // drop the whole caught-telemetry traceback block
136
+ continue;
137
+ }
138
+ // A real traceback stays in evidence line-by-line (minus noise lines
139
+ // already filtered above).
140
+ }
141
+ kept.push(line);
142
+ }
143
+ return kept.join('\n');
144
+ }
145
+ /**
146
+ * True when the output carries any execution signal: the browser-chrome
147
+ * `TEST-START` marker, the structured-log `TEST_START` marker, or the
148
+ * suite-specific xpcshell result-summary block.
149
+ */
150
+ function hasExecutionSignal(output) {
151
+ return (TEST_START_PATTERN.test(output) ||
152
+ STRUCTURED_TEST_START_PATTERN.test(output) ||
153
+ hasXpcshellResultSummary(output));
154
+ }
155
+ /**
156
+ * True when the output embeds a COMPLETED, GREEN suite summary: an
157
+ * execution signal, `Unexpected results: 0` (and no non-zero unexpected
158
+ * count), a `SUITE_END` marker, and no real `TEST-UNEXPECTED-*` lines.
159
+ * Such a run finished its suite; any startup-traceback-shaped noise in the
160
+ * same output is by definition non-fatal, so this vetoes signature-based
161
+ * crash classification (field report: fully green sharded runs reported
162
+ * `CRASH (N attempts)` because degradation warnings matched the psutil
163
+ * signals). Exported for direct unit testing.
164
+ */
165
+ export function hasCompletedGreenSummary(output) {
166
+ return (hasExecutionSignal(output) &&
167
+ SUITE_END_PATTERN.test(output) &&
168
+ GREEN_UNEXPECTED_SUMMARY_PATTERN.test(output) &&
169
+ !NONZERO_UNEXPECTED_SUMMARY_PATTERN.test(output) &&
170
+ realUnexpectedFailureLines(output).length === 0);
171
+ }
66
172
  /**
67
173
  * True when the captured output carries the suite-specific xpcshell
68
174
  * result-summary block, which proves tests executed even though the
@@ -83,13 +189,22 @@ function realUnexpectedFailureLines(output) {
83
189
  * Returns undefined for anything that looks like a genuine test result.
84
190
  */
85
191
  export function detectHarnessCrashSignature(output) {
86
- const hasTestStart = TEST_START_PATTERN.test(output);
192
+ const hasTestStart = hasExecutionSignal(output);
87
193
  const realFailures = realUnexpectedFailureLines(output);
88
- // Startup traceback cluster (resource monitor / psutil). Real test
89
- // failures take precedence: a traceback printed during teardown of a
90
- // genuinely failing run must not get the whole run retried.
91
- if (TRACEBACK_PATTERN.test(output) && realFailures.length === 0) {
92
- const signalLine = findLine(output, STARTUP_TRACEBACK_SIGNALS);
194
+ // A completed green embedded summary vetoes signature-based crash
195
+ // classification outright: the suite finished, so any startup-shaped
196
+ // noise in the same output was non-fatal. (The post-green shutdown
197
+ // re-entry shape below is exempt — it is deliberately a crash verdict on
198
+ // an otherwise-green log, keyed on its own explicit markers.)
199
+ const greenSummaryVeto = hasCompletedGreenSummary(output);
200
+ // Startup traceback cluster (resource monitor / psutil), scanned over
201
+ // noise-stripped evidence so degradation warnings and caught telemetry
202
+ // tracebacks never count. Real test failures take precedence: a
203
+ // traceback printed during teardown of a genuinely failing run must not
204
+ // get the whole run retried.
205
+ const evidence = stripNonSignalNoise(output);
206
+ if (!greenSummaryVeto && TRACEBACK_PATTERN.test(evidence) && realFailures.length === 0) {
207
+ const signalLine = findLine(evidence, STARTUP_TRACEBACK_SIGNALS);
93
208
  if (signalLine) {
94
209
  return { reason: 'harness startup traceback (resource monitor/psutil)', line: signalLine };
95
210
  }
@@ -98,7 +213,19 @@ export function detectHarnessCrashSignature(output) {
98
213
  // the no-output timeout. A trailing "Passed: 0" summary is part of this
99
214
  // shape and must not be read as a result.
100
215
  if (!hasTestStart) {
101
- const timeoutLine = findLine(output, [NO_OUTPUT_TIMEOUT_PATTERN]);
216
+ // Startup abort on the degraded-reading fallback family: the `_collect
217
+ // failed: '_DegradedReading' ...` variant is caught-and-logged (no
218
+ // Traceback header), so the traceback cluster above never sees it.
219
+ if (realFailures.length === 0) {
220
+ const degradedLine = findLine(evidence, DEGRADED_READING_CRASH_SIGNALS);
221
+ if (degradedLine) {
222
+ return {
223
+ reason: 'degraded resource reading crashed the harness fallback (_DegradedReading)',
224
+ line: degradedLine,
225
+ };
226
+ }
227
+ }
228
+ const timeoutLine = findLine(evidence, [NO_OUTPUT_TIMEOUT_PATTERN]);
102
229
  if (timeoutLine) {
103
230
  return { reason: 'no-output timeout before any test started', line: timeoutLine };
104
231
  }
@@ -133,15 +260,26 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
133
260
  if (signature) {
134
261
  return { kind: 'harness-crash', signature };
135
262
  }
136
- const ranTests = TEST_START_PATTERN.test(output) || hasXpcshellResultSummary(output);
263
+ const ranTests = hasExecutionSignal(output);
137
264
  if (!ranTests && requestedPaths.length > 0) {
138
265
  return { kind: 'no-tests' };
139
266
  }
140
- return exitCode === 0 ? { kind: 'tests-ran-ok' } : { kind: 'test-failures' };
267
+ if (exitCode === 0) {
268
+ return { kind: 'tests-ran-ok' };
269
+ }
270
+ // Exit codes follow the corrected verdict: a run whose embedded summary
271
+ // completed green is a pass even when the wrapper exit code went
272
+ // non-zero on harness noise (field report: a fully green --no-shard run
273
+ // exited 1). Real failures always carry a non-zero unexpected count or
274
+ // TEST-UNEXPECTED lines, both of which fail the green-summary check.
275
+ if (hasCompletedGreenSummary(output)) {
276
+ return { kind: 'tests-ran-ok', greenSummaryOverride: true };
277
+ }
278
+ return { kind: 'test-failures' };
141
279
  }
142
280
  /** Builds the operator-facing failure message after retries are exhausted. */
143
- export function buildHarnessCrashMessage(signature, attempts) {
144
- return (`mach test crashed in the harness itself (not in your tests) on all ${attempts} attempt(s).\n\n` +
281
+ export function buildHarnessCrashMessage(signature, attempts, commandLabel = 'mach test') {
282
+ return (`${commandLabel} crashed in the harness itself (not in your tests) on all ${attempts} attempt(s).\n\n` +
145
283
  `Detected shape: ${signature.reason}\n` +
146
284
  `Evidence line: ${signature.line}\n\n` +
147
285
  'This failure mode is environmental (mozlog resource monitor / psutil on macOS, focus-stall ' +
@@ -77,6 +77,13 @@ export declare function parseAppdirFromToml(tomlText: string, key: string): {
77
77
  *
78
78
  * Special-cases `startPath` itself when it already ends with
79
79
  * `xpcshell.toml` — operators sometimes pass a manifest path directly.
80
+ *
81
+ * When `startPath` is a DIRECTORY, the walk starts at the directory itself
82
+ * (checking `<dir>/xpcshell.toml` first) rather than at its parent. Field
83
+ * report (0.34.0 cycle): a directory whose own manifest is an
84
+ * `xpcshell.toml` was classified non-xpcshell — the walk began at the
85
+ * parent, missed the manifest, and `fireforge test <dir>` dispatched to
86
+ * the mochitest runner, which found no mochitests.
80
87
  */
81
88
  export declare function findNearestXpcshellManifest(engineDir: string, startPath: string): Promise<string | null>;
82
89
  /**
@@ -38,7 +38,7 @@
38
38
  * test.ts; we skip injection when `--app-path=` already appears in the
39
39
  * forwarded args).
40
40
  */
41
- import { readdir } from 'node:fs/promises';
41
+ import { readdir, stat } from 'node:fs/promises';
42
42
  import { dirname, join, resolve, sep } from 'node:path';
43
43
  import { pathExists, readJson, readText } from '../utils/fs.js';
44
44
  import { isObject, isString } from '../utils/validation.js';
@@ -110,19 +110,28 @@ function stripQuotes(raw) {
110
110
  *
111
111
  * Special-cases `startPath` itself when it already ends with
112
112
  * `xpcshell.toml` — operators sometimes pass a manifest path directly.
113
+ *
114
+ * When `startPath` is a DIRECTORY, the walk starts at the directory itself
115
+ * (checking `<dir>/xpcshell.toml` first) rather than at its parent. Field
116
+ * report (0.34.0 cycle): a directory whose own manifest is an
117
+ * `xpcshell.toml` was classified non-xpcshell — the walk began at the
118
+ * parent, missed the manifest, and `fireforge test <dir>` dispatched to
119
+ * the mochitest runner, which found no mochitests.
113
120
  */
114
121
  export async function findNearestXpcshellManifest(engineDir, startPath) {
115
122
  const absStart = resolve(engineDir, startPath);
116
123
  if (absStart.toLowerCase().endsWith(`${sep}xpcshell.toml`)) {
117
124
  return (await pathExists(absStart)) ? absStart : null;
118
125
  }
126
+ const startIsDirectory = await stat(absStart).then((stats) => stats.isDirectory(), () => false);
119
127
  const engineAbs = resolve(engineDir);
120
128
  let current = absStart;
121
- // First iteration walks down to a directory; subsequent ones walk up.
129
+ // First iteration resolves the starting directory (the path itself for a
130
+ // directory argument, its parent for a file); subsequent ones walk up.
122
131
  // Cap iterations defensively — a pathological symlink loop would
123
132
  // otherwise spin until the call stack overflows.
124
133
  for (let i = 0; i < 64; i += 1) {
125
- const dir = i === 0 ? dirname(absStart) : dirname(current);
134
+ const dir = i === 0 ? (startIsDirectory ? absStart : dirname(absStart)) : dirname(current);
126
135
  const candidate = join(dir, 'xpcshell.toml');
127
136
  if (await pathExists(candidate))
128
137
  return candidate;
@@ -530,6 +530,16 @@ export interface FurnaceRemoveOptions {
530
530
  export interface FurnaceCreateOptions {
531
531
  /** Component description */
532
532
  description?: string;
533
+ /**
534
+ * Engine-relative directory the test scaffold writes into, instead of
535
+ * the default `browser/base/content/test/<binaryName>/` (browser-chrome)
536
+ * or `browser/base/content/test/<binaryName>-xpcshell/<component>/`
537
+ * (xpcshell). Must stay under `browser/base/content/test/` so the
538
+ * manifest registration keeps working. Not supported for
539
+ * `--test-style=mochikit` (that harness lives in the upstream
540
+ * toolkit/content/tests/widgets tree).
541
+ */
542
+ testDir?: string;
533
543
  /** Include Fluent l10n support */
534
544
  localized?: boolean;
535
545
  /** Register in customElements.js (default: true) */
@@ -614,6 +624,8 @@ export interface WireOptions {
614
624
  export interface RegisterOptions {
615
625
  dryRun?: boolean;
616
626
  after?: string;
627
+ /** Scaffold a missing manifest (moz.build / xpcshell.toml) and wire the parent chain. */
628
+ createManifest?: boolean;
617
629
  }
618
630
  /**
619
631
  * Options for the patch delete command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -72,9 +72,9 @@
72
72
  "eslint-plugin-simple-import-sort": "^13.0.0",
73
73
  "fast-check": "^4.6.0",
74
74
  "husky": "^9.1.7",
75
- "knip": "6.17.1",
75
+ "knip": "6.23.0",
76
76
  "lint-staged": "^17.0.4",
77
- "prettier": "^3.7.4",
77
+ "prettier": "3.9.4",
78
78
  "tsx": "^4.7.0",
79
79
  "typescript": "~6.0.0",
80
80
  "typescript-eslint": "^8.0.0",