@hominis/fireforge 0.44.1 → 0.44.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 +36 -0
- package/README.md +18 -110
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +33 -11
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +34 -0
- package/dist/src/core/token-variant.js +84 -16
- package/dist/src/types/commands/options.d.ts +30 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
4
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
5
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
5
6
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
6
7
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
7
8
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
@@ -85,7 +86,10 @@ async function scanPatchFilesTargeted(args) {
|
|
|
85
86
|
const exists = await mapWithConcurrency(scanFiles, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
|
|
86
87
|
for (const [index, file] of scanFiles.entries()) {
|
|
87
88
|
if (exists[index] !== true) {
|
|
88
|
-
throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}
|
|
89
|
+
throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}. ` +
|
|
90
|
+
'--scan-file brings a path INTO patch ownership, which needs content to diff. ' +
|
|
91
|
+
'If the path was deleted and the patch already owns it, a plain re-export now ' +
|
|
92
|
+
'captures the deletion — no scan flag is needed.', '--scan-file');
|
|
89
93
|
}
|
|
90
94
|
if (!currentSet.has(file))
|
|
91
95
|
added.push(file);
|
|
@@ -93,9 +97,21 @@ async function scanPatchFilesTargeted(args) {
|
|
|
93
97
|
const removed = await findRemovedFiles(currentFilesAffected, engineDir);
|
|
94
98
|
return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
|
|
95
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Paths that have genuinely left the patch's ownership: absent from disk AND
|
|
102
|
+
* untracked in engine HEAD.
|
|
103
|
+
*
|
|
104
|
+
* An absent path that IS tracked in HEAD is a DELETION, not a de-ownership.
|
|
105
|
+
* The diff carries it as a `deleted file mode` section, so it must stay in
|
|
106
|
+
* `filesAffected` — pruning it desynchronises the manifest from the patch
|
|
107
|
+
* body in the direction that hurts: the body says "delete this file", the
|
|
108
|
+
* file list says the patch has nothing to do with it.
|
|
109
|
+
*/
|
|
96
110
|
async function findRemovedFiles(files, engineDir) {
|
|
97
111
|
const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
|
|
98
|
-
|
|
112
|
+
const absent = files.filter((_, index) => exists[index] !== true);
|
|
113
|
+
const trackedAbsent = await listTrackedInHead(engineDir, absent);
|
|
114
|
+
return absent.filter((file) => !trackedAbsent.has(file)).sort();
|
|
99
115
|
}
|
|
100
116
|
function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
|
|
101
117
|
for (const f of added)
|
|
@@ -9,6 +9,7 @@ import { getProjectPaths } from '../core/config.js';
|
|
|
9
9
|
import { stdioIsInteractive } from '../core/destructive.js';
|
|
10
10
|
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
11
11
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
12
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
12
13
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
13
14
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
14
15
|
import { isGitIndexLockConflict } from '../errors/git.js';
|
|
@@ -111,17 +112,28 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
111
112
|
// --allow-stale-furnace) before diffing. Runs in dry-run too so the failure
|
|
112
113
|
// surfaces early.
|
|
113
114
|
await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
|
|
114
|
-
const
|
|
115
|
-
|
|
115
|
+
const absentFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
116
|
+
// A path absent from disk but tracked in engine HEAD is a DELETION, and
|
|
117
|
+
// `git diff HEAD` renders it as a real `deleted file mode` section. Only
|
|
118
|
+
// a never-tracked path has nothing to diff. Skipping both alike meant a
|
|
119
|
+
// retired file quietly left the patch body while staying in its file
|
|
120
|
+
// list — the patch then claimed a file it could not restore.
|
|
121
|
+
const trackedAbsent = await listTrackedInHead(paths.engine, absentFiles);
|
|
122
|
+
const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
|
|
123
|
+
const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
|
|
124
|
+
if (untrackedAbsent.length === currentFilesAffected.length) {
|
|
116
125
|
warn(`Skipped ${patch.filename}: all affected files missing`);
|
|
117
|
-
warn(`Missing files: ${
|
|
126
|
+
warn(`Missing files: ${untrackedAbsent.join(', ')}`);
|
|
118
127
|
return false;
|
|
119
128
|
}
|
|
120
|
-
if (
|
|
121
|
-
warn(`${patch.filename}: missing files will be skipped: ${
|
|
129
|
+
if (untrackedAbsent.length > 0) {
|
|
130
|
+
warn(`${patch.filename}: missing files will be skipped: ${untrackedAbsent.join(', ')}`);
|
|
122
131
|
}
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
if (deletedFiles.length > 0) {
|
|
133
|
+
info(`${patch.filename}: capturing deletion of ${deletedFiles.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
const untrackedSet = new Set(untrackedAbsent);
|
|
136
|
+
const existingFiles = currentFilesAffected.filter((f) => !untrackedSet.has(f));
|
|
125
137
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
126
138
|
assertScanFileAdditionsHaveDiffHunks({
|
|
127
139
|
diffContent,
|
|
@@ -29,7 +29,10 @@ const TEST_HELP_TEXT = [
|
|
|
29
29
|
'--doctor) ends with exactly one raw stdout line',
|
|
30
30
|
' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
|
|
31
31
|
' no-tests|preflight|inconclusive|lock-timeout] [checks=<n>]',
|
|
32
|
-
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)]',
|
|
32
|
+
' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [log=<path>]',
|
|
33
|
+
"log=<path> names this run's complete output, written to",
|
|
34
|
+
'.fireforge/logs/ as the run streams, so a piped or truncated run',
|
|
35
|
+
'still leaves a re-readable artifact.',
|
|
33
36
|
'The status follows the harness classifier, not the raw exit code:',
|
|
34
37
|
'a crash-classified run says FAIL reason=crash even at exit 0, and',
|
|
35
38
|
'a green-summary-override pass says PASS despite a non-zero mach',
|
|
@@ -55,6 +58,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
55
58
|
.command('test [paths...]')
|
|
56
59
|
.description('Run tests via mach test')
|
|
57
60
|
.option('--headless', 'Run tests in headless mode')
|
|
61
|
+
.option('--full-output', "Pass mozbuild's full verbosity through for the TEST phase. mozbuild quiets terminal output to warnings and errors when it detects a coding agent, which removes TEST_START and console INFO — the lines a hang or stall diagnosis needs. This unsets that detection for test dispatches only; the build phase stays quiet.")
|
|
58
62
|
.option('--build', 'Run incremental UI build before testing')
|
|
59
63
|
.option('--build-only', 'Run the scoped pre-test build (packaging exactly [paths...], mixed harnesses allowed) and exit without dispatching tests — one build covers both harness halves, then run each half without --build')
|
|
60
64
|
.option('--extend-coverage', 'With --build/--build-only and explicit paths, union those paths into the recorded test-packaging coverage instead of replacing it, so an earlier scoped build stays covered. Refused when the build anchor moved (engine HEAD, mozconfig, or a previously fingerprinted packageable file changed).')
|
|
@@ -40,6 +40,11 @@ export interface TestRunContext {
|
|
|
40
40
|
headless: boolean;
|
|
41
41
|
/** Extra environment variables for the mach process. */
|
|
42
42
|
env?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Pass full mozbuild verbosity through for the TEST phase, by unsetting
|
|
45
|
+
* the coding-agent markers that quiet it. Off by default.
|
|
46
|
+
*/
|
|
47
|
+
fullOutput?: boolean;
|
|
43
48
|
}
|
|
44
49
|
/** Outcome of one (possibly retried) harness invocation. */
|
|
45
50
|
export interface TestRunOutcome {
|
|
@@ -60,10 +60,17 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
|
|
|
60
60
|
let verdict;
|
|
61
61
|
for (;;) {
|
|
62
62
|
attempts += 1;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Arity is kept minimal when there is nothing extra to pass: the
|
|
64
|
+
// dispatchers' trailing parameters are optional, and appending explicit
|
|
65
|
+
// `undefined`s would only make every call site's shape depend on
|
|
66
|
+
// features the run is not using.
|
|
67
|
+
result =
|
|
68
|
+
ctx.fullOutput === true
|
|
69
|
+
? await dispatch(ctx.engineDir, paths, extraArgs, env, true)
|
|
70
|
+
: env
|
|
71
|
+
? await dispatch(ctx.engineDir, paths, extraArgs, env)
|
|
72
|
+
: await dispatch(ctx.engineDir, paths, extraArgs);
|
|
73
|
+
result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch, ctx.fullOutput);
|
|
67
74
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
68
75
|
verdict = classifyHarnessRun(result.exitCode, combined, paths);
|
|
69
76
|
if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { getActiveRunLogPath } from '../core/run-log.js';
|
|
1
3
|
import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
|
|
2
4
|
import { setStdoutSealed } from '../utils/logger.js';
|
|
3
5
|
let emitted = false;
|
|
@@ -38,7 +40,14 @@ function writeVerdictLine(line) {
|
|
|
38
40
|
if (emitted)
|
|
39
41
|
return;
|
|
40
42
|
emitted = true;
|
|
41
|
-
|
|
43
|
+
// The run log's path rides the verdict line because the verdict must stay
|
|
44
|
+
// the run's LAST stdout write — a separate announcement after it would
|
|
45
|
+
// break that contract, and one before it is the first thing a `tail` cuts.
|
|
46
|
+
// Appended as an additive `key=value`, so consumers that tokenize the line
|
|
47
|
+
// are unaffected and a truncated tail still says where the full output is.
|
|
48
|
+
const logPath = getActiveRunLogPath();
|
|
49
|
+
const suffix = logPath === undefined ? '' : ` log=${logPath}`;
|
|
50
|
+
process.stdout.write(`${line}${suffix}\n`);
|
|
42
51
|
setStdoutSealed(true);
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
@@ -8,6 +8,7 @@ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
|
|
|
8
8
|
import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
|
|
9
9
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
10
10
|
import { ensureMochitestServerPortAvailable } from '../core/mochitest-server-port.js';
|
|
11
|
+
import { closeActiveRunLog, openRunLog, setActiveRunLog } from '../core/run-log.js';
|
|
11
12
|
import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
|
|
12
13
|
import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
|
|
13
14
|
import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
|
|
@@ -276,6 +277,10 @@ const TEST_BUILD_PREFLIGHT = {
|
|
|
276
277
|
export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
277
278
|
intro('FireForge Test');
|
|
278
279
|
resetVerdictEmission();
|
|
280
|
+
// Opened before ANY work so a preflight refusal is logged too — those are
|
|
281
|
+
// exactly the runs whose only output a `tail` throws away. Closed in
|
|
282
|
+
// `finally`, after the verdict line has already read the path.
|
|
283
|
+
setActiveRunLog(await openRunLog(projectRoot, 'test'));
|
|
279
284
|
try {
|
|
280
285
|
await runTestCommandBody(projectRoot, testPaths, options);
|
|
281
286
|
}
|
|
@@ -284,6 +289,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
284
289
|
emitFailVerdict('preflight');
|
|
285
290
|
throw error;
|
|
286
291
|
}
|
|
292
|
+
finally {
|
|
293
|
+
await closeActiveRunLog();
|
|
294
|
+
}
|
|
287
295
|
}
|
|
288
296
|
/**
|
|
289
297
|
* Verifies `engine/` did not change under the run before any PASS verdict
|
|
@@ -422,6 +430,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
422
430
|
baseExtraArgs: extraArgs,
|
|
423
431
|
harnessRetries,
|
|
424
432
|
headless: options.headless === true,
|
|
433
|
+
...(options.fullOutput === true ? { fullOutput: true } : {}),
|
|
425
434
|
...(perfSampleEnv ? { env: perfSampleEnv } : {}),
|
|
426
435
|
};
|
|
427
436
|
const postRebuildContext = options.build
|
|
@@ -35,6 +35,27 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
|
|
|
35
35
|
}
|
|
36
36
|
return normalizeTokenName(rawTokenName);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Reports a no-op add, naming WHERE the existing declaration lives.
|
|
40
|
+
*
|
|
41
|
+
* A variant skip has no category to name — the declaration lives in a
|
|
42
|
+
* `:root<selector>` block — so the location reads as that block. The
|
|
43
|
+
* variant path used to report no location at all, so a re-run meant to
|
|
44
|
+
* CHANGE a value exited 0 having silently changed nothing.
|
|
45
|
+
*
|
|
46
|
+
* @param tokenName - Full token name including prefix
|
|
47
|
+
* @param result - Result carrying the skip location, when known
|
|
48
|
+
* @param options - The invocation's options
|
|
49
|
+
*/
|
|
50
|
+
function reportSkippedToken(tokenName, result, options) {
|
|
51
|
+
const scope = options.variant !== undefined
|
|
52
|
+
? `:root${options.variant}`
|
|
53
|
+
: `category "${result.skippedExisting?.category ?? options.category ?? ''}"`;
|
|
54
|
+
const where = result.skippedExisting
|
|
55
|
+
? ` in ${scope} (line ${String(result.skippedExisting.line)}), unchanged`
|
|
56
|
+
: '';
|
|
57
|
+
info(`Token ${tokenName} already exists${where} (skipped)`);
|
|
58
|
+
}
|
|
38
59
|
/**
|
|
39
60
|
* Adds a design token to the CSS file and documentation.
|
|
40
61
|
*
|
|
@@ -68,8 +89,8 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
68
89
|
await validateTokenAdd(projectRoot, {
|
|
69
90
|
tokenName,
|
|
70
91
|
value,
|
|
71
|
-
category: options.category,
|
|
72
92
|
mode: options.mode,
|
|
93
|
+
...(options.category !== undefined ? { category: options.category } : {}),
|
|
73
94
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
74
95
|
...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
|
|
75
96
|
...(options.createCategory === true ? { createCategory: true } : {}),
|
|
@@ -83,7 +104,7 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
83
104
|
info(` Variant: :root${options.variant}`);
|
|
84
105
|
}
|
|
85
106
|
else {
|
|
86
|
-
info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
|
|
107
|
+
info(` Category: ${options.category ?? '(none)'}${options.createCategory === true ? ' (created if missing)' : ''}`);
|
|
87
108
|
}
|
|
88
109
|
info(` Mode: ${options.mode}`);
|
|
89
110
|
if (options.description)
|
|
@@ -96,24 +117,21 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
|
|
|
96
117
|
const result = await addToken(projectRoot, {
|
|
97
118
|
tokenName,
|
|
98
119
|
value,
|
|
99
|
-
category: options.category,
|
|
100
120
|
mode: options.mode,
|
|
121
|
+
...(options.category !== undefined ? { category: options.category } : {}),
|
|
101
122
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
102
123
|
...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
|
|
103
124
|
...(options.createCategory === true ? { createCategory: true } : {}),
|
|
104
125
|
...(options.variant !== undefined ? { variant: options.variant } : {}),
|
|
105
126
|
});
|
|
106
127
|
if (result.skipped) {
|
|
107
|
-
|
|
108
|
-
? ` in category "${result.skippedExisting.category ?? options.category}" (line ${String(result.skippedExisting.line)})`
|
|
109
|
-
: '';
|
|
110
|
-
info(`Token ${tokenName} already exists${where} (skipped)`);
|
|
128
|
+
reportSkippedToken(tokenName, result, options);
|
|
111
129
|
}
|
|
112
130
|
else {
|
|
113
131
|
const forgeConfig = await loadConfig(projectRoot);
|
|
114
132
|
const tokensCssFile = getTokensCssPath(forgeConfig.binaryName).split('/').pop();
|
|
115
133
|
if (result.categoryCreated)
|
|
116
|
-
success(`Created category "${options.category}"`);
|
|
134
|
+
success(`Created category "${options.category ?? ''}"`);
|
|
117
135
|
if (result.cssAdded)
|
|
118
136
|
success(`Added ${tokenName} to ${tokensCssFile}`);
|
|
119
137
|
if (result.docsAdded)
|
|
@@ -141,7 +159,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
141
159
|
token
|
|
142
160
|
.command('add <token-name> <value>')
|
|
143
161
|
.description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
|
|
144
|
-
.
|
|
162
|
+
.option('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing"). Required for a base declaration; ' +
|
|
163
|
+
'not required with --variant, which routes into a :root<selector> block where no ' +
|
|
164
|
+
'category applies')
|
|
145
165
|
.addOption(
|
|
146
166
|
// Use Commander's .choices() so invalid --mode values are rejected with
|
|
147
167
|
// the built-in "argument must be one of …" message and --help lists the
|
|
@@ -161,14 +181,16 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
161
181
|
.option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
|
|
162
182
|
.option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
|
|
163
183
|
"(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
|
|
164
|
-
'CSS-only — cannot be combined with --mode override.'
|
|
184
|
+
'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
|
|
185
|
+
'a new category and its variants, add the BASE token first (with --create-category), ' +
|
|
186
|
+
'then add each variant with --variant.')
|
|
165
187
|
.option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
|
|
166
188
|
.option('--dry-run', 'Show what would be changed without writing')
|
|
167
189
|
.action(withErrorHandling(async (tokenName, value, options) => {
|
|
168
190
|
await tokenAddCommand(getProjectRoot(), tokenName, value, {
|
|
169
|
-
category: options.category,
|
|
170
191
|
mode: options.mode,
|
|
171
192
|
...pickDefined({
|
|
193
|
+
category: options.category,
|
|
172
194
|
description: options.description,
|
|
173
195
|
darkValue: options.darkValue,
|
|
174
196
|
dryRun: options.dryRun,
|
|
@@ -37,6 +37,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
37
37
|
import { basename, join } from 'node:path';
|
|
38
38
|
import { pathExists } from '../utils/fs.js';
|
|
39
39
|
import { info, verbose, warn } from '../utils/logger.js';
|
|
40
|
+
import { normalizePathSlashes } from '../utils/paths.js';
|
|
40
41
|
import { detectPlatformGate } from './build-audit-platform.js';
|
|
41
42
|
import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
|
|
42
43
|
import { countTrailingSegmentMatches, GENERIC_PATH_SEGMENTS, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
|
|
@@ -229,8 +230,11 @@ function isConfidentMatch(source, candidate) {
|
|
|
229
230
|
if (countTrailingSegmentMatches(source, candidate) >= MIN_TRAILING_SEGMENT_OVERLAP) {
|
|
230
231
|
return true;
|
|
231
232
|
}
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
// The candidate is an absolute path built by `join` over a dist/ walk, so on
|
|
234
|
+
// Windows it arrives backslash-separated; a `/`-only split collapses it into
|
|
235
|
+
// one segment and the non-generic-segment bonus can never fire.
|
|
236
|
+
const sourceSegs = normalizePathSlashes(source).split('/').filter(Boolean);
|
|
237
|
+
const candSegs = normalizePathSlashes(candidate).split('/').filter(Boolean);
|
|
234
238
|
const generic = GENERIC_PATH_SEGMENTS;
|
|
235
239
|
// Skip the basename itself (which trail-matches by definition).
|
|
236
240
|
for (let i = 0; i < sourceSegs.length - 1; i += 1) {
|
|
@@ -26,7 +26,7 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
26
26
|
import { toError } from '../utils/errors.js';
|
|
27
27
|
import { pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
|
|
28
28
|
import { info } from '../utils/logger.js';
|
|
29
|
-
import { normalizePathSlashes } from '../utils/paths.js';
|
|
29
|
+
import { isPathInsideRoot, normalizePathSlashes } from '../utils/paths.js';
|
|
30
30
|
import { getFurnacePaths } from './furnace-config.js';
|
|
31
31
|
/** Chrome URL prefix under which registered custom-element files deploy. */
|
|
32
32
|
const CHROME_ELEMENTS_URL_PREFIX = 'chrome://global/content/elements/';
|
|
@@ -84,8 +84,10 @@ function isManagedEntry(key, value, jsconfigDir, customDir) {
|
|
|
84
84
|
return false;
|
|
85
85
|
if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string')
|
|
86
86
|
return false;
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Containment via `relative`, not a string prefix: a hard-coded `'/'`
|
|
88
|
+
// separator never matches the backslashes `resolve` emits on Windows, which
|
|
89
|
+
// demoted every managed entry to "unmanaged" there — no pruning, no updates.
|
|
90
|
+
return isPathInsideRoot(customDir, resolve(jsconfigDir, value[0]));
|
|
89
91
|
}
|
|
90
92
|
/**
|
|
91
93
|
* Reconciles the managed `compilerOptions.paths` entries of the configured
|
|
@@ -56,8 +56,16 @@ export interface RunFurnaceMutationOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
dryRun?: boolean;
|
|
58
58
|
/**
|
|
59
|
-
* Override the default
|
|
60
|
-
*
|
|
59
|
+
* Override the default 30 s furnace-lock timeout.
|
|
60
|
+
*
|
|
61
|
+
* Set from the caller's resolved `--wait-lock` budget, so ONE flag governs
|
|
62
|
+
* both locks a furnace mutation takes. Before that it was declared and
|
|
63
|
+
* never passed: `furnace deploy --wait-lock 1800` waited 1800 s for the
|
|
64
|
+
* engine session lock and then met `.fireforge/furnace.lock` with a fixed
|
|
65
|
+
* 30 s, which is the worst shape a budget can have — it pays the whole
|
|
66
|
+
* wait and still refuses. When absent, {@link sessionWaitLockMs} supplies
|
|
67
|
+
* the session-wide budget so the twelve furnace mutators that declare no
|
|
68
|
+
* flag are covered too.
|
|
61
69
|
*/
|
|
62
70
|
lockTimeoutMs?: number;
|
|
63
71
|
/**
|
|
@@ -67,6 +75,21 @@ export interface RunFurnaceMutationOptions {
|
|
|
67
75
|
*/
|
|
68
76
|
skipPendingRepairCheck?: boolean;
|
|
69
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Converts a resolved `--wait-lock` budget in seconds into the
|
|
80
|
+
* {@link RunFurnaceMutationOptions} slice that applies it to the furnace
|
|
81
|
+
* lock. Undefined in, empty out — so the caller can spread it
|
|
82
|
+
* unconditionally without assigning `undefined` under
|
|
83
|
+
* `exactOptionalPropertyTypes`.
|
|
84
|
+
*
|
|
85
|
+
* A helper rather than three inline spreads: the seconds-to-milliseconds
|
|
86
|
+
* conversion is exactly the kind of thing that gets copied once correctly
|
|
87
|
+
* and once with the factor dropped.
|
|
88
|
+
*
|
|
89
|
+
* @param waitLockSeconds - Resolved budget in seconds, or undefined
|
|
90
|
+
* @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
|
|
91
|
+
*/
|
|
92
|
+
export declare function waitLockMutationOptions(waitLockSeconds: number | undefined): Pick<RunFurnaceMutationOptions, 'lockTimeoutMs'>;
|
|
70
93
|
/**
|
|
71
94
|
* Returns true while a signal-driven rollback is in progress. The bin entry
|
|
72
95
|
* point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
|
|
@@ -4,12 +4,30 @@ import { FurnaceError } from '../errors/furnace.js';
|
|
|
4
4
|
import { assert } from '../utils/assert.js';
|
|
5
5
|
import { toError } from '../utils/errors.js';
|
|
6
6
|
import { verbose, warn } from '../utils/logger.js';
|
|
7
|
+
import { sessionWaitLockMs } from '../utils/options.js';
|
|
7
8
|
import { FIREFORGE_DIR } from './config-paths.js';
|
|
8
9
|
import { readLockStatus, releaseLockIfOwned, withFileLock } from './file-lock.js';
|
|
9
10
|
import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
|
|
10
11
|
import { restoreRollbackJournal, restoreRollbackJournalOrThrow, } from './furnace-rollback.js';
|
|
11
12
|
/** Sidecar lock filename used to serialize concurrent furnace mutations. */
|
|
12
13
|
const FURNACE_LOCK_FILENAME = 'furnace.lock';
|
|
14
|
+
/**
|
|
15
|
+
* Converts a resolved `--wait-lock` budget in seconds into the
|
|
16
|
+
* {@link RunFurnaceMutationOptions} slice that applies it to the furnace
|
|
17
|
+
* lock. Undefined in, empty out — so the caller can spread it
|
|
18
|
+
* unconditionally without assigning `undefined` under
|
|
19
|
+
* `exactOptionalPropertyTypes`.
|
|
20
|
+
*
|
|
21
|
+
* A helper rather than three inline spreads: the seconds-to-milliseconds
|
|
22
|
+
* conversion is exactly the kind of thing that gets copied once correctly
|
|
23
|
+
* and once with the factor dropped.
|
|
24
|
+
*
|
|
25
|
+
* @param waitLockSeconds - Resolved budget in seconds, or undefined
|
|
26
|
+
* @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
|
|
27
|
+
*/
|
|
28
|
+
export function waitLockMutationOptions(waitLockSeconds) {
|
|
29
|
+
return waitLockSeconds === undefined ? {} : { lockTimeoutMs: waitLockSeconds * 1000 };
|
|
30
|
+
}
|
|
13
31
|
/**
|
|
14
32
|
* Claims the rollback for one operation, or reports that another path
|
|
15
33
|
* already has it.
|
|
@@ -338,10 +356,17 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
|
|
|
338
356
|
const token = nextOperationToken++;
|
|
339
357
|
const operation = { root, kind, cleanups: [] };
|
|
340
358
|
const lockPath = getFurnaceLockPath(root);
|
|
359
|
+
// An explicit budget wins; otherwise the session-wide one applies. Naming
|
|
360
|
+
// the lock in the refusal is load-bearing: a mutation takes two, and a
|
|
361
|
+
// message that says only "timed out" sends the operator to raise a budget
|
|
362
|
+
// that was already large enough for the OTHER lock.
|
|
363
|
+
const timeoutMs = options.lockTimeoutMs ?? sessionWaitLockMs();
|
|
341
364
|
const lockOptions = {
|
|
342
|
-
...(
|
|
343
|
-
onTimeoutMessage: `Timed out waiting for the
|
|
365
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
366
|
+
onTimeoutMessage: `Timed out waiting for the FURNACE lock at ${lockPath} ` +
|
|
367
|
+
`(this is the second of the two locks a furnace mutation takes; the first is the engine session lock). ` +
|
|
344
368
|
'Another fireforge furnace command may be running. ' +
|
|
369
|
+
'Raise the budget with --wait-lock <seconds> or FIREFORGE_WAIT_LOCK, which governs both. ' +
|
|
345
370
|
'If no other process is running, remove the stale lock directory and retry.',
|
|
346
371
|
onStaleLockMessage: (ageMs) => `Removing stale furnace lock (age: ${Math.round(ageMs / 1000)}s). ` +
|
|
347
372
|
'A previous fireforge process may have crashed.',
|
|
@@ -47,6 +47,23 @@
|
|
|
47
47
|
* @returns True when the recognized teardown traceback is present
|
|
48
48
|
*/
|
|
49
49
|
export declare function hasKnownTeardownNoise(output: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True when a SINGLE line is the documented teardown family's
|
|
52
|
+
* `AttributeError`.
|
|
53
|
+
*
|
|
54
|
+
* The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
|
|
55
|
+
* lives on a neighbouring line, so a per-line test cannot apply the
|
|
56
|
+
* two-signal rule by itself. Callers pair this with
|
|
57
|
+
* `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
|
|
58
|
+
* "this capture carries the incident, and THIS line is its header" — which
|
|
59
|
+
* keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
|
|
60
|
+
* the same work it does everywhere else. A novel attribute is not this
|
|
61
|
+
* incident and must stay a candidate failure.
|
|
62
|
+
*
|
|
63
|
+
* @param line - A single output line
|
|
64
|
+
* @returns True when the line is the recognized teardown AttributeError
|
|
65
|
+
*/
|
|
66
|
+
export declare function isKnownTeardownNoiseLine(line: string): boolean;
|
|
50
67
|
/**
|
|
51
68
|
* Shared across the stdout and stderr filter instances of ONE mach run:
|
|
52
69
|
* SUITE_END typically arrives on stdout while the teardown traceback lands
|
|
@@ -70,6 +70,25 @@ function isRecognizedTeardownNoise(block) {
|
|
|
70
70
|
export function hasKnownTeardownNoise(output) {
|
|
71
71
|
return isRecognizedTeardownNoise(output);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* True when a SINGLE line is the documented teardown family's
|
|
75
|
+
* `AttributeError`.
|
|
76
|
+
*
|
|
77
|
+
* The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
|
|
78
|
+
* lives on a neighbouring line, so a per-line test cannot apply the
|
|
79
|
+
* two-signal rule by itself. Callers pair this with
|
|
80
|
+
* `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
|
|
81
|
+
* "this capture carries the incident, and THIS line is its header" — which
|
|
82
|
+
* keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
|
|
83
|
+
* the same work it does everywhere else. A novel attribute is not this
|
|
84
|
+
* incident and must stay a candidate failure.
|
|
85
|
+
*
|
|
86
|
+
* @param line - A single output line
|
|
87
|
+
* @returns True when the line is the recognized teardown AttributeError
|
|
88
|
+
*/
|
|
89
|
+
export function isKnownTeardownNoiseLine(line) {
|
|
90
|
+
return KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN.test(line);
|
|
91
|
+
}
|
|
73
92
|
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
74
93
|
export function createTeardownNoiseContext() {
|
|
75
94
|
return { shutdownSeen: false };
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface MachOptions {
|
|
|
25
25
|
* the test dispatchers only.
|
|
26
26
|
*/
|
|
27
27
|
annotateKnownTeardownNoise?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Environment variables to unset for this dispatch. Threaded to the exec
|
|
30
|
+
* layer's {@link ExecOptions.envUnset}.
|
|
31
|
+
*/
|
|
32
|
+
envUnset?: readonly string[];
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Result of running a mach command while capturing streamed output.
|
|
@@ -243,7 +248,7 @@ export declare function test(engineDir: string, testPaths?: string[], args?: str
|
|
|
243
248
|
* (merged over `process.env` by the exec layer). Used by
|
|
244
249
|
* `fireforge test --perf-samples` to publish the artifact-path contract.
|
|
245
250
|
*/
|
|
246
|
-
export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string
|
|
251
|
+
export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;
|
|
247
252
|
/**
|
|
248
253
|
* Runs `mach xpcshell-test` (the suite-specific xpcshell command) while
|
|
249
254
|
* capturing output. Unlike the generic `mach test`, the suite-specific
|
|
@@ -254,10 +259,10 @@ export declare function testWithOutput(engineDir: string, testPaths?: string[],
|
|
|
254
259
|
* Signature mirrors {@link testWithOutput} so the two are interchangeable in
|
|
255
260
|
* the dispatch path.
|
|
256
261
|
*/
|
|
257
|
-
export declare function xpcshellTestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string
|
|
262
|
+
export declare function xpcshellTestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;
|
|
258
263
|
/**
|
|
259
264
|
* Runs `mach mochitest` (covers browser-chrome / mochitest flavors) while
|
|
260
265
|
* capturing output. The suite-specific counterpart to {@link testWithOutput}
|
|
261
266
|
* for non-xpcshell single-suite runs — see {@link xpcshellTestWithOutput}.
|
|
262
267
|
*/
|
|
263
|
-
export declare function mochitestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string
|
|
268
|
+
export declare function mochitestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;
|