@hominis/fireforge 0.53.0 → 0.54.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## 0.53.0
3
+ ## 0.54.0
4
+
5
+ ### Test harness
6
+
7
+ - The browser and port checks that follow work a `fireforge test` already did under the lock wait out `--wait-browser` and `--wait-port` again, instead of probing once: the checks after a `--build` run's pre-test build, and those of every later run of a `--repeat` or `--negative-control` sequence. The pre-lock pass spent the budgets and the in-lock checks only probed, so a peer's browser that started during the build ended the run `reason=preflight note=browser-busy` (`control=inconclusive` for a control) and threw the build away.
8
+ - A negative-control diff that names one file in more than one section (a generator that writes one section per edit) is applied as one section with its hunks in file order, instead of being refused. Sections whose hunks overlap are still refused, naming both hunks and the one-section remedy.
9
+ - Windows: the `stale-compiled` refusal names the linked binary with forward slashes (`engine/obj-x/dist/bin/libxul.so`), not with mixed separators.
10
+
11
+ ### Patch queue
12
+
13
+ - `patch staged-dependency --remove` without `--kind` takes the kind from its matcher: `--specifier` selects imports and `--line` registrations. A selection with neither, such as `--creates` alone, searches both kinds, as the flag's help says. It used to search imports only, so removing a stale registration by `--creates` was refused until `--kind registration` was added. A miss lists the patch's declarations of every kind it searched.
14
+ - `re-export --expect-removed` combines with `--scan`, so one call can retire a widget's files from one patch while `--scan-file` adopts a new module into another. The retired path leaves its owner's file list before the scan, which cannot adopt it back. `--files`, `--all` and an implicit selection are still refused.
15
+
16
+ ### Run
17
+
18
+ - `fireforge run` passes the arguments after `--` to the browser, after the profile ones: `fireforge run --profile <dir> -- -marionette -remote-allow-system-access` drives a headed session on a kept profile, with `-purgecaches`, from chrome-context Marionette.
19
+
20
+ ### Other
21
+
22
+ - Tests: the `git diff --no-index` negative-control test no longer fails when the temp root's random suffix ends in `b`, which its fixture rewrite matched inside the old side's absolute path. The `application.ini` path test compares native paths on Windows.
4
23
 
5
24
  ### Test harness
6
25
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.53.0",
4
- "commit": "2f2367bfe9c2b8a834cbf09f9b19b02d8dbd6b66",
5
- "shortCommit": "2f2367bfe9c2",
3
+ "version": "0.54.0",
4
+ "commit": "2bf7356bab0d77849cb1ab136f0160995dabf41e",
5
+ "shortCommit": "2bf7356bab0d",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-10-22T20:59:15.472Z"
8
+ "buildTime": "2026-10-24T18:17:09.937Z"
9
9
  }
@@ -4,7 +4,10 @@
4
4
  *
5
5
  * A removal names its entries by any of `--file`, `--specifier`/`--line`,
6
6
  * `--creates` and `--owner`, and must give at least `--creates` or the
7
- * `--file` + matcher pair. Registration lines compare whitespace-
7
+ * `--file` + matcher pair. Without `--kind` the matcher picks the kind
8
+ * (`--specifier` an import, `--line` a registration), and a selection with
9
+ * neither searches both kinds, so `--creates` alone removes every
10
+ * declaration for that path. Registration lines compare whitespace-
8
11
  * insensitively (runs of whitespace collapse, ends trimmed), the way the
9
12
  * lint compares them, so a line copied from a patch hunk matches the stored
10
13
  * one whatever its padding. A selection that matches nothing is refused
@@ -12,6 +15,24 @@
12
15
  */
13
16
  import type { PatchStagedDependencies, PatchStagedDependencyOptions } from '../../types/commands/index.js';
14
17
  import { type StagedDependencyKind, type StagedEntryView } from './staged-dependency-entries.js';
18
+ /** One stored declaration together with its kind. */
19
+ export interface KindedEntryView {
20
+ kind: StagedDependencyKind;
21
+ view: StagedEntryView;
22
+ }
23
+ /** Singular noun for a declaration of the given kinds. */
24
+ export declare function declarationNoun(kinds: readonly StagedDependencyKind[]): string;
25
+ /**
26
+ * The kinds a `--remove` searches. `--kind` wins when given (and is
27
+ * validated by the caller); otherwise `--specifier` selects imports,
28
+ * `--line` registrations, and a selection with neither matcher searches
29
+ * both kinds.
30
+ *
31
+ * @param options - Command options
32
+ * @returns The kinds to search, or undefined when `--kind` was given
33
+ * @throws InvalidArgumentError when `--specifier` and `--line` are both given without `--kind`
34
+ */
35
+ export declare function inferRemovalKinds(options: PatchStagedDependencyOptions): readonly StagedDependencyKind[] | undefined;
15
36
  /**
16
37
  * Resolves the staged entries a `--remove` invocation names. The returned
17
38
  * views are the stored entries themselves, so the removal matches them
@@ -20,12 +41,13 @@ import { type StagedDependencyKind, type StagedEntryView } from './staged-depend
20
41
  * - `--file` + matcher identifying one entry infers `--creates`.
21
42
  * - `--file` + matcher identifying several entries without `--creates`
22
43
  * refuses with the candidate list.
23
- * - `--creates` alone selects every declaration for that created path.
44
+ * - `--creates` alone selects every declaration for that created path, of
45
+ * every kind searched.
24
46
  *
25
- * @param kind - Declaration kind (`--kind`)
47
+ * @param kinds - Declaration kinds to search
26
48
  * @param options - Command options
27
49
  * @param staged - The target patch's current staged dependencies
28
50
  * @returns The matching stored entries (never empty)
29
51
  * @throws InvalidArgumentError when the selectors are insufficient or match nothing
30
52
  */
