@pify/shell-background 0.5.0 → 0.6.0
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/README.md +8 -3
- package/extensions/shell-background.ts +1 -0
- package/package.json +1 -1
- package/src/config.ts +18 -3
- package/src/format.ts +12 -0
- package/src/redundancy.ts +127 -0
- package/src/shrink.ts +6 -0
- package/src/testreport.ts +158 -0
package/README.md
CHANGED
|
@@ -55,17 +55,22 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
|
|
|
55
55
|
"autoBackgroundMs": 30000,
|
|
56
56
|
"tailBytes": 65536,
|
|
57
57
|
"tailLines": 200,
|
|
58
|
-
"maxBackground": 8
|
|
58
|
+
"maxBackground": 8,
|
|
59
|
+
"deliverResults": true
|
|
59
60
|
}
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result reads; `tailLines` bounds how many lines of it the model is shown (0 disables the line shrink). `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
|
|
63
|
+
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result reads; `tailLines` bounds how many lines of it the model is shown (0 disables the line shrink). `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. `deliverResults` (boolean, default `true`): when a background job finishes, its result is pushed into the conversation and wakes the agent for a new turn. Set it to `false` to turn that off — the widget still updates and `shell_status {id}` still collects the result, but nothing is injected and the agent is not woken. Bad values fall back to the defaults with a warning rather than taking the tool down.
|
|
63
64
|
|
|
64
65
|
Since pi 0.99 the bash tool also returns a machine-readable result — `structuredContent` — which codemode scripts and tools calling `ctx.executeTool("bash", …)` receive instead of the text. This package keeps pi's shape so such a script reads either tool unchanged: `output` (the whole log, up to 1 MiB, head and tail around an omission marker), `truncated`, `full_output_path`, `exit_code`, `wall_time_seconds` — plus `status` and `job_id`, because a command here can still be running when the tool returns; then `exit_code` is absent and `shell_status {id}` collects it, with the same structured result.
|
|
65
66
|
|
|
66
67
|
A backgrounded command runs with no one to answer it, so its environment says so: `GIT_TERMINAL_PROMPT=0` and `GIT_EDITOR`/`EDITOR`/`VISUAL=true` are forced (a `git commit` without `-m` fails with "empty message" instead of waiting on an editor that never comes, and an https `git fetch` fails instead of prompting for a password), and pagers default to `cat` unless you set them. `CI` and `TERM` are left alone on purpose — dev servers and watchers read them.
|
|
67
68
|
|
|
68
|
-
The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes and control characters stripped — with two improvements borrowed from caveman's log compressors. A carriage-return redraw is **resolved to its last frame** rather than having the `\r` deleted: pi's own cleaning keeps every frame's text, so a 2000-frame progress bar arrives as one line holding all 2000 frames; here it arrives as `1999/2000`. And a long result is **shrunk by lines, not bytes**: the first and last lines are kept, every line that names trouble (`error`, `FAILED`, `Traceback`, `at …`, `File "…"`, `path:12:5:`, `FooError`, `caused by`, `exit status 1`) is kept wherever it sits, and the rest collapses into markers that count what they replaced — `[… 412 lines omitted (INFO ×380, DEBUG ×29) …]` — computed from exactly those lines. A byte window kept the *end* of a build log, where the summary is; a line window with that regex keeps the failing step in the middle too. Runs under three lines are shown as they are (a marker in place of one line only reads as a hole), the markers are recognised on a later poll so a shrunk result re-serialises identically (the provider prefix cache depends on it), and one note names what was left out and where the whole log is.
|
|
69
|
+
The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes and control characters stripped — with two improvements borrowed from caveman's log compressors. A carriage-return redraw is **resolved to its last frame** rather than having the `\r` deleted: pi's own cleaning keeps every frame's text, so a 2000-frame progress bar arrives as one line holding all 2000 frames; here it arrives as `1999/2000`. And a long result is **shrunk by lines, not bytes**: the first and last lines are kept, every line that names trouble (`error`, `FAILED`, `Traceback`, `at …`, `File "…"`, `path:12:5:`, `FooError`, `caused by`, `exit status 1`) is kept wherever it sits, and the rest collapses into markers that count what they replaced — `[… 412 lines omitted (INFO ×380, DEBUG ×29) …]` — computed from exactly those lines. A byte window kept the *end* of a build log, where the summary is; a line window with that regex keeps the failing step in the middle too. Runs under three lines are shown as they are (a marker in place of one line only reads as a hole), the markers are recognised on a later poll so a shrunk result re-serialises identically (the provider prefix cache depends on it), and one note names what was left out and where the whole log is. The line shrink is guarded against truncating a *distinct* document: before a run of lines is collapsed, one representative of every kind of line a surviving line does not already resemble is kept (caveman's redundancy guard, vocabulary-containment with digit masking so lines differing only in a timestamp count as one). So a 50k-line repetitive build log collapses hard, while a 50k-line document where every line is distinct is kept and the shrink reports no win — the honest outcome rather than a silent deletion.
|
|
70
|
+
|
|
71
|
+
A whole JSON test report in the tail — `pytest --json-report`, `jest --json` — is rendered as counts plus every failure with its stack trimmed to the first three and last two frames (`TEST SUMMARY: 240 passed, 3 failed` then each `FAILED: …`), instead of the raw document. Detection is on the exact top-level keys, so ordinary JSON is untouched; a truncated report is not valid JSON and falls through to the line view; the full report stays on disk. (JUnit XML lands in a file rather than on stdout and is left alone.)
|
|
72
|
+
|
|
73
|
+
A log that cannot be read says `(log unavailable: …)` rather than `(no output)`, which would send the model polling for output that will never come. The log file on disk keeps every byte.
|
|
69
74
|
|
|
70
75
|
## Coexistence with @pify/pretty
|
|
71
76
|
|
|
@@ -218,6 +218,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
218
218
|
/** Push a finished job's result into the conversation through the live pi. */
|
|
219
219
|
function deliver(job: Job): void {
|
|
220
220
|
renderWidget();
|
|
221
|
+
if (!settings.deliverResults) return;
|
|
221
222
|
pi.sendMessage(
|
|
222
223
|
{
|
|
223
224
|
customType: DELIVERY_TYPE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/shell-background",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface ShellBgSettings {
|
|
|
29
29
|
* — a command itself is never refused.
|
|
30
30
|
*/
|
|
31
31
|
maxBackground: number;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a finished background job pushes its result into the conversation
|
|
34
|
+
* (and triggers a new agent turn that explains it). false: the job still
|
|
35
|
+
* finishes, the widget updates, and shell_status collects the result, but
|
|
36
|
+
* nothing is injected and the agent is not woken.
|
|
37
|
+
*/
|
|
38
|
+
deliverResults: boolean;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export const DEFAULT_SETTINGS: ShellBgSettings = {
|
|
@@ -36,9 +43,12 @@ export const DEFAULT_SETTINGS: ShellBgSettings = {
|
|
|
36
43
|
tailBytes: 64 * 1024,
|
|
37
44
|
tailLines: 200,
|
|
38
45
|
maxBackground: 8,
|
|
46
|
+
deliverResults: true,
|
|
39
47
|
};
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
type NumericKey = Exclude<keyof ShellBgSettings, "deliverResults">;
|
|
50
|
+
|
|
51
|
+
const LIMITS: Record<NumericKey, { min: number; max: number }> = {
|
|
42
52
|
// 0 is allowed (disable); otherwise at least 1s so a typo of "30" (=30ms)
|
|
43
53
|
// does not make every command look long-running.
|
|
44
54
|
autoBackgroundMs: { min: 0, max: 3_600_000 },
|
|
@@ -63,7 +73,12 @@ export function resolveSettings(
|
|
|
63
73
|
warnings.push(`unknown setting "${key}"`);
|
|
64
74
|
continue;
|
|
65
75
|
}
|
|
66
|
-
|
|
76
|
+
if (key === "deliverResults") {
|
|
77
|
+
if (typeof value === "boolean") settings.deliverResults = value;
|
|
78
|
+
else warnings.push(`"deliverResults" must be true or false — using ${DEFAULT_SETTINGS.deliverResults}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const name = key as NumericKey;
|
|
67
82
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
68
83
|
warnings.push(`"${key}" must be a number — using ${DEFAULT_SETTINGS[name]}`);
|
|
69
84
|
continue;
|
|
@@ -83,7 +98,7 @@ export function resolveSettings(
|
|
|
83
98
|
return { settings, warnings };
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
function clamp(name:
|
|
101
|
+
function clamp(name: NumericKey, value: number, warnings: string[]): number {
|
|
87
102
|
const { min, max } = LIMITS[name];
|
|
88
103
|
const c = Math.round(Math.min(max, Math.max(min, value)));
|
|
89
104
|
if (c !== value) warnings.push(`"${name}" clamped to ${c} (allowed ${min}–${max})`);
|
package/src/format.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Job } from "./types.ts";
|
|
|
6
6
|
import { readTail } from "./tail.ts";
|
|
7
7
|
import { sanitizeOutput } from "./sanitize.ts";
|
|
8
8
|
import { DEFAULT_SHRINK, shrinkLines } from "./shrink.ts";
|
|
9
|
+
import { parseTestReport, renderTestReport } from "./testreport.ts";
|
|
9
10
|
|
|
10
11
|
function kb(bytes: number): string {
|
|
11
12
|
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))}KB` : `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
@@ -61,6 +62,17 @@ export function formatResult(job: Job, tailBytes: number, tailLines: number = DE
|
|
|
61
62
|
lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
|
|
62
63
|
return lines.join("\n");
|
|
63
64
|
}
|
|
65
|
+
// A whole JSON test report (pytest --json-report, jest --json) read from the
|
|
66
|
+
// tail becomes counts + every failure. Only when the tail holds the whole
|
|
67
|
+
// document — a truncated one is not valid JSON and falls through to the
|
|
68
|
+
// line view. The full report stays on disk.
|
|
69
|
+
if (!tail.truncated) {
|
|
70
|
+
const report = parseTestReport(text);
|
|
71
|
+
if (report) {
|
|
72
|
+
lines.push(renderTestReport(report));
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
64
76
|
const shrunk = shrinkLines(text, { maxLines: tailLines, tail: Math.max(DEFAULT_SHRINK.tail, Math.floor(tailLines / 2)) });
|
|
65
77
|
const notes: string[] = [];
|
|
66
78
|
if (shrunk.omitted > 0) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that separates *eliding repetition* from *truncating a document*.
|
|
3
|
+
* A faithful port of caveman's keepNonRedundant (JuliusBrussee/caveman,
|
|
4
|
+
* engine/compressors/redundancy.go).
|
|
5
|
+
*
|
|
6
|
+
* The line shrink (shrink.ts) keeps the head, the tail, and every line that
|
|
7
|
+
* names trouble, and collapses the rest. On a build log that is the right
|
|
8
|
+
* call: the dropped lines are repeated chunk/step noise, each represented by
|
|
9
|
+
* a surviving one. On a 50k-line document where every line is distinct — a
|
|
10
|
+
* bibliography, a generated source file — the same transform would silently
|
|
11
|
+
* delete most of the content. This gate runs before the collapse: a line
|
|
12
|
+
* about to be dropped is KEPT when no surviving line already resembles it, so
|
|
13
|
+
* a distinct document retains a representative of every kind of line and only
|
|
14
|
+
* true repetition is elided. Deterministic (prefix cache), bounded (the work
|
|
15
|
+
* is the same on a 200-line and a 20,000-line payload), pure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CONTAINMENT = 0.9; // a dropped line's vocabulary a survivor must carry to represent it
|
|
19
|
+
const MAX_CLASSES = 64; // kept lines sampled to seed the represented classes
|
|
20
|
+
const MAX_PROMOTIONS = 128; // first-of-kind lines kept before the payload is declared a document
|
|
21
|
+
const MAX_UNIT_TOKENS = 128; // vocabulary built per line, so one long line cannot make this quadratic
|
|
22
|
+
const MIN_WORD_TOKENS = 1; // letter-bearing tokens a line needs before its digits are masked
|
|
23
|
+
|
|
24
|
+
interface Profile {
|
|
25
|
+
vocab: Set<string>;
|
|
26
|
+
masked: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A line reduced to its word/number tokens, every run of digits optionally
|
|
31
|
+
* collapsed to `#`. Masking lets two log lines that differ only in a timestamp
|
|
32
|
+
* recognise each other; a line with no words keeps its digits, because there
|
|
33
|
+
* the numbers ARE the content (`['1973.', 251]` must not match `['1974.', 260]`).
|
|
34
|
+
*/
|
|
35
|
+
function tokenize(unit: string, maskDigits: boolean): { vocab: Set<string>; words: number } {
|
|
36
|
+
const vocab = new Set<string>();
|
|
37
|
+
let words = 0;
|
|
38
|
+
let token = "";
|
|
39
|
+
let hasLetter = false;
|
|
40
|
+
let inDigits = false;
|
|
41
|
+
const flush = () => {
|
|
42
|
+
if (token.length > 0 && !vocab.has(token)) {
|
|
43
|
+
vocab.add(token);
|
|
44
|
+
if (hasLetter) words++;
|
|
45
|
+
}
|
|
46
|
+
token = "";
|
|
47
|
+
hasLetter = false;
|
|
48
|
+
inDigits = false;
|
|
49
|
+
};
|
|
50
|
+
for (const ch of unit.toLowerCase()) {
|
|
51
|
+
if (ch >= "0" && ch <= "9") {
|
|
52
|
+
if (maskDigits) {
|
|
53
|
+
if (!inDigits) {
|
|
54
|
+
token += "#";
|
|
55
|
+
inDigits = true;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
token += ch;
|
|
59
|
+
inDigits = false;
|
|
60
|
+
}
|
|
61
|
+
} else if (/[a-z_]/.test(ch) || (ch.toLowerCase() !== ch.toUpperCase())) {
|
|
62
|
+
token += ch;
|
|
63
|
+
hasLetter = true;
|
|
64
|
+
inDigits = false;
|
|
65
|
+
} else {
|
|
66
|
+
flush();
|
|
67
|
+
}
|
|
68
|
+
if (vocab.size >= MAX_UNIT_TOKENS) return { vocab, words };
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
return { vocab, words };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function unitVocabulary(unit: string): Profile {
|
|
75
|
+
const masked = tokenize(unit, true);
|
|
76
|
+
if (masked.words >= MIN_WORD_TOKENS) return { vocab: masked.vocab, masked: true };
|
|
77
|
+
return { vocab: tokenize(unit, false).vocab, masked: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function representedBy(dropped: Profile, classes: Profile[]): boolean {
|
|
81
|
+
if (dropped.vocab.size === 0) return true; // punctuation-only line carries nothing to lose
|
|
82
|
+
const need = Math.ceil(CONTAINMENT * dropped.vocab.size - 1e-9);
|
|
83
|
+
for (const cls of classes) {
|
|
84
|
+
if (cls.masked !== dropped.masked) continue;
|
|
85
|
+
let hit = 0;
|
|
86
|
+
for (const token of dropped.vocab) {
|
|
87
|
+
if (cls.vocab.has(token)) {
|
|
88
|
+
hit++;
|
|
89
|
+
if (hit >= need) return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Promote back to kept any not-yet-kept line that no surviving line
|
|
98
|
+
* represents. Mutates `keep` in place, in document order.
|
|
99
|
+
*/
|
|
100
|
+
export function keepNonRedundant(units: readonly string[], keep: boolean[]): void {
|
|
101
|
+
if (units.length !== keep.length) return;
|
|
102
|
+
const kept: number[] = [];
|
|
103
|
+
for (let i = 0; i < keep.length; i++) if (keep[i]) kept.push(i);
|
|
104
|
+
|
|
105
|
+
const classes: Profile[] = [];
|
|
106
|
+
const step = kept.length > MAX_CLASSES ? Math.floor(kept.length / MAX_CLASSES) : 1;
|
|
107
|
+
for (let n = 0; n < kept.length && classes.length < MAX_CLASSES; n += step) {
|
|
108
|
+
classes.push(unitVocabulary(units[kept[n]!]!));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let promotions = 0;
|
|
112
|
+
for (let i = 0; i < units.length; i++) {
|
|
113
|
+
if (keep[i]) continue;
|
|
114
|
+
if (promotions >= MAX_PROMOTIONS) {
|
|
115
|
+
// More distinct kinds of line than any repetitive stream has: a document.
|
|
116
|
+
// Keep the rest; the caller's "did this actually shrink?" check then
|
|
117
|
+
// passes the original through. Prefer the honest zero.
|
|
118
|
+
keep[i] = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const vocab = unitVocabulary(units[i]!);
|
|
122
|
+
if (representedBy(vocab, classes)) continue;
|
|
123
|
+
keep[i] = true;
|
|
124
|
+
promotions++;
|
|
125
|
+
classes.push(vocab);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/shrink.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface Shrunk {
|
|
|
70
70
|
total: number;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
import { keepNonRedundant } from "./redundancy.ts";
|
|
74
|
+
|
|
73
75
|
export const DEFAULT_SHRINK: Required<ShrinkOptions> = { head: 3, tail: 100, maxLines: 200 };
|
|
74
76
|
|
|
75
77
|
function levelSummary(lines: string[]): string {
|
|
@@ -101,6 +103,10 @@ export function shrinkLines(text: string, opts: ShrinkOptions = {}): Shrunk {
|
|
|
101
103
|
const total = lines.length;
|
|
102
104
|
if (maxLines <= 0 || total <= maxLines) return { text, omitted: 0, total };
|
|
103
105
|
const keep = lines.map((line, i) => i < head || i >= total - tail || isImportantLine(line));
|
|
106
|
+
// Before collapsing, keep one representative of every DISTINCT line a run is
|
|
107
|
+
// about to drop, so a document of unique lines is not truncated — only true
|
|
108
|
+
// repetition is elided (caveman's redundancy guard).
|
|
109
|
+
keepNonRedundant(lines, keep);
|
|
104
110
|
const out: string[] = [];
|
|
105
111
|
let omitted = 0;
|
|
106
112
|
let i = 0;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A machine-readable test report, rendered as counts plus every failure.
|
|
3
|
+
* Ported from caveman's test-report compressor (JuliusBrussee/caveman,
|
|
4
|
+
* engine/compressors/testreport.go), narrowed to the two JSON shapes a bash
|
|
5
|
+
* command actually prints to stdout: pytest-json-report and Jest `--json`.
|
|
6
|
+
* (JUnit XML lands in a file, not on stdout, and parsing it well needs an XML
|
|
7
|
+
* dependency this package does not take.)
|
|
8
|
+
*
|
|
9
|
+
* No failure is ever dropped: the summary counts every outcome, and each
|
|
10
|
+
* failure keeps its name, message and a stack trimmed to its first three and
|
|
11
|
+
* last two frames. Detection gates on the exact top-level keys, so ordinary
|
|
12
|
+
* JSON that merely mentions them is left to the normal tail. Pure; the full
|
|
13
|
+
* log stays on disk for recovery.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface TestReport {
|
|
17
|
+
passed: number;
|
|
18
|
+
failed: number;
|
|
19
|
+
skipped: number;
|
|
20
|
+
errors: number;
|
|
21
|
+
duration?: number;
|
|
22
|
+
other: Record<string, number>;
|
|
23
|
+
failures: Array<{ name: string; message: string; type: "failure" | "error"; stack: string[] }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep a stack's first 3 and last 2 real frames, only when that drops at least 3. */
|
|
27
|
+
export function trimStack(frames: string[]): string[] {
|
|
28
|
+
const clean = frames
|
|
29
|
+
.map((f) => f.trim())
|
|
30
|
+
.filter((f) => f !== "" && (/^at /.test(f) || /\.(py|java|js|ts|rb|go):/.test(f)));
|
|
31
|
+
if (clean.length < 8) return clean;
|
|
32
|
+
return [...clean.slice(0, 3), `... (${clean.length - 5} frames omitted)`, ...clean.slice(-2)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function num(v: unknown): number {
|
|
36
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePytest(doc: Record<string, unknown>): TestReport | null {
|
|
40
|
+
if (!Array.isArray(doc.tests)) return null;
|
|
41
|
+
const r: TestReport = { passed: 0, failed: 0, skipped: 0, errors: 0, duration: num(doc.duration) || undefined, other: {}, failures: [] };
|
|
42
|
+
for (const t of doc.tests) {
|
|
43
|
+
if (!t || typeof t !== "object") continue;
|
|
44
|
+
const test = t as Record<string, unknown>;
|
|
45
|
+
const outcome = typeof test.outcome === "string" ? test.outcome : "";
|
|
46
|
+
const stageRepr = (name: string): string => {
|
|
47
|
+
const stage = test[name];
|
|
48
|
+
const lr = stage && typeof stage === "object" ? (stage as Record<string, unknown>).longrepr : undefined;
|
|
49
|
+
return typeof lr === "string" ? lr : "";
|
|
50
|
+
};
|
|
51
|
+
const longrepr = stageRepr("call") || stageRepr("setup") || stageRepr("teardown");
|
|
52
|
+
const nodeid = typeof test.nodeid === "string" ? test.nodeid : "(unknown)";
|
|
53
|
+
if (outcome === "passed") r.passed++;
|
|
54
|
+
else if (outcome === "skipped") r.skipped++;
|
|
55
|
+
else if (outcome === "failed" || outcome === "error") {
|
|
56
|
+
const isError = outcome === "error";
|
|
57
|
+
isError ? r.errors++ : r.failed++;
|
|
58
|
+
let message = "";
|
|
59
|
+
const stack: string[] = [];
|
|
60
|
+
for (const raw of longrepr.split("\n")) {
|
|
61
|
+
const line = raw.trim();
|
|
62
|
+
if (!line) continue;
|
|
63
|
+
if (!isError && line.startsWith("E ")) {
|
|
64
|
+
if (!message) message = line.slice(2).trim();
|
|
65
|
+
} else if (line.includes(".py:") || line.startsWith("at ")) stack.push(line);
|
|
66
|
+
else if (isError && !message && !line.includes(".py:")) message = line;
|
|
67
|
+
}
|
|
68
|
+
r.failures.push({ name: nodeid, message, type: isError ? "error" : "failure", stack: trimStack(stack) });
|
|
69
|
+
} else if (outcome) r.other[outcome] = (r.other[outcome] ?? 0) + 1;
|
|
70
|
+
}
|
|
71
|
+
return r;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJest(doc: Record<string, unknown>): TestReport | null {
|
|
75
|
+
if (!Array.isArray(doc.testResults)) return null;
|
|
76
|
+
const r: TestReport = {
|
|
77
|
+
passed: num(doc.numPassedTests),
|
|
78
|
+
failed: num(doc.numFailedTests),
|
|
79
|
+
skipped: num(doc.numPendingTests),
|
|
80
|
+
errors: 0,
|
|
81
|
+
other: {},
|
|
82
|
+
failures: [],
|
|
83
|
+
};
|
|
84
|
+
for (const suite of doc.testResults) {
|
|
85
|
+
const results = suite && typeof suite === "object" ? (suite as Record<string, unknown>).assertionResults : undefined;
|
|
86
|
+
if (!Array.isArray(results)) continue;
|
|
87
|
+
for (const a of results) {
|
|
88
|
+
if (!a || typeof a !== "object") continue;
|
|
89
|
+
const test = a as Record<string, unknown>;
|
|
90
|
+
if (test.status !== "failed") continue;
|
|
91
|
+
const ancestors = Array.isArray(test.ancestorTitles) ? (test.ancestorTitles as unknown[]).map(String) : [];
|
|
92
|
+
const title = typeof test.title === "string" ? test.title : "";
|
|
93
|
+
const name = (typeof test.fullName === "string" && test.fullName) || [...ancestors, title].filter(Boolean).join(" › ") || "(unnamed)";
|
|
94
|
+
let message = "";
|
|
95
|
+
const stack: string[] = [];
|
|
96
|
+
const msgs = Array.isArray(test.failureMessages) ? test.failureMessages : [];
|
|
97
|
+
if (msgs.length > 0 && typeof msgs[0] === "string") {
|
|
98
|
+
for (const raw of (msgs[0] as string).split("\n")) {
|
|
99
|
+
const line = raw.trim();
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
if (line.startsWith("at ") || line.includes(".test.") || line.includes(".spec.")) stack.push(line);
|
|
102
|
+
else if (!message) message = line;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
r.failures.push({ name, message, type: "failure", stack: trimStack(stack) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parse a JSON test report, or null when the text is not one. Detection is on exact top-level keys. */
|
|
112
|
+
export function parseTestReport(text: string): TestReport | null {
|
|
113
|
+
const trimmed = text.trim();
|
|
114
|
+
if (!trimmed.startsWith("{")) return null;
|
|
115
|
+
// Byte pre-check so ordinary JSON is not parsed twice.
|
|
116
|
+
if (!trimmed.includes('"exitcode"') && !trimmed.includes('"numFailedTests"')) return null;
|
|
117
|
+
let doc: unknown;
|
|
118
|
+
try {
|
|
119
|
+
doc = JSON.parse(trimmed);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
|
|
124
|
+
const top = doc as Record<string, unknown>;
|
|
125
|
+
const report = "exitcode" in top && Array.isArray(top.tests) ? parsePytest(top)
|
|
126
|
+
: "numFailedTests" in top && Array.isArray(top.testResults) ? parseJest(top)
|
|
127
|
+
: null;
|
|
128
|
+
if (!report) return null;
|
|
129
|
+
const total = report.passed + report.failed + report.skipped + report.errors + Object.values(report.other).reduce((a, b) => a + b, 0);
|
|
130
|
+
return total === 0 ? null : report;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderTestReport(r: TestReport): string {
|
|
134
|
+
const parts: string[] = [];
|
|
135
|
+
if (r.passed > 0) parts.push(`${r.passed} passed`);
|
|
136
|
+
if (r.failed > 0) parts.push(`${r.failed} failed`);
|
|
137
|
+
if (r.errors > 0) parts.push(`${r.errors} errors`);
|
|
138
|
+
if (r.skipped > 0) parts.push(`${r.skipped} skipped`);
|
|
139
|
+
for (const name of Object.keys(r.other).sort()) parts.push(`${r.other[name]} ${name}`);
|
|
140
|
+
let out = `TEST SUMMARY: ${parts.join(", ")}`;
|
|
141
|
+
if (r.duration && r.duration > 0) out += ` (${r.duration.toFixed(1)}s)`;
|
|
142
|
+
for (const f of r.failures) {
|
|
143
|
+
out += `\n\n${f.type === "error" ? "ERROR" : "FAILED"}: ${f.name}`;
|
|
144
|
+
if (f.message) for (const line of f.message.trim().split("\n")) out += `\n ${line}`;
|
|
145
|
+
for (const frame of f.stack) out += `\n ${frame}`;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `N failed, M passed`-style summary, or null — for a one-line collapsed row. */
|
|
151
|
+
export function testCountLine(r: TestReport): string {
|
|
152
|
+
const parts: string[] = [];
|
|
153
|
+
if (r.failed > 0) parts.push(`${r.failed} failed`);
|
|
154
|
+
if (r.errors > 0) parts.push(`${r.errors} errors`);
|
|
155
|
+
if (r.passed > 0) parts.push(`${r.passed} passed`);
|
|
156
|
+
if (r.skipped > 0) parts.push(`${r.skipped} skipped`);
|
|
157
|
+
return parts.join(", ");
|
|
158
|
+
}
|