Files
Jyotisha/frontend/scripts/run-tests.mjs
T
jesse-ux 2c64b66423 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.
2026-10-04 22:58:17 +08:00

229 lines
7.2 KiB
JavaScript

/**
* 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();
}