@hominis/fireforge 0.14.0 → 0.15.2
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 +44 -1
- package/README.md +41 -3
- package/dist/src/commands/build.js +12 -1
- package/dist/src/commands/furnace/create-templates.d.ts +47 -0
- package/dist/src/commands/furnace/create-templates.js +135 -0
- package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
- package/dist/src/commands/furnace/create-xpcshell.js +53 -0
- package/dist/src/commands/furnace/create.js +81 -109
- package/dist/src/commands/furnace/deploy.js +3 -3
- package/dist/src/commands/furnace/index.js +1 -0
- package/dist/src/commands/setup.d.ts +1 -1
- package/dist/src/commands/setup.js +3 -2
- package/dist/src/commands/test.js +20 -0
- package/dist/src/core/build-prepare.js +6 -1
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate.js +32 -0
- package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
- package/dist/src/core/furnace-apply-ftl.js +102 -0
- package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
- package/dist/src/core/furnace-apply-helpers.js +16 -12
- package/dist/src/core/furnace-apply.js +7 -4
- package/dist/src/core/furnace-config-tokens.d.ts +17 -0
- package/dist/src/core/furnace-config-tokens.js +43 -0
- package/dist/src/core/furnace-config.d.ts +6 -0
- package/dist/src/core/furnace-config.js +16 -3
- package/dist/src/core/furnace-constants.d.ts +20 -0
- package/dist/src/core/furnace-constants.js +32 -0
- package/dist/src/core/furnace-registration-ast.d.ts +13 -1
- package/dist/src/core/furnace-registration-ast.js +58 -25
- package/dist/src/core/furnace-registration.d.ts +27 -0
- package/dist/src/core/furnace-registration.js +96 -0
- package/dist/src/core/furnace-validate-accessibility.js +8 -2
- package/dist/src/core/furnace-validate-compatibility.js +18 -7
- package/dist/src/core/furnace-validate-helpers.d.ts +39 -1
- package/dist/src/core/furnace-validate-helpers.js +182 -18
- package/dist/src/core/furnace-validate-registration.d.ts +8 -2
- package/dist/src/core/furnace-validate-registration.js +34 -9
- package/dist/src/core/furnace-validate.js +2 -2
- package/dist/src/core/marionette-preflight.d.ts +46 -0
- package/dist/src/core/marionette-preflight.js +260 -0
- package/dist/src/core/patch-lint-cross.d.ts +1 -1
- package/dist/src/core/patch-lint-cross.js +1 -1
- package/dist/src/core/patch-lint.js +29 -9
- package/dist/src/types/commands/options.d.ts +16 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +19 -0
- package/dist/src/utils/process.d.ts +15 -2
- package/dist/src/utils/process.js +73 -0
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Marionette handshake preflight for `fireforge test --doctor`.
|
|
4
|
+
*
|
|
5
|
+
* Answers a single question before tests run: "does marionette come up?" A
|
|
6
|
+
* silent 360-second mach-test hang is indistinguishable from "tests failed
|
|
7
|
+
* to discover"; this helper surfaces the failure in under a minute with a
|
|
8
|
+
* clear PASS/FAIL line and the tail of the browser's stderr.
|
|
9
|
+
*
|
|
10
|
+
* The probe is a cascade of layered checks (engine → mach → python →
|
|
11
|
+
* profile → spawn → handshake). Each layer has a tight per-attempt budget
|
|
12
|
+
* so a broken earlier layer fails fast with a specific diagnosis rather
|
|
13
|
+
* than blocking on the final socket poll for the full overall budget.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { pathExists } from '../utils/fs.js';
|
|
21
|
+
import { info, warn } from '../utils/logger.js';
|
|
22
|
+
import { ensureMach } from './mach.js';
|
|
23
|
+
import { getPython } from './mach-python.js';
|
|
24
|
+
/** Marionette's default TCP port when the browser is launched with `--marionette`. */
|
|
25
|
+
const MARIONETTE_PORT = 2828;
|
|
26
|
+
/** Overall budget for the preflight (browser boot + socket handshake). */
|
|
27
|
+
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** Per-attempt socket connect timeout. Polling continues until the overall budget expires. */
|
|
29
|
+
const SOCKET_ATTEMPT_TIMEOUT_MS = 2_000;
|
|
30
|
+
/**
|
|
31
|
+
* Grace window after spawn() returns before we accept the child as
|
|
32
|
+
* "spawned OK". A browser binary that exits immediately (missing dylib,
|
|
33
|
+
* wrong CPU arch, corrupt profile) must be caught here — not 30 seconds
|
|
34
|
+
* later at the socket layer.
|
|
35
|
+
*/
|
|
36
|
+
const SPAWN_SETTLE_MS = 750;
|
|
37
|
+
/** Tail of stderr preserved for FAIL diagnostics. */
|
|
38
|
+
const STDERR_TAIL_LIMIT = 8 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Layer names, ordered by the probe sequence. Surfaced in `detail` so the
|
|
41
|
+
* operator sees which layer failed without having to guess.
|
|
42
|
+
*/
|
|
43
|
+
const LAYER_NAMES = [
|
|
44
|
+
'engine-present',
|
|
45
|
+
'mach-available',
|
|
46
|
+
'python-available',
|
|
47
|
+
'profile-creatable',
|
|
48
|
+
'browser-spawns',
|
|
49
|
+
'marionette-handshake',
|
|
50
|
+
];
|
|
51
|
+
function layerTag(name) {
|
|
52
|
+
const index = LAYER_NAMES.indexOf(name) + 1;
|
|
53
|
+
return `[layer ${index}/${LAYER_NAMES.length}: ${name}]`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs the marionette preflight. Returns PASS on first byte read from the
|
|
57
|
+
* marionette socket within the budget; FAIL otherwise, with a diagnostic
|
|
58
|
+
* identifying which layer of the cascade broke. Always tears down the
|
|
59
|
+
* spawned browser before returning.
|
|
60
|
+
*/
|
|
61
|
+
export async function runMarionettePreflight(engineDir, options = {}) {
|
|
62
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
|
|
63
|
+
const spawnSettleMs = options.spawnSettleMs ?? SPAWN_SETTLE_MS;
|
|
64
|
+
const port = options.port ?? MARIONETTE_PORT;
|
|
65
|
+
const spawnerFn = options.spawner ?? spawn;
|
|
66
|
+
const connectFn = options.connect ?? net.createConnection;
|
|
67
|
+
const startedAt = Date.now();
|
|
68
|
+
const elapsed = () => Date.now() - startedAt;
|
|
69
|
+
// Layer 1: engine directory exists.
|
|
70
|
+
if (!(await pathExists(engineDir))) {
|
|
71
|
+
return fail('engine-present', 'Engine directory not found — run "fireforge download" first.', elapsed());
|
|
72
|
+
}
|
|
73
|
+
// Layer 2: mach binary resolves in the engine.
|
|
74
|
+
try {
|
|
75
|
+
await ensureMach(engineDir);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return fail('mach-available', `mach not available in engine: ${error.message}`, elapsed());
|
|
79
|
+
}
|
|
80
|
+
// Layer 3: Python that mach requires is discoverable.
|
|
81
|
+
let python;
|
|
82
|
+
try {
|
|
83
|
+
python = await getPython(engineDir);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return fail('python-available', `Python interpreter required by mach is not available: ${error.message}`, elapsed());
|
|
87
|
+
}
|
|
88
|
+
// Layer 4: throwaway browser profile directory is creatable.
|
|
89
|
+
let profileDir;
|
|
90
|
+
try {
|
|
91
|
+
profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return fail('profile-creatable', `Could not create a throwaway browser profile in ${tmpdir()}: ${error.message}`, elapsed());
|
|
95
|
+
}
|
|
96
|
+
let child;
|
|
97
|
+
let stderrTail = '';
|
|
98
|
+
try {
|
|
99
|
+
// Layer 5: browser spawns and does not crash within the settle window.
|
|
100
|
+
try {
|
|
101
|
+
child = spawnerFn(python, [
|
|
102
|
+
join(engineDir, 'mach'),
|
|
103
|
+
'run',
|
|
104
|
+
'--marionette',
|
|
105
|
+
'--headless',
|
|
106
|
+
'--no-remote',
|
|
107
|
+
'-profile',
|
|
108
|
+
profileDir,
|
|
109
|
+
], {
|
|
110
|
+
cwd: engineDir,
|
|
111
|
+
env: { ...process.env, MOZ_HEADLESS: '1' },
|
|
112
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return fail('browser-spawns', `Could not spawn mach run: ${error.message}`, elapsed());
|
|
117
|
+
}
|
|
118
|
+
const spawnedChild = child;
|
|
119
|
+
child.stderr?.on('data', (data) => {
|
|
120
|
+
const chunk = data.toString();
|
|
121
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT);
|
|
122
|
+
});
|
|
123
|
+
// Short settle window — catches "binary exits immediately" failures
|
|
124
|
+
// (missing dylib, wrong CPU arch, corrupt profile) before the socket
|
|
125
|
+
// poll swallows the full overall budget waiting for bytes that will
|
|
126
|
+
// never come.
|
|
127
|
+
const settleDeadline = Math.min(spawnSettleMs, Math.max(0, timeoutMs - elapsed()));
|
|
128
|
+
if (settleDeadline > 0) {
|
|
129
|
+
await delay(settleDeadline);
|
|
130
|
+
}
|
|
131
|
+
if (hasChildExited(spawnedChild)) {
|
|
132
|
+
return fail('browser-spawns', `Browser process exited during spawn (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
|
|
133
|
+
`stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
|
|
134
|
+
}
|
|
135
|
+
// Layer 6: marionette handshake within the remaining budget.
|
|
136
|
+
const socketResult = await waitForMarionetteSocket(port, connectFn, () => {
|
|
137
|
+
return elapsed() < timeoutMs && !hasChildExited(spawnedChild);
|
|
138
|
+
});
|
|
139
|
+
if (socketResult.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
durationMs: elapsed(),
|
|
143
|
+
detail: `Marionette handshake received on 127.0.0.1:${port} in ${Date.now() - startedAt}ms.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Child may have exited before the socket was ever ready — surface that
|
|
147
|
+
// distinctly from "socket never answered" so the operator has a lead.
|
|
148
|
+
if (hasChildExited(spawnedChild)) {
|
|
149
|
+
return fail('marionette-handshake', `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
|
|
150
|
+
`stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
|
|
151
|
+
}
|
|
152
|
+
return fail('marionette-handshake', `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
|
|
153
|
+
`stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
if (child && !hasChildExited(child)) {
|
|
157
|
+
try {
|
|
158
|
+
child.kill('SIGTERM');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Already exited — nothing to do.
|
|
162
|
+
}
|
|
163
|
+
// Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
|
|
164
|
+
// so we don't leave a ghost mach process around after a failed probe.
|
|
165
|
+
await delay(500);
|
|
166
|
+
if (!hasChildExited(child)) {
|
|
167
|
+
try {
|
|
168
|
+
child.kill('SIGKILL');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Already gone.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await rm(profileDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
warn(`Could not clean up marionette preflight profile: ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function fail(layer, message, durationMs) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
durationMs,
|
|
187
|
+
detail: `${layerTag(layer)} ${message}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Returns true when the child process has exited (normal or signaled). */
|
|
191
|
+
function hasChildExited(child) {
|
|
192
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
193
|
+
}
|
|
194
|
+
async function waitForMarionetteSocket(port, connectFn, keepTrying) {
|
|
195
|
+
while (keepTrying()) {
|
|
196
|
+
const result = await attemptMarionetteConnect(port, connectFn);
|
|
197
|
+
if (result.ok) {
|
|
198
|
+
return { ok: true };
|
|
199
|
+
}
|
|
200
|
+
await delay(400);
|
|
201
|
+
}
|
|
202
|
+
return { ok: false };
|
|
203
|
+
}
|
|
204
|
+
function attemptMarionetteConnect(port, connectFn) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const socket = connectFn({ host: '127.0.0.1', port });
|
|
207
|
+
let settled = false;
|
|
208
|
+
const finish = (ok) => {
|
|
209
|
+
if (settled)
|
|
210
|
+
return;
|
|
211
|
+
settled = true;
|
|
212
|
+
try {
|
|
213
|
+
socket.destroy();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Ignore — already closed.
|
|
217
|
+
}
|
|
218
|
+
resolve({ ok });
|
|
219
|
+
};
|
|
220
|
+
const attemptTimer = setTimeout(() => {
|
|
221
|
+
finish(false);
|
|
222
|
+
}, SOCKET_ATTEMPT_TIMEOUT_MS);
|
|
223
|
+
attemptTimer.unref();
|
|
224
|
+
socket.once('connect', () => {
|
|
225
|
+
// Connect alone is insufficient — the marionette server performs a
|
|
226
|
+
// handshake send as soon as the socket opens, so wait for at least one
|
|
227
|
+
// byte to confirm the server is actually speaking marionette.
|
|
228
|
+
const readTimer = setTimeout(() => {
|
|
229
|
+
finish(false);
|
|
230
|
+
}, SOCKET_ATTEMPT_TIMEOUT_MS);
|
|
231
|
+
readTimer.unref();
|
|
232
|
+
socket.once('data', () => {
|
|
233
|
+
clearTimeout(readTimer);
|
|
234
|
+
finish(true);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
socket.once('error', () => {
|
|
238
|
+
finish(false);
|
|
239
|
+
});
|
|
240
|
+
socket.once('close', () => {
|
|
241
|
+
finish(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function delay(ms) {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const timer = setTimeout(resolve, ms);
|
|
248
|
+
timer.unref();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
|
|
252
|
+
export function reportMarionettePreflight(result) {
|
|
253
|
+
if (result.ok) {
|
|
254
|
+
info(`Marionette preflight: PASS (${result.durationMs}ms) — ${result.detail}`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=marionette-preflight.js.map
|
|
@@ -91,7 +91,7 @@ export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Ma
|
|
|
91
91
|
/**
|
|
92
92
|
* Cross-patch lint rule: the same path is newly created (`--- /dev/null →
|
|
93
93
|
* +++ b/path`) by more than one patch. This is the failure mode that
|
|
94
|
-
* motivated the rule —
|
|
94
|
+
* motivated the rule — a fork landed three patches each trying to create
|
|
95
95
|
* the same file, and the error surfaced only when import rolled back
|
|
96
96
|
* mid-apply.
|
|
97
97
|
*
|
|
@@ -114,7 +114,7 @@ export function collectNewFileCreatorsByPath(ctx) {
|
|
|
114
114
|
/**
|
|
115
115
|
* Cross-patch lint rule: the same path is newly created (`--- /dev/null →
|
|
116
116
|
* +++ b/path`) by more than one patch. This is the failure mode that
|
|
117
|
-
* motivated the rule —
|
|
117
|
+
* motivated the rule — a fork landed three patches each trying to create
|
|
118
118
|
* the same file, and the error surfaced only when import rolled back
|
|
119
119
|
* mid-apply.
|
|
120
120
|
*
|
|
@@ -110,12 +110,14 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
|
|
|
110
110
|
// Load furnace config gracefully — skip token-prefix check if unavailable
|
|
111
111
|
let tokenPrefix;
|
|
112
112
|
let tokenAllowlist;
|
|
113
|
+
let runtimeVariables;
|
|
113
114
|
try {
|
|
114
115
|
const root = join(repoDir, '..');
|
|
115
116
|
const furnaceConfig = await loadFurnaceConfig(root);
|
|
116
117
|
if (furnaceConfig.tokenPrefix) {
|
|
117
118
|
tokenPrefix = furnaceConfig.tokenPrefix;
|
|
118
119
|
tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
|
|
120
|
+
runtimeVariables = new Set(furnaceConfig.runtimeVariables ?? []);
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
catch (error) {
|
|
@@ -154,20 +156,38 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
|
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
|
-
// Check for non-tokenized custom properties
|
|
159
|
+
// Check for non-tokenized custom properties. A variable that is both
|
|
160
|
+
// declared and consumed inside the same file is auto-exempted as a
|
|
161
|
+
// runtime state channel (see furnace.json → runtimeVariables).
|
|
158
162
|
if (tokenPrefix) {
|
|
163
|
+
const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
|
|
164
|
+
const localDeclarations = new Set();
|
|
165
|
+
let declMatch;
|
|
166
|
+
while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
|
|
167
|
+
const name = declMatch[1];
|
|
168
|
+
if (name)
|
|
169
|
+
localDeclarations.add(name);
|
|
170
|
+
}
|
|
159
171
|
const varPattern = /var\(\s*(--[\w-]+)/g;
|
|
160
172
|
let match;
|
|
161
173
|
while ((match = varPattern.exec(cssContent)) !== null) {
|
|
162
174
|
const prop = match[1];
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
if (!prop)
|
|
176
|
+
continue;
|
|
177
|
+
if (prop.startsWith(tokenPrefix))
|
|
178
|
+
continue;
|
|
179
|
+
if (tokenAllowlist?.has(prop))
|
|
180
|
+
continue;
|
|
181
|
+
if (runtimeVariables?.has(prop))
|
|
182
|
+
continue;
|
|
183
|
+
if (localDeclarations.has(prop))
|
|
184
|
+
continue;
|
|
185
|
+
issues.push({
|
|
186
|
+
file,
|
|
187
|
+
check: 'token-prefix-violation',
|
|
188
|
+
message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
|
|
189
|
+
severity: 'error',
|
|
190
|
+
});
|
|
171
191
|
}
|
|
172
192
|
}
|
|
173
193
|
}
|
|
@@ -178,6 +178,12 @@ export interface TestOptions {
|
|
|
178
178
|
headless?: boolean;
|
|
179
179
|
/** Run incremental UI build before testing */
|
|
180
180
|
build?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Run a marionette preflight before tests. Reports PASS/FAIL in under a
|
|
183
|
+
* minute. When test paths are supplied, a FAIL aborts before mach test is
|
|
184
|
+
* spawned. When no paths are supplied, runs the preflight only and exits.
|
|
185
|
+
*/
|
|
186
|
+
doctor?: boolean;
|
|
181
187
|
}
|
|
182
188
|
/**
|
|
183
189
|
* Options for the furnace apply command.
|
|
@@ -265,6 +271,16 @@ export interface FurnaceCreateOptions {
|
|
|
265
271
|
register?: boolean;
|
|
266
272
|
/** Scaffold Mochitest directory and register in moz.build */
|
|
267
273
|
withTests?: boolean;
|
|
274
|
+
/**
|
|
275
|
+
* Scaffold an xpcshell test harness (headless, no tabbrowser) instead of
|
|
276
|
+
* browser-chrome. Required for forks without a `tabbrowser` (storage-only
|
|
277
|
+
* code, observer-driven modules). Implies `withTests` when set. Writes an
|
|
278
|
+
* `xpcshell.toml` + `test_<name>.js` under
|
|
279
|
+
* `engine/browser/base/content/test/<binary-name>-xpcshell/` and leaves
|
|
280
|
+
* moz.build registration to the operator (add the directory to
|
|
281
|
+
* `XPCSHELL_TESTS_MANIFESTS`).
|
|
282
|
+
*/
|
|
283
|
+
xpcshell?: boolean;
|
|
268
284
|
/** Stock component tag names composed internally by this component */
|
|
269
285
|
compose?: string[];
|
|
270
286
|
}
|
|
@@ -44,6 +44,13 @@ export interface FireForgeConfig {
|
|
|
44
44
|
wire?: WireConfig;
|
|
45
45
|
/** Patch lint configuration */
|
|
46
46
|
patchLint?: PatchLintConfig;
|
|
47
|
+
/**
|
|
48
|
+
* Project marker prefix appended to lines FireForge writes into
|
|
49
|
+
* upstream Firefox source files (e.g. the `customElements.js` tag list).
|
|
50
|
+
* `"MYBROWSER"` emits a trailing ` // MYBROWSER:` on each inserted line.
|
|
51
|
+
* Keeps modifications discoverable and re-applies idempotent.
|
|
52
|
+
*/
|
|
53
|
+
markerComment?: string;
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* Wire command configuration.
|
|
@@ -63,6 +63,25 @@ export interface FurnaceConfig {
|
|
|
63
63
|
tokenPrefix?: string;
|
|
64
64
|
/** Custom properties allowed even though they don't match tokenPrefix (e.g. ["--background-color-box"]) */
|
|
65
65
|
tokenAllowlist?: string[];
|
|
66
|
+
/**
|
|
67
|
+
* Custom properties used as runtime state channels — written and read by the
|
|
68
|
+
* component itself (e.g. per-frame camera/tile positions) rather than
|
|
69
|
+
* consumed as design tokens. Listed names are exempt from the
|
|
70
|
+
* `token-prefix-violation` check even when they do not match `tokenPrefix`
|
|
71
|
+
* and are not in `tokenAllowlist`. Use this for cross-component runtime
|
|
72
|
+
* variables (e.g. set in JS, read in CSS of a child). Component-local
|
|
73
|
+
* variables that are both declared and consumed inside the same component's
|
|
74
|
+
* own CSS file are auto-exempted and do not need an entry here.
|
|
75
|
+
*/
|
|
76
|
+
runtimeVariables?: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Chrome documents scanned by the `missing-token-link` validator to confirm
|
|
79
|
+
* the tokens CSS file is `<link>`ed. Forks with multiple chrome host
|
|
80
|
+
* documents (e.g. `mybrowser.xhtml` beside the stock `browser.xhtml`) should
|
|
81
|
+
* list every document that may own the link. When omitted, defaults to
|
|
82
|
+
* `['browser/base/content/browser.xhtml']` — the upstream Firefox path.
|
|
83
|
+
*/
|
|
84
|
+
tokenHostDocuments?: string[];
|
|
66
85
|
/**
|
|
67
86
|
* Override the default Fluent (.ftl) base path within the engine.
|
|
68
87
|
* Defaults to `toolkit/locales/en-US/toolkit/global` when not set.
|
|
@@ -51,12 +51,23 @@ export interface StreamOptions extends ExecOptions {
|
|
|
51
51
|
export declare function execStream(command: string, args: string[], options?: StreamOptions): Promise<number>;
|
|
52
52
|
/**
|
|
53
53
|
* Executes a command and inherits stdio (shows output directly).
|
|
54
|
+
*
|
|
55
|
+
* Graceful shutdown: when the FireForge process receives SIGINT/SIGTERM, the
|
|
56
|
+
* signal is forwarded to the child as SIGTERM and a short grace timer (default
|
|
57
|
+
* 1500ms) runs before escalating to SIGKILL. A second matching signal during
|
|
58
|
+
* the grace period triggers an immediate SIGKILL — matching the usual
|
|
59
|
+
* "hit Ctrl-C again to force-quit" UX. Without this, Firefox's AsyncShutdown
|
|
60
|
+
* / profileBeforeChange blockers (which flush in-memory state to disk) can be
|
|
61
|
+
* racing the OS child-exit path, losing the last few seconds of edits.
|
|
62
|
+
*
|
|
54
63
|
* @param command - Command to execute
|
|
55
64
|
* @param args - Command arguments
|
|
56
65
|
* @param options - Execution options
|
|
57
66
|
* @returns Exit code of the process
|
|
58
67
|
*/
|
|
59
|
-
export declare function execInherit(command: string, args: string[], options?: ExecOptions
|
|
68
|
+
export declare function execInherit(command: string, args: string[], options?: ExecOptions & {
|
|
69
|
+
shutdownGraceMs?: number;
|
|
70
|
+
}): Promise<number>;
|
|
60
71
|
/**
|
|
61
72
|
* Executes a command while inheriting stdin, streaming stdout/stderr live,
|
|
62
73
|
* and capturing the emitted output for diagnostics.
|
|
@@ -65,7 +76,9 @@ export declare function execInherit(command: string, args: string[], options?: E
|
|
|
65
76
|
* @param options - Execution options
|
|
66
77
|
* @returns Execution result with stdout, stderr, and exit code
|
|
67
78
|
*/
|
|
68
|
-
export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions
|
|
79
|
+
export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions & {
|
|
80
|
+
shutdownGraceMs?: number;
|
|
81
|
+
}): Promise<ExecResult>;
|
|
69
82
|
/**
|
|
70
83
|
* Finds an executable in the system PATH.
|
|
71
84
|
* @param name - Name of the executable
|
|
@@ -108,6 +108,15 @@ export async function execStream(command, args, options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
110
|
* Executes a command and inherits stdio (shows output directly).
|
|
111
|
+
*
|
|
112
|
+
* Graceful shutdown: when the FireForge process receives SIGINT/SIGTERM, the
|
|
113
|
+
* signal is forwarded to the child as SIGTERM and a short grace timer (default
|
|
114
|
+
* 1500ms) runs before escalating to SIGKILL. A second matching signal during
|
|
115
|
+
* the grace period triggers an immediate SIGKILL — matching the usual
|
|
116
|
+
* "hit Ctrl-C again to force-quit" UX. Without this, Firefox's AsyncShutdown
|
|
117
|
+
* / profileBeforeChange blockers (which flush in-memory state to disk) can be
|
|
118
|
+
* racing the OS child-exit path, losing the last few seconds of edits.
|
|
119
|
+
*
|
|
111
120
|
* @param command - Command to execute
|
|
112
121
|
* @param args - Command arguments
|
|
113
122
|
* @param options - Execution options
|
|
@@ -121,14 +130,74 @@ export async function execInherit(command, args, options = {}) {
|
|
|
121
130
|
stdio: 'inherit',
|
|
122
131
|
signal: buildSignalFromTimeout(options.timeout),
|
|
123
132
|
});
|
|
133
|
+
const graceMs = options.shutdownGraceMs ?? 1500;
|
|
134
|
+
const { dispose } = installGracefulShutdownForwarder(child, graceMs);
|
|
124
135
|
child.on('error', (error) => {
|
|
136
|
+
dispose();
|
|
125
137
|
reject(error);
|
|
126
138
|
});
|
|
127
139
|
child.on('close', (code, signal) => {
|
|
140
|
+
dispose();
|
|
128
141
|
resolve(exitCodeFromClose(code, signal));
|
|
129
142
|
});
|
|
130
143
|
});
|
|
131
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Wires parent-process SIGINT/SIGTERM to a child: first signal → child.kill
|
|
147
|
+
* (SIGTERM) + grace timer; second matching signal → immediate SIGKILL; grace
|
|
148
|
+
* timer expiry → SIGKILL. Returns a `dispose()` that clears the listeners and
|
|
149
|
+
* any outstanding timer. Callers must invoke `dispose()` from both the child's
|
|
150
|
+
* `close` and `error` handlers so the process does not accumulate signal
|
|
151
|
+
* listeners across repeated spawns.
|
|
152
|
+
*/
|
|
153
|
+
function installGracefulShutdownForwarder(child, graceMs) {
|
|
154
|
+
let graceTimer;
|
|
155
|
+
const forwarded = new Set();
|
|
156
|
+
const escalate = () => {
|
|
157
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
child.kill('SIGKILL');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Child is already gone — nothing to do.
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const handleSignal = (signal) => {
|
|
167
|
+
if (forwarded.has(signal)) {
|
|
168
|
+
// Second receipt of the same signal while still running: escalate now.
|
|
169
|
+
escalate();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
forwarded.add(signal);
|
|
173
|
+
try {
|
|
174
|
+
child.kill('SIGTERM');
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// If the child can't accept SIGTERM (already dead), nothing to do.
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
graceTimer = setTimeout(escalate, graceMs);
|
|
181
|
+
graceTimer.unref();
|
|
182
|
+
};
|
|
183
|
+
const onSigint = () => {
|
|
184
|
+
handleSignal('SIGINT');
|
|
185
|
+
};
|
|
186
|
+
const onSigterm = () => {
|
|
187
|
+
handleSignal('SIGTERM');
|
|
188
|
+
};
|
|
189
|
+
process.on('SIGINT', onSigint);
|
|
190
|
+
process.on('SIGTERM', onSigterm);
|
|
191
|
+
const dispose = () => {
|
|
192
|
+
process.off('SIGINT', onSigint);
|
|
193
|
+
process.off('SIGTERM', onSigterm);
|
|
194
|
+
if (graceTimer) {
|
|
195
|
+
clearTimeout(graceTimer);
|
|
196
|
+
graceTimer = undefined;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return { dispose };
|
|
200
|
+
}
|
|
132
201
|
/**
|
|
133
202
|
* Executes a command while inheriting stdin, streaming stdout/stderr live,
|
|
134
203
|
* and capturing the emitted output for diagnostics.
|
|
@@ -149,10 +218,14 @@ export async function execInheritCapture(command, args, options = {}) {
|
|
|
149
218
|
const err = createStreamCollector(process.stderr);
|
|
150
219
|
child.stdout.on('data', out.onData);
|
|
151
220
|
child.stderr.on('data', err.onData);
|
|
221
|
+
const graceMs = options.shutdownGraceMs ?? 1500;
|
|
222
|
+
const { dispose } = installGracefulShutdownForwarder(child, graceMs);
|
|
152
223
|
child.on('error', (error) => {
|
|
224
|
+
dispose();
|
|
153
225
|
reject(error);
|
|
154
226
|
});
|
|
155
227
|
child.on('close', (code, signal) => {
|
|
228
|
+
dispose();
|
|
156
229
|
resolve({
|
|
157
230
|
stdout: out.getText(),
|
|
158
231
|
stderr: err.getText(),
|