@hominis/fireforge 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/re-export-files.js +4 -1
  9. package/dist/src/commands/re-export.js +8 -1
  10. package/dist/src/commands/test-run.d.ts +10 -0
  11. package/dist/src/commands/test-run.js +13 -4
  12. package/dist/src/commands/test.js +46 -7
  13. package/dist/src/core/config-validate.js +26 -0
  14. package/dist/src/core/furnace-jsconfig.js +22 -2
  15. package/dist/src/core/git-base.d.ts +15 -0
  16. package/dist/src/core/git-base.js +32 -0
  17. package/dist/src/core/git-diff.d.ts +8 -0
  18. package/dist/src/core/git-diff.js +224 -59
  19. package/dist/src/core/git-file-ops.d.ts +39 -0
  20. package/dist/src/core/git-file-ops.js +82 -1
  21. package/dist/src/core/mach.d.ts +17 -0
  22. package/dist/src/core/mach.js +21 -0
  23. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  24. package/dist/src/core/patch-lint-checkjs.js +213 -67
  25. package/dist/src/core/patch-lint-css.d.ts +23 -0
  26. package/dist/src/core/patch-lint-css.js +172 -0
  27. package/dist/src/core/patch-lint.d.ts +34 -11
  28. package/dist/src/core/patch-lint.js +19 -163
  29. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  30. package/dist/src/core/test-xpcshell-retry.js +9 -4
  31. package/dist/src/core/typecheck-shim.d.ts +3 -1
  32. package/dist/src/core/typecheck-shim.js +43 -3
  33. package/dist/src/types/commands/options.d.ts +17 -0
  34. package/dist/src/types/config.d.ts +11 -2
  35. package/package.json +1 -1
@@ -11,7 +11,7 @@
11
11
  * harness runs cost startup time but make results reproducible; the
12
12
  * combined invocation stays available via `--no-shard`.
13
13
  */
14
- import { testWithOutput } from '../core/mach.js';
14
+ import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
15
15
  import { buildHarnessCrashMessage, classifyHarnessRun, } from '../core/test-harness-crash.js';
16
16
  import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
17
17
  import { BuildError } from '../errors/build.js';
@@ -19,6 +19,14 @@ import { info, note, warn } from '../utils/logger.js';
19
19
  import { maybeInjectAppdirArg } from './test-appdir.js';
20
20
  /** Default bounded retry budget for recognized harness crashes. */
21
21
  export const DEFAULT_HARNESS_RETRIES = 2;
22
+ /** Resolves the capturing mach dispatcher for a suite. */
23
+ function dispatchForSuite(suite) {
24
+ if (suite === 'xpcshell')
25
+ return xpcshellTestWithOutput;
26
+ if (suite === 'mochitest')
27
+ return mochitestWithOutput;
28
+ return testWithOutput;
29
+ }
22
30
  /**
23
31
  * Runs one mach test invocation for `paths`, retrying recognized harness
24
32
  * crashes up to the configured budget. Every attempt goes through the
@@ -27,6 +35,7 @@ export const DEFAULT_HARNESS_RETRIES = 2;
27
35
  export async function runTestsWithRetries(ctx, paths) {
28
36
  const extraArgs = [...ctx.baseExtraArgs];
29
37
  const appdirInjectionAttempted = await maybeInjectAppdirArg(ctx.engineDir, paths, ctx.objDir, extraArgs);
38
+ const dispatch = dispatchForSuite(ctx.suite);
30
39
  const maxAttempts = Math.max(1, ctx.harnessRetries + 1);
31
40
  let attempts = 0;
32
41
  let result;
@@ -34,9 +43,9 @@ export async function runTestsWithRetries(ctx, paths) {
34
43
  for (;;) {
35
44
  attempts += 1;
36
45
  result = ctx.env
37
- ? await testWithOutput(ctx.engineDir, paths, extraArgs, ctx.env)
38
- : await testWithOutput(ctx.engineDir, paths, extraArgs);
39
- result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, ctx.env);
46
+ ? await dispatch(ctx.engineDir, paths, extraArgs, ctx.env)
47
+ : await dispatch(ctx.engineDir, paths, extraArgs);
48
+ result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, ctx.env, dispatch);
40
49
  const combined = `${result.stdout}\n${result.stderr}`;
41
50
  verdict = classifyHarnessRun(result.exitCode, combined, paths);
42
51
  if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, withBuildLock, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
+ import { buildHarnessCrashMessage, detectHarnessCrashSignature, } from '../core/test-harness-crash.js';
8
9
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
9
10
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
10
11
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
@@ -42,6 +43,25 @@ async function classifyTestHarnesses(engineDir, normalizedPaths) {
42
43
  }
43
44
  return result;
44
45
  }
46
+ /**
47
+ * Picks the mach dispatch target for a (non-mixed) run. A single-suite run
48
+ * auto-routes to the suite-specific command (`mach xpcshell-test` /
49
+ * `mach mochitest`), which degrades a broken host resource monitor to a
50
+ * warning instead of crashing generic `mach test` at startup (E1). Mixed runs
51
+ * are rejected before this point; a path-less "run all" or an explicit
52
+ * `--generic-mach-test` opt-out stays on the generic command.
53
+ */
54
+ function resolveTestSuite(classification, forceGeneric) {
55
+ if (forceGeneric)
56
+ return 'generic';
57
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length === 0) {
58
+ return 'xpcshell';
59
+ }
60
+ if (classification.nonXpcshell.length > 0 && classification.xpcshell.length === 0) {
61
+ return 'mochitest';
62
+ }
63
+ return 'generic';
64
+ }
45
65
  function buildMixedHarnessMessage(classification) {
46
66
  return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
47
67
  'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
@@ -80,16 +100,31 @@ async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
80
100
  }
