дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown. Co-authored-by: jaw52 <[email protected]>
141 рядки
5.5 KiB
TypeScript
141 рядки
5.5 KiB
TypeScript
/** pytest: verbose lines, `-rA` / short summary lines, progress dots and failure sections. */
|
|
import { parseDuration, type TestEvent, type TestOutputParser, type TestStatus } from "./types";
|
|
|
|
type Outcome = "PASSED" | "FAILED" | "ERROR" | "SKIPPED" | "XFAIL" | "XPASS";
|
|
|
|
const OUTCOME = String.raw`(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)`;
|
|
// `tests/test_x.py::TestC::test_y[1] PASSED [ 50%]`
|
|
const VERBOSE_RE = new RegExp(String.raw`^(\S+\.py::\S+)\s+${OUTCOME}(?:\s|$)`);
|
|
// `[gw0] PASSED tests/test_x.py::test_add` (xdist) and short summary `FAILED tests/test_x.py::test_sub - msg`
|
|
const PREFIX_RE = new RegExp(
|
|
String.raw`^(?:\[gw\d+\]\s+(?:\[\s*\d+%\]\s+)?)?${OUTCOME}\s+(\S+\.py::\S+?)(?:\s+-\s+.*)?$`,
|
|
);
|
|
// `tests/test_x.py .F.s [100%]`
|
|
const PROGRESS_RE = /^(\S+\.py) ([.FEsxX]+)\s*(?:\[\s*\d+%\])?$/;
|
|
const SECTION_RE = /^_{3,} (.+?) _{3,}$/;
|
|
const LOCATION_RE = /^(\S+\.py):(\d+): /;
|
|
const SUMMARY_RE = /^=+ (.*\b(?:passed|failed|error|errors|skipped|deselected|xfailed|xpassed|no tests ran)\b.*?) =+$/;
|
|
const BANNER_RE = /^=+ .* =+$/;
|
|
|
|
function status(outcome: Outcome): Exclude<TestStatus, "running"> {
|
|
switch (outcome) {
|
|
case "PASSED":
|
|
case "XFAIL":
|
|
return "passed";
|
|
case "SKIPPED":
|
|
return "skipped";
|
|
default:
|
|
return "failed";
|
|
}
|
|
}
|
|
|
|
const split = (nodeid: string) => nodeid.split("::").filter(Boolean);
|
|
|
|
export function pytestParser(): TestOutputParser {
|
|
/** Node ids reported so far, used to resolve failure section titles. */
|
|
const known: string[][] = [];
|
|
let failure: string[] | null = null;
|
|
let locationFound = false;
|
|
/**
|
|
* Failure sections seen before the test's node id (non-verbose runs print
|
|
* names only in the short summary at the end): buffered until resolved.
|
|
*/
|
|
let pending: { parts: string[]; events: TestEvent[] } | null = null;
|
|
const unresolved: Array<{ parts: string[]; events: TestEvent[] }> = [];
|
|
|
|
const endsWith = (path: string[], parts: string[]) => {
|
|
const tail = path.slice(path.length - parts.length);
|
|
return tail.length === parts.length && tail.every((p, i) => p === parts[i]);
|
|
};
|
|
|
|
/** Records a node id; returns buffered failure events that now belong to it. */
|
|
const remember = (path: string[]): TestEvent[] => {
|
|
if (!known.some((k) => k.join("::") === path.join("::"))) known.push(path);
|
|
const out: TestEvent[] = [];
|
|
for (let i = unresolved.length - 1; i >= 0; i--) {
|
|
const u = unresolved[i]!;
|
|
if (!endsWith(path, u.parts)) continue;
|
|
unresolved.splice(i, 1);
|
|
for (const e of u.events) if (e.type === "output" || e.type === "location") out.push({ ...e, path });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/** Section titles are `test_name` or `TestClass.test_name`. */
|
|
const titleParts = (title: string) => title.replace(/^(ERROR at \w+ of |ERROR collecting )/, "").split(".");
|
|
|
|
return {
|
|
feed(line) {
|
|
const events: TestEvent[] = [];
|
|
let m: RegExpExecArray | null;
|
|
if ((m = VERBOSE_RE.exec(line))) {
|
|
const path = split(m[1]!);
|
|
events.push({ type: "result", path, status: status(m[2] as Outcome) }, ...remember(path));
|
|
return events;
|
|
}
|
|
if ((m = PREFIX_RE.exec(line))) {
|
|
const path = split(m[2]!);
|
|
events.push({ type: "result", path, status: status(m[1] as Outcome) }, ...remember(path));
|
|
failure = null;
|
|
pending = null;
|
|
return events;
|
|
}
|
|
if ((m = PROGRESS_RE.exec(line))) {
|
|
const failed = /[FE]/.test(m[2]!);
|
|
const allSkipped = /^[sx]+$/.test(m[2]!);
|
|
events.push({ type: "result", path: [m[1]!], status: failed ? "failed" : allSkipped ? "skipped" : "passed" });
|
|
return events;
|
|
}
|
|
if ((m = SUMMARY_RE.exec(line))) {
|
|
failure = null;
|
|
const counts: { passed?: number; failed?: number; skipped?: number } = {};
|
|
for (const c of m[1]!.matchAll(/(\d+) (passed|failed|errors?|skipped|xfailed|xpassed)/g)) {
|
|
const n = Number(c[1]);
|
|
if (c[2] === "passed" || c[2] === "xfailed") counts.passed = (counts.passed ?? 0) + n;
|
|
else if (c[2] === "skipped") counts.skipped = n;
|
|
else counts.failed = (counts.failed ?? 0) + n;
|
|
}
|
|
const d = / in ([\d.]+s)/.exec(m[1]!);
|
|
events.push({ type: "summary", ...counts, durationMs: parseDuration(d?.[1]) });
|
|
return events;
|
|
}
|
|
if ((m = SECTION_RE.exec(line))) {
|
|
const parts = titleParts(m[1]!);
|
|
const hit = known.find((k) => endsWith(k, parts));
|
|
failure = hit ?? parts;
|
|
pending = hit ? null : { parts, events: [] };
|
|
if (pending) unresolved.push(pending);
|
|
locationFound = false;
|
|
return events;
|
|
}
|
|
if (BANNER_RE.test(line)) {
|
|
failure = null;
|
|
pending = null;
|
|
return events;
|
|
}
|
|
if (failure) {
|
|
const sink = pending ? pending.events : events;
|
|
sink.push({ type: "output", path: failure, line });
|
|
const loc = LOCATION_RE.exec(line);
|
|
if (loc && !locationFound) {
|
|
locationFound = true;
|
|
sink.push({
|
|
type: "location",
|
|
path: failure,
|
|
location: { path: loc[1]!, line: Number(loc[2]), resolve: "path" },
|
|
});
|
|
}
|
|
}
|
|
return events;
|
|
},
|
|
end() {
|
|
// Sections that never got a node id: report them under their title.
|
|
const out: TestEvent[] = [];
|
|
for (const u of unresolved.splice(0)) {
|
|
out.push({ type: "result", path: u.parts, status: "failed" }, ...u.events);
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|