Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
237 lines
7.7 KiB
JavaScript
237 lines
7.7 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, readdirSync } 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");
|
|
// Expanded here instead of handing `tests/*.test.ts` to `node --test`: Node 20
|
|
// does not expand test globs ("Could not find tests/*.test.ts"), while the old
|
|
// `tsx --test tests/*.test.ts tests/*.test.tsx` script relied on the shell. Same
|
|
// order as the shell: every *.test.ts sorted, then every *.test.tsx sorted.
|
|
function testFiles() {
|
|
const names = readdirSync(path.join(frontendDir, "tests"));
|
|
const pick = (suffix) => names.filter((name) => name.endsWith(suffix)).sort().map((name) => `tests/${name}`);
|
|
return [...pick(".test.ts"), ...pick(".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", ...testFiles(), ...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,
|
|
...testFiles(),
|
|
...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();
|
|
}
|