81
101
  return bundleCheck.expectedPath;
82
102
  }
83
- async function runPreTestBuild(projectRoot, paths, projectConfig) {
103
+ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries) {
84
104
  await withBuildLock(projectRoot, async () => {
85
105
  await prepareBuildEnvironment(projectRoot, paths, projectConfig);
86
106
  const s = spinner('Running incremental build...');
87
- const buildResult = await buildUI(paths.engine);
88
- if (buildResult.exitCode !== 0) {
107
+ // The pre-test build runs through mach too, so the same resource-monitor
108
+ // startup crash that aborts `mach test` can abort `mach build faster`.
109
+ // Run the harness-crash classifier over the build output and retry within
110
+ // the same `--harness-retries` budget rather than hard-failing with a
111
+ // bare "Pre-test build failed" (field report E2).
112
+ const maxAttempts = Math.max(1, harnessRetries + 1);
113
+ for (let attempt = 1;; attempt += 1) {
114
+ const buildResult = await buildUI(paths.engine);
115
+ if (buildResult.exitCode === 0) {
116
+ s.stop('Build complete');
117
+ return;
118
+ }
119
+ const signature = detectHarnessCrashSignature(`${buildResult.stdout}\n${buildResult.stderr}`);
120
+ if (signature && attempt < maxAttempts) {
121
+ s.message(`Pre-test build hit a harness crash (${signature.reason}); ` +
122
+ `retrying (attempt ${attempt + 1} of ${maxAttempts})...`);
123
+ continue;
124
+ }
89
125
  s.error('Pre-test build failed');
90
- throw new BuildError('Pre-test build failed', 'mach build faster');
126
+ throw new BuildError(signature ? buildHarnessCrashMessage(signature, attempt) : 'Pre-test build failed', 'mach build faster');
91
127
  }
92
- s.stop('Build complete');
93
128
  });
94
129
  }
95
130
  function logTestSelection(normalizedPaths) {
@@ -239,9 +274,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
239
274
  // missing-bundle path instead of a cryptic `Browser process exited
240
275
  // during spawn (exit code 1, signal none). stderr tail: (empty)`.
241
276
  const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
277
+ const harnessRetries = options.harnessRetries ?? DEFAULT_HARNESS_RETRIES;
242
278
  // Run incremental build if requested
243
279
  if (options.build) {
244
- await runPreTestBuild(projectRoot, paths, projectConfig);
280
+ await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries);
245
281
  info('');
246
282
  }
247
283
  else {
@@ -299,6 +335,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
299
335
  if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
300
336
  throw new GeneralError(buildMixedHarnessMessage(classification));
301
337
  }
338
+ const suite = resolveTestSuite(classification, options.genericMachTest === true);
302
339
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
303
340
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
304
341
  : [];
@@ -325,8 +362,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
325
362
  engineDir: paths.engine,
326
363
  objDir: buildCheck.objDir,
327
364
  classification,
365
+ suite,
328
366
  baseExtraArgs: extraArgs,
329
- harnessRetries: options.harnessRetries ?? DEFAULT_HARNESS_RETRIES,
367
+ harnessRetries,
330
368
  ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
331
369
  };
332
370
  const postRebuildContext = options.build
@@ -381,6 +419,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
381
419
  }
