@hominis/fireforge 0.51.0 → 0.52.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 (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/status-output.js +5 -1
  4. package/dist/src/commands/test-build-phase.d.ts +2 -0
  5. package/dist/src/commands/test-build-phase.js +11 -11
  6. package/dist/src/commands/test-dispatch.js +19 -10
  7. package/dist/src/commands/test-orchestrate.d.ts +5 -0
  8. package/dist/src/commands/test-orchestrate.js +5 -2
  9. package/dist/src/commands/test-stale-gate.js +7 -0
  10. package/dist/src/commands/test.js +4 -1
  11. package/dist/src/core/build-baseline-types.d.ts +13 -0
  12. package/dist/src/core/build-baseline-validate.js +9 -0
  13. package/dist/src/core/build-baseline.js +7 -0
  14. package/dist/src/core/furnace-config-validate-exceptions.d.ts +4 -3
  15. package/dist/src/core/furnace-config-validate-exceptions.js +26 -6
  16. package/dist/src/core/furnace-validate-accessibility.d.ts +1 -1
  17. package/dist/src/core/furnace-validate-accessibility.js +22 -11
  18. package/dist/src/core/furnace-validate-exceptions.d.ts +2 -1
  19. package/dist/src/core/furnace-validate-exceptions.js +31 -13
  20. package/dist/src/core/negative-control-compiled.d.ts +10 -1
  21. package/dist/src/core/negative-control-compiled.js +15 -1
  22. package/dist/src/core/test-run-completeness.d.ts +12 -5
  23. package/dist/src/core/test-run-completeness.js +15 -8
  24. package/dist/src/core/test-stale-check.d.ts +10 -1
  25. package/dist/src/core/test-stale-check.js +15 -1
  26. package/dist/src/core/typecheck-shim.d.ts +2 -1
  27. package/dist/src/core/typecheck-shim.js +28 -4
  28. package/dist/src/types/furnace.d.ts +9 -0
  29. package/docs/configuration.md +20 -4
  30. package/docs/furnace.md +19 -1
  31. package/docs/machine-output.md +5 -1
  32. package/docs/testing.md +21 -6
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Test harness
6
+
7
+ - `test --jobs` gives each harness instance its own mochitest test list (`--testrun-manifest-file`). On the shared default `tests.json` the second instance ran the first shard's file and the verdict passed, or it found no file at all. A `--jobs` run now always reads each dispatch's mozlog raw log, and a dispatch whose raw log starts none of its requested files fails with `started=0/<m>` instead of passing on another shard's results.
8
+ - A negative control on compiled source escalates its pre-test build to a full `mach build`, with a notice that names the control's files. `mach build faster` compiles nothing, so under `--build` such a control still tested the previous binary and reported `control=missed`.
9
+ - A `coverage-replaced` refusal names who wrote the coverage record: its invocation, when, and the writing process's pid (marked `still running` while it is), `FIREFORGE_SESSION_ID` and invocation id. Every build baseline now records that `recorder`, and `status --test-coverage` shows it.
10
+
11
+ ### Furnace
12
+
13
+ - A `validationExceptions` entry can name a `file` of the component, as one can name a `tag`. `hardcoded-text` judges each module on its own and lists the modules in `files`, so a vendored bundle whose bytes are pinned can be accepted while the authored modules beside it keep the check. A `furnace-ignore: hardcoded-text` comment now covers only the file it is in.
14
+
15
+ ### Typecheck
16
+
17
+ - `typecheck.litTypes` declares the global type `MozLitElementWith<Extra>`. A widget casts its `MozLitElement` base to it to type query getters and members declared only in `static properties`. Without `litTypes`, and under per-patch checkJs, the type is `any`, so the same source checks in both.
18
+
19
+ ### Other
20
+
21
+ - CI: `github/codeql-action` 4.39.0.
22
+
3
23
  ## 0.51.0
4
24
 
5
25
  ### Test harness
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.51.0",
4
- "commit": "bd93338b037832fdcd69259a70a3a4b134b98906",
5
- "shortCommit": "bd93338b0378",
3
+ "version": "0.52.0",
4
+ "commit": "8926d4e4fe3a6ce4c52f3cf1530776d09352b130",
5
+ "shortCommit": "8926d4e4fe3a",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-10-19T12:33:48.474Z"
8
+ "buildTime": "2026-10-19T23:32:47.811Z"
9
9
  }
@@ -206,7 +206,11 @@ export function renderTestCoverageStatus(baseline) {
206
206
  }
207
207
  info(`Last build: ${baseline.builtAt} (binary: ${baseline.binaryName}, ` +
208
208
  `engine HEAD: ${baseline.engineHeadSha || '(unborn)'})`);
209
- info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}`);
209
+ const recorder = baseline.recorder;
210
+ info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}` +
211
+ (recorder === undefined
212
+ ? ''
213
+ : ` (pid ${recorder.pid}${recorder.sessionId !== undefined ? `, session ${recorder.sessionId}` : ''})`));
210
214
  const coverage = baseline.testPackagingCoverage;