31
- export declare function selectRemovalEntries(kind: StagedDependencyKind, options: PatchStagedDependencyOptions, staged: PatchStagedDependencies | undefined): StagedEntryView[];
53
+ export declare function selectRemovalEntries(kinds: readonly StagedDependencyKind[], options: PatchStagedDependencyOptions, staged: PatchStagedDependencies | undefined): KindedEntryView[];
@@ -5,7 +5,10 @@
5
5
  *
6
6
  * A removal names its entries by any of `--file`, `--specifier`/`--line`,
7
7
  * `--creates` and `--owner`, and must give at least `--creates` or the
8
- * `--file` + matcher pair. Registration lines compare whitespace-
8
+ * `--file` + matcher pair. Without `--kind` the matcher picks the kind
9
+ * (`--specifier` an import, `--line` a registration), and a selection with
10
+ * neither searches both kinds, so `--creates` alone removes every
11
+ * declaration for that path. Registration lines compare whitespace-
9
12
  * insensitively (runs of whitespace collapse, ends trimmed), the way the
10
13
  * lint compares them, so a line copied from a patch hunk matches the stored
11
14
  * one whatever its padding. A selection that matches nothing is refused
@@ -18,10 +21,38 @@ import { dependencyLabel, importView, registrationView, } from './staged-depende
18
21
  function normalizeRegistrationLine(line) {
19
22
  return line.trim().replace(/\s+/g, ' ');
20
23
  }
