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