211
215
  if (coverage === undefined) {
212
216
  info('Test packaging coverage: full (implicit — baseline predates coverage recording)');
@@ -16,4 +16,6 @@ import type { HarnessClassification } from './test-modes.js';
16
16
  export declare function runTestBuildPhase(projectRoot: string, paths: ProjectPaths, projectConfig: FireForgeConfig, harnessRetries: number, options: TestOptions, request: {
17
17
  classification: HarnessClassification;
18
18
  normalizedPaths: string[];
19
+ /** Forces a full pre-test build (a compiled negative control). */
20
+ fullBuildReason?: string;
19
21
  }): Promise<boolean>;
@@ -27,7 +27,7 @@ import { emitPassVerdict } from './test-verdict.js';
27
27
  * @param options - See {@link RunPreTestBuildOptions}
28
28
  */
29
29
  async function runPreTestBuild(options) {
30
- const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, } = options;
30
+ const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, fullBuildReason, } = options;
31
31
  await withBuildLock(projectRoot, async () => {
32
32
  // Pass the previous baseline exactly like `fireforge build` does, so
33
33
  // auto-configure runs under the same conditions on both paths. The
@@ -66,17 +66,16 @@ async function runPreTestBuild(options) {
66
66
  if (retentionNotice !== undefined)
67
67
  notice(retentionNotice);
68
68
  }
69
- const buildKind = preparation.fullBuildRequired ? 'full' : 'faster';
70
- if (preparation.fullBuildRequired) {
69
+ const fullBuild = preparation.fullBuildRequired || fullBuildReason !== undefined;
70
+ const buildKind = fullBuild ? 'full' : 'faster';
71
+ if (fullBuild) {
71
72
  // Warning severity rather than info: agent-facing output filters
72
73
  // keep warnings and errors only, and this is the line that explains an
73
74
  // otherwise unexplained multi-minute build.
74
- notice(preparation.fullBuildReason ??
75
+ notice((preparation.fullBuildRequired ? preparation.fullBuildReason : fullBuildReason) ??
75
76
  'A jar.mn registration changed since the last successful build; escalating this pre-test build to a full mach build so new install-manifest destinations are created.');
76
77
  }
77
- const s = spinner(preparation.fullBuildRequired
78
- ? 'Running required full build...'
79
- : 'Running incremental build...');
78
+ const s = spinner(fullBuild ? 'Running required full build...' : 'Running incremental build...');
80
79
  // The pre-test build routes through the same protected mach dispatch as
81
80
  // `fireforge build` / `build --ui`: in-venv resource-monitor guard plus
82
81
  // the uniform recognized-crash retry budget, with a fresh mach process
@@ -116,7 +115,7 @@ async function runPreTestBuild(options) {
116
115
  ...recorded,
117
116
  previousBaseline,
118
117
  recordedBy: describeBuildInvocation(extend !== undefined, testPackagingCoverage),
119
- staticComponentsHandling: preparation.fullBuildRequired
118
+ staticComponentsHandling: fullBuild
120
119
  ? 'refresh'
121
120
  : plan !== undefined && (plan.keepFull || Object.keys(plan.retained).length > 0)
122
121
  ? 'carry-forward'
@@ -131,8 +130,8 @@ async function runPreTestBuild(options) {
131
130
  }
132
131
  s.error('Pre-test build failed');
133
132
  throw new BuildError(result.crashSignature
134
- ? buildHarnessCrashMessage(result.crashSignature, result.attempts, 'mach build faster')
135
- : 'Pre-test build failed', preparation.fullBuildRequired ? 'mach build' : 'mach build faster');
133
+ ? buildHarnessCrashMessage(result.crashSignature, result.attempts, fullBuild ? 'mach build' : 'mach build faster')
134
+ : 'Pre-test build failed', fullBuild ? 'mach build' : 'mach build faster');
136
135
  });
137
136
  }
138
137
  /**
@@ -201,7 +200,7 @@ function describeBuildInvocation(extending, coverage) {
201
200
  * next steps, and emits the run's single `PASS` verdict.
202
201
  */
203
202
  export async function runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, request) {
204
- const { classification, normalizedPaths } = request;
203
+ const { classification, normalizedPaths, fullBuildReason } = request;
205
204
  const extending = options.extendCoverage === true;
206
205
  if (extending) {
207
206
  assertExtendCoverageUsage(options, normalizedPaths);
@@ -235,6 +234,7 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
235
234
  testPackagingCoverage: coverage,
236
235
  refuseUnexportedDrift: options.refuseUnexportedDrift === true,
237
236
  extend: extending ? { requestedPaths: normalizedPaths } : undefined,
237
+ fullBuildReason,
238
238
  furnace: await planPreTestFurnace(projectRoot, paths, projectConfig, options, normalizedPaths),
239
239
  });
240
240
  info('');
@@ -85,25 +85,34 @@ async function allocateLoopbackPort() {
85
85
  });
86
86
  });
87
87
  }
88
+ /**
89
+ * The mochitest harness writes the dispatched test list to
90
+ * `_tests/testing/mochitest/tests.json` and the browser fetches it from
91
+ * there, so instances sharing the default name read each other's list (and
92
+ * one instance's cleanup removes it under the other). Each instance names
93
+ * its own file.
94
+ */
95
+ function testRunManifestArg(index) {
96
+ return `--testrun-manifest-file=fireforge-tests-${process.pid}-job${index + 1}.json`;
97
+ }
88
98
  /**
89
99
  * The context for `--jobs` instance `index` (0-based): its own output
90
- * prefix, and from the second instance on its own Marionette port, so
91
- * browsers started at the same moment never race for 2828. The mochitest
92
- * server ports are left to the engine harness's busy-port fallback, which
93
- * `--jobs` requires (auto ports).
100
+ * prefix and test-list manifest, and from the second instance on its own
101
+ * Marionette port, so browsers started at the same moment never race for
102
+ * 2828. The mochitest server ports are left to the engine harness's
103
+ * busy-port fallback, which `--jobs` requires (auto ports).
94
104
  */
95
105
  async function instanceContext(base, index, xpcshellOnly) {
96
106
  const ctx = { ...base, outputPrefix: `[job ${index + 1}] ` };
97
- if (index === 0 || xpcshellOnly)
107
+ if (xpcshellOnly)
98
108
  return ctx;
109
+ const own = [...base.baseExtraArgs, testRunManifestArg(index)];
110
+ if (index === 0)
111
+ return { ...ctx, baseExtraArgs: own };
99
112
  const port = await allocateLoopbackPort();
100
113
  return {
101
114
  ...ctx,
102
- baseExtraArgs: [
103
- ...base.baseExtraArgs,
104
- `--setpref=marionette.port=${port}`,
105
- `--marionette=127.0.0.1:${port}`,
106
- ],
115
+ baseExtraArgs: [...own, `--setpref=marionette.port=${port}`, `--marionette=127.0.0.1:${port}`],
107
116
  };
108
117
  }
109
118
  /** The shards to run, or undefined for one combined invocation. */
@@ -30,6 +30,11 @@ export interface TestRunHooks {
30
30
  * source, which only reaches the binary when the build compiles it.
31
31
  */
32
32
  beforeBuild?: () => Promise<void>;
33
+ /**
34
+ * Why the pre-test build must be a full `mach build`: `mach build faster`
35
+ * compiles nothing, so a compiled control's binary needs the full one.
36
+ */
37
+ fullBuildReason?: string;
33
38
  /** `--repeat` run this sub-run is (1-based), for `--results`. */
34
39
  repetition?: number;
35
40
  /** Where dispatches write their mozlog raw logs (`--results`, `--shard-size`). */
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { getProjectPaths } from '../core/config.js';
19
19
  import { applyNegativeControl, planNegativeControl, recoverPendingNegativeControl, restoreNegativeControl, } from '../core/negative-control.js';
20
- import { formatCompiledControlRefusal } from '../core/negative-control-compiled.js';
20
+ import { formatCompiledControlBuildNotice, formatCompiledControlRefusal, } from '../core/negative-control-compiled.js';
21
21
  import { getActiveRunLogPath, writeToActiveRunLog } from '../core/run-log.js';
22
22
  import { GeneralError, InvalidArgumentError, PreflightRefusalError } from '../errors/base.js';
23
23
  import { TestFailureError } from '../errors/build.js';
@@ -187,7 +187,10 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
187
187
  ...(plan === undefined
188
188
  ? {}
189
189
  : compiledControl && index === 0
190
- ? { beforeBuild: controlHook(controlPaths, plan, slot) }
190
+ ? {
191
+ beforeBuild: controlHook(controlPaths, plan, slot),
192
+ fullBuildReason: formatCompiledControlBuildNotice(compiled),
193
+ }
191
194
  : { beforeDispatch: controlHook(controlPaths, plan, slot) }),
192
195
  });
193
196
  }
@@ -11,6 +11,7 @@ import { filterByCarriedFullCoverage } from '../core/coverage-dispatch.js';
11
11
  import { checkStaleBuildForTest, checkStaticComponentsStale, findChangedTestManifestsForPaths, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, FULL_SUITE_REQUEST, } from '../core/test-stale-check.js';
