mirror of
https://github.com/EveryInc/compound-engineering-plugin.git
synced 2026-09-19 01:09:55 +08:00
114 lines
4.7 KiB
TypeScript
114 lines
4.7 KiB
TypeScript
// `bun run test` entry point: one parallel pass, then one serial re-run of the
|
|
// files that failed, in a fresh bun process.
|
|
//
|
|
// Why: bun has an open defect where a test worker loses a child process's exit
|
|
// or pipe notification (oven-sh/bun#34069, #41024). The lost event is per
|
|
// spawn, not a dead worker: later tests in the same file can still pass. The
|
|
// first-pass tell is TimeoutError-only failures, often mixed with passes, at
|
|
// exactly the per-test timeout. The same files pass in a fresh process. Any
|
|
// assertion failure or error anywhere keeps the first result with no re-run,
|
|
// so a defect that only shows under parallel load is not retried away.
|
|
import { spawnSync } from "node:child_process"
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
|
|
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/
|
|
|
|
export type JunitCase = { file: string; failure: string | null }
|
|
|
|
/** Every testcase in a bun junit report, in report order, with its failure type if any. */
|
|
export function junitCases(xml: string): JunitCase[] {
|
|
const out: JunitCase[] = []
|
|
const suites: string[] = []
|
|
let current: JunitCase | null = null
|
|
for (const tag of xml.matchAll(/<(\/?)(testsuite|testcase|failure|error)\b([^>]*?)(\/?)>/g)) {
|
|
const [, closing, name, attrs, selfClosing] = tag
|
|
const attr = (key: string) => attrs.match(new RegExp(`\\b${key}="([^"]*)"`))?.[1]
|
|
if (name === "testsuite") {
|
|
if (closing) suites.pop()
|
|
else if (!selfClosing) suites.push(attr("file") ?? attr("name") ?? "")
|
|
} else if (name === "testcase") {
|
|
if (closing) current = null
|
|
else {
|
|
const file = attr("file") ?? suites.findLast((s) => TEST_FILE.test(s)) ?? ""
|
|
if (TEST_FILE.test(file)) out.push((current = { file, failure: null }))
|
|
if (selfClosing) current = null
|
|
}
|
|
} else if (!closing && current && !current.failure) {
|
|
current.failure = attr("type") ?? name
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Files to re-run after a first pass whose failures are all TimeoutError.
|
|
* A later passing test does not disqualify the file: the bun defect drops
|
|
* individual child-exit notifications, so the same worker can pass the next
|
|
* spawn. A non-timeout failure anywhere, including in another file, means
|
|
* the first result stands and nothing is re-run.
|
|
*/
|
|
export function rerunCandidates(cases: JunitCase[]): string[] {
|
|
const byFile = new Map<string, JunitCase[]>()
|
|
for (const c of cases) byFile.set(c.file, [...(byFile.get(c.file) ?? []), c])
|
|
const failed = [...byFile].filter(([, cs]) => cs.some((c) => c.failure))
|
|
const timeoutOnly = failed.every(([, cs]) => cs.filter((c) => c.failure).every((c) => c.failure === "TimeoutError"))
|
|
return failed.length > 0 && timeoutOnly ? failed.map(([file]) => file).sort() : []
|
|
}
|
|
|
|
/** Caller argv minus test-file paths and reporter/parallel flags this wrapper owns. */
|
|
export function passthroughArgs(argv: string[]): string[] {
|
|
const skipValue = new Set(["--reporter", "--reporter-outfile"])
|
|
const out: string[] = []
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i]
|
|
if (arg === "--parallel" || arg.startsWith("--reporter-outfile=") || arg === "--reporter=junit") continue
|
|
if (skipValue.has(arg)) {
|
|
if (argv[i + 1] && !argv[i + 1].startsWith("-")) i++
|
|
continue
|
|
}
|
|
if (TEST_FILE.test(arg)) continue
|
|
out.push(arg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function run(args: string[]): number {
|
|
const result = spawnSync(process.execPath, ["test", ...args], { stdio: "inherit" })
|
|
if (result.error) throw result.error
|
|
return result.status ?? 1
|
|
}
|
|
|
|
function main(argv: string[]): number {
|
|
const reportDir = mkdtempSync(path.join(tmpdir(), "bun-test-report-"))
|
|
const report = path.join(reportDir, "junit.xml")
|
|
try {
|
|
const first = run(["--parallel", "--reporter=junit", `--reporter-outfile=${report}`, ...argv])
|
|
if (first === 0) return 0
|
|
|
|
const failed = existsSync(report) ? rerunCandidates(junitCases(readFileSync(report, "utf8"))) : []
|
|
if (failed.length === 0) return first
|
|
|
|
console.error(
|
|
`\nEvery first-pass failure was a TimeoutError, the bun lost-child-exit shape.` +
|
|
` Re-running ${failed.length} file(s) serially in a fresh process (oven-sh/bun#34069):` +
|
|
`\n ${failed.join("\n ")}\n`,
|
|
)
|
|
const second = run([...passthroughArgs(argv), ...failed])
|
|
if (second === 0) {
|
|
console.error(
|
|
"\nEvery re-run file passed in a fresh process, so the first-pass failures were" +
|
|
" process-local (a lost child-exit notification), not a defect the tests reproduce.",
|
|
)
|
|
}
|
|
return second
|
|
} finally {
|
|
rmSync(reportDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
process.exitCode = main(process.argv.slice(2))
|
|
}
|