@oar-sh/oar 0.9.11 → 0.9.12
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 +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// The text pipeline of a background task's console output: what the relay
|
|
2
|
+
// does to a shell or Monitor task's log before anyone sees it, and the
|
|
3
|
+
// pieces a viewer needs to show it.
|
|
4
|
+
//
|
|
5
|
+
// - stripControlKeepSgr: terminal control codes out, colours (SGR) kept.
|
|
6
|
+
// - createLineFolder: a stream fed in arbitrary pieces becomes lines; a `\r`
|
|
7
|
+
// redraw (progress bars) collapses to its final state, adjacent identical
|
|
8
|
+
// lines fold into one with a count, and a line is cut at 4,000 characters.
|
|
9
|
+
// - ansiToSegments: one line as styled segments, 16 colours.
|
|
10
|
+
// - scrubTaskOutput / scrubSecrets: credentials out before output leaves the
|
|
11
|
+
// relay (the page, a paired relay's agent).
|
|
12
|
+
// - parseRunnerSummary: pass/fail counts of the common test runners.
|
|
13
|
+
// - stallMinutes and the stall setting's bounds.
|
|
14
|
+
//
|
|
15
|
+
// Pure ESM with no Node imports: the relay uses it for the REST tail, the
|
|
16
|
+
// health poller and the remote action. The browser cannot import shared/
|
|
17
|
+
// (only server/public is served); server/public/app/task-output-text.mjs keeps
|
|
18
|
+
// a copy of the folder, the segments and the stall minutes, and its test pins
|
|
19
|
+
// it to this module.
|
|
20
|
+
|
|
21
|
+
// ─── Limits ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export const TASK_OUTPUT_LIMITS = Object.freeze({
|
|
24
|
+
// Initial tail: this many lines or this many bytes, whichever comes first.
|
|
25
|
+
tailLinesDefault: 2000,
|
|
26
|
+
tailLinesMax: 5000,
|
|
27
|
+
requestBytesMax: 256 * 1024,
|
|
28
|
+
// What a modal loads in total, paging back.
|
|
29
|
+
modalBytesMax: 5 * 1024 * 1024,
|
|
30
|
+
// One live emit, and the window the health poller parses.
|
|
31
|
+
chunkBytes: 64 * 1024,
|
|
32
|
+
healthBytes: 64 * 1024,
|
|
33
|
+
lineCharsMax: 4000,
|
|
34
|
+
outputPathMax: 1024,
|
|
35
|
+
// Ended rows: kept this long, at most this many per conversation.
|
|
36
|
+
endedKeepMs: 60 * 60 * 1000,
|
|
37
|
+
endedMax: 20,
|
|
38
|
+
// The worker's spool stops appending here.
|
|
39
|
+
spoolBytesMax: 50 * 1024 * 1024,
|
|
40
|
+
// The remote action's tail.
|
|
41
|
+
remoteLinesDefault: 200,
|
|
42
|
+
remoteLinesMax: 500,
|
|
43
|
+
remoteBytesMax: 32 * 1024,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const TASK_OUTPUT_KINDS = Object.freeze(['file', 'spool', 'snapshot', 'none']);
|
|
47
|
+
|
|
48
|
+
/** The kind a task row's output has: what it names, else `file` with a path and `none` without. */
|
|
49
|
+
export function normalizeTaskOutputKind(kind, outputPath) {
|
|
50
|
+
const text = String(kind ?? '').trim().toLowerCase();
|
|
51
|
+
if (TASK_OUTPUT_KINDS.includes(text)) return text;
|
|
52
|
+
return outputPath ? 'file' : 'none';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The path a worker names for a task's output, or '' when it is not one the
|
|
57
|
+
* relay reads: absolute (POSIX or a Windows drive / UNC path), at most 1,024
|
|
58
|
+
* characters, no NUL. Pure string checks; the relay's reader also refuses
|
|
59
|
+
* symlinks and anything that is not a regular file.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeTaskOutputPath(value) {
|
|
62
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
63
|
+
if (!text || text.length > TASK_OUTPUT_LIMITS.outputPathMax || text.includes('\0')) return '';
|
|
64
|
+
const absolute = text.startsWith('/') || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith('\\\\');
|
|
65
|
+
return absolute ? text : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── The stall setting ───────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// "Mark a background task as quiet after N minutes" (app_settings key
|
|
71
|
+
// task_stall_minutes).
|
|
72
|
+
export const TASK_STALL_MINUTES_MIN = 1;
|
|
73
|
+
export const TASK_STALL_MINUTES_MAX = 120;
|
|
74
|
+
export const DEFAULT_TASK_STALL_MINUTES = 10;
|
|
75
|
+
export const TASK_STALL_MINUTES_SETTING_KEY = 'task_stall_minutes';
|
|
76
|
+
|
|
77
|
+
export function normalizeTaskStallMinutes(value, fallback = DEFAULT_TASK_STALL_MINUTES) {
|
|
78
|
+
if (value === null || value === undefined) return fallback;
|
|
79
|
+
if (typeof value === 'string' && value.trim() === '') return fallback;
|
|
80
|
+
const number = Number(value);
|
|
81
|
+
if (!Number.isFinite(number)) return fallback;
|
|
82
|
+
return Math.min(TASK_STALL_MINUTES_MAX, Math.max(TASK_STALL_MINUTES_MIN, Math.round(number)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An unset or unreadable setting is the default. */
|
|
86
|
+
export function readTaskStallMinutesSetting(storedValue) {
|
|
87
|
+
return normalizeTaskStallMinutes(storedValue);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseTaskStallMinutesUpdate(value) {
|
|
91
|
+
const minutes = normalizeTaskStallMinutes(value, null);
|
|
92
|
+
if (minutes === null) return { ok: false, error: 'minutes must be a number' };
|
|
93
|
+
return { ok: true, minutes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toMillis(value) {
|
|
97
|
+
if (value == null || value === '') return NaN;
|
|
98
|
+
const number = Number(value);
|
|
99
|
+
if (Number.isFinite(number)) return number;
|
|
100
|
+
return Date.parse(String(value));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whole minutes since the last output, or null when unknown. */
|
|
104
|
+
export function stallMinutes(lastOutputAt, now = Date.now()) {
|
|
105
|
+
const last = toMillis(lastOutputAt);
|
|
106
|
+
const current = toMillis(now);
|
|
107
|
+
if (!Number.isFinite(last) || !Number.isFinite(current) || last <= 0) return null;
|
|
108
|
+
return Math.max(0, Math.floor((current - last) / 60_000));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Control codes ───────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
// SGR (colours and text style) is the only escape kept. Its parameters are
|
|
114
|
+
// digits and semicolons; the colon form is rare and goes with the rest.
|
|
115
|
+
const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
116
|
+
// OSC (titles, hyperlinks, clipboard): ESC ] … BEL | ST, stripped whole. An
|
|
117
|
+
// unterminated one at the end is stripped to the end.
|
|
118
|
+
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
|
|
119
|
+
// Every CSI (cursor moves, erases, modes) that is not SGR.
|
|
120
|
+
const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
121
|
+
// Character-set designations (ESC ( B) and the remaining two-byte escapes.
|
|
122
|
+
const CHARSET_PATTERN = /\x1b[()*+\-./][0-~]/g;
|
|
123
|
+
// The remaining two-byte escapes (ESC 7, ESC =, ESC M, a CSI cut short) and
|
|
124
|
+
// a lone ESC; never the start of a kept SGR.
|
|
125
|
+
const OTHER_ESCAPE_PATTERN = /\x1b(?!\[[0-9;]*m)[0-~]?/g;
|
|
126
|
+
// C0 except \t \n \r, DEL, C1, and a lone ESC left over.
|
|
127
|
+
const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Drops OSC, every CSI but SGR, the other escapes, C1 and C0 controls except
|
|
131
|
+
* `\n`, `\r` and `\t`; keeps `ESC [ … m`.
|
|
132
|
+
*/
|
|
133
|
+
export function stripControlKeepSgr(text) {
|
|
134
|
+
const value = String(text ?? '');
|
|
135
|
+
if (!/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/.test(value)) return value;
|
|
136
|
+
return value
|
|
137
|
+
.replace(OSC_PATTERN, '')
|
|
138
|
+
.replace(CSI_PATTERN, (match) => (/^\x1b\[[0-9;]*m$/.test(match) ? match : ''))
|
|
139
|
+
.replace(CHARSET_PATTERN, '')
|
|
140
|
+
.replace(OTHER_ESCAPE_PATTERN, '')
|
|
141
|
+
.replace(CONTROL_PATTERN, '');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The text without its SGR codes: what a reader without colours sees. */
|
|
145
|
+
export function stripSgr(text) {
|
|
146
|
+
return String(text ?? '').replace(SGR_PATTERN, '');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Lines ───────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function cutLine(text) {
|
|
152
|
+
return text.length > TASK_OUTPUT_LIMITS.lineCharsMax ? text.slice(0, TASK_OUTPUT_LIMITS.lineCharsMax) : text;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A line's final state: a `\r` not followed by `\n` discards what came before
|
|
156
|
+
// it (a trailing one still waits for what follows).
|
|
157
|
+
function finalLineState(raw) {
|
|
158
|
+
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
159
|
+
const at = body.lastIndexOf('\r');
|
|
160
|
+
return at === -1 ? body : body.slice(at + 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Lines from a stream fed in arbitrary pieces. `feed(text)` returns
|
|
165
|
+
* `{ lines, partial, changedFrom }`: every completed line so far as
|
|
166
|
+
* `{ text, count }` (adjacent identical lines folded, `count` unbounded), the
|
|
167
|
+
* open partial line in its current state ('' when there is none), and the
|
|
168
|
+
* index of the first line this feed added or changed (a folded repeat bumps
|
|
169
|
+
* the count of the line before). `end()` closes the partial line and returns
|
|
170
|
+
* the same shape. `lines` is the folder's own array: copy it to keep it.
|
|
171
|
+
*/
|
|
172
|
+
export function createLineFolder() {
|
|
173
|
+
const lines = [];
|
|
174
|
+
let partial = '';
|
|
175
|
+
let changedFrom = 0;
|
|
176
|
+
|
|
177
|
+
function push(text) {
|
|
178
|
+
const line = cutLine(text);
|
|
179
|
+
const last = lines[lines.length - 1];
|
|
180
|
+
if (last && last.text === line) {
|
|
181
|
+
last.count += 1;
|
|
182
|
+
changedFrom = Math.min(changedFrom, lines.length - 1);
|
|
183
|
+
} else {
|
|
184
|
+
changedFrom = Math.min(changedFrom, lines.length);
|
|
185
|
+
lines.push({ text: line, count: 1 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function result() {
|
|
190
|
+
const out = { lines, partial: finalLineState(partial), changedFrom };
|
|
191
|
+
changedFrom = lines.length;
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
feed(text) {
|
|
197
|
+
const chunk = String(text ?? '');
|
|
198
|
+
if (chunk) {
|
|
199
|
+
const parts = (partial + chunk).split('\n');
|
|
200
|
+
partial = parts.pop();
|
|
201
|
+
for (const raw of parts) push(finalLineState(raw));
|
|
202
|
+
// The open line stays small: a progress bar redraws itself
|
|
203
|
+
// thousands of times on one line.
|
|
204
|
+
const keepCr = partial.endsWith('\r');
|
|
205
|
+
partial = cutLine(finalLineState(partial)) + (keepCr ? '\r' : '');
|
|
206
|
+
}
|
|
207
|
+
return result();
|
|
208
|
+
},
|
|
209
|
+
end() {
|
|
210
|
+
const last = finalLineState(partial);
|
|
211
|
+
partial = '';
|
|
212
|
+
if (last) push(last);
|
|
213
|
+
return result();
|
|
214
|
+
},
|
|
215
|
+
get lines() {
|
|
216
|
+
return lines;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A whole text as the line model the console shows: `{ text, count, ansi }`
|
|
223
|
+
* per line (`ansi` when it carries colour codes), the last one marked
|
|
224
|
+
* `partial` when the text does not end with a newline.
|
|
225
|
+
*/
|
|
226
|
+
export function foldTaskOutput(text) {
|
|
227
|
+
const folder = createLineFolder();
|
|
228
|
+
const { lines, partial } = folder.feed(text);
|
|
229
|
+
const model = lines.map((line) => ({ text: line.text, count: line.count, ansi: line.text.includes('\x1b') }));
|
|
230
|
+
if (partial) model.push({ text: partial, count: 1, ansi: partial.includes('\x1b'), partial: true });
|
|
231
|
+
return model;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Colours ─────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
// The 16-colour palette (xterm defaults), for mapping 256 and truecolor.
|
|
237
|
+
const PALETTE_16 = [
|
|
238
|
+
[0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
|
|
239
|
+
[127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
function nearest16([r, g, b]) {
|
|
243
|
+
let best = 0;
|
|
244
|
+
let bestDistance = Infinity;
|
|
245
|
+
PALETTE_16.forEach(([pr, pg, pb], index) => {
|
|
246
|
+
const distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
|
247
|
+
if (distance < bestDistance) {
|
|
248
|
+
best = index;
|
|
249
|
+
bestDistance = distance;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return best;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rgbOf256(n) {
|
|
256
|
+
if (n < 16) return PALETTE_16[n];
|
|
257
|
+
if (n >= 232) {
|
|
258
|
+
const level = 8 + (n - 232) * 10;
|
|
259
|
+
return [level, level, level];
|
|
260
|
+
}
|
|
261
|
+
const index = n - 16;
|
|
262
|
+
const steps = [0, 95, 135, 175, 215, 255];
|
|
263
|
+
return [steps[Math.floor(index / 36)], steps[Math.floor(index / 6) % 6], steps[index % 6]];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function clampByte(value) {
|
|
267
|
+
const number = Number(value);
|
|
268
|
+
return Number.isFinite(number) ? Math.min(255, Math.max(0, number)) : 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function plainStyle() {
|
|
272
|
+
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function applySgr(state, params) {
|
|
276
|
+
const codes = params === '' ? [0] : params.split(';').map((part) => (part === '' ? 0 : Number(part)));
|
|
277
|
+
for (let i = 0; i < codes.length; i += 1) {
|
|
278
|
+
const code = codes[i];
|
|
279
|
+
if (code === 0) Object.assign(state, plainStyle());
|
|
280
|
+
else if (code === 1) state.bold = true;
|
|
281
|
+
else if (code === 2) state.dim = true;
|
|
282
|
+
else if (code === 3) state.italic = true;
|
|
283
|
+
else if (code === 4) state.underline = true;
|
|
284
|
+
else if (code === 22) {
|
|
285
|
+
state.bold = false;
|
|
286
|
+
state.dim = false;
|
|
287
|
+
} else if (code === 23) state.italic = false;
|
|
288
|
+
else if (code === 24) state.underline = false;
|
|
289
|
+
else if (code >= 30 && code <= 37) state.fg = code - 30;
|
|
290
|
+
else if (code === 39) state.fg = null;
|
|
291
|
+
else if (code >= 40 && code <= 47) state.bg = code - 40;
|
|
292
|
+
else if (code === 49) state.bg = null;
|
|
293
|
+
else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
|
|
294
|
+
else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
|
|
295
|
+
else if (code === 38 || code === 48) {
|
|
296
|
+
const key = code === 38 ? 'fg' : 'bg';
|
|
297
|
+
if (codes[i + 1] === 5 && i + 2 < codes.length) {
|
|
298
|
+
state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
|
|
299
|
+
i += 2;
|
|
300
|
+
} else if (codes[i + 1] === 2 && i + 4 < codes.length) {
|
|
301
|
+
state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
|
|
302
|
+
i += 4;
|
|
303
|
+
} else {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
|
|
312
|
+
* colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
|
|
313
|
+
* Adjacent segments never share a style. Any escape that is not SGR is
|
|
314
|
+
* dropped from the text.
|
|
315
|
+
*/
|
|
316
|
+
export function ansiToSegments(lineText) {
|
|
317
|
+
const text = String(lineText ?? '');
|
|
318
|
+
const state = plainStyle();
|
|
319
|
+
const segments = [];
|
|
320
|
+
const pushText = (value) => {
|
|
321
|
+
const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
|
|
322
|
+
if (!clean) return;
|
|
323
|
+
const last = segments[segments.length - 1];
|
|
324
|
+
if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
|
|
325
|
+
&& last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
|
|
326
|
+
last.text += clean;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
segments.push({ text: clean, ...state });
|
|
330
|
+
};
|
|
331
|
+
let at = 0;
|
|
332
|
+
const pattern = new RegExp(SGR_PATTERN.source, 'g');
|
|
333
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
334
|
+
pushText(text.slice(at, match.index));
|
|
335
|
+
applySgr(state, match[1]);
|
|
336
|
+
at = match.index + match[0].length;
|
|
337
|
+
}
|
|
338
|
+
pushText(text.slice(at));
|
|
339
|
+
return segments;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Secrets ─────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
const REDACTED = '[redacted]';
|
|
345
|
+
|
|
346
|
+
// The generic rule of the CLI helpers (services/cli-process-runner.mjs):
|
|
347
|
+
// anything token-shaped. Right for a login's error tail, too eager for a
|
|
348
|
+
// test log (long snake_case test names, 40-character commit ids).
|
|
349
|
+
const CLI_TOKEN_PATTERNS = Object.freeze([
|
|
350
|
+
/sk-ant-[A-Za-z0-9_-]+/g,
|
|
351
|
+
/[A-Za-z0-9_-]{32,}/g,
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Redacts anything token-shaped plus an exact secret the caller knows it
|
|
356
|
+
* handed over (the pasted Claude login code), so an error tail can be
|
|
357
|
+
* surfaced to the UI without leaking a credential. Long PKCE segments in an
|
|
358
|
+
* authorize URL get caught by the generic rule too, which is fine: the URL is
|
|
359
|
+
* already carried in the login payload. (Moved here from
|
|
360
|
+
* services/cli-process-runner.mjs, which re-exports it.)
|
|
361
|
+
*/
|
|
362
|
+
export function scrubSecrets(text, submittedCode = '') {
|
|
363
|
+
let output = String(text == null ? '' : text);
|
|
364
|
+
const code = String(submittedCode == null ? '' : submittedCode).trim();
|
|
365
|
+
if (code.length >= 4) {
|
|
366
|
+
output = output.split(code).join(REDACTED);
|
|
367
|
+
}
|
|
368
|
+
for (const pattern of CLI_TOKEN_PATTERNS) output = output.replace(pattern, REDACTED);
|
|
369
|
+
return output;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Known credential shapes. Each keeps its prefix so a reader still sees what
|
|
373
|
+
// kind of value was there.
|
|
374
|
+
const KNOWN_TOKEN_RULES = Object.freeze([
|
|
375
|
+
// Anthropic, OpenAI and other `sk-` keys.
|
|
376
|
+
[/\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, 'sk-[redacted]'],
|
|
377
|
+
// GitHub tokens (classic and fine-grained).
|
|
378
|
+
[/\b(gh[pousr]_)[A-Za-z0-9]{20,}/g, '$1[redacted]'],
|
|
379
|
+
[/\b(github_pat_)[A-Za-z0-9_]{20,}/g, '$1[redacted]'],
|
|
380
|
+
// Slack.
|
|
381
|
+
[/\b(xox[abposr]-)[A-Za-z0-9-]{10,}/g, '$1[redacted]'],
|
|
382
|
+
// npm.
|
|
383
|
+
[/\b(npm_)[A-Za-z0-9]{30,}/g, '$1[redacted]'],
|
|
384
|
+
// AWS access key ids.
|
|
385
|
+
[/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, '$1[redacted]'],
|
|
386
|
+
// Google API keys.
|
|
387
|
+
[/\b(AIza)[0-9A-Za-z_-]{30,}/g, '$1[redacted]'],
|
|
388
|
+
// Bearer / Basic credentials in a header or a curl line.
|
|
389
|
+
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, '$1 [redacted]'],
|
|
390
|
+
// A password in a URL: scheme://user:password@host.
|
|
391
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[redacted]@'],
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
// NAME=value with a secret-sounding environment name (API_TOKEN=…,
|
|
395
|
+
// DB_PASSWORD="…"); the name stays. A value without a letter (a count, a
|
|
396
|
+
// port) or a placeholder (true, null, ***) is no secret.
|
|
397
|
+
const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"'`]*[A-Za-z])([^\s"'`]{4,})/gi;
|
|
398
|
+
// `token: value`, `"password": "value"`, `api_key = value`, Authorization: …
|
|
399
|
+
// A Bearer/Basic value was handled above and keeps its scheme word.
|
|
400
|
+
const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"',;]*[A-Za-z])([^\s"',;]{4,})/gi;
|
|
401
|
+
// Anything else token-shaped: 32 or more of [A-Za-z0-9_-] with an upper-case
|
|
402
|
+
// letter, a lower-case letter and a digit. Random keys have all three; test
|
|
403
|
+
// names, paths and hex commit ids rarely do.
|
|
404
|
+
const GENERIC_TOKEN_PATTERN = /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/g;
|
|
405
|
+
// A PEM private key block, whole.
|
|
406
|
+
const PEM_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g;
|
|
407
|
+
|
|
408
|
+
function scrubPlain(text) {
|
|
409
|
+
let output = text.replace(PEM_PATTERN, '[redacted private key]');
|
|
410
|
+
for (const [pattern, replacement] of KNOWN_TOKEN_RULES) output = output.replace(pattern, replacement);
|
|
411
|
+
output = output
|
|
412
|
+
.replace(ENV_ASSIGNMENT_PATTERN, (_match, name, quote) => `${name}=${quote}${REDACTED}`)
|
|
413
|
+
.replace(KEY_VALUE_PATTERN, (_match, key, separator) => `${key}${separator}${REDACTED}`)
|
|
414
|
+
.replace(GENERIC_TOKEN_PATTERN, REDACTED);
|
|
415
|
+
return output;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Credentials out of a task's output: the known token shapes, secret-named
|
|
420
|
+
* `KEY=value` and `key: value` pairs, Bearer/Basic values, URL passwords,
|
|
421
|
+
* private keys, and long random-looking tokens. SGR codes are left alone
|
|
422
|
+
* (the text between them is scrubbed), so colours survive.
|
|
423
|
+
*/
|
|
424
|
+
export function scrubTaskOutput(text) {
|
|
425
|
+
const value = String(text ?? '');
|
|
426
|
+
if (!value) return value;
|
|
427
|
+
if (!value.includes('\x1b')) return scrubPlain(value);
|
|
428
|
+
return value.split(/(\x1b\[[0-9;]*m)/).map((part, index) => (index % 2 === 1 ? part : scrubPlain(part))).join('');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** What leaves the relay: control codes out (SGR kept), then credentials out. */
|
|
432
|
+
export function cleanTaskOutput(text) {
|
|
433
|
+
return scrubTaskOutput(stripControlKeepSgr(text));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Test runners ────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
const PASS_MARK = /^\s*(?:[✓✔√]|\[PASS\])\s+/;
|
|
439
|
+
const FAIL_MARK = /^\s*(?:[✗✘×✕✖]|\[FAIL\])\s+/;
|
|
440
|
+
|
|
441
|
+
function lineTextOf(line) {
|
|
442
|
+
const raw = line && typeof line === 'object' ? line.text : line;
|
|
443
|
+
return stripSgr(String(raw ?? '')).replace(/\r/g, '');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function countOf(line) {
|
|
447
|
+
const count = line && typeof line === 'object' ? Number(line.count) : 1;
|
|
448
|
+
return Number.isInteger(count) && count > 0 ? count : 1;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function countsFrom(text) {
|
|
452
|
+
const counts = {};
|
|
453
|
+
for (const match of text.matchAll(/(\d+)\s+(passed|failed|skipped|todo|pending|flaky|errors?|xfailed|xpassed|deselected|warnings?|interrupted|did not run)\b/gi)) {
|
|
454
|
+
counts[match[2].toLowerCase()] = (counts[match[2].toLowerCase()] || 0) + Number(match[1]);
|
|
455
|
+
}
|
|
456
|
+
return counts;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function summary(runner, { passed = 0, failed = 0, skipped = 0, flaky = 0 } = {}, extra = {}) {
|
|
460
|
+
return {
|
|
461
|
+
runner,
|
|
462
|
+
passed,
|
|
463
|
+
failed,
|
|
464
|
+
skipped,
|
|
465
|
+
...(flaky ? { flaky } : {}),
|
|
466
|
+
...extra,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Final summaries, checked from the end of the log back. Each returns the
|
|
471
|
+
// counts or null.
|
|
472
|
+
const FINAL_SUMMARIES = [
|
|
473
|
+
// vitest: " Tests 2 failed | 412 passed | 3 skipped (417)"
|
|
474
|
+
{
|
|
475
|
+
runner: 'vitest',
|
|
476
|
+
match(texts, index) {
|
|
477
|
+
const text = texts[index];
|
|
478
|
+
if (!/^\s*Tests\s{2,}.*\(\d+\)\s*$/.test(text) || /^\s*Tests:/.test(text)) return null;
|
|
479
|
+
const counts = countsFrom(text);
|
|
480
|
+
return summary('vitest', {
|
|
481
|
+
passed: counts.passed || 0,
|
|
482
|
+
failed: counts.failed || 0,
|
|
483
|
+
skipped: (counts.skipped || 0) + (counts.todo || 0),
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
// jest: "Tests: 2 failed, 3 skipped, 412 passed, 417 total"
|
|
488
|
+
{
|
|
489
|
+
runner: 'jest',
|
|
490
|
+
match(texts, index) {
|
|
491
|
+
const text = texts[index];
|
|
492
|
+
if (!/^\s*Tests:\s+.*\b\d+\s+total\s*$/.test(text)) return null;
|
|
493
|
+
const counts = countsFrom(text);
|
|
494
|
+
return summary('jest', {
|
|
495
|
+
passed: counts.passed || 0,
|
|
496
|
+
failed: counts.failed || 0,
|
|
497
|
+
skipped: (counts.skipped || 0) + (counts.todo || 0) + (counts.pending || 0),
|
|
498
|
+
});
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
// pytest: "======= 2 failed, 412 passed, 3 skipped in 3.21s ======="
|
|
502
|
+
{
|
|
503
|
+
runner: 'pytest',
|
|
504
|
+
match(texts, index) {
|
|
505
|
+
const text = texts[index];
|
|
506
|
+
if (!/^=+ .*\bin [\d.]+s(?: \([^)]*\))? =+\s*$/.test(text)) return null;
|
|
507
|
+
const counts = countsFrom(text);
|
|
508
|
+
if (!Object.keys(counts).length && !/no tests ran/.test(text)) return null;
|
|
509
|
+
return summary('pytest', {
|
|
510
|
+
passed: (counts.passed || 0) + (counts.xpassed || 0),
|
|
511
|
+
failed: (counts.failed || 0) + (counts.error || 0) + (counts.errors || 0),
|
|
512
|
+
skipped: (counts.skipped || 0) + (counts.xfailed || 0) + (counts.deselected || 0),
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
// node:test, spec reporter ("ℹ pass 1") and TAP ("# pass 1"): the block
|
|
517
|
+
// ends with duration_ms.
|
|
518
|
+
{
|
|
519
|
+
runner: 'node:test',
|
|
520
|
+
match(texts, index) {
|
|
521
|
+
if (!/^\s*(?:ℹ|#)\s+duration_ms\s+[\d.]+\s*$/.test(texts[index])) return null;
|
|
522
|
+
const counts = {};
|
|
523
|
+
for (let i = index - 1; i >= 0 && i >= index - 12; i -= 1) {
|
|
524
|
+
const field = /^\s*(?:ℹ|#)\s+(tests|suites|pass|fail|cancelled|skipped|todo)\s+(\d+)\s*$/.exec(texts[i]);
|
|
525
|
+
if (!field) break;
|
|
526
|
+
counts[field[1]] = Number(field[2]);
|
|
527
|
+
}
|
|
528
|
+
if (counts.pass === undefined && counts.fail === undefined) return null;
|
|
529
|
+
return summary('node:test', {
|
|
530
|
+
passed: counts.pass || 0,
|
|
531
|
+
failed: (counts.fail || 0) + (counts.cancelled || 0),
|
|
532
|
+
skipped: (counts.skipped || 0) + (counts.todo || 0),
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
// Playwright: " 1 failed" … " 3 skipped" … " 412 passed (1.4m)": the
|
|
537
|
+
// block's lines sit together at the end, the passed line carries the time.
|
|
538
|
+
{
|
|
539
|
+
runner: 'playwright',
|
|
540
|
+
match(texts, index) {
|
|
541
|
+
const text = texts[index];
|
|
542
|
+
const own = /^\s{2}(\d+) (passed|failed|flaky|skipped|interrupted|did not run)(?: \(([\dhms.\s]+)\))?\s*$/.exec(text);
|
|
543
|
+
if (!own) return null;
|
|
544
|
+
// Walk up over the block: count lines, and the indented test titles
|
|
545
|
+
// listed under "failed" / "flaky".
|
|
546
|
+
const counts = {};
|
|
547
|
+
let timed = !!own[3];
|
|
548
|
+
let i = index;
|
|
549
|
+
for (; i >= 0; i -= 1) {
|
|
550
|
+
const line = texts[i];
|
|
551
|
+
const head = /^\s{2}(\d+) (passed|failed|flaky|skipped|interrupted|did not run)(?: \(([\dhms.\s]+)\))?\s*$/.exec(line);
|
|
552
|
+
if (head) {
|
|
553
|
+
counts[head[2]] = Number(head[1]);
|
|
554
|
+
if (head[3]) timed = true;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (/^\s{4}\S/.test(line)) continue;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
if (!timed) return null;
|
|
561
|
+
return summary('playwright', {
|
|
562
|
+
passed: counts.passed || 0,
|
|
563
|
+
failed: (counts.failed || 0) + (counts.interrupted || 0),
|
|
564
|
+
skipped: (counts.skipped || 0) + (counts['did not run'] || 0),
|
|
565
|
+
flaky: counts.flaky || 0,
|
|
566
|
+
});
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
// turbo (also run through pnpm): " Tasks: 3 successful, 4 total", with a
|
|
570
|
+
// " Failed: pkg#task" line when one failed.
|
|
571
|
+
{
|
|
572
|
+
runner: 'turbo',
|
|
573
|
+
match(texts, index) {
|
|
574
|
+
const text = texts[index];
|
|
575
|
+
const head = /^\s*Tasks:\s+(\d+) successful(?:, (\d+) failed)?, (\d+) total\s*$/.exec(text);
|
|
576
|
+
if (!head) return null;
|
|
577
|
+
const successful = Number(head[1]);
|
|
578
|
+
const total = Number(head[3]);
|
|
579
|
+
const failed = head[2] !== undefined ? Number(head[2]) : Math.max(0, total - successful);
|
|
580
|
+
return summary('turbo', { passed: successful, failed });
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
// What a log looks like while a run is still going: the runner's own header
|
|
586
|
+
// lines. Markers alone (✓ …) say nothing, a build log has them too.
|
|
587
|
+
const RUNNER_HEADERS = [
|
|
588
|
+
['playwright', /^Running \d+ tests? using \d+ workers?/],
|
|
589
|
+
['vitest', /^\s*(?:RUN|DEV)\s+v\d+\.\d+/],
|
|
590
|
+
['jest', /^\s*(?:PASS|FAIL)\s+\S+\.(?:[cm]?[jt]sx?)\b/],
|
|
591
|
+
['pytest', /^=+ test session starts =+$/],
|
|
592
|
+
['node:test', /^(?:TAP version \d+|[✔✖] .+ \([\d.]+ms\)\s*$)/],
|
|
593
|
+
['tap', /^(?:not )?ok \d+ - /],
|
|
594
|
+
['turbo', /^\s*•\s+Running \S+ in \d+ packages?/],
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
function liveCounts(runner, texts) {
|
|
598
|
+
let passed = 0;
|
|
599
|
+
let failed = 0;
|
|
600
|
+
let skipped = 0;
|
|
601
|
+
let firstFailureLine = null;
|
|
602
|
+
const fail = (index, amount = 1) => {
|
|
603
|
+
failed += amount;
|
|
604
|
+
if (firstFailureLine === null) firstFailureLine = index;
|
|
605
|
+
};
|
|
606
|
+
texts.forEach((text, index) => {
|
|
607
|
+
const repeat = texts.counts[index];
|
|
608
|
+
if (runner === 'pytest') {
|
|
609
|
+
const verbose = /\s(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)(?:\s+\[\s*\d+%\])?\s*$/.exec(text);
|
|
610
|
+
if (verbose) {
|
|
611
|
+
if (verbose[1] === 'PASSED' || verbose[1] === 'XPASS') passed += repeat;
|
|
612
|
+
else if (verbose[1] === 'FAILED' || verbose[1] === 'ERROR') fail(index, repeat);
|
|
613
|
+
else skipped += repeat;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// Progress lines: "tests/test_a.py ..F.s [ 40%]"
|
|
617
|
+
// and their wrapped continuations ("........ [ 56%]").
|
|
618
|
+
const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
|
|
619
|
+
|| /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
|
|
620
|
+
if (dots) {
|
|
621
|
+
for (const mark of dots[1]) {
|
|
622
|
+
if (mark === '.' || mark === 'X') passed += 1;
|
|
623
|
+
else if (mark === 'F' || mark === 'E') fail(index);
|
|
624
|
+
else skipped += 1;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
|
|
630
|
+
const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
|
|
631
|
+
if (!tap) return;
|
|
632
|
+
// A subtest line is indented; only top-level results count.
|
|
633
|
+
if (/^\s/.test(text)) return;
|
|
634
|
+
if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) skipped += repeat;
|
|
635
|
+
else if (tap[1]) fail(index, repeat);
|
|
636
|
+
else passed += repeat;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (runner === 'vitest') {
|
|
640
|
+
// File lines: " ✓ src/a.test.ts (12 tests) 34ms", " ❯ src/b.test.ts (5 tests | 2 failed) 20ms".
|
|
641
|
+
// The × lines under a failed file repeat its failures.
|
|
642
|
+
const file = /^\s*[✓❯×]\s+\S+\s+\((\d+) tests?(?: \| (\d+) failed)?(?: \| (\d+) skipped)?\)/.exec(text);
|
|
643
|
+
if (file) {
|
|
644
|
+
const total = Number(file[1]);
|
|
645
|
+
const failedHere = Number(file[2] || 0);
|
|
646
|
+
const skippedHere = Number(file[3] || 0);
|
|
647
|
+
passed += Math.max(0, total - failedHere - skippedHere);
|
|
648
|
+
skipped += skippedHere;
|
|
649
|
+
if (failedHere) fail(index, failedHere);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (runner === 'jest') {
|
|
656
|
+
// Without --verbose jest marks files only; each failed test gets a
|
|
657
|
+
// "● suite › test" block (but "● Console" is a log block).
|
|
658
|
+
if (/^\s*FAIL\s/.test(text)) {
|
|
659
|
+
if (firstFailureLine === null) firstFailureLine = index;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (/^\s*●\s(?!Console\b)/.test(text)) {
|
|
663
|
+
fail(index, repeat);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (PASS_MARK.test(text)) passed += repeat;
|
|
667
|
+
else if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (runner === 'playwright' && /^\s*-\s+\d+\s/.test(text)) {
|
|
671
|
+
skipped += repeat;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (PASS_MARK.test(text)) passed += repeat;
|
|
675
|
+
else if (FAIL_MARK.test(text)) {
|
|
676
|
+
// node:test's "✖ failing tests:" heads the repeat of the failures.
|
|
677
|
+
if (/failing tests:\s*$/.test(text)) return;
|
|
678
|
+
fail(index, repeat);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
return { passed, failed, skipped, firstFailureLine };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Where the first failure is told in detail, for a run that has its final
|
|
685
|
+
// summary (the per-test marks may sit far above).
|
|
686
|
+
function firstFailureIndex(runner, texts) {
|
|
687
|
+
const patterns = {
|
|
688
|
+
playwright: [/^\s{2}\d+\) /, /^\s*[✘×]\s+\d+\s/],
|
|
689
|
+
vitest: [/^\s*FAIL\s/, /^\s*[×✗]\s/],
|
|
690
|
+
jest: [/^\s*●\s/, /^\s*[✕×]\s/],
|
|
691
|
+
pytest: [/^_{3,} .+ _{3,}$/, /\s(?:FAILED|ERROR)(?:\s+\[\s*\d+%\])?\s*$/, /^\S+\.py\s+[.sxX]*[FE]/],
|
|
692
|
+
'node:test': [/^\s*✖\s(?!failing tests:)/, /^not ok \d+ /],
|
|
693
|
+
tap: [/^not ok \d+ /],
|
|
694
|
+
turbo: [/ERR!|ERROR|command finished with error/i],
|
|
695
|
+
}[runner] || [FAIL_MARK];
|
|
696
|
+
// In order of preference: the detailed report first, the mark second.
|
|
697
|
+
for (const pattern of patterns) {
|
|
698
|
+
const index = texts.findIndex((text) => pattern.test(text));
|
|
699
|
+
if (index !== -1) return index;
|
|
700
|
+
}
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Pass/fail counts of a test run from its log lines (strings or the line
|
|
706
|
+
* model's `{ text, count }`; colours are ignored), or null when the log is
|
|
707
|
+
* not a known runner's. Returns `{ runner, passed, failed, skipped, flaky?,
|
|
708
|
+
* firstFailureLine, final }`: `final` when the counts come from the runner's
|
|
709
|
+
* closing summary, otherwise they are counted from the per-test marks while
|
|
710
|
+
* the run is still going. `firstFailureLine` is the index (into `lines`) of
|
|
711
|
+
* the first failure, or null. Runners: vitest, Playwright, node:test (spec
|
|
712
|
+
* and TAP), jest, pytest, turbo (and pnpm running it).
|
|
713
|
+
*/
|
|
714
|
+
export function parseRunnerSummary(lines) {
|
|
715
|
+
const list = Array.isArray(lines) ? lines : String(lines ?? '').split('\n');
|
|
716
|
+
const texts = list.map(lineTextOf);
|
|
717
|
+
texts.counts = list.map(countOf);
|
|
718
|
+
for (let index = texts.length - 1; index >= 0; index -= 1) {
|
|
719
|
+
if (!texts[index].trim()) continue;
|
|
720
|
+
for (const candidate of FINAL_SUMMARIES) {
|
|
721
|
+
const found = candidate.match(texts, index);
|
|
722
|
+
if (!found) continue;
|
|
723
|
+
const failure = found.failed > 0 ? firstFailureIndex(found.runner, texts.slice(0, index)) : null;
|
|
724
|
+
return { ...found, firstFailureLine: failure, final: true };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
let runner = null;
|
|
728
|
+
for (const text of texts) {
|
|
729
|
+
const header = RUNNER_HEADERS.find(([, pattern]) => pattern.test(text));
|
|
730
|
+
if (header) {
|
|
731
|
+
runner = header[0];
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (!runner) return null;
|
|
736
|
+
return { runner, ...liveCounts(runner, texts), final: false };
|
|
737
|
+
}
|