@hominis/fireforge 0.44.0 → 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 +65 -2
- package/README.md +20 -121
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/doctor-check-core.d.ts +9 -0
- package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
- package/dist/src/commands/doctor-patch-manifest.js +155 -0
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +48 -62
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/diff.js +6 -1
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/remove-mutations.js +5 -1
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/import.js +6 -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-resolve.js +12 -3
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/config-mutate.js +20 -19
- package/dist/src/core/furnace-apply-ftl.js +4 -1
- package/dist/src/core/furnace-apply-helpers.js +11 -5
- 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/git-base.d.ts +14 -0
- package/dist/src/core/git-base.js +17 -0
- package/dist/src/core/git.js +3 -1
- 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/patch-lint-checkjs.js +25 -4
- package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
- package/dist/src/core/patch-manifest-consistency.js +96 -23
- package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
- package/dist/src/core/patch-manifest-files-affected.js +77 -0
- package/dist/src/core/patch-manifest.d.ts +2 -1
- package/dist/src/core/patch-manifest.js +2 -1
- 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-category.js +61 -7
- package/dist/src/core/token-dark-mode.js +20 -2
- package/dist/src/core/token-docs.js +1 -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 +98 -8
- package/dist/src/core/tree-store.js +5 -1
- package/dist/src/core/typecheck.js +7 -1
- package/dist/src/types/commands/options.d.ts +53 -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 +2 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run output logs under `.fireforge/logs/`.
|
|
3
|
+
*
|
|
4
|
+
* A run's diagnosis used to exist in exactly one place: the terminal. Piping
|
|
5
|
+
* through `tail`/`grep` — the ergonomic default when output is long — keeps
|
|
6
|
+
* the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
|
|
7
|
+
* broke, and it launders the exit code besides. The operator rule ("never
|
|
8
|
+
* pipe a run") was written down three times downstream and broken after each
|
|
9
|
+
* writing, which is the tell that it is not an operator problem: the tool is
|
|
10
|
+
* the only party that can make the log survive the mistake.
|
|
11
|
+
*
|
|
12
|
+
* So `test` and `build` write their own complete copy as they stream, and the
|
|
13
|
+
* `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
|
|
14
|
+
* backgrounded run then still leaves a re-readable artifact.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
|
|
17
|
+
* a run must never fail because one could not be opened, written, or pruned.
|
|
18
|
+
* Every entry point swallows its errors and degrades to "no log", which is
|
|
19
|
+
* exactly the behaviour that existed before this module.
|
|
20
|
+
*/
|
|
21
|
+
/** A run log sink. `write` never throws. */
|
|
22
|
+
export interface RunLog {
|
|
23
|
+
/** Absolute path to the log file. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Appends raw output. Silently drops on any stream error. */
|
|
26
|
+
write: (chunk: string) => void;
|
|
27
|
+
/** Flushes and closes. Resolves even when the stream already failed. */
|
|
28
|
+
close: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** `.fireforge/logs` under `projectRoot`. */
|
|
31
|
+
export declare function getRunLogDir(projectRoot: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
|
|
34
|
+
* logs of the same kind first.
|
|
35
|
+
*
|
|
36
|
+
* @param projectRoot - Project root directory
|
|
37
|
+
* @param command - Command kind, e.g. `test` or `build`
|
|
38
|
+
* @param now - Clock injection point for deterministic tests
|
|
39
|
+
* @returns The sink, or undefined when no log could be opened
|
|
40
|
+
*/
|
|
41
|
+
export declare function openRunLog(projectRoot: string, command: string, now?: Date): Promise<RunLog | undefined>;
|
|
42
|
+
/** Installs (or clears, with `undefined`) the run log for this process. */
|
|
43
|
+
export declare function setActiveRunLog(log: RunLog | undefined): void;
|
|
44
|
+
/** Absolute path of the active run log, or undefined when none is open. */
|
|
45
|
+
export declare function getActiveRunLogPath(): string | undefined;
|
|
46
|
+
/** Appends to the active run log. A no-op when none is open. */
|
|
47
|
+
export declare function writeToActiveRunLog(chunk: string): void;
|
|
48
|
+
/** Closes and clears the active run log. Resolves even when none is open. */
|
|
49
|
+
export declare function closeActiveRunLog(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* A writable that forwards to `base` AND to the active run log.
|
|
52
|
+
*
|
|
53
|
+
* Used by the stdio-inheriting capture path, whose collectors take a mirror
|
|
54
|
+
* stream rather than per-chunk callbacks — so the tee reuses the mirror the
|
|
55
|
+
* exec layer already supports instead of growing a second hook.
|
|
56
|
+
*
|
|
57
|
+
* @param base - The stream to forward to (normally `process.stdout`)
|
|
58
|
+
* @returns A writable teeing into `base` and the run log
|
|
59
|
+
*/
|
|
60
|
+
export declare function teeToRunLog(base: NodeJS.WritableStream): NodeJS.WritableStream;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Per-run output logs under `.fireforge/logs/`.
|
|
4
|
+
*
|
|
5
|
+
* A run's diagnosis used to exist in exactly one place: the terminal. Piping
|
|
6
|
+
* through `tail`/`grep` — the ergonomic default when output is long — keeps
|
|
7
|
+
* the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT
|
|
8
|
+
* broke, and it launders the exit code besides. The operator rule ("never
|
|
9
|
+
* pipe a run") was written down three times downstream and broken after each
|
|
10
|
+
* writing, which is the tell that it is not an operator problem: the tool is
|
|
11
|
+
* the only party that can make the log survive the mistake.
|
|
12
|
+
*
|
|
13
|
+
* So `test` and `build` write their own complete copy as they stream, and the
|
|
14
|
+
* `FIREFORGE-VERDICT:` line names the path. A piped, truncated, or
|
|
15
|
+
* backgrounded run then still leaves a re-readable artifact.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is BEST-EFFORT by construction. A log is a diagnostic aid;
|
|
18
|
+
* a run must never fail because one could not be opened, written, or pruned.
|
|
19
|
+
* Every entry point swallows its errors and degrades to "no log", which is
|
|
20
|
+
* exactly the behaviour that existed before this module.
|
|
21
|
+
*/
|
|
22
|
+
import { createWriteStream } from 'node:fs';
|
|
23
|
+
import { mkdir, readdir, unlink } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { Writable } from 'node:stream';
|
|
26
|
+
import { verbose } from '../utils/logger.js';
|
|
27
|
+
import { FIREFORGE_DIR } from './config-paths.js';
|
|
28
|
+
/** Directory under `.fireforge/` holding run logs. */
|
|
29
|
+
const LOGS_DIRNAME = 'logs';
|
|
30
|
+
/**
|
|
31
|
+
* How many logs to keep per command kind. Retention is per-kind so a busy
|
|
32
|
+
* `test` loop cannot evict every `build` log, which is the pair an operator
|
|
33
|
+
* most often needs to read together.
|
|
34
|
+
*/
|
|
35
|
+
const RETAINED_LOGS_PER_COMMAND = 20;
|
|
36
|
+
/** `.fireforge/logs` under `projectRoot`. */
|
|
37
|
+
export function getRunLogDir(projectRoot) {
|
|
38
|
+
return join(projectRoot, FIREFORGE_DIR, LOGS_DIRNAME);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A filesystem-safe, sortable timestamp: `2026-08-28T14-32-05-123Z`.
|
|
42
|
+
*
|
|
43
|
+
* Colons are not portable in filenames (Windows rejects them outright), so
|
|
44
|
+
* the ISO form is punctuated with dashes only. Lexical order still equals
|
|
45
|
+
* chronological order, which is what the pruning below relies on.
|
|
46
|
+
*/
|
|
47
|
+
function logTimestamp(now) {
|
|
48
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Makes room for one new log by deleting the oldest, so that at most
|
|
52
|
+
* {@link RETAINED_LOGS_PER_COMMAND} logs for `command` exist once the new
|
|
53
|
+
* one is created. Pruning runs BEFORE the file is opened — hence the
|
|
54
|
+
* `- 1`, without which the directory settles at N + 1.
|
|
55
|
+
*
|
|
56
|
+
* Best-effort: a failure here must not stop the run that is about to be
|
|
57
|
+
* logged.
|
|
58
|
+
*/
|
|
59
|
+
async function pruneRunLogs(dir, command) {
|
|
60
|
+
const keep = RETAINED_LOGS_PER_COMMAND - 1;
|
|
61
|
+
try {
|
|
62
|
+
const entries = await readdir(dir);
|
|
63
|
+
const mine = entries.filter((name) => name.startsWith(`${command}-`) && name.endsWith('.log'));
|
|
64
|
+
if (mine.length <= keep)
|
|
65
|
+
return;
|
|
66
|
+
// Names embed a lexically-sortable timestamp, so sorting is ordering by
|
|
67
|
+
// age without stat-ing every file.
|
|
68
|
+
const stale = mine.sort().slice(0, mine.length - keep);
|
|
69
|
+
await Promise.all(stale.map(async (name) => {
|
|
70
|
+
try {
|
|
71
|
+
await unlink(join(dir, name));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A concurrent run may have pruned it already.
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
verbose(`Run-log pruning skipped: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens `.fireforge/logs/<command>-<timestamp>.log` for a run, pruning older
|
|
84
|
+
* logs of the same kind first.
|
|
85
|
+
*
|
|
86
|
+
* @param projectRoot - Project root directory
|
|
87
|
+
* @param command - Command kind, e.g. `test` or `build`
|
|
88
|
+
* @param now - Clock injection point for deterministic tests
|
|
89
|
+
* @returns The sink, or undefined when no log could be opened
|
|
90
|
+
*/
|
|
91
|
+
export async function openRunLog(projectRoot, command, now = new Date()) {
|
|
92
|
+
const dir = getRunLogDir(projectRoot);
|
|
93
|
+
let stream;
|
|
94
|
+
const path = join(dir, `${command}-${logTimestamp(now)}.log`);
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
await pruneRunLogs(dir, command);
|
|
98
|
+
stream = createWriteStream(path, { flags: 'a' });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
verbose(`Run log unavailable (${path}): ${String(error)}`);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// A stream error after open (disk full, a removed directory) must not
|
|
105
|
+
// become an unhandled 'error' event and kill the run the log exists to
|
|
106
|
+
// describe. Latch it and drop every later write.
|
|
107
|
+
let broken = false;
|
|
108
|
+
stream.on('error', (error) => {
|
|
109
|
+
broken = true;
|
|
110
|
+
verbose(`Run log write failed (${path}): ${error.message}`);
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
path,
|
|
114
|
+
write: (chunk) => {
|
|
115
|
+
if (broken)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
stream.write(chunk);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
broken = true;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
close: async () => {
|
|
125
|
+
await new Promise((resolve) => {
|
|
126
|
+
stream.end(() => {
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The run log for the command currently executing, if any.
|
|
135
|
+
*
|
|
136
|
+
* Module state rather than a threaded parameter, for the same reason
|
|
137
|
+
* `test-verdict.ts` keeps its emitted-once flag here: the sink has to be
|
|
138
|
+
* reachable from `mach.ts`'s stream callbacks, which sit five frames below
|
|
139
|
+
* the command that opened it, behind dispatch signatures that exist to
|
|
140
|
+
* describe TESTS rather than logging. Threading it would have touched every
|
|
141
|
+
* dispatcher, the retry wrapper and the shard loop to deliver one pointer.
|
|
142
|
+
*
|
|
143
|
+
* One command runs per process, so a single slot is the whole lifetime.
|
|
144
|
+
*/
|
|
145
|
+
let activeRunLog;
|
|
146
|
+
/** Installs (or clears, with `undefined`) the run log for this process. */
|
|
147
|
+
export function setActiveRunLog(log) {
|
|
148
|
+
activeRunLog = log;
|
|
149
|
+
}
|
|
150
|
+
/** Absolute path of the active run log, or undefined when none is open. */
|
|
151
|
+
export function getActiveRunLogPath() {
|
|
152
|
+
return activeRunLog?.path;
|
|
153
|
+
}
|
|
154
|
+
/** Appends to the active run log. A no-op when none is open. */
|
|
155
|
+
export function writeToActiveRunLog(chunk) {
|
|
156
|
+
activeRunLog?.write(chunk);
|
|
157
|
+
}
|
|
158
|
+
/** Closes and clears the active run log. Resolves even when none is open. */
|
|
159
|
+
export async function closeActiveRunLog() {
|
|
160
|
+
const log = activeRunLog;
|
|
161
|
+
activeRunLog = undefined;
|
|
162
|
+
await log?.close();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A writable that forwards to `base` AND to the active run log.
|
|
166
|
+
*
|
|
167
|
+
* Used by the stdio-inheriting capture path, whose collectors take a mirror
|
|
168
|
+
* stream rather than per-chunk callbacks — so the tee reuses the mirror the
|
|
169
|
+
* exec layer already supports instead of growing a second hook.
|
|
170
|
+
*
|
|
171
|
+
* @param base - The stream to forward to (normally `process.stdout`)
|
|
172
|
+
* @returns A writable teeing into `base` and the run log
|
|
173
|
+
*/
|
|
174
|
+
export function teeToRunLog(base) {
|
|
175
|
+
return new Writable({
|
|
176
|
+
write(chunk, _encoding, callback) {
|
|
177
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
178
|
+
base.write(text);
|
|
179
|
+
writeToActiveRunLog(text);
|
|
180
|
+
callback();
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -8,7 +8,30 @@ export interface PostRebuildFailureContext {
|
|
|
8
8
|
requestedPaths: readonly string[];
|
|
9
9
|
firstFailureLine?: string;
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Finds the first high-signal failure line from captured mach test output.
|
|
13
|
+
*
|
|
14
|
+
* Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
|
|
15
|
+
* `AttributeError:` arm above has exactly the same standing as
|
|
16
|
+
* `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
|
|
17
|
+
* is how a run whose real defect was an export shard's file-count assertion
|
|
18
|
+
* got diagnosed as the recognized mozsystemmonitor teardown crash: the
|
|
19
|
+
* information was present and the SELECTION was wrong, which is the
|
|
20
|
+
* expensive kind of wrong, because the named cause is a real, documented,
|
|
21
|
+
* unrelated upstream defect and therefore reads as an answer.
|
|
22
|
+
*
|
|
23
|
+
* So a capture carrying the recognized teardown incident gets two passes:
|
|
24
|
+
* candidates excluding its `AttributeError` header first, and only if that
|
|
25
|
+
* finds nothing does the full set apply. The narrowness the rest of the
|
|
26
|
+
* codebase maintains is preserved exactly — the exclusion is gated on
|
|
27
|
+
* `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
|
|
28
|
+
* closed attribute allowlist and a `resourcemonitor.py` frame. A novel
|
|
29
|
+
* attribute, or a traceback from anywhere else, stays a real failure and is
|
|
30
|
+
* still eligible to be reported.
|
|
31
|
+
*
|
|
32
|
+
* @param output - Raw captured stdout/stderr from the run
|
|
33
|
+
* @returns The chosen failure line, or the first non-empty line
|
|
34
|
+
*/
|
|
12
35
|
export declare function findFirstUsefulFailureLine(output: string): string | undefined;
|
|
13
36
|
/** Starts a post-rebuild context block for a focused test failure. */
|
|
14
37
|
export declare function createPostRebuildFailureContext(rebuildCommand: string, requestedPaths: readonly string[]): PostRebuildFailureContext;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { hasKnownTeardownNoise, isKnownTeardownNoiseLine } from './mach-known-noise-filter.js';
|
|
2
3
|
function getNonEmptyOutputLines(output) {
|
|
3
4
|
return output
|
|
4
5
|
.split(/\r?\n/)
|
|
@@ -8,23 +9,53 @@ function getNonEmptyOutputLines(output) {
|
|
|
8
9
|
function findFirstMatchingLine(lines, patterns) {
|
|
9
10
|
return lines.find((line) => patterns.some((pattern) => pattern.test(line)));
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
const FAILURE_LINE_PATTERNS = [
|
|
13
|
+
/\bTEST-UNEXPECTED-[A-Z-]+\b/,
|
|
14
|
+
/\bPROCESS-CRASH\b/i,
|
|
15
|
+
/\bTIMEOUT\b/i,
|
|
16
|
+
/timed out/i,
|
|
17
|
+
/HominisBrowserUnavailableError/i,
|
|
18
|
+
/Marionette.*(?:session|startup|start).*fail/i,
|
|
19
|
+
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
20
|
+
/SessionNotCreatedException/i,
|
|
21
|
+
/Browser process exited during spawn/i,
|
|
22
|
+
/Failed to load (?:resource|chrome):\/\//i,
|
|
23
|
+
/\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
|
|
24
|
+
/AttributeError:\s+/,
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Finds the first high-signal failure line from captured mach test output.
|
|
28
|
+
*
|
|
29
|
+
* Selection is FIRST-MATCHING-LINE, not first-matching-pattern, so the bare
|
|
30
|
+
* `AttributeError:` arm above has exactly the same standing as
|
|
31
|
+
* `TEST-UNEXPECTED-FAIL` — whichever appears earlier in the text wins. That
|
|
32
|
+
* is how a run whose real defect was an export shard's file-count assertion
|
|
33
|
+
* got diagnosed as the recognized mozsystemmonitor teardown crash: the
|
|
34
|
+
* information was present and the SELECTION was wrong, which is the
|
|
35
|
+
* expensive kind of wrong, because the named cause is a real, documented,
|
|
36
|
+
* unrelated upstream defect and therefore reads as an answer.
|
|
37
|
+
*
|
|
38
|
+
* So a capture carrying the recognized teardown incident gets two passes:
|
|
39
|
+
* candidates excluding its `AttributeError` header first, and only if that
|
|
40
|
+
* finds nothing does the full set apply. The narrowness the rest of the
|
|
41
|
+
* codebase maintains is preserved exactly — the exclusion is gated on
|
|
42
|
+
* `hasKnownTeardownNoise` for the WHOLE capture, so it requires both the
|
|
43
|
+
* closed attribute allowlist and a `resourcemonitor.py` frame. A novel
|
|
44
|
+
* attribute, or a traceback from anywhere else, stays a real failure and is
|
|
45
|
+
* still eligible to be reported.
|
|
46
|
+
*
|
|
47
|
+
* @param output - Raw captured stdout/stderr from the run
|
|
48
|
+
* @returns The chosen failure line, or the first non-empty line
|
|
49
|
+
*/
|
|
12
50
|
export function findFirstUsefulFailureLine(output) {
|
|
13
51
|
const lines = getNonEmptyOutputLines(output);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/(?:failed|unable) to (?:start|create|open).*Marionette/i,
|
|
22
|
-
/SessionNotCreatedException/i,
|
|
23
|
-
/Browser process exited during spawn/i,
|
|
24
|
-
/Failed to load (?:resource|chrome):\/\//i,
|
|
25
|
-
/\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError):\s+/,
|
|
26
|
-
/AttributeError:\s+/,
|
|
27
|
-
]);
|
|
52
|
+
if (hasKnownTeardownNoise(output)) {
|
|
53
|
+
const withoutNoise = lines.filter((line) => !isKnownTeardownNoiseLine(line));
|
|
54
|
+
const preferred = findFirstMatchingLine(withoutNoise, FAILURE_LINE_PATTERNS);
|
|
55
|
+
if (preferred !== undefined)
|
|
56
|
+
return preferred;
|
|
57
|
+
}
|
|
58
|
+
const matched = findFirstMatchingLine(lines, FAILURE_LINE_PATTERNS);
|
|
28
59
|
return matched ?? lines[0];
|
|
29
60
|
}
|
|
30
61
|
/** Starts a post-rebuild context block for a focused test failure. */
|
|
@@ -4,11 +4,11 @@ export interface XpcshellRetryClassification {
|
|
|
4
4
|
nonXpcshell: readonly string[];
|
|
5
5
|
}
|
|
6
6
|
/** Dispatches a (possibly suite-specific) mach test run, mirroring `testWithOutput`. */
|
|
7
|
-
export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string
|
|
7
|
+
export type TestDispatch = (engineDir: string, testPaths: string[], args: string[], env?: Record<string, string>, fullOutput?: boolean) => Promise<MachCommandResult>;
|
|
8
8
|
/**
|
|
9
9
|
* Removes a stale xpcshell install symlink and retries the focused mach test
|
|
10
10
|
* once. The retry uses the same `dispatch` (suite-specific or generic) the
|
|
11
11
|
* caller is already running on, so an xpcshell-suite run repairs and re-runs
|
|
12
12
|
* via `mach xpcshell-test` rather than falling back to the generic command.
|
|
13
13
|
*/
|
|
14
|
-
export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch): Promise<MachCommandResult>;
|
|
14
|
+
export declare function retryAfterXpcshellSymlinkRepair(engineDir: string, objDir: string | undefined, result: MachCommandResult, classification: XpcshellRetryClassification, normalizedPaths: string[], extraArgs: string[], env?: Record<string, string>, dispatch?: TestDispatch, fullOutput?: boolean): Promise<MachCommandResult>;
|
|
@@ -7,12 +7,17 @@ import { tryRepairStaleXpcshellTestSymlink } from './test-stale-symlink.js';
|
|
|
7
7
|
* caller is already running on, so an xpcshell-suite run repairs and re-runs
|
|
8
8
|
* via `mach xpcshell-test` rather than falling back to the generic command.
|
|
9
9
|
*/
|
|
10
|
-
export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput) {
|
|
10
|
+
export async function retryAfterXpcshellSymlinkRepair(engineDir, objDir, result, classification, normalizedPaths, extraArgs, env, dispatch = testWithOutput, fullOutput) {
|
|
11
11
|
if (result.exitCode !== 0 &&
|
|
12
12
|
classification.xpcshell.length > 0 &&
|
|
13
13
|
classification.nonXpcshell.length === 0) {
|
|
14
14
|
const repaired = await tryRepairStaleXpcshellTestSymlink(engineDir, objDir, `${result.stdout}\n${result.stderr}`);
|
|
15
15
|
if (repaired) {
|
|
16
|
+
// The repaired re-run is the same logical invocation, so it keeps
|
|
17
|
+
// the verbosity the run was started with.
|
|
18
|
+
if (fullOutput === true) {
|
|
19
|
+
return dispatch(engineDir, normalizedPaths, extraArgs, env, true);
|
|
20
|
+
}
|
|
16
21
|
return env
|
|
17
22
|
? dispatch(engineDir, normalizedPaths, extraArgs, env)
|
|
18
23
|
: dispatch(engineDir, normalizedPaths, extraArgs);
|
|
@@ -19,6 +19,63 @@ function singleLineBannerPattern(category) {
|
|
|
19
19
|
function multiLineBlockNameMatches(blockLine, category) {
|
|
20
20
|
return blockLine.replace(/^\s*\*\s*/, '').trim() === category;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Body of the first `/* … *\/` comment on `line`, or `null` when the line
|
|
24
|
+
* carries no closed block comment.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately index arithmetic rather than a regex: every regex spelling of
|
|
27
|
+
* "banner comment" this module used to carry was super-linear on a line that
|
|
28
|
+
* repeats the opening shape (CodeQL `js/polynomial-redos`), and `tokens.css`
|
|
29
|
+
* is a file FireForge reads out of a consumer's engine tree, so a
|
|
30
|
+
* pathological line needs no attacker to arrive.
|
|
31
|
+
*/
|
|
32
|
+
function blockCommentBody(line) {
|
|
33
|
+
const open = line.indexOf('/*');
|
|
34
|
+
if (open === -1)
|
|
35
|
+
return null;
|
|
36
|
+
const close = line.indexOf('*/', open + 2);
|
|
37
|
+
if (close === -1)
|
|
38
|
+
return null;
|
|
39
|
+
return line.slice(open + 2, close);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when `line` carries a single-line banner comment — a closed block
|
|
43
|
+
* comment whose body both opens and closes with `=`. Recognises the decorative
|
|
44
|
+
* all-`=` rule as well as a named `= Foo =` banner, which is what the section
|
|
45
|
+
* scan wants: either shape ends the preceding section.
|
|
46
|
+
*/
|
|
47
|
+
function isSingleLineBannerLine(line) {
|
|
48
|
+
const body = blockCommentBody(line)?.trim();
|
|
49
|
+
return body !== undefined && body.length >= 2 && body.startsWith('=') && body.endsWith('=');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Name declared by a single-line banner (`/* = Foo = *\/` → `Foo`), or `null`
|
|
53
|
+
* when the line is not a named banner. The `=` runs on both sides are
|
|
54
|
+
* required; a decorative all-`=` rule carries no name and yields `null`.
|
|
55
|
+
*/
|
|
56
|
+
function singleLineBannerName(line) {
|
|
57
|
+
const body = blockCommentBody(line);
|
|
58
|
+
if (body === null)
|
|
59
|
+
return null;
|
|
60
|
+
let start = 0;
|
|
61
|
+
let end = body.length;
|
|
62
|
+
while (start < end && /\s/.test(body[start] ?? ''))
|
|
63
|
+
start++;
|
|
64
|
+
const leadingEqStart = start;
|
|
65
|
+
while (start < end && body[start] === '=')
|
|
66
|
+
start++;
|
|
67
|
+
if (start === leadingEqStart)
|
|
68
|
+
return null;
|
|
69
|
+
while (end > start && /\s/.test(body[end - 1] ?? ''))
|
|
70
|
+
end--;
|
|
71
|
+
const trailingEqEnd = end;
|
|
72
|
+
while (end > start && body[end - 1] === '=')
|
|
73
|
+
end--;
|
|
74
|
+
if (end === trailingEqEnd)
|
|
75
|
+
return null;
|
|
76
|
+
const name = body.slice(start, end).trim();
|
|
77
|
+
return name.length > 0 ? name : null;
|
|
78
|
+
}
|
|
22
79
|
/**
|
|
23
80
|
* True when `lines` contain a category header (single-line or multi-line
|
|
24
81
|
* banner shape) whose name EQUALS `category`. Shared by the pre-add
|
|
@@ -60,14 +117,11 @@ export function categoryHeaderExists(lines, category) {
|
|
|
60
117
|
*/
|
|
61
118
|
function discoverCategoryHeaders(lines) {
|
|
62
119
|
const categories = new Set();
|
|
63
|
-
const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
|
|
64
120
|
for (let i = 0; i < lines.length; i++) {
|
|
65
121
|
const line = lines[i] ?? '';
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
if (extracted.length > 0)
|
|
70
|
-
categories.add(extracted);
|
|
122
|
+
const extracted = singleLineBannerName(line);
|
|
123
|
+
if (extracted !== null) {
|
|
124
|
+
categories.add(extracted);
|
|
71
125
|
continue;
|
|
72
126
|
}
|
|
73
127
|
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
@@ -181,7 +235,7 @@ export function findCategorySection(lines, category, tokensCssPath) {
|
|
|
181
235
|
let sectionEnd = lines.length;
|
|
182
236
|
for (let i = scanStart; i < lines.length; i++) {
|
|
183
237
|
const line = lines[i] ?? '';
|
|
184
|
-
if (
|
|
238
|
+
if (isSingleLineBannerLine(line) ||
|
|
185
239
|
(/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
|
|
186
240
|
/^\s*\}/.test(line)) {
|
|
187
241
|
sectionEnd = i;
|
|
@@ -12,6 +12,24 @@
|
|
|
12
12
|
* fallback helper returns the outer `@media` block's close so the caller can
|
|
13
13
|
* materialise a fresh `:root` wrapper rather than dropping the dark value.
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* True when `line` carries a `:root` selector whose opening brace is on the
|
|
17
|
+
* same line, with no intervening brace of either kind.
|
|
18
|
+
*
|
|
19
|
+
* Index arithmetic rather than `/:root[^{}]*\{/`: that pattern restarts at
|
|
20
|
+
* every `:root` in the line, which is quadratic on a line repeating the
|
|
21
|
+
* selector (CodeQL `js/polynomial-redos`). `tokens.css` comes from a
|
|
22
|
+
* consumer's engine tree, so such a line needs no attacker to arrive.
|
|
23
|
+
*/
|
|
24
|
+
function rootOpensBraceOnLine(line) {
|
|
25
|
+
const brace = line.indexOf('{');
|
|
26
|
+
if (brace === -1)
|
|
27
|
+
return false;
|
|
28
|
+
const root = line.lastIndexOf(':root', brace - ':root'.length);
|
|
29
|
+
if (root === -1 || root + ':root'.length > brace)
|
|
30
|
+
return false;
|
|
31
|
+
return !line.slice(root + ':root'.length, brace).includes('}');
|
|
32
|
+
}
|
|
15
33
|
/**
|
|
16
34
|
* Strips the content of `/* ... *\/` block comments from an array of
|
|
17
35
|
* CSS source lines while preserving each line's length. Indexed scans
|
|
@@ -85,9 +103,9 @@ export function findDarkRootInsertionIndex(lines) {
|
|
|
85
103
|
let rootOpenLine = -1;
|
|
86
104
|
for (let i = darkMediaLine; i < stripped.length; i++) {
|
|
87
105
|
const line = stripped[i] ?? '';
|
|
88
|
-
if (/(
|
|
106
|
+
if (/(?:^|[\s,{]):root\b/.test(line)) {
|
|
89
107
|
// Brace on the same line?
|
|
90
|
-
if (
|
|
108
|
+
if (rootOpensBraceOnLine(line)) {
|
|
91
109
|
rootOpenLine = i;
|
|
92
110
|
break;
|
|
93
111
|
}
|
|
@@ -44,7 +44,7 @@ export async function addTokenToDocs(engineDir, options, annotation) {
|
|
|
44
44
|
let unmappedAdded = false;
|
|
45
45
|
let countUpdated = false;
|
|
46
46
|
const isLiteral = !options.value.startsWith('var(');
|
|
47
|
-
const mapsTo = isLiteral ? '—' : options.value.replace(
|
|
47
|
+
const mapsTo = isLiteral ? '—' : options.value.replace(/^var\(([^)]+)\)/, '$1');
|
|
48
48
|
const tokenCell = `\`${options.tokenName}\``;
|
|
49
49
|
const valueCell = `\`${options.value}\``;
|
|
50
50
|
// --- Main token table: Category | Token | Value | Maps to | Mode ---
|
|
@@ -19,8 +19,13 @@ export interface AddTokenOptions {
|
|
|
19
19
|
tokenName: string;
|
|
20
20
|
/** CSS value (e.g., "1px", "var(--space-small)", "light-dark(#fff, #000)") */
|
|
21
21
|
value: string;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Token category matching section headers in the CSS file. Required for a
|
|
24
|
+
* base declaration; ignored — and no longer required — under
|
|
25
|
+
* {@link AddTokenOptions.variant}, which routes into a `:root<selector>`
|
|
26
|
+
* block and never touches a category section.
|
|
27
|
+
*/
|
|
28
|
+
category?: string | undefined;
|
|
24
29
|
/** Dark mode behavior */
|
|
25
30
|
mode: TokenMode;
|
|
26
31
|
/** Comment description for the CSS file */
|