fix(gate): keep quality-gate logs readable (BUG-1230)
Summarize successful steps, print the failing check and error text, and split the validate job into seven steps. Local npm test stays TAP.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Frontend test entry.
|
||||
*
|
||||
* Local runs keep the previous TAP stream (`tsx --test tests/*.test.ts tests/*.test.tsx`).
|
||||
* CI and Gitea (`CI` or `GITEA_ACTIONS`) print Node's dot reporter to stdout and
|
||||
* write the full TAP report to gate-logs/frontend-tests.tap. The dot reporter's
|
||||
* trailing "Failed tests" stack dump is not copied to the log: the same failures
|
||||
* are printed afterwards from the TAP file as the test name plus the error body
|
||||
* (at most 60 lines each). Stacks stay in the TAP file.
|
||||
* The process exit code is the test runner's exit code (BUG-995).
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(frontendDir, "..");
|
||||
const tsxCli = path.join(frontendDir, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const testGlobs = ["tests/*.test.ts", "tests/*.test.tsx"];
|
||||
const failureBlockLimit = 60;
|
||||
|
||||
function envEnabled(name) {
|
||||
const value = process.env[name];
|
||||
if (value == null || value === "") return false;
|
||||
return !/^(0|false|no|off)$/i.test(value);
|
||||
}
|
||||
|
||||
function gateMode() {
|
||||
return envEnabled("CI") || envEnabled("GITEA_ACTIONS");
|
||||
}
|
||||
|
||||
function stripAnsi(text) {
|
||||
return text.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function runRunner(args, { suppressFailedTestsTrailer = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [tsxCli, ...args], {
|
||||
cwd: frontendDir,
|
||||
stdio: suppressFailedTestsTrailer ? ["inherit", "pipe", "inherit"] : "inherit",
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (suppressFailedTestsTrailer && child.stdout) {
|
||||
let pending = "";
|
||||
let dropping = false;
|
||||
const writeLine = (line) => {
|
||||
if (dropping) return;
|
||||
if (stripAnsi(line).trim() === "Failed tests:") {
|
||||
dropping = true;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(line);
|
||||
};
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
pending += chunk;
|
||||
let newline = pending.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
writeLine(pending.slice(0, newline + 1));
|
||||
pending = pending.slice(newline + 1);
|
||||
newline = pending.indexOf("\n");
|
||||
}
|
||||
});
|
||||
child.stdout.on("end", () => {
|
||||
if (pending) writeLine(pending);
|
||||
});
|
||||
}
|
||||
child.on("close", (code) => {
|
||||
resolve(code == null ? 1 : code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function failureName(raw) {
|
||||
return raw.trim().replace(/^\d+\s+(?:-\s+)?/, "");
|
||||
}
|
||||
|
||||
function isFileSuite(name, block) {
|
||||
const base = name.replaceAll("\\", "/").split("/").pop() ?? name;
|
||||
if (/\.test\.tsx?$/.test(base)) return true;
|
||||
return block.some((line) => /^\s*type:\s*'suite'/.test(line));
|
||||
}
|
||||
|
||||
function unquoteYaml(value) {
|
||||
const match = /^(['"])(.*)\1$/.exec(value);
|
||||
return match ? match[2] : value;
|
||||
}
|
||||
|
||||
export function errorBody(block) {
|
||||
const lines = [];
|
||||
let inError = false;
|
||||
let errorIndent = 0;
|
||||
const push = (line) => {
|
||||
if (lines.length < failureBlockLimit) lines.push(line);
|
||||
};
|
||||
for (const raw of block) {
|
||||
if (lines.length >= failureBlockLimit) break;
|
||||
if (!inError) {
|
||||
const error = /^(\s*)error:\s*(.*)$/.exec(raw);
|
||||
if (error) {
|
||||
const rest = error[2].trim();
|
||||
if (rest === "|-" || rest === "|" || rest === ">" || rest === ">-") {
|
||||
inError = true;
|
||||
errorIndent = error[1].length + 2;
|
||||
continue;
|
||||
}
|
||||
if (rest) push(unquoteYaml(rest));
|
||||
continue;
|
||||
}
|
||||
const code = /^\s*code:\s*(.+)$/.exec(raw);
|
||||
if (code && lines.length > 0) push(`code: ${code[1].trim()}`);
|
||||
continue;
|
||||
}
|
||||
if (raw.trim() === "") {
|
||||
push("");
|
||||
continue;
|
||||
}
|
||||
const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
|
||||
if (indent >= errorIndent) {
|
||||
push(raw.slice(errorIndent).replace(/\s+$/, ""));
|
||||
continue;
|
||||
}
|
||||
inError = false;
|
||||
const code = /^\s*code:\s*(.+)$/.exec(raw);
|
||||
if (code) push(`code: ${code[1].trim()}`);
|
||||
}
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
if (lines.length > 0) return lines;
|
||||
|
||||
const fallback = [];
|
||||
for (const raw of block) {
|
||||
if (/^\s*(?:duration_ms|type|location|failureType|name|expected|actual|operator|stack|error):/.test(raw)) continue;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed === "---" || trimmed === "..." || trimmed.startsWith("#")) continue;
|
||||
fallback.push(trimmed);
|
||||
if (fallback.length >= failureBlockLimit) break;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function summarizeTap(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const counts = {};
|
||||
for (const line of lines) {
|
||||
const match = /^# (tests|pass|fail|cancelled) (\d+)\s*$/.exec(line);
|
||||
if (match) counts[match[1]] = match[2];
|
||||
}
|
||||
const failures = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = /^(\s*)not ok\b(.*)$/.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const block = [];
|
||||
for (let cursor = index + 1; cursor < lines.length && block.length < failureBlockLimit; cursor += 1) {
|
||||
const next = lines[cursor];
|
||||
if (/^\s*(?:not )?ok\b/.test(next)) break;
|
||||
if (/^# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/.test(next)) break;
|
||||
block.push(next);
|
||||
}
|
||||
const name = failureName(match[2]);
|
||||
if (isFileSuite(name, block)) continue;
|
||||
failures.push({ name, block: errorBody(block) });
|
||||
}
|
||||
return { counts, failures };
|
||||
}
|
||||
|
||||
function printSummary(tapPath) {
|
||||
const text = readFileSync(tapPath, "utf8");
|
||||
const { counts, failures } = summarizeTap(text);
|
||||
const tests = counts.tests ?? "?";
|
||||
const pass = counts.pass ?? "?";
|
||||
const fail = counts.fail ?? "?";
|
||||
const cancelled = counts.cancelled ?? "?";
|
||||
console.log(`# tests ${tests}`);
|
||||
console.log(`# pass ${pass}`);
|
||||
console.log(`# fail ${fail}`);
|
||||
console.log(`# cancelled ${cancelled}`);
|
||||
console.log("# tap gate-logs/frontend-tests.tap");
|
||||
for (const failure of failures) {
|
||||
console.log(`not ok - ${failure.name}`);
|
||||
for (const line of failure.block) console.log(line);
|
||||
}
|
||||
return failures.length;
|
||||
}
|
||||
|
||||
function invokedDirectly() {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
const extra = process.argv.slice(2);
|
||||
if (!gateMode()) {
|
||||
const code = await runRunner(["--test", ...testGlobs, ...extra]);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const logDir = path.join(repoRoot, "gate-logs");
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const tapPath = path.join(logDir, "frontend-tests.tap");
|
||||
const code = await runRunner(
|
||||
[
|
||||
"--test",
|
||||
"--test-reporter=dot",
|
||||
"--test-reporter-destination=stdout",
|
||||
"--test-reporter=tap",
|
||||
"--test-reporter-destination",
|
||||
tapPath,
|
||||
...testGlobs,
|
||||
...extra,
|
||||
],
|
||||
{ suppressFailedTestsTrailer: true },
|
||||
);
|
||||
try {
|
||||
printSummary(tapPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`failed to read ${tapPath}: ${message}`);
|
||||
process.exit(code === 0 ? 1 : code);
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
if (invokedDirectly()) {
|
||||
await runTests();
|
||||
}
|
||||
Reference in New Issue
Block a user