382
420
  return n;
383
421
  })
422
+ .option('--generic-mach-test', 'Force dispatch through generic `mach test` instead of the suite-specific `mach xpcshell-test` / `mach mochitest` a single-suite run auto-selects (the suite-specific commands skip the mozlog resource monitor that crashes `mach test` on some hosts).')
384
423
  .option('--no-shard', 'Run multiple test paths in one combined mach invocation instead of sequential per-file shards')
385
424
  .option('--perf-samples <path>', 'Publish a perf-sample artifact path to the harness via <BINARYNAME>_PERF_SAMPLE_JSON (resolved against the project root)')
386
425
  .option('--marionette-port <port>', 'Override the Marionette control port (default 2828) for the stale-browser probe, the --doctor preflight, and (unless --mach-arg includes --flavor=xpcshell) auto-forwarded mach args: --setpref=marionette.port=<n> (browser listener) and --marionette=127.0.0.1:<n> (mochitest client). Omits the client flag when --mach-arg already sets --marionette. Use when 2828 is busy or CI assigns another port.', (raw) => {
@@ -243,6 +243,28 @@ const PATCH_LINT_CHECKJS_COMPILER_OPTION_KEYS = [
243
243
  'noUnusedLocals',
244
244
  'noUnusedParameters',
245
245
  ];
246
+ /**
247
+ * Validates the reviewed `paths` mapping: an object of pattern → string[]
248
+ * targets, each pattern carrying at most one `*`. Lets patch-owned modules
249
+ * be typed from their real sources without an ambient stub shim.
250
+ */
251
+ function parseCheckJsPathsMapping(raw) {
252
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
253
+ throw new ConfigError('Config field "patchLint.checkJsCompilerOptions.paths" must be a plain object');
254
+ }
255
+ const rec = raw;
256
+ const out = {};
257
+ for (const [pattern, targets] of Object.entries(rec)) {
258
+ if ((pattern.match(/\*/g) ?? []).length > 1) {
259
+ throw new ConfigError(`Config field "patchLint.checkJsCompilerOptions.paths" key "${pattern}" may contain at most one "*"`);
260
+ }
261
+ if (!Array.isArray(targets) || targets.some((t) => typeof t !== 'string')) {
262
+ throw new ConfigError(`Config field "patchLint.checkJsCompilerOptions.paths.${pattern}" must be an array of strings`);
263
+ }
264
+ out[pattern] = targets;
265
+ }
266
+ return out;
267
+ }
246
268
  function parsePatchLintCheckJsCompilerOptions(raw) {
247
269
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
248
270
  throw new ConfigError('Config field "patchLint.checkJsCompilerOptions" must be a plain object');
@@ -251,6 +273,10 @@ function parsePatchLintCheckJsCompilerOptions(raw) {
251
273
  const allowed = new Set(PATCH_LINT_CHECKJS_COMPILER_OPTION_KEYS);
252
274
  const out = {};
253
275
  for (const key of Object.keys(rec)) {
276
+ if (key === 'paths') {
277
+ out.paths = parseCheckJsPathsMapping(rec[key]);
278
+ continue;
279
+ }
254
280
  if (!allowed.has(key)) {
255
281
  throw new ConfigError(`Config field "patchLint.checkJsCompilerOptions" has unknown key "${key}"`);
256
282
  }
@@ -51,12 +51,28 @@ async function computeDesiredChromePathEntries(config, customDir, jsconfigAbsPat
51
51
  for (const file of files.sort()) {
52
52
  if (!file.endsWith('.mjs'))
53
53
  continue;
54
- const sourcePath = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
54
+ // Emit a `./`-prefixed relative value. TypeScript treats a bare
55
+ // `paths` value (`moz-widget/moz-widget.mjs`) as non-relative and
56
+ // rejects it without `baseUrl` (TS5090); a `./`-prefixed value
57
+ // resolves against the jsconfig directory with no `baseUrl` (which
58
+ // TS6 deprecates, TS5101). `../`-prefixed paths are already relative
59
+ // and left untouched.
60
+ const rel = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
61
+ const sourcePath = rel.startsWith('.') ? rel : `./${rel}`;
55
62
  entries[`${CHROME_ELEMENTS_URL_PREFIX}${file}`] = [sourcePath];
56
63
  }
57
64
  }
58
65
  return entries;
59
66
  }
67
+ /**
68
+ * Compares two `paths` values treating a leading `./` as insignificant, so
69
+ * the reconciler does not churn between `./x` and bare `x` forms (either
70
+ * direction). Used to decide whether a managed entry is stale.
71
+ */
72
+ function samePathValue(a, b) {
73
+ const strip = (p) => (p.startsWith('./') ? p.slice(2) : p);
74
+ return strip(a) === strip(b);
75
+ }
60
76
  /** True when `key`/`value` is a Furnace-managed chrome-elements mapping. */
61
77
  function isManagedEntry(key, value, jsconfigDir, customDir) {
62
78
  if (!key.startsWith(CHROME_ELEMENTS_URL_PREFIX))
@@ -119,7 +135,11 @@ export async function syncFurnaceJsconfigPaths(root, config, options) {
119
135
  result.pruned.push(key);
120
136
  continue;
121
137
  }
122
- if (value[0] !== want[0]) {
138
+ // Treat `./x` and bare `x` as equal so a previously-synced bare value (or
139
+ // a hand-written `./` prefix) is not rewritten as "stale" on every run.
140
+ // The existing value is kept verbatim when equivalent — no churn either
141
+ // way; only a genuinely different target updates (to the `./` form).
142
+ if (!samePathValue(value[0] ?? '', want[0] ?? '')) {
123
143
  result.updated.push(key);
124
144
  nextPaths[key] = want;
125
145
  }
@@ -63,6 +63,21 @@ export declare function git(args: string[], cwd: string, options?: {
63
63
  timeout?: number;
64
64
  env?: Record<string, string>;
65
65
  }): Promise<string>;
66
+ /**
67
+ * Splits a pathspec list into chunks whose joined byte length stays well under
68
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
69
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
70
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
71
+ * leaves room for the fixed git arguments plus the inherited environment.
72
+ *
73
+ * Chunk boundaries are output-neutral for every batched caller here: each
74
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
75
+ * how the paths are grouped across invocations never affects the result.
76
+ * @param paths - Pathspecs to chunk
77
+ * @param budgetBytes - Maximum joined byte length per chunk
78
+ * @returns Path chunks, each safe to pass as a single argv tail
79
+ */
80
+ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): string[][];
66
81
  /**
67
82
  * Configures git performance settings for large trees.
68
83
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -72,6 +72,38 @@ export async function git(args, cwd, options) {
72
72
  }
73
73
  return result.stdout;
74
74
  }
75
+ /**
76
+ * Splits a pathspec list into chunks whose joined byte length stays well under
77
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
78
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
79
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
80
+ * leaves room for the fixed git arguments plus the inherited environment.
81
+ *
82
+ * Chunk boundaries are output-neutral for every batched caller here: each
83
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
84
+ * how the paths are grouped across invocations never affects the result.
85
+ * @param paths - Pathspecs to chunk
86
+ * @param budgetBytes - Maximum joined byte length per chunk
87
+ * @returns Path chunks, each safe to pass as a single argv tail
88
+ */
89
+ export function chunkPathspecs(paths, budgetBytes = 96_000) {
90
+ const chunks = [];
91
+ let current = [];
92
+ let used = 0;
93
+ for (const path of paths) {
94
+ const cost = Buffer.byteLength(path) + 1;
95
+ if (current.length > 0 && used + cost > budgetBytes) {
96
+ chunks.push(current);
97
+ current = [];
98
+ used = 0;
99
+ }
100
+ current.push(path);
101
+ used += cost;
102
+ }
103
+ if (current.length > 0)
104
+ chunks.push(current);
105
+ return chunks;
106
+ }
75
107
  /**
76
108
  * Configures git performance settings for large trees.
77
109
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -40,6 +40,14 @@ export declare function getAllDiff(repoDir: string): Promise<string>;
40
40
  * Builds a combined diff against HEAD for the provided files without touching
41
41
  * the real git index. Tracked files use `git diff HEAD`; untracked files use
42
42
  * synthesized new-file diffs.
43
+ *
44
+ * Performance: the work is batched into a handful of `git` invocations
45
+ * (one `ls-tree` to classify, one `diff` over all tracked files, one
46
+ * `hash-object` over all new text files) rather than the ~2 spawns per file the
47
+ * previous per-file loop issued — that fan-out dominated the cold-run cost on a
48
+ * Firefox-sized checkout (~700 serial spawns, ~99s). Binary, directory, and
49
+ * recursion paths stay per-file because they are rare and (for binary) mutate
50
+ * the index.
43
51
  * @param repoDir - Repository directory
44
52
  * @param files - File paths to diff (relative to repo root)
45
53
  * @returns Combined diff content