21
- function stagedViews(kind, staged) {
22
- return kind === 'import'
23
- ? (staged?.forwardImports ?? []).map(importView)
24
- : (staged?.registrations ?? []).map(registrationView);
24
+ function stagedViews(kinds, staged) {
25
+ return kinds.flatMap((kind) => kind === 'import'
26
+ ? (staged?.forwardImports ?? []).map((entry) => ({ kind, view: importView(entry) }))
27
+ : (staged?.registrations ?? []).map((entry) => ({ kind, view: registrationView(entry) })));
28
+ }
29
+ /** Singular noun for a declaration of the given kinds. */
30
+ export function declarationNoun(kinds) {
31
+ if (kinds.length !== 1)
32
+ return 'staged declaration';
33
+ return kinds[0] === 'import' ? 'staged forward-import' : 'staged registration';
34
+ }
35
+ /**
36
+ * The kinds a `--remove` searches. `--kind` wins when given (and is
37
+ * validated by the caller); otherwise `--specifier` selects imports,
38
+ * `--line` registrations, and a selection with neither matcher searches
39
+ * both kinds.
40
+ *
41
+ * @param options - Command options
42
+ * @returns The kinds to search, or undefined when `--kind` was given
43
+ * @throws InvalidArgumentError when `--specifier` and `--line` are both given without `--kind`
44
+ */
45
+ export function inferRemovalKinds(options) {
46
+ if (options.kind !== undefined)
47
+ return undefined;
48
+ if (options.specifier !== undefined && options.line !== undefined) {
49
+ throw new InvalidArgumentError('--specifier names an import and --line a registration; pass one of them, or --kind.', '--specifier/--line');
50
+ }
51
+ if (options.specifier !== undefined)
52
+ return ['import'];
53
+ if (options.line !== undefined)
54
+ return ['registration'];
55
+ return ['import', 'registration'];
25
56
  }
26
57
  /**
27
58
  * Resolves the staged entries a `--remove` invocation names. The returned
@@ -31,47 +62,53 @@ function stagedViews(kind, staged) {
31
62
  * - `--file` + matcher identifying one entry infers `--creates`.
32
63
  * - `--file` + matcher identifying several entries without `--creates`
33
64
  * refuses with the candidate list.
34
- * - `--creates` alone selects every declaration for that created path.
65
+ * - `--creates` alone selects every declaration for that created path, of
66
+ * every kind searched.
35
67
  *
36
- * @param kind - Declaration kind (`--kind`)
68
+ * @param kinds - Declaration kinds to search
37
69
  * @param options - Command options
38
70
  * @param staged - The target patch's current staged dependencies
39
71
  * @returns The matching stored entries (never empty)
40
72
  * @throws InvalidArgumentError when the selectors are insufficient or match nothing
41
73
  */
42
- export function selectRemovalEntries(kind, options, staged) {
43
- const matcherFlag = kind === 'import' ? '--specifier' : '--line';
44
- const matcher = kind === 'import' ? options.specifier : options.line;
74
+ export function selectRemovalEntries(kinds, options, staged) {
75
+ const single = kinds.length === 1 ? kinds[0] : undefined;
76
+ const matcherFlag = single === 'import'
77
+ ? '--specifier'
78
+ : single === 'registration'
79
+ ? '--line'
80
+ : '--specifier or --line';
81
+ const matcher = single === 'import' ? options.specifier : single === 'registration' ? options.line : undefined;
45
82
  const hasPair = Boolean(options.file) && Boolean(matcher);
46
83
  if (!options.creates && !hasPair) {
47
- throw new InvalidArgumentError(`--remove${kind === 'registration' ? ' --kind registration' : ''} requires --creates, ` +
84
+ throw new InvalidArgumentError(`--remove${options.kind === 'registration' ? ' --kind registration' : ''} requires --creates, ` +
48
85
  `or --file with ${matcherFlag}; --creates alone removes every declaration for that path.`, matcher && !options.file
49
86
  ? '--file'
50
87
  : options.file && !matcher
51
88
  ? matcherFlag
52
89
  : `--creates or --file + ${matcherFlag}`);
53
90
  }
54
- const wanted = matcher === undefined ? undefined : comparableMatcher(kind, matcher);
55
- const views = stagedViews(kind, staged);
56
- const candidates = views.filter((view) => (!options.file || view.file === options.file) &&
57
- (wanted === undefined || comparableMatcher(kind, view.matcher) === wanted) &&
91
+ const views = stagedViews(kinds, staged);
92
+ const candidates = views.filter(({ kind, view }) => (!options.file || view.file === options.file) &&
93
+ (matcher === undefined ||
94
+ comparableMatcher(kind, view.matcher) === comparableMatcher(kind, matcher)) &&
58
95
  (!options.creates || view.creates === options.creates) &&
59
96
  (options.owner === undefined || view.owner === options.owner));
97
+ const label = ({ kind, view }) => ` - ${dependencyLabel(kind, view)}`;
60
98
  if (candidates.length === 0) {
61
- const noun = kind === 'import' ? 'staged forward-import' : 'staged registration';
99
+ const noun = declarationNoun(kinds);
62
100
  const existing = views.length === 0
63
101
  ? `This patch has no ${noun}s.`
64
- : `This patch's ${noun}s:\n${views.map((view) => ` - ${dependencyLabel(kind, view)}`).join('\n')}`;
102
+ : `This patch's ${noun}s:\n${views.map(label).join('\n')}`;
65
103
  throw new InvalidArgumentError(`--remove matched no ${noun}. ${existing}`, '--remove');
66
104
  }
67
105
  if (candidates.length > 1 && !options.creates) {
68
- const list = candidates.map((view) => ` - ${dependencyLabel(kind, view)}`).join('\n');
69
- const noun = kind === 'import' ? 'staged forward-imports' : 'staged registrations';
70
- throw new GeneralError(`--remove matches ${candidates.length} ${noun} on this patch; pass --creates to pick one:\n${list}`);
106
+ throw new GeneralError(`--remove matches ${candidates.length} ${declarationNoun(kinds)}s on this patch; ` +
107
+ `pass --creates to pick one:\n${candidates.map(label).join('\n')}`);
71
108
  }
72
- const single = candidates.length === 1 ? candidates[0] : undefined;
73
- if (single !== undefined && !options.creates) {
74
- info(`Inferred --creates ${single.creates} from the single matching staged entry.`);
109
+ const only = candidates.length === 1 ? candidates[0] : undefined;
110
+ if (only !== undefined && !options.creates) {
111
+ info(`Inferred --creates ${only.view.creates} from the single matching staged entry.`);
75
112
  }
76
113
  return candidates;
77
114
  }
@@ -13,7 +13,7 @@ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
13
13
  import { stagedDependencyDelta } from './staged-dependency-batch.js';
14
14
  import { applyMode, countReasonUpdates, countStagedEntries, dependencyLabel, importView, registrationView, } from './staged-dependency-entries.js';
15
15
  import { runBatchAdd } from './staged-dependency-from.js';
16
- import { selectRemovalEntries } from './staged-dependency-remove.js';
16
+ import { declarationNoun, inferRemovalKinds, selectRemovalEntries, } from './staged-dependency-remove.js';
17
17
  import { validateStagedDependencyAdd } from './staged-dependency-validate.js';
18
18
  /** Joins the names of the missing flags for error attribution. */
19
19
  function missingFlagList(pairs) {
@@ -89,16 +89,17 @@ function requireRegistrationOptions(options) {
89
89
  /**
90
90
  * Renders a one-line summary of a staged-dependency metadata change.
91
91
  */
92
- function describeStagedDependencyChange(before, after, mode, kind, targets) {
92
+ function describeStagedDependencyChange(before, after, mode, kinds, targets) {
93
93
  const beforeCount = countStagedEntries(before);
94
94
  const afterCount = countStagedEntries(after);
95
- const noun = kind === 'import' ? 'staged forward-import' : 'staged registration';
95
+ const targetKinds = [...new Set(targets.map((target) => target.kind))];
96
+ const noun = declarationNoun(targetKinds.length > 0 ? targetKinds : kinds);
96
97
  if (mode === 'clear') {
97
98
  return beforeCount === 0
98
99
  ? 'stagedDependencies was already empty — no change'
99
100
  : `cleared ${beforeCount} staged dependency declaration(s)`;
100
101
  }
101
- const label = targets.map((target) => dependencyLabel(kind, target)).join('; ');
102
+ const label = targets.map(({ kind, view }) => dependencyLabel(kind, view)).join('; ');
102
103
  if (targets.length === 0)
103
104
  return 'stagedDependencies unchanged';
104
105
  if (mode === 'add') {
@@ -127,7 +128,11 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
127
128
  await runBatchAdd(projectRoot, identifier, mode, options);
128
129
  return;
129
130
  }
130
- const kind = kindFromOptions(options);
131
+ // Without --kind, --remove searches the kind its matcher names, or both.
132
+ const kinds = (mode === 'remove' ? inferRemovalKinds(options) : undefined) ?? [
133
+ kindFromOptions(options),
134
+ ];
135
+ const kind = kinds.length === 1 ? kinds[0] : undefined;
131
136
  // The patch queue loads before the declaration fields resolve so
132
137
  // `--remove` can infer `--creates` from the target patch's staged
133
138
  // entries.
@@ -143,24 +148,25 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
143
148
  validateStagedDependencyAdd(added, manifest.patches);
144
149
  }
145
150
  const targets = mode === 'remove'
146
- ? selectRemovalEntries(kind, options, targetPatch.stagedDependencies)
151
+ ? selectRemovalEntries(kinds, options, targetPatch.stagedDependencies)
147
152
  : importDependency !== undefined
148
- ? [importView(importDependency)]
153
+ ? [{ kind: 'import', view: importView(importDependency) }]
149
154
  : registrationDependency !== undefined
150
- ? [registrationView(registrationDependency)]
155
+ ? [{ kind: 'registration', view: registrationView(registrationDependency) }]
151
156
  : [];
152
- const applyKind = (entries, own, toView, addition) => {
153
- if (!own)
157
+ const applyKind = (entries, entryKind, toView, addition) => {
158
+ if (!kinds.includes(entryKind))
154
159
  return [...entries];
160
+ const own = targets.filter((target) => target.kind === entryKind).map(({ view }) => view);
155
161
  if (mode !== 'remove')
156
- return applyMode(entries, mode, targets[0], toView, addition);
157
- return targets.reduce((remaining, target) => applyMode(remaining, 'remove', target, toView, undefined), [...entries]);
162
+ return applyMode(entries, mode, own[0], toView, addition);
163
+ return own.reduce((remaining, target) => applyMode(remaining, 'remove', target, toView, undefined), [...entries]);
158
164
  };
159
165
  const applyToStaged = (staged) => {
160
166
  if (mode === 'clear')
161
167
  return {};
162
- const forwardImports = applyKind(staged?.forwardImports ?? [], kind === 'import', importView, importDependency);
163
- const registrations = applyKind(staged?.registrations ?? [], kind === 'registration', registrationView, registrationDependency);
168
+ const forwardImports = applyKind(staged?.forwardImports ?? [], 'import', importView, importDependency);
169
+ const registrations = applyKind(staged?.registrations ?? [], 'registration', registrationView, registrationDependency);
164
170
  return {
165
171
  ...(forwardImports.length > 0 ? { forwardImports } : {}),
166
172
  ...(registrations.length > 0 ? { registrations } : {}),
@@ -168,7 +174,7 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
168
174
  };
169
175
  const existing = targetPatch.stagedDependencies;
170
176
  const projected = applyToStaged(existing);
171
- const summary = describeStagedDependencyChange(existing, projected, mode, kind, targets);
177
+ const summary = describeStagedDependencyChange(existing, projected, mode, kinds, targets);
172
178
  if (isDryRun) {
173
179
  info(`[dry-run] ${targetPatch.filename}: ${summary}.`);
174
180
  outro('Dry run complete — no changes made');
@@ -188,7 +194,7 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
188
194
  }
189
195
  const before = result.before.stagedDependencies;
190
196
  const after = result.after.stagedDependencies;
191
- info(`${targetPatch.filename}: ${describeStagedDependencyChange(before, after, mode, kind, targets)}.`);
197
+ info(`${targetPatch.filename}: ${describeStagedDependencyChange(before, after, mode, kinds, targets)}.`);
192
198
  if (mode === 'remove' && countStagedEntries(after) === countStagedEntries(before)) {
193
199
  // The selection matched when the queue was read; a peer removed the
194
200
  // entries before the locked write. Still a miss, so still a refusal.
@@ -201,7 +207,8 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
201
207
  args: {
202
208
  filename: targetPatch.filename,
203
209
  mode,
204
- kind,
210
+ // A kind-less --remove searched both kinds; the delta names which.
211
+ ...(kind !== undefined ? { kind } : {}),
205
212
  ...stagedDependencyDelta(before, after),
206
213
  },
207
214
  ...(options.yes === true ? { yes: true } : {}),
@@ -223,7 +230,7 @@ export function registerPatchStagedDependency(parent, context) {
223
230
  .option('--add', 'Add a staged dependency declaration')
224
231
  .option('--remove', 'Remove matching staged dependency declaration(s); a selection that matches nothing exits 8')
225
232
  .option('--clear', 'Drop the stagedDependencies field entirely')
226
- .option('--kind <kind>', 'Declaration shape: "import" (forward import, the default) or "registration" (jar.mn packaging line, customElements or actor registration)')
233
+ .option('--kind <kind>', 'Declaration shape: "import" (forward import, the default) or "registration" (jar.mn packaging line, customElements or actor registration); --remove without it takes the kind --specifier or --line names, or both kinds')
227
234
  .option('--file <path>', 'Declaring file path relative to engine/')
228
235
  .option('--specifier <specifier>', 'Exact import specifier as it appears in source (--kind import)')
229
236
  .option('--line <text>', 'Registration/packaging line as the patch adds it, compared whitespace-insensitively (--kind registration)')
@@ -97,10 +97,16 @@ function validatePathFlagValues(options) {
97
97
  }
98
98
  }
99
99
  }
100
+ /**
101
+ * A retirement names its owner among explicitly selected patches. It
102
+ * combines with `--scan`: the retired path leaves the scan's base first,
103
+ * and the scan cannot adopt it back, since a retired created file is absent
104
+ * and a retired upstream file is clean at HEAD.
105
+ */
100
106
  function validateExpectedRemovalUsage(patches, options) {
101
107
  if (options.expectRemoved?.length &&
102
- (options.scan || options.all || options.files !== undefined || patches.length === 0)) {
103
- throw new InvalidArgumentError('--expect-removed requires explicit selected patches and the scan-less path (no --scan, --files, or --all).', '--expect-removed');
108
+ (options.all || options.files !== undefined || patches.length === 0)) {
109
+ throw new InvalidArgumentError('--expect-removed requires explicit selected patches and cannot combine with --files or --all.', '--expect-removed');
104
110
  }
105
111
  }
106
112
  /**
@@ -130,6 +130,7 @@ async function runInteractive(engineDir, options, profile) {
130
130
  const exitCode = await run(engineDir, [
131
131
  ...(options.headless ? ['--headless'] : []),
132
132
  ...profile.args,
133
+ ...(options.args ?? []),
133
134
  ]);
134
135
  // Exit-code whitelist:
135
136
  // 0: clean shutdown
@@ -234,7 +235,12 @@ async function runSmokeExit(engineDir, options, profile) {
234
235
  const startedAt = Date.now();
235
236
  let result;
236
237
  try {
237
- const machArgs = ['run', ...(options.headless ? ['--headless'] : []), ...profile.args];
238
+ const machArgs = [
239
+ 'run',
240
+ ...(options.headless ? ['--headless'] : []),
241
+ ...profile.args,
242
+ ...(options.args ?? []),
243
+ ];
238
244
  result = await runMachSmoke(machArgs, engineDir, {
239
245
  smokeTimeoutMs,
240
246
  onStdoutLine: (line) => {
@@ -394,6 +400,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
394
400
  program
395
401
  .command('run')
396
402
  .description('Launch the built browser')
403
+ .argument('[args...]', 'Arguments passed to the browser after the profile ones, given after "--" (e.g. -- -marionette -remote-allow-system-access)')
397
404
  .option('--smoke-exit <seconds>', 'Smoke-run mode (POSIX only): launch, capture console, SIGTERM the process group after <seconds>. Exit 0 on a clean window, 12 on unallowed errors, 13 on launch failure.', commanderArgParser((value) => {
398
405
  const parsed = Number.parseInt(value, 10);
399
406
  if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value.trim()) {
@@ -410,8 +417,8 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
410
417
  .option('--temp-profile', 'Launch with a fresh temporary profile that is removed when the run ends. The default for --smoke-exit.')
411
418
  .action(withErrorHandling(
412
419
  // `args` is the variadic positional, not a Commander flag, so it
413
- // never appears in this object.
414
- async (options) => {
415
- await runCommand(getProjectRoot(), pickDefined(options));
420
+ // arrives apart from the options object.
421
+ async (args, options) => {
422
+ await runCommand(getProjectRoot(), pickDefined({ ...options, ...(args.length > 0 ? { args } : {}) }));
416
423
  }));
417
424
  }
@@ -27,6 +27,7 @@ import { stripEnginePrefix } from '../utils/paths.js';
27
27
  import { controlBuildRecorded, runControlRestoreBuild } from './test-control-restore.js';
28
28
  import { classifyTestHarnesses } from './test-modes.js';
29
29
  import { deriveControlOutcome, describeControlOutcome, formatAggregateBody, formatRunLine, parseCapturedVerdict, worstRun, } from './test-multi-verdict.js';
30
+ import { rearmSpentWaits } from './test-prelock.js';
30
31
  import { emitAggregateVerdict, emitFailVerdict, setVerdictCapture, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
31
32
  function assertMultiRunUsage(testPaths, options) {
32
33
  const flag = options.negativeControl !== undefined ? '--negative-control' : '--repeat';
@@ -58,7 +59,9 @@ async function splitHarnesses(engineDir, testPaths, options) {
58
59
  }
59
60
  /** Options for every run after the first: the build and doctor run once. */
60
61
  function laterRunOptions(options) {
61
- const later = { ...options };
62
+ // A later run follows the earlier runs' work under the same lock hold, so
63
+ // its browser and port checks wait out the budgets again.
64
+ const later = { ...rearmSpentWaits(options) };
62
65
  delete later.build;
63
66
  delete later.extendCoverage;
64
67
  delete later.refuseUnexportedDrift;
@@ -48,3 +48,14 @@ export declare const TEST_BUILD_PREFLIGHT: {
48
48
  * @param options - Parsed test options
49
49
  */
50
50
  export declare function runTestPreLockPreflight(projectRoot: string, testPaths: string[], options: TestOptions): Promise<TestOptions>;
51
+ /**
52
+ * `options` with the wait budgets the pre-lock pass spent put back, for a
53
+ * browser or port check that follows work this invocation already did
54
+ * under the lock: its pre-test build, or an earlier run of a sequence. A
55
+ * busy browser there is a peer that started after the lock was taken, and
56
+ * refusing would throw the build away, so it is waited out like the first
57
+ * check's.
58
+ *
59
+ * @param options - In-lock run options
60
+ */
61
+ export declare function rearmSpentWaits(options: TestOptions): TestOptions;
@@ -219,15 +219,40 @@ async function refuseStalePackagingBeforeLock(projectRoot, engineDir, options, r
219
219
  'against a fresh build of the requested paths.', error.note);
220
220
  }
221
221
  }
222
- /** `options` without one wait budget, which this pass already spent. */
222
+ /**
223
+ * `options` without one wait budget, which this pass already spent. The
224
+ * budget moves to `spentWaits`, for {@link rearmSpentWaits}.
225
+ */
223
226
  function withoutOption(options, key) {
224
- const rest = { ...options };
227
+ const rest = { ...options, spentWaits: { ...options.spentWaits, [key]: options[key] } };
225
228
  if (key === 'waitBrowser')
226
229
  delete rest.waitBrowser;
227
230
  else
228
231
  delete rest.waitPort;
229
232
  return rest;
230
233
  }
234
+ /**
235
+ * `options` with the wait budgets the pre-lock pass spent put back, for a
236
+ * browser or port check that follows work this invocation already did
237
+ * under the lock: its pre-test build, or an earlier run of a sequence. A
238
+ * busy browser there is a peer that started after the lock was taken, and
239
+ * refusing would throw the build away, so it is waited out like the first
240
+ * check's.
241
+ *
242
+ * @param options - In-lock run options
243
+ */
244
+ export function rearmSpentWaits(options) {
245
+ const spent = options.spentWaits;
246
+ if (spent === undefined)
247
+ return options;
248
+ const rearmed = { ...options };
249
+ if (spent.waitBrowser !== undefined)
250
+ rearmed.waitBrowser = spent.waitBrowser;
251
+ if (spent.waitPort !== undefined)
252
+ rearmed.waitPort = spent.waitPort;
253
+ delete rearmed.spentWaits;
254
+ return rearmed;
255
+ }
231
256
  /**
232
257
  * Spends `--wait-browser` and `--wait-port` before the lock, mirroring
233
258
  * which in-lock checks would apply to this run, and strips each budget it
@@ -86,7 +86,8 @@ const TEST_HELP_TEXT = [
86
86
  'launchable binary (with the build that left it missing, when one is',
87
87
  'recorded), the named-file checkJs pass of --build, and the',
88
88
  '--wait-browser/--wait-port budgets. Each is re-checked once under the',
89
- 'lock.',
89
+ 'lock; the checks after a pre-test build or an earlier run of the',
90
+ 'sequence wait the budgets out again.',
90
91
  ].join('\n');
91
92
  function registerTestMultiRunOptions(test) {
92
93
  return test
@@ -25,7 +25,7 @@ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-
25
25
  import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
26
26
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
27
27
  import { orchestrateTestRuns } from './test-orchestrate.js';
28
- import { resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
28
+ import { rearmSpentWaits, resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
29
29
  import { resetTestResults, withRawLogDir, writeTestResults } from './test-results.js';
30
30
  import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
31
31
  import { emitFailVerdict, resetVerdictEmission, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
@@ -362,7 +362,9 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
362
362
  if (buildId !== undefined)
363
363
  setVerdictRunAttribute('build-id', buildId);
364
364
  }
365
- const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options, buildCheck.objDir);
365
+ // After a pre-test build, a busy browser or port is a peer that started
366
+ // during it; waiting it out keeps the build this run just paid for.
367
+ const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options.build === true ? rearmSpentWaits(options) : options, buildCheck.objDir);
366
368
  if (options.doctor) {
367
369
  const doctorOutcome = await runDoctorPreflight({
368
370
  engineDir: paths.engine,
@@ -70,8 +70,8 @@ export function parseControlDiff(content, options = {}) {
70
70
  refuse('no diff sections found. Write the control with git, e.g. ' +
71
71
  '`git -C engine diff -- <file> > control.diff` after making the edit, then revert the edit.');
72
72
  }
73
- const seen = new Set();
74
- return sections.map((section) => {
73
+ const files = new Map();
74
+ for (const section of sections) {
75
75
  let path = normalizePathSlashes(section.targetPath);
76
76
  if (section.isBinary)
77
77
  refuse(`${path} is a binary section.`);
@@ -90,11 +90,36 @@ export function parseControlDiff(content, options = {}) {
90
90
  }
91
91
  if (section.hunks.length === 0)
92
92
  refuse(`${path} has no hunks.`);
93
- if (seen.has(path))
94
- refuse(`${path} appears in more than one section.`);
95
- seen.add(path);
96
- return { path, hunks: section.hunks };
93
+ const existing = files.get(path);
94
+ if (existing === undefined) {
95
+ files.set(path, { path, hunks: [...section.hunks], sections: 1 });
96
+ }
97
+ else {
98
+ existing.hunks.push(...section.hunks);
99
+ existing.sections += 1;
100
+ }
101
+ }
102
+ return [...files.values()].map(({ path, hunks, sections: count }) => ({
103
+ path,
104
+ hunks: count === 1 ? hunks : mergeSectionHunks(path, hunks),
105
+ }));
106
+ }
107
+ /**
108
+ * Orders the hunks of a file that several sections name (a generator that
109
+ * writes one section per edit) as one section's would be. Hunks that
110
+ * overlap cannot be applied one after another, so they are refused.
111
+ */
112
+ function mergeSectionHunks(path, hunks) {
113
+ const ordered = [...hunks].sort((left, right) => left.oldStart - right.oldStart);
114
+ ordered.forEach((hunk, index) => {
115
+ const previous = ordered[index - 1];
116
+ if (previous !== undefined && previous.oldStart + previous.oldCount > hunk.oldStart) {
117
+ refuse(`${path} appears in more than one section, and hunks from them overlap ` +
118
+ `(@@ -${previous.oldStart},${previous.oldCount} @@ and @@ -${hunk.oldStart},${hunk.oldCount} @@). ` +
119
+ 'Make every edit to the file, then write one section for it with `git -C engine diff -- <file>`.');
120
+ }
97
121
  });
122
+ return ordered;
98
123
  }
99
124
  function hunkSides(hunk, label, index) {
100
125
  const oldLines = [];
@@ -139,7 +139,7 @@ export async function findCompiledSourcesNewerThanBinary(engineDir, baseline, la
139
139
  }
140
140
  return {
141
141
  paths: paths.sort(),
142
- linkedPath: relative(engineDir, linked.path),
142
+ linkedPath: normalizePathSlashes(relative(engineDir, linked.path)),
143
143
  linkedAt: new Date(linked.mtimeMs),
144
144
  };
145
145
  }
@@ -648,6 +648,18 @@ export interface TestOptions extends EngineLockWaitable {
648
648
  waitBrowser?: number | boolean;
649
649
  /** Queue behind the mochitest/Marionette port holder. */
650
650
  waitPort?: number | boolean;
651
+ /**
652
+ * The `--wait-browser`/`--wait-port` budgets the pre-lock pass already
653
+ * spent (and stripped from `waitBrowser`/`waitPort`). The first in-lock
654
+ * check probes once; a check that follows work this invocation did under
655
+ * the lock (its pre-test build, or an earlier run of a `--repeat` or
656
+ * `--negative-control` sequence) waits them out again, because refusing
657
+ * there throws that work away.
658
+ */
659
+ spentWaits?: {
660
+ waitBrowser?: number | boolean;
661
+ waitPort?: number | boolean;
662
+ };
651
663
  /** Also write the run log here, outside `.fireforge/logs/` retention. */
652
664
  keepLog?: string;
653
665
  /** Forward mochitest's `--restart-between-tests` (mochitest dispatch only). */
@@ -656,6 +656,13 @@ owning process is gone.
656
656
  every run, and a temporary profile starts without one.
657
657
  - `--temp-profile` gives a plain `run` the same fresh, removed-afterwards
658
658
  profile.
659
+ - Arguments after `--` go to the browser, after the profile ones. A headed
660
+ session on a kept profile that a chrome-context Marionette client drives
661
+ is `fireforge run --profile <dir> -- -marionette -remote-allow-system-access`,
662
+ with `marionette.port` set in the profile's `user.js` so peers' harnesses
663
+ keep 2828. The objdir app launched directly gets neither `-purgecaches`
664
+ nor the startup-cache cleanup, so it can serve a widget script an earlier
665
+ launch cached.
659
666
 
660
667
  A smoke run whose browser exits with a clean status before the window
661
668
  elapses, without printing any console output, fails with exit 13.
@@ -169,7 +169,7 @@ list rather than the subset left after filtering, so nothing dropped upstream
169
169
  can pass as a silent success.
170
170
 
171
171
  A file can be retired from a selected patch with repeatable
172
- `--expect-removed <path>` on scan-less re-export. This works for an absent
172
+ `--expect-removed <path>`. This works for an absent
173
173
  file the patch created, and for an upstream file the patch modified or
174
174
  deleted whose engine copy is back at HEAD:
175
175
 
@@ -190,7 +190,10 @@ validated before writes, in dry-run too. Other paths keep
190
190
  both refusal checks; retirement grants no unmanaged or foreign-drift
191
191
  exception. Removing every file would empty the patch and requires `patch
192
192
  delete` (or `patch retire`, which keeps the order number) instead.
193
- `--expect-removed` cannot combine with `--scan`, `--files` or `--all`.
193
+ `--expect-removed` cannot combine with `--files` or `--all`. It combines
194
+ with `--scan`, so one call can retire a widget's files from one patch while
195
+ `--scan-file` adopts a new module into another: the retired path leaves the
196
+ owner's file list before its scan, which cannot adopt it back.
194
197
 
195
198
  The same run drops every staged declaration, registration or forward
196
199
  import, in any patch of the queue, whose `creates` path it retired and no
@@ -386,11 +389,14 @@ hash-object` and `git update-index`. A collision (two rows at one order) or
386
389
  copy and the index do not differ on key order alone.
387
390
  - `patch staged-dependency <name> --remove` names the entries to remove by
388
391
  any of `--file`, `--specifier`/`--line`, `--creates` and `--owner`. It needs
389
- `--creates`, or `--file` with the matcher. `--creates` alone removes every
390
- declaration for that created path, which is the cleanup after a merge
391
- deletes it. Registration lines compare whitespace-insensitively, as the
392
- lint does. A selection that matches nothing is refused with exit 8, on a
393
- dry run too.
392
+ `--creates`, or `--file` with the matcher. Without `--kind`, `--specifier`
393
+ selects imports, `--line` selects registrations, and a selection with
394
+ neither searches both kinds, so `--creates` alone removes every
395
+ declaration for that created path, imports and registrations alike, which
396
+ is the cleanup after a merge deletes it. Registration lines compare
397
+ whitespace-insensitively, as the lint does. A selection that matches
398
+ nothing is refused with exit 8, on a dry run too, and the refusal lists
399
+ the patch's declarations of every kind it searched.
394
400
  - `patch staged-dependency <name> --add --from <json-file>` adds many
395
401
  declarations in one call under one lock hold. The file is a `lint
396
402
  --per-patch --report` document (its findings' `stagedDeclarations` for
package/docs/testing.md CHANGED
@@ -83,7 +83,10 @@ Furnace deployed it to, since that is what the run reads; the component
83
83
  source itself is never touched. Only modifications of existing text files
84
84
  are accepted: a created, deleted, renamed or binary file, or a symlinked
85
85
  target, is refused. Hunks must match exactly; the only tolerance is a line
86
- offset, as in `git apply`, never fuzz.
86
+ offset, as in `git apply`, never fuzz. A file named by more than one
87
+ section (a generator that writes one section per edit) is applied as one
88
+ section with every hunk in file order; sections whose hunks overlap are
89
+ refused, with the advice to write one section for the file.
87
90
 
88
91
  The verdict says how the control came out with `control=`:
89
92
 
@@ -329,7 +332,11 @@ engine session lock is requested, so a run waiting out a browser or a port
329
332
  does not hold every peer's build, test and re-export behind it. The run then
330
333
  joins the lock queue and re-checks once under the lock; the normal preflight
331
334
  refusal and its remedy apply if the resource is busy again (or still busy)
332
- there. With `--kill-stale-marionette` a reapable stale browser counts as
335
+ there. A check that follows work the run already did under the lock waits
336
+ the budgets out again instead: the browser and port checks after a
337
+ `--build` run's pre-test build, and those of every later run of a `--repeat`
338
+ or `--negative-control` sequence, so a peer's browser that started during
339
+ the build does not throw the build away. With `--kill-stale-marionette` a reapable stale browser counts as
333
340
  clear before the lock, and the port waits stay under the lock, where the
334
341
  stale listener can be reaped.
335
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",