12
12
  import { formatStaleExemptions } from '../core/test-stale-exempt.js';
13
13
  import { PreflightRefusalError } from '../errors/base.js';
14
+ import { isProcessAlive } from '../utils/errors.js';
14
15
  import { info, warn } from '../utils/logger.js';
15
16
  import { describePerfStaleImpact } from './test-perf-scope.js';
16
17
  /**
@@ -93,6 +94,12 @@ async function enforceCoverageGate(engineDir, baseline, normalizedPaths) {
93
94
  throw new PreflightRefusalError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : [], changedManifests, {
94
95
  changedInputs: Object.fromEntries(remaining.map((request) => [request.path, request.changed])),
95
96
  carriesFullAnchor: baseline?.fullCoverageAnchor !== undefined,
97
+ recordedBy: baseline?.recordedBy,
98
+ builtAt: baseline?.builtAt,
99
+ recorder: baseline?.recorder,
100
+ recorderAlive: baseline?.recorder !== undefined &&
101
+ baseline.recorder.pid !== process.pid &&
102
+ isProcessAlive(baseline.recorder.pid),
96
103
  }), 'coverage-replaced');
97
104
  }
98
105
  /**
@@ -142,7 +142,9 @@ export async function testCommand(projectRoot, testPaths, options = {}, underLoc
142
142
  renderPreflightRefusal(error);
143
143
  emitFailVerdict('preflight', refusalNote(error));
144
144
  });
145
- const collectsResults = options.results !== undefined || options.shardSize !== undefined;
145
+ // `--jobs` always reads each dispatch's raw log, so a shard that ran
146
+ // files other than its own fails instead of passing on their results.
147
+ const collectsResults = options.results !== undefined || options.shardSize !== undefined || (options.jobs ?? 1) > 1;
146
148
  try {
147
149
  await underLock(() => (collectsResults ? withRawLogDir((dir) => orchestrate(dir)) : orchestrate()), lockMode);
148
150
  }
@@ -346,6 +348,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
346
348
  if (await runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, {
347
349
  classification,
348
350
  normalizedPaths: hooks.coveragePaths ?? normalizedPaths,
351
+ ...(hooks.fullBuildReason === undefined ? {} : { fullBuildReason: hooks.fullBuildReason }),
349
352
  })) {
350
353
  return;
351
354
  }
@@ -144,6 +144,19 @@ export interface BuildBaseline {
144
144
  * as "unknown" when absent.
145
145
  */
146
146
  recordedBy?: string;
147
+ /**
148
+ * The process that wrote this baseline: its pid, its invocation id (the
149
+ * one its history records carry) and the caller's `FIREFORGE_SESSION_ID`
150
+ * when set. A coverage refusal names it, so a run whose claim another
151
+ * session's scoped build replaced can tell whose build that was.
152
+ */
153
+ recorder?: BaselineRecorder;
154
+ }
155
+ /** See {@link BuildBaseline.recorder}. */
156
+ export interface BaselineRecorder {
157
+ pid: number;
158
+ invocationId: string;
159
+ sessionId?: string;
147
160
  }
148
161
  /**
149
162
  * State of the engine at the last full build, as far as compiled-in XPCOM
@@ -32,6 +32,13 @@ function isCoverageRecordMap(value) {
32
32
  Array.isArray(record['inputs']) &&
33
33
  record['inputs'].every(isBaselinePath)));
34
34
  }
35
+ /** A `recorder` stamp: an integer pid, an invocation id and an optional session id. */
36
+ function isBaselineRecorder(value) {
37
+ return (isObject(value) &&
38
+ Number.isInteger(value['pid']) &&
39
+ typeof value['invocationId'] === 'string' &&
40
+ (value['sessionId'] === undefined || typeof value['sessionId'] === 'string'));
41
+ }
35
42
  /** Validates persisted fields without converting malformed coverage into a full claim. */
