@hominis/fireforge 0.16.2 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +9 -2
- package/dist/bin/fireforge.js +11 -2
- package/dist/src/commands/doctor-furnace.js +83 -1
- package/dist/src/commands/doctor.js +18 -0
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
- package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
- package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
- package/dist/src/commands/furnace/create-templates.d.ts +17 -7
- package/dist/src/commands/furnace/create-templates.js +85 -31
- package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
- package/dist/src/commands/furnace/create-xpcshell.js +1 -1
- package/dist/src/commands/import.js +63 -11
- package/dist/src/commands/patch/delete.js +10 -1
- package/dist/src/commands/setup-support.js +60 -7
- package/dist/src/commands/status.js +28 -1
- package/dist/src/commands/test.js +20 -4
- package/dist/src/commands/token.js +7 -1
- package/dist/src/core/branding.d.ts +10 -0
- package/dist/src/core/branding.js +7 -9
- package/dist/src/core/build-prepare.js +8 -1
- package/dist/src/core/file-lock.js +49 -15
- package/dist/src/core/furnace-operation.d.ts +17 -0
- package/dist/src/core/furnace-operation.js +30 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
- package/dist/src/core/furnace-validate-helpers.js +53 -2
- package/dist/src/core/git.js +39 -10
- package/dist/src/core/manifest-rules.js +16 -0
- package/dist/src/core/marionette-preflight.js +43 -12
- package/dist/src/core/patch-files.d.ts +12 -1
- package/dist/src/core/patch-files.js +14 -11
- package/dist/src/core/patch-lint.js +62 -11
- package/package.json +1 -1
|
@@ -43,23 +43,39 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
|
|
|
43
43
|
try {
|
|
44
44
|
const lockStat = await stat(lockPath);
|
|
45
45
|
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
// Check PID FIRST, independent of age. Before this ordering change the
|
|
47
|
+
// function age-gated everything: a lock younger than `staleMs` (default
|
|
48
|
+
// 5 minutes) was never removed even when its PID file pointed at a
|
|
49
|
+
// process that had already exited. That's the exact situation an
|
|
50
|
+
// operator lands in after SIGINT'ing `furnace preview` — the signal
|
|
51
|
+
// handler calls `process.exit`, `withFileLock`'s `finally { rm }`
|
|
52
|
+
// never runs, and the next command has to either wait 5 minutes for
|
|
53
|
+
// the staleness gate or manually remove the lock directory. With the
|
|
54
|
+
// PID-first check, an explicitly-dead owner unblocks immediately.
|
|
55
|
+
const pidCheck = await readLockOwnerPid(lockPath);
|
|
56
|
+
if (pidCheck.present) {
|
|
57
|
+
if (!isProcessAlive(pidCheck.pid)) {
|
|
58
|
+
const staleMessage = onStaleLockMessage?.(ageMs);
|
|
59
|
+
if (staleMessage) {
|
|
60
|
+
warn(staleMessage);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
verbose(`Lock at ${lockPath} owner PID ${pidCheck.pid} is no longer running — removing (age: ${Math.round(ageMs / 1000)}s)`);
|
|
64
|
+
}
|
|
65
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
66
|
+
return true;
|
|
59
67
|
}
|
|
68
|
+
// PID is alive — respect it regardless of age. A slow `mach build`
|
|
69
|
+
// legitimately holds the lock past the stale threshold and we don't
|
|
70
|
+
// want to race-remove it.
|
|
71
|
+
verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pidCheck.pid} is still running — not removing`);
|
|
72
|
+
return false;
|
|
60
73
|
}
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
// No readable PID file. Fall back to the age-only heuristic so locks
|
|
75
|
+
// written by earlier FireForge releases (which may not have written a
|
|
76
|
+
// PID file) still clear after the staleness window elapses.
|
|
77
|
+
if (ageMs <= staleMs) {
|
|
78
|
+
return false;
|
|
63
79
|
}
|
|
64
80
|
const staleMessage = onStaleLockMessage?.(ageMs);
|
|
65
81
|
if (staleMessage) {
|
|
@@ -78,6 +94,24 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
|
|
|
78
94
|
throw toError(error);
|
|
79
95
|
}
|
|
80
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads the owner PID from a lock directory's PID file. Returns `{ present:
|
|
99
|
+
* false }` when the PID file is missing or the content does not parse as a
|
|
100
|
+
* finite integer (caller falls back to the age-only staleness heuristic).
|
|
101
|
+
*/
|
|
102
|
+
async function readLockOwnerPid(lockPath) {
|
|
103
|
+
try {
|
|
104
|
+
const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
|
|
105
|
+
const pid = parseInt(pidContent.trim(), 10);
|
|
106
|
+
if (Number.isFinite(pid)) {
|
|
107
|
+
return { present: true, pid };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// PID file missing or unreadable — treat as absent.
|
|
112
|
+
}
|
|
113
|
+
return { present: false };
|
|
114
|
+
}
|
|
81
115
|
/** Runs an async operation while holding a directory lock, with stale-lock recovery. */
|
|
82
116
|
export async function withFileLock(lockPath, operation, options = {}) {
|
|
83
117
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
@@ -74,6 +74,23 @@ export declare function rollbackActiveOperationsForSignal(signal: FurnaceShutdow
|
|
|
74
74
|
* touch this directly.
|
|
75
75
|
*/
|
|
76
76
|
export declare function getFurnaceLockPath(root: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* Forcibly removes the furnace lock directory for every active operation.
|
|
79
|
+
*
|
|
80
|
+
* The bin-layer signal handler calls `process.exit` after rollback, which
|
|
81
|
+
* short-circuits Node's normal unwinding — `withFileLock`'s `finally { rm
|
|
82
|
+
* }` never runs, so the lock directory survives the process. The next
|
|
83
|
+
* FireForge command then has to either wait out the staleness window or
|
|
84
|
+
* have the operator remove the lock manually. This sweeper runs inside
|
|
85
|
+
* the signal-handler pipeline BEFORE `process.exit`, so the lock is gone
|
|
86
|
+
* by the time the next command starts.
|
|
87
|
+
*
|
|
88
|
+
* Errors are logged and swallowed: we do not want a slow I/O failure at
|
|
89
|
+
* shutdown to prevent the process from exiting. The doctor-side stale
|
|
90
|
+
* lock check (`src/commands/doctor-furnace.ts`) is the backup path for
|
|
91
|
+
* any lock that escapes this sweep.
|
|
92
|
+
*/
|
|
93
|
+
export declare function forceReleaseFurnaceLocksForActiveOperations(): Promise<void>;
|
|
77
94
|
/**
|
|
78
95
|
* Runs a furnace-mutating body under the apply-wide lock and registers it
|
|
79
96
|
* with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { FurnaceError } from '../errors/furnace.js';
|
|
4
5
|
import { toError } from '../utils/errors.js';
|
|
5
|
-
import { warn } from '../utils/logger.js';
|
|
6
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
6
7
|
import { FIREFORGE_DIR } from './config-paths.js';
|
|
7
8
|
import { withFileLock } from './file-lock.js';
|
|
8
9
|
import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
|
|
@@ -136,6 +137,34 @@ async function persistPendingRepair(root, operation, reason) {
|
|
|
136
137
|
export function getFurnaceLockPath(root) {
|
|
137
138
|
return join(root, FIREFORGE_DIR, FURNACE_LOCK_FILENAME);
|
|
138
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Forcibly removes the furnace lock directory for every active operation.
|
|
142
|
+
*
|
|
143
|
+
* The bin-layer signal handler calls `process.exit` after rollback, which
|
|
144
|
+
* short-circuits Node's normal unwinding — `withFileLock`'s `finally { rm
|
|
145
|
+
* }` never runs, so the lock directory survives the process. The next
|
|
146
|
+
* FireForge command then has to either wait out the staleness window or
|
|
147
|
+
* have the operator remove the lock manually. This sweeper runs inside
|
|
148
|
+
* the signal-handler pipeline BEFORE `process.exit`, so the lock is gone
|
|
149
|
+
* by the time the next command starts.
|
|
150
|
+
*
|
|
151
|
+
* Errors are logged and swallowed: we do not want a slow I/O failure at
|
|
152
|
+
* shutdown to prevent the process from exiting. The doctor-side stale
|
|
153
|
+
* lock check (`src/commands/doctor-furnace.ts`) is the backup path for
|
|
154
|
+
* any lock that escapes this sweep.
|
|
155
|
+
*/
|
|
156
|
+
export async function forceReleaseFurnaceLocksForActiveOperations() {
|
|
157
|
+
const paths = new Set([...activeOperations.values()].map((op) => getFurnaceLockPath(op.root)));
|
|
158
|
+
for (const lockPath of paths) {
|
|
159
|
+
try {
|
|
160
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
161
|
+
verbose(`Removed furnace lock at ${lockPath} during signal teardown`);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
verbose(`Could not remove furnace lock at ${lockPath} during signal teardown: ${toError(error).message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
139
168
|
/**
|
|
140
169
|
* Runs a furnace-mutating body under the apply-wide lock and registers it
|
|
141
170
|
* with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
|
|
@@ -61,7 +61,39 @@ export declare function stripCssBlockComments(content: string): string;
|
|
|
61
61
|
export declare function hasRelativeModuleImport(mjsContent: string): boolean;
|
|
62
62
|
/** Detects whether a module defines a custom element at runtime. */
|
|
63
63
|
export declare function hasCustomElementDefineCall(mjsContent: string): boolean;
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Detects whether the module's `customElements.define(...)` call includes a
|
|
66
|
+
* literal `extends:` option (third argument). That shape is the marker for
|
|
67
|
+
* a customized built-in element — the class extends a specific
|
|
68
|
+
* `HTMLxxxElement` rather than the autonomous `MozLitElement` path.
|
|
69
|
+
*
|
|
70
|
+
* Firefox's own widgets use this pattern for toolkit anchors (e.g.
|
|
71
|
+
* `moz-support-link` extends `HTMLAnchorElement` with
|
|
72
|
+
* `customElements.define("moz-support-link", ..., { extends: "a" })`), and
|
|
73
|
+
* the validator's `not-moz-lit-element` check must allow them through or
|
|
74
|
+
* `furnace override` of a valid upstream component fails its own
|
|
75
|
+
* `furnace validate` pass with nothing the operator can fix.
|
|
76
|
+
*/
|
|
77
|
+
export declare function hasCustomElementExtendsOption(mjsContent: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Checks whether a declared component class extends a valid element base.
|
|
80
|
+
*
|
|
81
|
+
* Two shapes are accepted:
|
|
82
|
+
*
|
|
83
|
+
* 1. Autonomous custom element: `class X extends MozLitElement` — the
|
|
84
|
+
* default FireForge pattern for fork-authored components and most
|
|
85
|
+
* toolkit widgets.
|
|
86
|
+
* 2. Customized built-in: `class X extends HTML<Something>Element` paired
|
|
87
|
+
* with a `customElements.define(..., ..., { extends: "<tagname>" })`
|
|
88
|
+
* call. Firefox's `moz-support-link`, `moz-button-group` tabbing
|
|
89
|
+
* widgets, and a handful of other toolkit components use this form;
|
|
90
|
+
* `furnace override` of those components writes the original source
|
|
91
|
+
* verbatim and the validator must not reject them.
|
|
92
|
+
*
|
|
93
|
+
* A class that extends a plain `HTMLElement` WITHOUT a `extends:` option
|
|
94
|
+
* is still rejected — that's the legitimate `not-moz-lit-element` case
|
|
95
|
+
* the rule was originally designed to catch.
|
|
96
|
+
*/
|
|
65
97
|
export declare function classExtendsMozLitElement(mjsContent: string): boolean;
|
|
66
98
|
/** Collects CSS custom property references used via var(--token-name). */
|
|
67
99
|
export declare function collectCssVariableReferences(cssContent: string): string[];
|
|
@@ -269,7 +269,46 @@ export function hasRelativeModuleImport(mjsContent) {
|
|
|
269
269
|
export function hasCustomElementDefineCall(mjsContent) {
|
|
270
270
|
return /customElements\.define\s*\(/.test(mjsContent);
|
|
271
271
|
}
|
|
272
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Detects whether the module's `customElements.define(...)` call includes a
|
|
274
|
+
* literal `extends:` option (third argument). That shape is the marker for
|
|
275
|
+
* a customized built-in element — the class extends a specific
|
|
276
|
+
* `HTMLxxxElement` rather than the autonomous `MozLitElement` path.
|
|
277
|
+
*
|
|
278
|
+
* Firefox's own widgets use this pattern for toolkit anchors (e.g.
|
|
279
|
+
* `moz-support-link` extends `HTMLAnchorElement` with
|
|
280
|
+
* `customElements.define("moz-support-link", ..., { extends: "a" })`), and
|
|
281
|
+
* the validator's `not-moz-lit-element` check must allow them through or
|
|
282
|
+
* `furnace override` of a valid upstream component fails its own
|
|
283
|
+
* `furnace validate` pass with nothing the operator can fix.
|
|
284
|
+
*/
|
|
285
|
+
export function hasCustomElementExtendsOption(mjsContent) {
|
|
286
|
+
// Match `customElements.define(..., { ..., extends: "..." })`. Tolerant of
|
|
287
|
+
// whitespace, line breaks, and other object properties. The `[^)]*` stops
|
|
288
|
+
// the inner greedy match at the closing define call paren so a later
|
|
289
|
+
// `define` call on a different custom element in the same module does
|
|
290
|
+
// not bleed its options in.
|
|
291
|
+
return /customElements\.define\s*\([^)]*\bextends\s*:\s*["'`]/.test(mjsContent);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Checks whether a declared component class extends a valid element base.
|
|
295
|
+
*
|
|
296
|
+
* Two shapes are accepted:
|
|
297
|
+
*
|
|
298
|
+
* 1. Autonomous custom element: `class X extends MozLitElement` — the
|
|
299
|
+
* default FireForge pattern for fork-authored components and most
|
|
300
|
+
* toolkit widgets.
|
|
301
|
+
* 2. Customized built-in: `class X extends HTML<Something>Element` paired
|
|
302
|
+
* with a `customElements.define(..., ..., { extends: "<tagname>" })`
|
|
303
|
+
* call. Firefox's `moz-support-link`, `moz-button-group` tabbing
|
|
304
|
+
* widgets, and a handful of other toolkit components use this form;
|
|
305
|
+
* `furnace override` of those components writes the original source
|
|
306
|
+
* verbatim and the validator must not reject them.
|
|
307
|
+
*
|
|
308
|
+
* A class that extends a plain `HTMLElement` WITHOUT a `extends:` option
|
|
309
|
+
* is still rejected — that's the legitimate `not-moz-lit-element` case
|
|
310
|
+
* the rule was originally designed to catch.
|
|
311
|
+
*/
|
|
273
312
|
export function classExtendsMozLitElement(mjsContent) {
|
|
274
313
|
const hasClassDeclaration = /class\s+\w+\s+extends\s+/.test(mjsContent);
|
|
275
314
|
if (!hasClassDeclaration) {
|
|
@@ -278,7 +317,19 @@ export function classExtendsMozLitElement(mjsContent) {
|
|
|
278
317
|
// structural issues.
|
|
279
318
|
return true;
|
|
280
319
|
}
|
|
281
|
-
|
|
320
|
+
if (/class\s+\w+\s+extends\s+MozLitElement\b/.test(mjsContent)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
// Customized built-in: extend a specific HTMLxxxElement AND the define
|
|
324
|
+
// call carries an `extends:` option. Both halves are required — a class
|
|
325
|
+
// that extends `HTMLAnchorElement` without the matching define option is
|
|
326
|
+
// almost certainly an author mistake, and a define call with `extends:`
|
|
327
|
+
// without a matching class is unreachable.
|
|
328
|
+
const extendsCustomizedBuiltin = /class\s+\w+\s+extends\s+HTML[A-Z]\w*Element\b/.test(mjsContent);
|
|
329
|
+
if (extendsCustomizedBuiltin && hasCustomElementExtendsOption(mjsContent)) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
282
333
|
}
|
|
283
334
|
/** Collects CSS custom property references used via var(--token-name). */
|
|
284
335
|
export function collectCssVariableReferences(cssContent) {
|
package/dist/src/core/git.js
CHANGED
|
@@ -83,6 +83,15 @@ async function stageAllFilesChunked(dir, options = {}) {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Interval between heartbeat progress messages during the monolithic
|
|
88
|
+
* `git add -A`. On a fresh ~600 MB Firefox tree the monolithic add runs
|
|
89
|
+
* 60–120 seconds, during which git emits nothing to stdout/stderr. Without
|
|
90
|
+
* a heartbeat the CLI spinner stays pinned on "Indexing Firefox source …"
|
|
91
|
+
* for the full window, looks hung, and in the eval scenario operators
|
|
92
|
+
* SIGINT'd mid-way assuming the process had stalled.
|
|
93
|
+
*/
|
|
94
|
+
const GIT_ADD_HEARTBEAT_MS = 15_000;
|
|
86
95
|
/**
|
|
87
96
|
* Stages all files in the repository.
|
|
88
97
|
* Tries a monolithic `git add -A` first; if that times out, falls back to
|
|
@@ -90,19 +99,39 @@ async function stageAllFilesChunked(dir, options = {}) {
|
|
|
90
99
|
*/
|
|
91
100
|
export async function stageAllFiles(dir, options = {}) {
|
|
92
101
|
const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
|
|
102
|
+
const reportProgress = options.onProgress;
|
|
103
|
+
const heartbeatStartedAt = Date.now();
|
|
104
|
+
// Periodic heartbeat so non-TTY log scrapers (CI, tail -f) AND operators
|
|
105
|
+
// watching a spinner both see that the add is still making progress
|
|
106
|
+
// rather than a dead process. Each tick reports elapsed seconds so the
|
|
107
|
+
// expected 1–3 minute window (see `download.ts`' info banner) is
|
|
108
|
+
// observable as it unfolds.
|
|
109
|
+
const heartbeatTimer = reportProgress
|
|
110
|
+
? setInterval(() => {
|
|
111
|
+
const elapsedS = Math.round((Date.now() - heartbeatStartedAt) / 1000);
|
|
112
|
+
reportProgress(`Indexing Firefox source (still staging, ${elapsedS}s elapsed)`);
|
|
113
|
+
}, GIT_ADD_HEARTBEAT_MS)
|
|
114
|
+
: null;
|
|
115
|
+
heartbeatTimer?.unref();
|
|
93
116
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
try {
|
|
118
|
+
await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!isTimeoutError(error)) {
|
|
123
|
+
throw await maybeWrapIndexLockError(dir, error);
|
|
124
|
+
}
|
|
125
|
+
options.onProgress?.('Monolithic git add timed out; falling back to chunked staging...');
|
|
100
126
|
}
|
|
101
|
-
|
|
127
|
+
// The killed process may have left an index lock
|
|
128
|
+
await cleanupIndexLock(dir);
|
|
129
|
+
await stageAllFilesChunked(dir, options);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (heartbeatTimer)
|
|
133
|
+
clearInterval(heartbeatTimer);
|
|
102
134
|
}
|
|
103
|
-
// The killed process may have left an index lock
|
|
104
|
-
await cleanupIndexLock(dir);
|
|
105
|
-
await stageAllFilesChunked(dir, options);
|
|
106
135
|
}
|
|
107
136
|
/**
|
|
108
137
|
* Initializes a new git repository with an orphan branch.
|
|
@@ -119,6 +119,22 @@ function getUnregistrableAdvice(filePath) {
|
|
|
119
119
|
'"fireforge wire <name> --dom <path>" to insert the #include, or add the directive manually ' +
|
|
120
120
|
'in the top-level chrome document.');
|
|
121
121
|
}
|
|
122
|
+
// xpcshell.toml manifests scaffolded by `furnace create --test-style
|
|
123
|
+
// xpcshell` or `furnace chrome-doc create --with-tests` live under
|
|
124
|
+
// `browser/base/content/test/<binary-name>-xpcshell/<component>/`. The
|
|
125
|
+
// scaffold intentionally does NOT auto-register — wiring
|
|
126
|
+
// `XPCSHELL_TESTS_MANIFESTS` requires a deliberate choice about which
|
|
127
|
+
// moz.build should own the entry. `register` surfaces that same guidance
|
|
128
|
+
// instead of falling through to browser.toml-centric advice, which was
|
|
129
|
+
// the eval finding (operator saw "register browser.toml" hint for a
|
|
130
|
+
// path that was xpcshell-shaped and contained no browser.toml).
|
|
131
|
+
if (filePath.endsWith('/xpcshell.toml')) {
|
|
132
|
+
return (`xpcshell.toml manifests are not auto-registered by "fireforge register". ` +
|
|
133
|
+
`Add "XPCSHELL_TESTS_MANIFESTS += ['${basename(filePath)}']" to the ` +
|
|
134
|
+
`moz.build that covers ${filePath.replace(/\/xpcshell\.toml$/, '/')}, ` +
|
|
135
|
+
'or let `furnace create --test-style xpcshell` print the warning that names the canonical wiring location. ' +
|
|
136
|
+
'Browser-chrome tests (browser.toml) are auto-registered via "fireforge register <path>/browser.toml".');
|
|
137
|
+
}
|
|
122
138
|
const testMatch = filePath.match(/^browser\/base\/content\/test\/([^/]+)\/(?!browser\.toml$).+$/);
|
|
123
139
|
if (testMatch) {
|
|
124
140
|
const dir = testMatch[1];
|
|
@@ -110,6 +110,16 @@ export async function runMarionettePreflight(engineDir, options = {}) {
|
|
|
110
110
|
cwd: engineDir,
|
|
111
111
|
env: { ...process.env, MOZ_HEADLESS: '1' },
|
|
112
112
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
113
|
+
// `detached: true` puts the child in a new process group so we can
|
|
114
|
+
// signal it and every descendant (Firefox, its helpers) via
|
|
115
|
+
// `process.kill(-pid, …)` in the finally block. Without this, the
|
|
116
|
+
// child is Python running mach; a SIGTERM kills Python but the
|
|
117
|
+
// Firefox grandchild inherits the stderr pipe FD and keeps Node's
|
|
118
|
+
// event loop alive even after the preflight PASS log. The symptom
|
|
119
|
+
// is `fireforge test --doctor` printing `Marionette preflight:
|
|
120
|
+
// PASS` and then hanging indefinitely in `uv__io_poll` — see the
|
|
121
|
+
// eval finding.
|
|
122
|
+
detached: true,
|
|
113
123
|
});
|
|
114
124
|
}
|
|
115
125
|
catch (error) {
|
|
@@ -154,24 +164,21 @@ export async function runMarionettePreflight(engineDir, options = {}) {
|
|
|
154
164
|
}
|
|
155
165
|
finally {
|
|
156
166
|
if (child && !hasChildExited(child)) {
|
|
157
|
-
|
|
158
|
-
child.kill('SIGTERM');
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
// Already exited — nothing to do.
|
|
162
|
-
}
|
|
167
|
+
killProcessGroup(child, 'SIGTERM');
|
|
163
168
|
// Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
|
|
164
169
|
// so we don't leave a ghost mach process around after a failed probe.
|
|
165
170
|
await delay(500);
|
|
166
171
|
if (!hasChildExited(child)) {
|
|
167
|
-
|
|
168
|
-
child.kill('SIGKILL');
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
// Already gone.
|
|
172
|
-
}
|
|
172
|
+
killProcessGroup(child, 'SIGKILL');
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
// Destroy the stderr pipe explicitly. Firefox (a grandchild of the Python
|
|
176
|
+
// mach wrapper we spawned) can inherit and hold the stderr FD even after
|
|
177
|
+
// its direct parent exits — until the pipe closes, Node's readable
|
|
178
|
+
// stream stays attached and `uv__io_poll` keeps the event loop alive.
|
|
179
|
+
// `destroy()` closes the local end regardless, so `fireforge test
|
|
180
|
+
// --doctor` exits cleanly after a passing preflight.
|
|
181
|
+
child?.stderr?.destroy();
|
|
175
182
|
try {
|
|
176
183
|
await rm(profileDir, { recursive: true, force: true });
|
|
177
184
|
}
|
|
@@ -180,6 +187,30 @@ export async function runMarionettePreflight(engineDir, options = {}) {
|
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Sends `signal` to the child's whole process group when possible, falling
|
|
192
|
+
* back to a direct `child.kill` for environments that don't support
|
|
193
|
+
* `detached: true` (Windows in particular: Node still returns a pid, but
|
|
194
|
+
* `kill(-pid, …)` is not a supported kernel primitive).
|
|
195
|
+
*/
|
|
196
|
+
function killProcessGroup(child, signal) {
|
|
197
|
+
if (child.pid !== undefined && process.platform !== 'win32') {
|
|
198
|
+
try {
|
|
199
|
+
process.kill(-child.pid, signal);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// ESRCH / EPERM — fall through to the narrow kill below so we at
|
|
204
|
+
// least signal the direct child.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
child.kill(signal);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Already exited — nothing to do.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
183
214
|
function fail(layer, message, durationMs) {
|
|
184
215
|
return {
|
|
185
216
|
ok: false,
|
|
@@ -7,5 +7,16 @@ export declare function countPatches(patchesDir: string): Promise<number>;
|
|
|
7
7
|
export declare function isNewFilePatch(patchPath: string): Promise<boolean>;
|
|
8
8
|
/** Returns the first target file path referenced by a patch, if any. */
|
|
9
9
|
export declare function getTargetFileFromPatch(patchPath: string): Promise<string | null>;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Returns all target file paths referenced by a multi-file patch.
|
|
12
|
+
*
|
|
13
|
+
* Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
|
|
14
|
+
* (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
|
|
15
|
+
* included alongside text hunks. Before this delegation the custom regex
|
|
16
|
+
* only matched `+++ b/…` lines, dropping every binary file from
|
|
17
|
+
* `filesAffected` — verify reported `files-affected-mismatch` against
|
|
18
|
+
* branding patches and `doctor --repair-patches-manifest` "repaired" the
|
|
19
|
+
* manifest by rewriting it to the text-only subset, hiding the true scope
|
|
20
|
+
* of the patch.
|
|
21
|
+
*/
|
|
11
22
|
export declare function getAllTargetFilesFromPatch(patchPath: string): Promise<string[]>;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { extname, join } from 'node:path';
|
|
4
4
|
import { pathExists, readText } from '../utils/fs.js';
|
|
5
|
-
import { extractOrder } from './patch-parse.js';
|
|
5
|
+
import { extractAffectedFiles, extractOrder } from './patch-parse.js';
|
|
6
6
|
/** Discovers patch files in a directory and returns them in apply order. */
|
|
7
7
|
export async function discoverPatches(patchesDir) {
|
|
8
8
|
if (!(await pathExists(patchesDir))) {
|
|
@@ -35,17 +35,20 @@ export async function getTargetFileFromPatch(patchPath) {
|
|
|
35
35
|
const match = /^\+\+\+ b\/(.+)$/m.exec(content);
|
|
36
36
|
return match?.[1] ?? null;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Returns all target file paths referenced by a multi-file patch.
|
|
40
|
+
*
|
|
41
|
+
* Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
|
|
42
|
+
* (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
|
|
43
|
+
* included alongside text hunks. Before this delegation the custom regex
|
|
44
|
+
* only matched `+++ b/…` lines, dropping every binary file from
|
|
45
|
+
* `filesAffected` — verify reported `files-affected-mismatch` against
|
|
46
|
+
* branding patches and `doctor --repair-patches-manifest` "repaired" the
|
|
47
|
+
* manifest by rewriting it to the text-only subset, hiding the true scope
|
|
48
|
+
* of the patch.
|
|
49
|
+
*/
|
|
39
50
|
export async function getAllTargetFilesFromPatch(patchPath) {
|
|
40
51
|
const content = await readText(patchPath);
|
|
41
|
-
|
|
42
|
-
const regex = /^\+\+\+ b\/(.+)$/gm;
|
|
43
|
-
let match;
|
|
44
|
-
while ((match = regex.exec(content)) !== null) {
|
|
45
|
-
if (match[1]) {
|
|
46
|
-
files.push(match[1]);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return files;
|
|
52
|
+
return extractAffectedFiles(content);
|
|
50
53
|
}
|
|
51
54
|
//# sourceMappingURL=patch-files.js.map
|
|
@@ -60,7 +60,28 @@ export function countNonBinaryDiffLines(diffContent) {
|
|
|
60
60
|
const PATCH_LINE_THRESHOLDS = {
|
|
61
61
|
general: { notice: 800, warning: 1500, error: 3000 },
|
|
62
62
|
test: { notice: 1500, warning: 3000, error: 6000 },
|
|
63
|
+
/**
|
|
64
|
+
* Branding patches have a legitimate reason to be large: they include
|
|
65
|
+
* every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
|
|
66
|
+
* fork-specific `configure.sh` / `brand.properties` under a single
|
|
67
|
+
* `browser/branding/<name>/` subtree. The general hard limit of 3000
|
|
68
|
+
* lines fires on even a first-export branding patch (the eval saw 15904
|
|
69
|
+
* lines for a freshly-setup fork), which is loud but not actionable —
|
|
70
|
+
* the patch already is the minimum branding diff. A dedicated tier keeps
|
|
71
|
+
* the size guidance while moving the hard limit to a threshold that
|
|
72
|
+
* corresponds to "something other than branding is bundled in here too".
|
|
73
|
+
*/
|
|
74
|
+
branding: { notice: 3000, warning: 8000, error: 20000 },
|
|
63
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* Returns true when every file in a patch lives under `browser/branding/`.
|
|
78
|
+
* Used by `lintPatchSize` to pick the branding threshold tier.
|
|
79
|
+
*/
|
|
80
|
+
function isBrandingOnlyPatch(files) {
|
|
81
|
+
if (files.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
return files.every((file) => file.startsWith('browser/branding/'));
|
|
84
|
+
}
|
|
64
85
|
/**
|
|
65
86
|
* Returns true if the filename looks like a JS/MJS/JSM file.
|
|
66
87
|
* Handles `.sys.mjs` as well.
|
|
@@ -133,10 +154,18 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
|
|
|
133
154
|
// Strip block comments before scanning
|
|
134
155
|
const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
135
156
|
// Check only introduced raw color values when diff context is available.
|
|
136
|
-
// Skip files on the raw-color allowlist (exact path or basename match)
|
|
157
|
+
// Skip files on the raw-color allowlist (exact path or basename match) and
|
|
158
|
+
// auto-exempt files under `browser/branding/` — those are the fork's
|
|
159
|
+
// visual identity assets (app-about dialogs, installer pages, branded
|
|
160
|
+
// CSS copied from Firefox's `unofficial` template) and belong to the
|
|
161
|
+
// design-decision layer the design-token system does not govern.
|
|
162
|
+
// Without this auto-exemption, every first-time setup's copied CSS
|
|
163
|
+
// failed `raw-color-value` with no actionable fix other than manually
|
|
164
|
+
// listing each path in `rawColorAllowlist`.
|
|
137
165
|
const allowlist = config?.patchLint?.rawColorAllowlist;
|
|
138
166
|
const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
|
|
139
|
-
|
|
167
|
+
const isBranding = file.startsWith('browser/branding/');
|
|
168
|
+
if (!isAllowlisted && !isBranding) {
|
|
140
169
|
// Strip lines with inline fireforge-ignore: raw-color-value suppression.
|
|
141
170
|
// Check against rawCss (before comment stripping) so the CSS comment marker is still present.
|
|
142
171
|
const sourceForSuppression = addedLinesByFile
|
|
@@ -239,14 +268,25 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
|
|
|
239
268
|
continue;
|
|
240
269
|
const content = await readText(filePath);
|
|
241
270
|
const expectedHeader = getLicenseHeader(license, style);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
271
|
+
// Auto-exempt `browser/branding/` when the file carries ANY recognised
|
|
272
|
+
// license header in the matching comment style. The setup-generated
|
|
273
|
+
// branding directory is copied from Firefox's `unofficial` template,
|
|
274
|
+
// which arrives with Mozilla MPL-2.0 headers — those are legitimate
|
|
275
|
+
// for copyright purposes (the assets are Mozilla's) even when the
|
|
276
|
+
// fork's own code is 0BSD / EUPL-1.2 / GPL-2.0-or-later. The narrower
|
|
277
|
+
// license-match rule would force operators to either rewrite the
|
|
278
|
+
// copied headers (misrepresenting authorship) or suppress the lint
|
|
279
|
+
// with `--skip-lint` (hiding real issues elsewhere).
|
|
280
|
+
if (content.startsWith(expectedHeader))
|
|
281
|
+
continue;
|
|
282
|
+
if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
|
|
283
|
+
continue;
|
|
284
|
+
issues.push({
|
|
285
|
+
file,
|
|
286
|
+
check: 'missing-license-header',
|
|
287
|
+
message: `New file is missing the required ${license} license header.`,
|
|
288
|
+
severity: 'error',
|
|
289
|
+
});
|
|
250
290
|
}
|
|
251
291
|
return issues;
|
|
252
292
|
}
|
|
@@ -402,8 +442,19 @@ export function lintPatchSize(filesAffected, lineCount) {
|
|
|
402
442
|
severity: 'warning',
|
|
403
443
|
});
|
|
404
444
|
}
|
|
445
|
+
// Tier selection: test > branding > general. Tests keep their elevated
|
|
446
|
+
// thresholds because a big regression test is legitimate (table-driven
|
|
447
|
+
// harnesses run into the thousands of lines). Branding patches get their
|
|
448
|
+
// own tier so a first-export of setup-generated branding doesn't fire
|
|
449
|
+
// the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
|
|
450
|
+
// for the eval data motivating this tier.
|
|
405
451
|
const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
|
|
406
|
-
const
|
|
452
|
+
const branding = !allTests && isBrandingOnlyPatch(filesAffected);
|
|
453
|
+
const thresholds = allTests
|
|
454
|
+
? PATCH_LINE_THRESHOLDS.test
|
|
455
|
+
: branding
|
|
456
|
+
? PATCH_LINE_THRESHOLDS.branding
|
|
457
|
+
: PATCH_LINE_THRESHOLDS.general;
|
|
407
458
|
if (lineCount >= thresholds.error) {
|
|
408
459
|
issues.push({
|
|
409
460
|
file: '(patch)',
|