Summarize successful steps, print the failing check and error text, and split the validate job into seven steps. Local npm test stays TAP.
229 lines
7.2 KiB
JavaScript
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();
|
|
}
|