36
43
  export function isBuildBaseline(value) {
37
44
  if (!isObject(value))
@@ -59,6 +66,8 @@ export function isBuildBaseline(value) {
59
66
  return false;
60
67
  if (value['recordedBy'] !== undefined && typeof value['recordedBy'] !== 'string')
61
68
  return false;
69
+ if (value['recorder'] !== undefined && !isBaselineRecorder(value['recorder']))
70
+ return false;
62
71
  if (value['testCoverageRecords'] !== undefined &&
63
72
  !isCoverageRecordMap(value['testCoverageRecords']))
64
73
  return false;
@@ -32,6 +32,7 @@ import { hashEngineFile } from './coverage-extend.js';
32
32
  import { getHead, hasChanges, isMissingHeadError } from './git.js';
33
33
  import { git } from './git-base.js';
34
34
  import { getUntrackedFiles } from './git-status.js';
35
+ import { historyRecordIdentity } from './history-log-file.js';
35
36
  /*
36
37
  * Keep the IO fan-out bounded: a patched Firefox checkout can carry hundreds
37
38
  * of dirty packageable paths and serial reads add directly to every build.
@@ -72,6 +73,11 @@ export async function readBuildBaseline(projectRoot, invalid = 'ignore') {
72
73
  return undefined;
73
74
  }
74
75
  }
76
+ /** This process's {@link BuildBaseline.recorder} stamp. */
77
+ function baselineRecorder() {
78
+ const { pid, invocationId, sessionId } = historyRecordIdentity();
79
+ return { pid, invocationId, ...(sessionId !== undefined ? { sessionId } : {}) };
80
+ }
75
81
  /**
76
82
  * Records a successful build by writing a fresh baseline marker. Captures
77
83
  * engine HEAD SHA (or an empty string when the engine has no HEAD yet) and
@@ -121,6 +127,7 @@ export async function writeBuildBaseline(options) {
121
127
  ...(mozconfigHash !== undefined ? { mozconfigHash } : {}),
122
128
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
123
129
  ...(recordedBy !== undefined ? { recordedBy } : {}),
130
+ recorder: baselineRecorder(),
124
131
  };
125
132
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
126
133
  }
@@ -1,8 +1,9 @@
1
1
  import { type FurnaceOptInCheck, type FurnaceValidationException } from '../types/furnace.js';
2
2
  /**
3
- * Parses `validationExceptions[]`: `{ component, check, tag?, reason }`
4
- * objects, no other keys, every field a non-empty string, and no two
5
- * entries for the same component, check and tag.
3
+ * Parses `validationExceptions[]`: `{ component, check, tag?, file?, reason }`
4
+ * objects, no other keys, every field a non-empty string, at most one of
5
+ * `tag` and `file` (a bare file name of the component), and no two entries
6
+ * for the same component, check and scope.
6
7
  */
7
8
  export declare function parseValidationExceptions(raw: unknown): FurnaceValidationException[];
8
9
  /**
@@ -11,7 +11,7 @@
11
11
  import { FurnaceError } from '../errors/furnace.js';
12
12
  import { FURNACE_OPT_IN_CHECKS, } from '../types/furnace.js';
13
13
  import { isArray, isObject, isString } from '../utils/validation.js';
14
- const EXCEPTION_KEYS = new Set(['component', 'check', 'tag', 'reason']);
14
+ const EXCEPTION_KEYS = new Set(['component', 'check', 'tag', 'file', 'reason']);
15
15
  function requireNonEmptyString(entry, key, field) {
16
16
  const value = entry[key];
17
17
  if (!isString(value) || value.trim().length === 0) {
@@ -20,9 +20,10 @@ function requireNonEmptyString(entry, key, field) {
20
20
  return value;
21
21
  }
22
22
  /**
23
- * Parses `validationExceptions[]`: `{ component, check, tag?, reason }`
24
- * objects, no other keys, every field a non-empty string, and no two
25
- * entries for the same component, check and tag.
23
+ * Parses `validationExceptions[]`: `{ component, check, tag?, file?, reason }`
24
+ * objects, no other keys, every field a non-empty string, at most one of
25
+ * `tag` and `file` (a bare file name of the component), and no two entries
26
+ * for the same component, check and scope.
26
27
  */
27
28
  export function parseValidationExceptions(raw) {
28
29
  if (!isArray(raw)) {
@@ -46,9 +47,28 @@ export function parseValidationExceptions(raw) {
46
47
  };
47
48
  if (entry['tag'] !== undefined)
48
49
  exception.tag = requireNonEmptyString(entry, 'tag', field);
49
- const identity = `${exception.component}\u0000${exception.check}\u0000${exception.tag ?? ''}`;
50
+ if (entry['file'] !== undefined) {
51
+ if (exception.tag !== undefined) {
52
+ throw new FurnaceError(`Furnace config: "${field}" may name a "tag" or a "file", not both`);
53
+ }
54
+ const file = requireNonEmptyString(entry, 'file', field);
55
+ if (/[\\/]/.test(file) || file === '.' || file === '..') {
56
+ throw new FurnaceError(`Furnace config: "${field}.file" must be a file name in the component's directory, not a path`);
57
+ }
58
+ exception.file = file;
59
+ }
60
+ const scope = exception.tag !== undefined
61
+ ? `tag:${exception.tag}`
62
+ : exception.file !== undefined
63
+ ? `file:${exception.file}`
64
+ : '';
65
+ const identity = `${exception.component}\u0000${exception.check}\u0000${scope}`;
50
66
  if (seen.has(identity)) {
51
- const on = exception.tag !== undefined ? ` on <${exception.tag}>` : '';
67
+ const on = exception.tag !== undefined
68
+ ? ` on <${exception.tag}>`
69
+ : exception.file !== undefined
70
+ ? ` in ${exception.file}`
71
+ : '';
52
72
  throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]${on}`);
53
73
  }
54
74
  seen.add(identity);
@@ -34,6 +34,6 @@ export declare function validateAccessibility(componentDir: string, tagName: str
34
34
  * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
35
35
  *
36
36
  * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
37
- * findings (matches the pre-existing escape hatch).
37
+ * findings in that file (matches the pre-existing escape hatch).
38
38
  */
39
39
  export declare function containsHardcodedTemplateText(content: string): boolean;
@@ -11,8 +11,9 @@ import { collectLitTemplateRegions, parseTemplateAttributes } from './lit-templa
11
11
  * findings do not depend on which of the component's modules it lives in.
12
12
  * Returns undefined when the host module is missing.
13
13
  */
14
- async function readComposedComponentSource(componentDir, tagName) {
15
- const hostPath = join(componentDir, `${tagName}.mjs`);
14
+ async function readComposedComponentModules(componentDir, tagName) {
15
+ const hostName = `${tagName}.mjs`;
16
+ const hostPath = join(componentDir, hostName);
16
17
  if (!(await pathExists(hostPath)))
17
18
  return undefined;
18
19
  const entries = await readdir(componentDir).catch(() => []);
@@ -21,10 +22,11 @@ async function readComposedComponentSource(componentDir, tagName) {
21
22
  name.endsWith('.mjs') &&
22
23
  !/\.(test|spec|stories)\./.test(name))
23
24
  .sort();
24
- const sources = [await readText(hostPath)];
25
- for (const name of companions)
26
- sources.push(await readText(join(componentDir, name)));
27
- return sources.join('\n');
25
+ const modules = [{ name: hostName, content: await readText(hostPath) }];
26
+ for (const name of companions) {
27
+ modules.push({ name, content: await readText(join(componentDir, name)) });
28
+ }
29
+ return modules;
28
30
  }
29
31
  /**
30
32
  * Validates accessibility patterns in a component's composed source: its
@@ -44,9 +46,10 @@ async function readComposedComponentSource(componentDir, tagName) {
44
46
  * adds the icon-only `<moz-button>` naming check.
45
47
  */
46
48
  export async function validateAccessibility(componentDir, tagName, customConfig, optInChecks = []) {
47
- const content = await readComposedComponentSource(componentDir, tagName);
48
- if (content === undefined)
49
+ const modules = await readComposedComponentModules(componentDir, tagName);
50
+ if (modules === undefined)
49
51
  return [];
52
+ const content = modules.map((module) => module.content).join('\n');
50
53
  const issues = [];
51
54
  if (!hasAriaRole(content) && hasGenericInteractiveElement(content)) {
52
55
  issues.push(createIssue(tagName, 'warning', 'no-aria-role', 'Generic interactive markup has no native semantics. Prefer native elements, or add role= when native markup cannot provide the semantics.'));
@@ -72,8 +75,16 @@ export async function validateAccessibility(componentDir, tagName, customConfig,
72
75
  tags: syntheticTags,
73
76
  });
74
77
  }
75
- if (containsHardcodedTemplateText(content)) {
76
- issues.push(createIssue(tagName, 'warning', 'hardcoded-text', 'Possible hardcoded string found. Use data-l10n-id for localization.'));
78
+ // Judged per module, so the finding names its files and an exception can
79
+ // accept one module (a vendored bundle) without muting the others.
80
+ const textFiles = modules
81
+ .filter((module) => containsHardcodedTemplateText(module.content))
82
+ .map((module) => module.name);
83
+ if (textFiles.length > 0) {
84
+ issues.push({
85
+ ...createIssue(tagName, 'warning', 'hardcoded-text', `Possible hardcoded string found in ${textFiles.join(', ')}. Use data-l10n-id for localization.`),
86
+ files: textFiles,
87
+ });
77
88
  }
78
89
  const isInteractive = hasClick || hasKeyboardHandler;
79
90
  if (isInteractive && !hasDelegatesFocusEnabled(content)) {
@@ -337,7 +348,7 @@ function isWithinLocalizedElement(content, matchIndex) {
337
348
  * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
338
349
  *
339
350
  * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
340
- * findings (matches the pre-existing escape hatch).
351
+ * findings in that file (matches the pre-existing escape hatch).
341
352
  */
342
353
  export function containsHardcodedTemplateText(content) {
343
354
  if (/furnace-ignore:\s*hardcoded-text/.test(content)) {
@@ -2,7 +2,8 @@
2
2
  * Applies `furnace.json#validationExceptions[]` to a `furnace validate`
3
3
  * result. An exception suppresses every finding of its check on its
4
4
  * component, or with `tag`, a finding whose element tags it (and its
5
- * sibling tag entries) cover. One that suppresses nothing is stale: the
5
+ * sibling tag entries) cover, or with `file`, a finding whose module files
6
+ * it (and its sibling file entries) cover. One that suppresses nothing is stale: the
6
7
  * finding was fixed (or renamed) and the review record outlived it, so it
7
8
  * is reported as a warning naming the entry to delete, which keeps the
8
9
  * list honest under `--max-warnings 0`.
@@ -6,19 +6,34 @@ export const EXCEPTION_ON_ERROR_CHECK = 'validation-exception-on-error';
6
6
  function matches(exception, issue) {
7
7
  return exception.component === issue.component && exception.check === issue.check;
8
8
  }
9
+ /** An exception's narrowing: the finding field it reads and its value. */
10
+ function scopeOf(entry) {
11
+ if (entry.tag !== undefined)
12
+ return { key: 'tags', value: entry.tag };
13
+ if (entry.file !== undefined)
14
+ return { key: 'files', value: entry.file };
15
+ return undefined;
16
+ }
9
17
  /**
10
- * The exceptions that suppress a warning: an untagged one alone, or tagged
11
- * ones that together cover every tag the finding names.
18
+ * The exceptions that suppress a warning: an unscoped one alone, or scoped
19
+ * ones that together cover every tag (or every file) the finding names.
12
20
  */
13
21
  function coveringExceptions(issue, candidates) {
14
- const untagged = candidates.find((entry) => entry.tag === undefined);
15
- if (untagged !== undefined)
16
- return [untagged];
17
- const tags = issue.tags ?? [];
18
- if (tags.length === 0)
19
- return [];
20
- const covering = tags.map((tag) => candidates.find((entry) => entry.tag === tag));
21
- return covering.every((entry) => entry !== undefined) ? [...new Set(covering)] : [];
22
+ const unscoped = candidates.find((entry) => scopeOf(entry) === undefined);
23
+ if (unscoped !== undefined)
24
+ return [unscoped];
25
+ for (const key of ['tags', 'files']) {
26
+ const named = issue[key] ?? [];
27
+ if (named.length === 0)
28
+ continue;
29
+ const covering = named.map((value) => candidates.find((entry) => {
30
+ const scope = scopeOf(entry);
31
+ return scope?.key === key && scope.value === value;
32
+ }));
33
+ if (covering.every((entry) => entry !== undefined))
34
+ return [...new Set(covering)];
35
+ }
36
+ return [];
22
37
  }
23
38
  /**
24
39
  * Splits findings into counted and excepted, and reports stale exceptions.
@@ -47,11 +62,14 @@ export function applyValidationExceptions(issues, exceptions, judged) {
47
62
  }
48
63
  const covering = coveringExceptions(issue, candidates);
49
64
  if (covering.length === 0) {
50
- // A tagged exception that covers some of the tags is not stale: its
51
- // element still carries the finding, beside one it does not cover.
65
+ // A scoped exception that covers some of the tags (or files) is not
66
+ // stale: its element still carries the finding, beside one it does
67
+ // not cover.
52
68
  for (const entry of candidates) {
53
- if (entry.tag !== undefined && issue.tags?.includes(entry.tag) === true)
69
+ const scope = scopeOf(entry);
70
+ if (scope !== undefined && issue[scope.key]?.includes(scope.value) === true) {
54
71
  used.add(entry);
72
+ }
55
73
  }
56
74
  outcome.issues.push(issue);
57
75
  continue;
@@ -7,7 +7,9 @@
7
7
  * never compiled: the run tests the unmutated binary and reports
8
8
  * `control=missed`, which reads as a test that cannot fail. Such a target
9
9
  * is now refused without `--build`, and with `--build` the control is
10
- * applied before the build so the binary under test carries it.
10
+ * applied before the build so the binary under test carries it. That build
11
+ * is a full `mach build`: the pre-test build is otherwise `mach build
12
+ * faster`, which compiles nothing, so the control still missed.
11
13
  */
12
14
  /**
13
15
  * The engine-relative targets that reach the runtime only through a
@@ -18,6 +20,13 @@
18
20
  * @param enginePaths - The control's engine-relative targets
19
21
  */
20
22
  export declare function findCompiledControlTargets(engineDir: string, enginePaths: readonly string[]): Promise<string[]>;
23
+ /**
24
+ * The notice that explains why a compiled control's pre-test build is a
25
+ * full `mach build` instead of `mach build faster`.
26
+ *
27
+ * @param compiled - Engine-relative compiled targets
28
+ */
29
+ export declare function formatCompiledControlBuildNotice(compiled: readonly string[]): string;
21
30
  /**
22
31
  * The refusal for a compiled control without `--build`, naming the manual
23
32
  * procedure for a run that wants to keep its build out of FireForge.
@@ -8,7 +8,9 @@
8
8
  * never compiled: the run tests the unmutated binary and reports
9
9
  * `control=missed`, which reads as a test that cannot fail. Such a target
10
10
  * is now refused without `--build`, and with `--build` the control is
11
- * applied before the build so the binary under test carries it.
11
+ * applied before the build so the binary under test carries it. That build
12
+ * is a full `mach build`: the pre-test build is otherwise `mach build
13
+ * faster`, which compiles nothing, so the control still missed.
12
14
  */
13
15
  import { extname } from 'node:path';
14
16
  import { classifyRuntimeDelivery } from './objdir-install-manifests.js';
@@ -55,6 +57,18 @@ export async function findCompiledControlTargets(engineDir, enginePaths) {
55
57
  }
56
58
  return compiled;
57
59
  }
60
+ /**
61
+ * The notice that explains why a compiled control's pre-test build is a
62
+ * full `mach build` instead of `mach build faster`.
63
+ *
64
+ * @param compiled - Engine-relative compiled targets
65
+ */
66
+ export function formatCompiledControlBuildNotice(compiled) {
67
+ const files = compiled.map((path) => `engine/${path}`).join(', ');
68
+ return (`The negative control edits compiled source (${files}); escalating this pre-test build to ` +
69
+ 'a full mach build, since mach build faster compiles nothing and the binary under test ' +
70
+ 'would not carry the control.');
71
+ }
58
72
  /**
59
73
  * The refusal for a compiled control without `--build`, naming the manual
60
74
  * procedure for a run that wants to keep its build out of FireForge.
@@ -17,10 +17,14 @@
17
17
  * `FAIL reason=test-failures`, and a short start count is stamped on the
18
18
  * verdict line as `started=<n>/<m> first-unstarted=<path>`.
19
19
  *
20
- * The start-count check only runs when at least one start marker names a
21
- * requested file. The suite-specific xpcshell dispatch can prove execution
22
- * with a result-summary block alone and print no `TEST_START` line, and a
23
- * run that never names its files cannot be counted against them.
20
+ * From streamed or captured text, the start-count check only runs when at
21
+ * least one start marker names a requested file. The suite-specific xpcshell
22
+ * dispatch can prove execution with a result-summary block alone and print
23
+ * no `TEST_START` line, and a run that never names its files cannot be
24
+ * counted against them. The structured raw log names every test by its
25
+ * manifest id, so a raw log whose starts name none of the requested files
26
+ * ran something else: a `--jobs` instance that read another instance's test
27
+ * list once passed on that instance's file.
24
28
  */
25
29
  import { type HarnessRunVerdict } from './test-harness-crash.js';
26
30
  import type { IncompleteRunEvidence } from './test-harness-signature.js';
@@ -62,8 +66,11 @@ export declare function createStartMarkerTracker(): StartMarkerTracker;
62
66
  * @param evidence - Captured mach stdout and stderr, or the markers
63
67
  * collected from the complete stream
64
68
  * @param requestedPaths - Dispatched paths, in dispatch order
69
+ * @param authoritative - The markers come from the raw log, so a run whose
70
+ * starts name no requested file is counted (as none started) instead of
71
+ * being left uncounted
65
72
  */
66
- export declare function countStartedFiles(evidence: string | StartMarkers, requestedPaths: readonly string[]): {
73
+ export declare function countStartedFiles(evidence: string | StartMarkers, requestedPaths: readonly string[], authoritative?: boolean): {
67
74
  started: number;
68
75
  requested: number;
69
76
  firstUnstarted?: string;
@@ -18,10 +18,14 @@
18
18
  * `FAIL reason=test-failures`, and a short start count is stamped on the
19
19
  * verdict line as `started=<n>/<m> first-unstarted=<path>`.
20
20
  *
21
- * The start-count check only runs when at least one start marker names a
22
- * requested file. The suite-specific xpcshell dispatch can prove execution
23
- * with a result-summary block alone and print no `TEST_START` line, and a
24
- * run that never names its files cannot be counted against them.
21
+ * From streamed or captured text, the start-count check only runs when at
22
+ * least one start marker names a requested file. The suite-specific xpcshell
23
+ * dispatch can prove execution with a result-summary block alone and print
24
+ * no `TEST_START` line, and a run that never names its files cannot be
25
+ * counted against them. The structured raw log names every test by its
26
+ * manifest id, so a raw log whose starts name none of the requested files
27
+ * ran something else: a `--jobs` instance that read another instance's test
28
+ * list once passed on that instance's file.
25
29
  */
26
30
  import { SHUTDOWN_REENTRY_PATTERN, START_MARKER_PATTERN, TEST_FILE_BASENAME_PATTERN, } from './test-harness-crash.js';
27
31
  /**
@@ -80,8 +84,11 @@ export function createStartMarkerTracker() {
80
84
  * @param evidence - Captured mach stdout and stderr, or the markers
81
85
  * collected from the complete stream
82
86
  * @param requestedPaths - Dispatched paths, in dispatch order
87
+ * @param authoritative - The markers come from the raw log, so a run whose
88
+ * starts name no requested file is counted (as none started) instead of
89
+ * being left uncounted
83
90
  */
84
- export function countStartedFiles(evidence, requestedPaths) {
91
+ export function countStartedFiles(evidence, requestedPaths, authoritative = false) {
85
92
  const requested = requestedPaths.filter((path) => TEST_FILE_BASENAME_PATTERN.test(basenameOf(path)));
86
93
  if (requested.length === 0)
87
94
  return undefined;
@@ -92,8 +99,8 @@ export function countStartedFiles(evidence, requestedPaths) {
92
99
  };
93
100
  // Counting needs a dialect that names the requested files on its start
94
101
  // lines. When no start line names any of them, the output cannot be
95
- // counted against the request at all.
96
- if (!requested.some((path) => names(path, started)))
102
+ // counted against the request at all, unless it is the raw log.
103
+ if (!authoritative && !requested.some((path) => names(path, started)))
97
104
  return undefined;
98
105
  const unstarted = requested.filter((path) => !names(path, started) && !names(path, skipped));
99
106
  const firstUnstarted = unstarted[0];
@@ -125,7 +132,7 @@ export function vetoIncompletePass(verdict, output, requestedPaths, tracked) {
125
132
  // tracker means nothing was streamed through it, and the capture is then
126
133
  // the only evidence there is.
127
134
  const useTracked = tracked !== undefined && tracked.markers.started.length > 0;
128
- const counted = countStartedFiles(useTracked ? tracked.markers : output, requestedPaths);
135
+ const counted = countStartedFiles(useTracked ? tracked.markers : output, requestedPaths, useTracked && tracked.source === 'raw');
129
136
  const unexpected = verdict.unexpected ?? 0;
130
137
  const truncated = counted?.firstUnstarted !== undefined;
131
138
  if (unexpected === 0 && shutdownLine === undefined && !truncated)
@@ -1,4 +1,4 @@
1
- import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
1
+ import type { BaselineRecorder, BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
2
2
  import { type StaleExemption } from './test-stale-exempt.js';
3
3
  /** Result of the stale-build preflight probe. */
4
4
  export interface StaleBuildResult {
@@ -97,6 +97,15 @@ export interface CoverageRefusalDetail {
97
97
  changedInputs?: Readonly<Record<string, readonly string[]>>;
98
98
  /** The record carries a full build's anchor forward for unchanged paths. */
99
99
  carriesFullAnchor?: boolean;
100
+ /**
101
+ * Who wrote the record and when, so a run whose coverage a peer's scoped
102
+ * build replaced can tell whose build that was.
103
+ */
104
+ recordedBy?: string | undefined;
105
+ builtAt?: string | undefined;
106
+ recorder?: BaselineRecorder | undefined;
107
+ /** Whether the recorder's process is still running (it may still be using the packaging). */
108
+ recorderAlive?: boolean | undefined;
100
109
  }
101
110
  /**
102
111
  * Formats the refusal shown when a non-`--build` run requests paths the
@@ -163,6 +163,20 @@ export async function findChangedTestManifestsForPaths(engineDir, baseline, unco
163
163
  });
164
164
  return hits.slice(0, STALE_PATHS_LIMIT);
165
165
  }
166
+ /** The refusal's sentence naming who wrote the coverage record. */
167
+ function formatCoverageHolder(detail) {
168
+ if (detail.recorder === undefined && detail.recordedBy === undefined)
169
+ return '';
170
+ const what = detail.recordedBy !== undefined ? `"${detail.recordedBy}"` : 'a build';
171
+ const when = detail.builtAt !== undefined ? ` at ${detail.builtAt}` : '';
172
+ const recorder = detail.recorder;
173
+ const who = recorder === undefined
174
+ ? ''
175
+ : ` (pid ${recorder.pid}${detail.recorderAlive === true ? ', still running' : ''}` +
176
+ (recorder.sessionId !== undefined ? `, session ${recorder.sessionId}` : '') +
177
+ `, invocation ${recorder.invocationId})`;
178
+ return ` The current record was written by ${what}${when}${who}.`;
179
+ }
166
180
  /**
167
181
  * Formats the refusal shown when a non-`--build` run requests paths the
168
182
  * packaged runtime's scoped `test --build` coverage does not include. It is
@@ -210,7 +224,7 @@ export function formatTestCoverageRefusal(uncovered, coverage, changedManifests
210
224
  return (`The packaged test runtime was produced by a scoped "fireforge test --build" covering only: ${scope}.\n` +
211
225
  `The requested run needs ${cap(uncovered)}, which that packaging does not cover — support ` +
212
226
  'fixtures for those manifests may be missing from obj-*/_tests/, and the run can hang ' +
213
- `rather than fail.${affectedLine}${manifestLine} ${rebuildHint} ` +
227
+ `rather than fail.${formatCoverageHolder(detail)}${affectedLine}${manifestLine} ${rebuildHint} ` +
214
228
  '(--allow-stale-build does not bypass this check — it accepts stale content, not missing coverage.)');
215
229
  }
216
230
  /**
@@ -82,7 +82,8 @@ export interface ComposedShim {
82
82
  * shim path
83
83
  * @param extraShimPath - Optional project-relative path to an extra `.d.ts`
84
84
  * @param options - `litTypes` appends {@link LIT_TYPES_SHIM} after the
85
- * built-in shim (before the extra shim, which can still merge into it)
85
+ * built-in shim (before the extra shim, which can still merge into it),
86
+ * and {@link LOOSE_LIT_SHIM} otherwise
86
87
  */
87
88
  export declare function composeShimSource(projectRoot: string, extraShimPath: string | undefined, options?: {
88
89
  litTypes?: boolean | undefined;
@@ -295,8 +295,13 @@ export const UNDEFINED_IDENTIFIER_CODES = new Set([
295
295
  * `chrome:*` wildcard as `any`: `LitElement` (with `ReactiveElement`'s
296
296
  * lifecycle) and `MozLitElement` carry closed member lists, so a misspelt
297
297
  * member of a widget host is TS2339, as `Cu` is in the built-in shim.
298
- * Template helpers and directives stay loose. A project shim can merge
299
- * members into `LitElementShimMembers`; a project whose jsconfig `paths`
298
+ * Template helpers and directives stay loose. The global
299
+ * `MozLitElementWith<Extra>` types a widget's query getters and
300
+ * `static properties` members: the widget casts its `MozLitElement` import
301
+ * to it before extending ({@link LOOSE_LIT_SHIM} keeps it `any` without
302
+ * typed Lit). A project
303
+ * shim can merge members into `LitElementShimMembers`; a project whose
304
+ * jsconfig `paths`
300
305
  * maps these URLs to the real sources gets those instead, since a resolved
301
306
  * file wins over an ambient module.
302
307
  */
@@ -399,6 +404,24 @@ declare module 'chrome://global/content/lit-utils.mjs' {
399
404
  export const SelectControlBaseElement: any;
400
405
  export const SelectControlItemMixin: any;
401
406
  }
407
+ // MozLitElement with instance members Extra: what a widget casts its base
408
+ // to for the getters static queries installs (and properties it declares
409
+ // only in static properties). A class field would redefine a query getter
410
+ // and throw, and a constructor declaration reads it before renderRoot.
411
+ type MozLitElementWith<Extra> = Pick<
412
+ typeof import('chrome://global/content/lit-utils.mjs').MozLitElement,
413
+ keyof typeof import('chrome://global/content/lit-utils.mjs').MozLitElement
414
+ > &
415
+ (new () => import('chrome://global/content/lit-utils.mjs').MozLitElement & Extra);
416
+ `;
417
+ /**
418
+ * Without typed Lit, `MozLitElementWith<Extra>` is `any`, as the base it
419
+ * casts is: the same widget source checks under per-patch checkJs and a
420
+ * project typecheck that leaves `litTypes` off.
421
+ */
422
+ const LOOSE_LIT_SHIM = `
423
+ // FireForge loose Lit (typecheck.litTypes off): see typecheck-shim.ts.
424
+ type MozLitElementWith<Extra> = any;
402
425
  `;
403
426
  /** Hint appended to reported undefined-identifier diagnostics. */
404
427
  export const UNDEFINED_IDENTIFIER_HINT = '(undefined identifier — import or declare it, add the global to the extra shim, ' +
@@ -462,10 +485,11 @@ async function inlineTripleSlashReferences(source, baseDir, seen) {
462
485
  * shim path
463
486
  * @param extraShimPath - Optional project-relative path to an extra `.d.ts`
464
487
  * @param options - `litTypes` appends {@link LIT_TYPES_SHIM} after the
465
- * built-in shim (before the extra shim, which can still merge into it)
488
+ * built-in shim (before the extra shim, which can still merge into it),
489
+ * and {@link LOOSE_LIT_SHIM} otherwise
466
490
  */
467
491
  export async function composeShimSource(projectRoot, extraShimPath, options = {}) {
468
- const base = options.litTypes === true ? `${FIREFOX_GLOBALS_SHIM}\n${LIT_TYPES_SHIM}` : FIREFOX_GLOBALS_SHIM;
492
+ const base = `${FIREFOX_GLOBALS_SHIM}\n${options.litTypes === true ? LIT_TYPES_SHIM : LOOSE_LIT_SHIM}`;
469
493
  if (!extraShimPath) {
470
494
  return { source: base, extraShimAppended: false };
471
495
  }
@@ -115,6 +115,13 @@ export interface FurnaceValidationException {
115
115
  * check for every other element of the component.
116
116
  */
117
117
  tag?: string;
118
+ /**
119
+ * Narrows the exception to the findings in this module file of the
120
+ * component (a finding's `files`), by file name: a vendored bundle whose
121
+ * bytes are pinned can be accepted without muting the check for the
122
+ * authored modules beside it.
123
+ */
124
+ file?: string;
118
125
  /** Why the finding is accepted. Required: the entry is the review record. */
119
126
  reason: string;
120
127
  }
@@ -379,6 +386,8 @@ export interface ValidationIssue {
379
386
  message: string;
380
387
  /** Element tags the finding is about, when it is about particular elements. */
381
388
  tags?: string[];
389
+ /** Component module files the finding is in (`hardcoded-text`), by file name. */
390
+ files?: string[];
382
391
  }
383
392
  /** Resolved test-harness selection for a `furnace create` run. */
384
393
  export type ResolvedTestStyle = 'mochikit' | 'browser-chrome' | 'xpcshell' | 'none';
@@ -306,10 +306,26 @@ shim: `LitElement` and the helpers of
306
306
  member lists, as `Cu` does, so a misspelt member of a widget host is an
307
307
  error rather than `any`. Template helpers and directives stay loose. It is
308
308
  off by default because a reactive property declared only in
309
- `static properties` or `static queries` then needs a constructor
310
- assignment or a JSDoc declaration to be known. A project shim can add
311
- members by merging `interface LitElementShimMembers`; a jsconfig `paths`
312
- entry that maps these URLs to real sources takes precedence.
309
+ `static properties` or `static queries` then needs a declaration to be
310
+ known. Since 0.52.0 the shim declares the global type
311
+ `MozLitElementWith<Extra>`: a widget casts its base to it, and its
312
+ instances carry `Extra` beside MozLitElement's members. A query getter
313
+ cannot be declared any other way: a class field redefines the getter
314
+ MozLitElement installs and throws, and a constructor declaration reads it
315
+ before `renderRoot` exists.
316
+
317
+ ```js
318
+ const Base = /** @type {MozLitElementWith<{ input: HTMLInputElement }>} */ (MozLitElement);
319
+ export class MozSearch extends Base {
320
+ static queries = { input: 'input' };
321
+ }
322
+ ```
323
+
324
+ Without `litTypes` (and under per-patch checkJs, which does not type Lit)
325
+ `MozLitElementWith<Extra>` is `any`, as the base is, so the same source
326
+ checks in both. A project shim can add members by merging
327
+ `interface LitElementShimMembers`; a jsconfig `paths` entry that maps these
328
+ URLs to real sources takes precedence.
313
329
 
314
330
  Per-patch checkJs gives each test script its own program. Since 0.51.0 a
315
331
  test's `resource:///` and `chrome://` imports (including a
package/docs/furnace.md CHANGED
@@ -136,12 +136,30 @@ entries cover all of them:
136
136
  }
137
137
  ```
138
138
 
139
+ An optional `file` (since 0.52.0) narrows an entry the same way to findings
140
+ in one module of the component, by file name. `hardcoded-text` names the
141
+ modules it found text in, so a vendored bundle whose bytes are pinned (and
142
+ so cannot carry a `furnace-ignore` comment) can be accepted while the
143
+ authored modules beside it keep the check. An entry names a `tag` or a
144
+ `file`, not both:
145
+
146
+ ```json
147
+ {
148
+ "component": "moz-editor",
149
+ "check": "hardcoded-text",
150
+ "file": "moz-editor-codemirror.mjs",
151
+ "reason": "The vendored bundle's width probe is never rendered."
152
+ }
153
+ ```
154
+
139
155
  Accessibility checks judge the composed component: the host
140
156
  `<tag>.mjs` and its companion modules (`<tag>-*.mjs`, tests and stories
141
157
  left out) read as one source. Moving a template between the component's
142
158
  modules therefore no longer changes its findings: a keyboard handler in a
143
159
  companion covers an `@click` in the host, and an `@click` in a companion
144
- is judged too. Toolkit's `panel-item` counts as natively
160
+ is judged too. `hardcoded-text` judges each module on its own, so a
161
+ `// furnace-ignore: hardcoded-text` comment covers the file it is in, not
162
+ its siblings. Toolkit's `panel-item` counts as natively
145
163
  keyboard-activated, beside `button`, form controls and the `moz-*` buttons.
146
164
 
147
165
  `minComponents` sets the fewest components a full `furnace validate` must
@@ -92,6 +92,8 @@ Additive keys since 0.51.0 outside `test`:
92
92
  `@click`). An exception that matched an error adds a
93
93
  `validation-exception-on-error` error, and a full run under
94
94
  `furnace.json#minComponents` a `component-floor` error on `furnace.json`.
95
+ - `furnace validate --json` (since 0.52.0): a `hardcoded-text` finding
96
+ carries `files`, the component's module file names it was found in.
95
97
 
96
98
  ## Versioning
97
99
 
@@ -199,7 +201,9 @@ shard did not pass; under `--shard-size` `shards=` counts files and
199
201
  session lock shared, and `results=<path>` names the `--results` document. A
200
202
  run whose engine changed mid-run only in files the running browser cannot
201
203
  see keeps its verdict and appends `drift-tolerated=<n>`. A negative control
202
- on compiled source appends `control-build=compiled`.
204
+ on compiled source appends `control-build=compiled`. Since 0.52.0 a raw log
205
+ that starts none of a dispatch's requested files counts as
206
+ `started=0/<m> ... started-source=raw` instead of leaving the run uncounted.
203
207
 
204
208
  ### The additive `shuffle=` key
205
209
 
package/docs/testing.md CHANGED
@@ -112,7 +112,11 @@ install manifest ships) can only reach the binary through a build. Without
112
112
  `--build` it is refused before the lock. With `--build` it is applied before
113
113
  the pre-test build, so the binary under test carries it, stays applied for
114
114
  every `--repeat` run, and is restored with a fresh mtime so the next build
115
- recompiles the original. The verdict adds `control-build=compiled`. A
115
+ recompiles the original. Since 0.52.0 that pre-test build is a full
116
+ `mach build`, with a notice naming the control's files: the default
117
+ `mach build faster` compiles nothing, so before, the control still tested
118
+ the previous binary and reported `missed`. The verdict adds
119
+ `control-build=compiled`. A
116
120
  build-less control whose packaging is not current is refused before the
117
121
  lock with the advice to add `--build`, instead of ending `inconclusive`
118
122
  after a full queue.
@@ -219,7 +223,12 @@ A scoped build after a full build keeps the claim full while nothing outside
219
223
  its scope moved. Otherwise it carries the full build's anchor forward, and a
220
224
  later build-less run over any path stays covered while that path's own
221
225
  staging inputs are unchanged since the full build. A refusal names the inputs
222
- that did change.
226
+ that did change, and since 0.52.0 who wrote the coverage record: its
227
+ invocation, when, and the writing process's pid (marked `still running`
228
+ while it is), `FIREFORGE_SESSION_ID` and invocation id, the id its history
229
+ records carry. `status --test-coverage` shows the pid and session too. A
230
+ peer whose run a scoped build or a test-file edit refused can tell whose
231
+ build to wait for.
223
232
 
224
233
  `--extend-coverage` remains accepted. Retention is now the default, so the
225
234
  flag only turns a drop into a refusal before the build. Before 0.50.0 a
@@ -582,10 +591,16 @@ helpers.
582
591
  `--jobs <n>` runs a request's shards on n harness instances inside one lock
583
592
  hold. Each instance has its own profile and its own Marionette port, and
584
593
  auto ports (required) move each instance's mochitest server ports; output
585
- lines carry a `[job <i>]` prefix in the terminal and the run log. With
586
- `--no-shard`, or a single directory argument, the files are split in order
587
- into n contiguous chunks. Parallel browsers raise host load, so measure
588
- before relying on it.
594
+ lines carry a `[job <i>]` prefix in the terminal and the run log. Since
595
+ 0.52.0 each instance also writes its own mochitest test list
596
+ (`--testrun-manifest-file`): on the harness's shared default
597
+ `_tests/testing/mochitest/tests.json`, the second instance ran the first
598
+ shard's file and passed, or found none. A `--jobs` run always reads each
599
+ dispatch's mozlog raw log, and a dispatch whose raw log starts none of its
600
+ requested files fails with `started=0/<m>` instead of passing on another
601
+ shard's results. With `--no-shard`, or a single directory argument, the
602
+ files are split in order into n contiguous chunks. Parallel browsers raise
603
+ host load, so measure before relying on it.
589
604
 
590
605
  `--shard-size <n>` dispatches n files per mach invocation (mochitest restarts
591
606
  the browser between files), paying the harness start once per chunk instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",