mirror of
https://github.com/oven-sh/bun.git
synced 2026-10-25 21:40:30 +08:00
### What does this PR do? `bun lint` has not been in a release yet. This keeps it out of release builds (`IS_CANARY` is false) and leaves it on in canary builds. | | canary build | release build | |---|---|---| | `bun lint` | the linter, unless the project has its own `lint` | the project's own `lint` script, file or executable, else `error: Script not found "lint"`. Same as 1.4.3 | | `bun --help` | lists `lint` | does not list `lint` | | README from `bun init` | "Run `bun lint` to find problems in the code and `bun fmt` to format it." | "Run `bun fmt` to format the code." | A release build also runs CI, so: | | on a release build | |---|---| | `scripts/runner.node.ts`, `test/expectations.txt` | new `STABLE` modifier, skips `test/cli/lint/` (29 files) | | `init.test.ts`, `bunfig-errors.test.ts`, `glob/match.test.ts` | the cases that run `bun lint` are skipped (`isCanary` in the harness) | | `scripts/orderfile/generate.ts` | does not trace `bun lint`, which would exit 1 and fail the step | `docs/lint/index.mdx` gets a note that the command is in canary builds only. Not changed: the React templates of `bun init` still write an empty `[lint]` section to `bunfig.toml`, and shell completions still offer `lint`. ### How did you verify your code works? | | result | |---|---| | debug build of this branch (canary) | `bun --help`, `bun lint a.js`, `bun lint --help`, `bun lint` with a `lint` script and the `bun init -y` README are unchanged. The 25 touched tests pass | | debug build of this branch with `--canary=off` | everything in the release column above. `bun lint`, `bun lint a.js`, `bun lint --help`, `bun lint --no-script` and `bun --cwd . lint` print `error: Script not found "lint"` and exit 1. A `lint` script and a `lint.ts` run. `bun fmt` and `bun check` are still commands | | the four touched test files on that build | pass, with the `bun lint` cases skipped (1 in `bunfig-errors`, 8 in `glob/match`, 3 in `init`) | | that build with `IS_ENABLED` forced to `true` | both new tests in `test/cli/bun.test.ts` fail | | test runner with `--exec-path` set to the 1.4.3 release | `STABLE` is set, `Skipping tests: [ "cli/lint/" ] 29 / 6159` | | test runner with a canary | `STABLE` is not set, the lint test is selected | | `isCanary` | `false` on the 1.4.3 release, `true` on a canary and on the debug build | PR CI builds are canary, so CI does not cover the release column. Not run: the order file generator, and the test runner against a release build of this branch (a debug build prints `-debug` in `--revision`, so it never gets `STABLE`).
3509 lines
124 KiB
JavaScript
Executable File
3509 lines
124 KiB
JavaScript
Executable File
#! /usr/bin/env node
|
||
|
||
// This is a script that runs `bun test` to test Bun itself.
|
||
// It is not intended to be used as a test runner for other projects.
|
||
//
|
||
// - It runs each `bun test` in a separate process, to catch crashes.
|
||
// - It cannot use Bun APIs, since it is run using Node.js.
|
||
// - It does not import dependencies, so it's faster to start.
|
||
|
||
import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import {
|
||
accessSync,
|
||
appendFileSync,
|
||
existsSync,
|
||
constants as fs,
|
||
linkSync,
|
||
mkdirSync,
|
||
mkdtempSync,
|
||
readdirSync,
|
||
readFileSync,
|
||
realpathSync,
|
||
rmSync,
|
||
statSync,
|
||
symlinkSync,
|
||
unlink,
|
||
unlinkSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import { readFile } from "node:fs/promises";
|
||
import type { Socket } from "node:net";
|
||
import { availableParallelism, userInfo } from "node:os";
|
||
import { basename, dirname, extname, join, relative, sep } from "node:path";
|
||
import { createInterface } from "node:readline";
|
||
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
|
||
import { parseArgs } from "node:util";
|
||
import { prestartMap as dockerPrestartMap } from "../test/docker/prestart-map.mjs";
|
||
import {
|
||
getAbi,
|
||
getAbiVersion,
|
||
getArch,
|
||
getDistro,
|
||
getDistroVersion,
|
||
getHostname,
|
||
getOs,
|
||
isAndroid,
|
||
isLinux,
|
||
isMacOS,
|
||
isWindows,
|
||
run,
|
||
tmpdir,
|
||
which,
|
||
} from "./agent.ts";
|
||
import {
|
||
escapeCodeBlock,
|
||
escapeHtml,
|
||
getBranch,
|
||
getBuildLabel,
|
||
getBuildMetadata,
|
||
getBuildUrl,
|
||
getCommit,
|
||
getFileUrl,
|
||
getPullRequestFiles,
|
||
getSecret,
|
||
isBuildkite,
|
||
isCI,
|
||
isGithubAction,
|
||
markBuildkiteStepReported,
|
||
parseJunitFileSuites,
|
||
printEnvironment,
|
||
reportAnnotationToBuildkite,
|
||
startGroup,
|
||
stripAnsi,
|
||
unescapeGitHubAction,
|
||
uploadArtifact,
|
||
type JunitFileSuite,
|
||
} from "./buildkite.ts";
|
||
|
||
const isX64 = process.arch === "x64";
|
||
|
||
function escapePowershell(string: string): string {
|
||
return string.replace(/'/g, "''").replace(/`/g, "``");
|
||
}
|
||
|
||
async function unzip(filename: string, output?: string): Promise<string> {
|
||
const destination = output || mkdtempSync(join(tmpdir(), "unzip-"));
|
||
if (isWindows) {
|
||
const command = `$ProgressPreference = 'SilentlyContinue'; Expand-Archive -Force -LiteralPath "${escapePowershell(filename)}" -DestinationPath "${escapePowershell(destination)}"`;
|
||
await run(["powershell", "-Command", command]);
|
||
} else {
|
||
await run(["unzip", "-o", "-q", filename, "-d", destination]);
|
||
}
|
||
return destination;
|
||
}
|
||
|
||
function getShell(): string | undefined {
|
||
if (isWindows) {
|
||
const pwsh = which(["pwsh", "powershell"]);
|
||
if (pwsh) {
|
||
return pwsh;
|
||
}
|
||
}
|
||
|
||
const sh = which(["bash", "sh"]);
|
||
if (sh) {
|
||
return sh;
|
||
}
|
||
|
||
return process.env.SHELL;
|
||
}
|
||
|
||
interface LiveOutputFilterOptions {
|
||
/** the runner itself runs in GitHub Actions */
|
||
github?: boolean;
|
||
/** the runner itself runs in Buildkite */
|
||
buildkite?: boolean;
|
||
}
|
||
|
||
type LiveOutputFilter = ((chunk: string) => string) & { end: () => string };
|
||
|
||
/**
|
||
* Creates a filter for the live output of one child process stream, for the CI log.
|
||
*
|
||
* The runner spawns every `bun test` with GITHUB_ACTIONS=true so that bun prints each
|
||
* failure as a `::error` workflow command it can parse, and the file headers as
|
||
* `::group::` commands. Those lines are for the parser, not for the log: GitHub renders
|
||
* them as annotations, Buildkite prints them verbatim. So outside GitHub Actions every
|
||
* line that starts with `::` is dropped. In GitHub Actions the group commands are dropped
|
||
* (the runner groups the log itself) and the rest is kept for GitHub to render. In
|
||
* Buildkite a line that starts with one of its group markers (`--- `) is defused too.
|
||
*
|
||
* The output arrives in pipe-sized chunks, and bun writes a `::error` line as many small
|
||
* writes, so a chunk usually ends in the middle of one. An incomplete last line that
|
||
* starts with `::`, or that is so far only the start of a marker, is held back until
|
||
* the rest of it arrives. `end()` returns what is still held back when the stream ends.
|
||
*
|
||
* @returns the text to write for each chunk
|
||
*/
|
||
function createLiveOutputFilter({
|
||
github = isGithubAction,
|
||
buildkite = isBuildkite,
|
||
}: LiveOutputFilterOptions = {}): LiveOutputFilter {
|
||
const ansi = /(?:\u001b\[[0-9;]*[a-zA-Z])*/.source;
|
||
const command = `^${ansi}::${github ? "(?:end)?group::" : ""}.*`;
|
||
const commands = new RegExp(`${command}(?:\r\n|\r|\n)`, "gm");
|
||
const lastCommand = new RegExp(`${command}\r?$`);
|
||
const groupMarkers = /^(?:---|\+\+\+|~~~|\^\^\^) /gm;
|
||
const markers = buildkite ? ["::", "--- ", "+++ ", "~~~ ", "^^^ "] : ["::"];
|
||
|
||
/** `line` is an incomplete line. */
|
||
const holdBack = (line: string) => {
|
||
const visible = stripAnsi(line);
|
||
return (
|
||
visible.startsWith("::") || markers.some(marker => marker.length > visible.length && marker.startsWith(visible))
|
||
);
|
||
};
|
||
|
||
let pending = "";
|
||
let atLineStart = true;
|
||
const filter = (chunk: string) => {
|
||
let text = pending + chunk;
|
||
const startsLine = atLineStart;
|
||
|
||
// A trailing \r may be the first half of a \r\n, so the line it ends is not complete yet.
|
||
// Once written, the next chunk starts a line either way: the \n of a \r\n is an empty one.
|
||
const endsWithCR = text.endsWith("\r");
|
||
const searchEnd = endsWithCR ? text.length - 2 : text.length - 1;
|
||
const lastLineStart = Math.max(text.lastIndexOf("\n", searchEnd), text.lastIndexOf("\r", searchEnd)) + 1;
|
||
const lastLine = text.slice(lastLineStart);
|
||
if (lastLine && (lastLineStart > 0 || startsLine) && holdBack(lastLine)) {
|
||
pending = lastLine;
|
||
text = text.slice(0, lastLineStart);
|
||
atLineStart = true;
|
||
} else {
|
||
pending = "";
|
||
if (text) atLineStart = endsWithCR || lastLineStart === text.length;
|
||
}
|
||
|
||
// A chunk that starts in the middle of a line continues a line that was already written.
|
||
let head = "";
|
||
if (!startsLine) {
|
||
const end = /\r\n|\r|\n/.exec(text);
|
||
const split = end ? end.index + end[0].length : text.length;
|
||
head = text.slice(0, split);
|
||
text = text.slice(split);
|
||
}
|
||
|
||
text = text.replace(commands, "");
|
||
if (buildkite) text = text.replace(groupMarkers, " ");
|
||
return head + text;
|
||
};
|
||
filter.end = () => {
|
||
const text = pending;
|
||
pending = "";
|
||
atLineStart = true;
|
||
return text.replace(lastCommand, "");
|
||
};
|
||
return filter;
|
||
}
|
||
|
||
function getLoggedInUserCountOrDetails(): number | string | undefined {
|
||
if (isWindows) {
|
||
const pwsh = which(["pwsh", "powershell"]);
|
||
if (pwsh) {
|
||
const { status, stdout } = spawnSync(
|
||
pwsh,
|
||
[
|
||
"-Command",
|
||
`Get-CimInstance -ClassName Win32_Process -Filter "Name = 'sshd.exe'" | Get-CimAssociatedInstance -Association Win32_SessionProcess | Get-CimAssociatedInstance -Association Win32_LoggedOnUser | Where-Object {$_.Name -ne 'SYSTEM'} | Measure-Object | Select-Object -ExpandProperty Count`,
|
||
],
|
||
{ encoding: "utf8" },
|
||
);
|
||
if (status === 0) {
|
||
return parseInt(stdout) || undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
const { status, stdout } = spawnSync("who", { encoding: "utf8" });
|
||
if (status === 0) {
|
||
const users = stdout
|
||
.split("\n")
|
||
.filter(line => /tty|pts/i.test(line))
|
||
// Only count REMOTE logins (have an `(ip)` suffix from sshd). A local
|
||
// console/auto-login (e.g. cirruslabs CI VM images log the admin user in
|
||
// on ttys000 at boot) has no source host and isn't a human debugging the
|
||
// job — waiting for it would hang the runner forever.
|
||
.filter(line => /\([^)]+\)\s*$/.test(line))
|
||
.map(line => {
|
||
// `who` output: `username terminal date time (host)`. The date/time
|
||
// field has spaces, so a plain split() can't slice it cleanly — take
|
||
// the first two tokens and pull the host from the trailing `(...)`.
|
||
const [username, terminal] = line.split(/\s+/);
|
||
const ip = line.match(/\(([^)]+)\)\s*$/)?.[1] || "";
|
||
return { username, terminal, ip };
|
||
});
|
||
|
||
if (users.length === 0) {
|
||
return 0;
|
||
}
|
||
|
||
let message = `${users.length} currently logged in users:`;
|
||
|
||
for (const user of users) {
|
||
message += `\n- ${user.username} on ${user.terminal}${user.ip ? ` from ${user.ip}` : ""}`;
|
||
}
|
||
|
||
return message;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
let isQuiet = false;
|
||
const cwd = import.meta.dirname ? dirname(import.meta.dirname) : process.cwd();
|
||
const testsPath = join(cwd, "test");
|
||
const ciRemapServerPath = join(cwd, "scripts", "ci-remap-server");
|
||
|
||
const runnerStartedAt = Date.now();
|
||
const jobBudgetMs = () => {
|
||
const minutes = parseInt(process.env.BUILDKITE_TIMEOUT || "", 10);
|
||
if (!Number.isFinite(minutes) || minutes <= 0) return Infinity;
|
||
return minutes * 60_000 - (Date.now() - runnerStartedAt);
|
||
};
|
||
const spawnTimeout = 5_000;
|
||
const spawnBunTimeout = 20_000; // when running with ASAN/LSAN bun can take a bit longer to exit, not a bug.
|
||
const testTimeout = 3 * 60_000;
|
||
const integrationTimeout = 5 * 60_000;
|
||
|
||
const resolutionGatingFlags = new Set([
|
||
"--expose-internals",
|
||
"--experimental-quic",
|
||
"--experimental-stream-iter",
|
||
"--no-warnings",
|
||
]);
|
||
|
||
function getNodeParallelTestTimeout(testPath: string): number {
|
||
if (testPath.includes("test-dns")) return 60_000;
|
||
if (testPath.includes("test-cluster-")) return 60_000; // cluster IPC + socket-handle passing is process-heavy under runner concurrency
|
||
if (testPath.includes("-docker-")) return 60_000;
|
||
if (testPath.includes("test-stdin-pipe-large")) return 60_000; // pipes 1MB stdin->stdout through an extra child process; slow under runner concurrency
|
||
// test-fs-read-stream-pos.js exit condition is a pure timing race (writer must append
|
||
// between two consecutive ReadStream preads) with a 90s upstream safety timer; solo
|
||
// runtimes are ~1s on linux-x64 but 1-40s on Windows since #34834 raised its timer
|
||
// resolution, and aarch64 CI retries running alone have exceeded 20s (builds 85866,
|
||
// 85400). 120s lets the safety timer fire.
|
||
if (testPath.includes("test-fs-read-stream-pos")) return 120_000;
|
||
if (!isCI) return 60_000; // everything slower in debug mode
|
||
if (options.step?.includes("-asan-")) return 60_000;
|
||
return 20_000;
|
||
}
|
||
|
||
process.on("SIGTRAP", () => {
|
||
console.warn("Test runner received SIGTRAP. Doing nothing.");
|
||
});
|
||
|
||
const { values: options, positionals: filters } = parseArgs({
|
||
allowPositionals: true,
|
||
options: {
|
||
["node-tests"]: {
|
||
type: "boolean",
|
||
default: false,
|
||
},
|
||
/** Path to bun binary */
|
||
["exec-path"]: {
|
||
type: "string",
|
||
default: "bun",
|
||
},
|
||
["step"]: {
|
||
type: "string",
|
||
default: undefined,
|
||
},
|
||
["build-id"]: {
|
||
type: "string",
|
||
default: undefined,
|
||
},
|
||
["bail"]: {
|
||
type: "boolean",
|
||
default: false,
|
||
},
|
||
["shard"]: {
|
||
type: "string",
|
||
default: process.env.BUILDKITE_PARALLEL_JOB || "0",
|
||
},
|
||
["max-shards"]: {
|
||
type: "string",
|
||
default: process.env.BUILDKITE_PARALLEL_JOB_COUNT || "1",
|
||
},
|
||
["include"]: {
|
||
type: "string",
|
||
multiple: true,
|
||
default: undefined,
|
||
},
|
||
["exclude"]: {
|
||
type: "string",
|
||
multiple: true,
|
||
default: undefined,
|
||
},
|
||
["quiet"]: {
|
||
type: "boolean",
|
||
default: false,
|
||
},
|
||
["smoke"]: {
|
||
type: "string",
|
||
default: undefined,
|
||
},
|
||
["vendor"]: {
|
||
type: "string",
|
||
default: undefined,
|
||
},
|
||
["retries"]: {
|
||
type: "string",
|
||
default: isCI ? "3" : "0", // N retries = N+1 attempts
|
||
},
|
||
["junit"]: {
|
||
type: "boolean",
|
||
default: false, // Disabled for now, because it's too much $
|
||
},
|
||
["junit-temp-dir"]: {
|
||
type: "string",
|
||
default: "junit-reports",
|
||
},
|
||
["junit-upload"]: {
|
||
type: "boolean",
|
||
default: isBuildkite,
|
||
},
|
||
["coredump-upload"]: {
|
||
type: "boolean",
|
||
default: isBuildkite && isLinux && !isAndroid,
|
||
},
|
||
["parallel"]: {
|
||
type: "boolean",
|
||
default: false,
|
||
},
|
||
/** Write per-file results as JSON to this path (for test-fix workflows). */
|
||
["results-json"]: {
|
||
type: "string",
|
||
default: undefined,
|
||
},
|
||
},
|
||
});
|
||
|
||
const cliOptions = options;
|
||
|
||
if (cliOptions.junit) {
|
||
try {
|
||
cliOptions["junit-temp-dir"] = mkdtempSync(join(tmpdir(), cliOptions["junit-temp-dir"]));
|
||
} catch (err) {
|
||
cliOptions.junit = false;
|
||
console.error(`Error creating JUnit temp directory: ${err instanceof Error ? err.message : err}`);
|
||
}
|
||
}
|
||
|
||
if (options.quiet) {
|
||
isQuiet = true;
|
||
}
|
||
|
||
let allFiles: string[] = [];
|
||
let newFiles: string[] = [];
|
||
if (isBuildkite) {
|
||
// The pipeline-upload step (.buildkite/ci.ts) already fetched the PR file
|
||
// list once and stored it as build meta-data. Read it from there so each of
|
||
// the ~150 test shards doesn't repeat the GitHub API call and burn through
|
||
// the token's hourly rate limit.
|
||
const cachedAll = getBuildMetadata("pr-all-files");
|
||
const cachedNew = getBuildMetadata("pr-new-files");
|
||
if (cachedAll) {
|
||
try {
|
||
allFiles = JSON.parse(cachedAll) as string[];
|
||
newFiles = cachedNew ? (JSON.parse(cachedNew) as string[]) : [];
|
||
console.log(`- PR file list from build meta-data: ${allFiles.length} files, ${newFiles.length} new files`);
|
||
} catch (e) {
|
||
console.error("Failed to parse pr-*-files meta-data:", e);
|
||
allFiles = [];
|
||
newFiles = [];
|
||
}
|
||
}
|
||
if (allFiles.length === 0) {
|
||
try {
|
||
({ allFiles, newFiles } = await getPullRequestFiles());
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set whenever options["coredump-upload"] is on, which is also the only time it is read.
|
||
let coresDir: string | undefined;
|
||
|
||
if (options["coredump-upload"]) {
|
||
// the image's bake sets this sysctl to /var/bun-cores-$distro-$release-$arch (the `coreDumps` tool of scripts/build/ci-images/spec.ts)
|
||
const sysctl = await spawnWithTimeout({ command: "sysctl", args: ["-n", "kernel.core_pattern"] });
|
||
coresDir = sysctl.stdout;
|
||
if (sysctl.ok) {
|
||
if (coresDir.startsWith("|")) {
|
||
throw new Error("cores are being piped not saved");
|
||
}
|
||
// change /foo/bar/%e-%p.core to /foo/bar
|
||
coresDir = dirname(sysctl.stdout);
|
||
} else {
|
||
throw new Error(`Failed to check core_pattern: ${sysctl.error}`);
|
||
}
|
||
}
|
||
|
||
let remapPort: number | undefined = undefined;
|
||
|
||
interface TestExpectation {
|
||
filename: string;
|
||
expectations: string[];
|
||
bugs: string[] | undefined;
|
||
modifiers: string[] | undefined;
|
||
comment: string | undefined;
|
||
}
|
||
|
||
function getTestExpectations(): TestExpectation[] {
|
||
const expectationsPath = join(cwd, "test", "expectations.txt");
|
||
if (!existsSync(expectationsPath)) {
|
||
return [];
|
||
}
|
||
const lines = readFileSync(expectationsPath, "utf-8").split(/\r?\n/);
|
||
|
||
const expectations: TestExpectation[] = [];
|
||
|
||
for (const line of lines) {
|
||
const content = line.trim();
|
||
if (!content || content.startsWith("#")) {
|
||
continue;
|
||
}
|
||
|
||
let comment: string | undefined;
|
||
const commentIndex = content.indexOf("#");
|
||
let cleanLine = content;
|
||
if (commentIndex !== -1) {
|
||
comment = content.substring(commentIndex + 1).trim();
|
||
cleanLine = content.substring(0, commentIndex).trim();
|
||
}
|
||
|
||
let modifiers: string[] = [];
|
||
let remaining = cleanLine;
|
||
let modifierMatch = remaining.match(/^\[(.*?)\]/);
|
||
if (modifierMatch) {
|
||
modifiers = modifierMatch[1]!.trim().split(/\s+/);
|
||
remaining = remaining.substring(modifierMatch[0].length).trim();
|
||
}
|
||
|
||
let expectationValues = ["Skip"];
|
||
const expectationMatch = remaining.match(/\[(.*?)\]$/);
|
||
if (expectationMatch) {
|
||
expectationValues = expectationMatch[1]!.trim().split(/\s+/);
|
||
remaining = remaining.substring(0, remaining.length - expectationMatch[0].length).trim();
|
||
}
|
||
|
||
const filename = remaining.trim();
|
||
if (filename) {
|
||
expectations.push({
|
||
filename,
|
||
expectations: expectationValues,
|
||
bugs: undefined,
|
||
modifiers: modifiers.length ? modifiers : undefined,
|
||
comment,
|
||
});
|
||
}
|
||
}
|
||
|
||
return expectations;
|
||
}
|
||
|
||
const skipsForExceptionValidation = (() => {
|
||
const path = join(cwd, "test/no-validate-exceptions.txt");
|
||
if (!existsSync(path)) {
|
||
return [];
|
||
}
|
||
return readFileSync(path, "utf-8")
|
||
.split("\n")
|
||
.map(line => line.trim())
|
||
.filter(line => !line.startsWith("#") && line.length > 0);
|
||
})();
|
||
|
||
const skipsForLeaksan = (() => {
|
||
const path = join(cwd, "test/no-validate-leaksan.txt");
|
||
if (!existsSync(path)) {
|
||
return [];
|
||
}
|
||
return readFileSync(path, "utf-8")
|
||
.split("\n")
|
||
.map(line => line.trim())
|
||
.filter(line => !line.startsWith("#") && line.length > 0);
|
||
})();
|
||
|
||
// Informational: a file that passes only on a retry and is not listed here is annotated as a new flake.
|
||
const flakyTests = (() => {
|
||
const path = join(cwd, "test/flaky-tests.txt");
|
||
if (!existsSync(path)) return new Set<string>();
|
||
return new Set(
|
||
readFileSync(path, "utf-8")
|
||
.split("\n")
|
||
.map(line => line.split("#")[0]!.trim())
|
||
.filter(line => line.length > 0),
|
||
);
|
||
})();
|
||
const isKnownFlakyTest = (title: string) => flakyTests.has(title.replaceAll("\\", "/"));
|
||
|
||
/** The fields of test/parallel-allowlist.json that are read here. */
|
||
interface ParallelAllowlist {
|
||
dirs: string[];
|
||
excludeFiles: string[];
|
||
}
|
||
|
||
const parallelAllowlist = (() => {
|
||
try {
|
||
const { dirs, excludeFiles } = JSON.parse(
|
||
readFileSync(join(cwd, "test", "parallel-allowlist.json"), "utf-8"),
|
||
) as ParallelAllowlist;
|
||
return { dirs: new Set(dirs), excludeFiles: new Set(excludeFiles) };
|
||
} catch (error) {
|
||
console.warn("test/parallel-allowlist.json not loaded:", (error instanceof Error && error.message) || error);
|
||
return { dirs: new Set<string>(), excludeFiles: new Set<string>() };
|
||
}
|
||
})();
|
||
|
||
const isParallelAllowlisted = (testPath: string) => {
|
||
const path = testPath.replaceAll("\\", "/");
|
||
const slash = path.lastIndexOf("/");
|
||
return (
|
||
parallelAllowlist.dirs.has(slash === -1 ? "" : path.slice(0, slash)) && !parallelAllowlist.excludeFiles.has(path)
|
||
);
|
||
};
|
||
|
||
const dockerServicePrefixes = Object.keys(dockerPrestartMap);
|
||
const needsDockerService = (testPath: string) => {
|
||
const path = testPath.replaceAll("\\", "/");
|
||
return dockerServicePrefixes.some(prefix => path.startsWith(prefix));
|
||
};
|
||
|
||
/**
|
||
* Returns whether we should validate exception checks running the given test
|
||
*/
|
||
const shouldValidateExceptions = (test: string): boolean => {
|
||
// Skip-list entries use `/`; on Windows callers pass `\`-separated paths
|
||
// (path.relative) which never match. Normalize before lookup.
|
||
const t = test.replaceAll(sep, "/");
|
||
return !(skipsForExceptionValidation.includes(t) || skipsForExceptionValidation.includes("test/" + t));
|
||
};
|
||
|
||
/**
|
||
* Returns whether we should validate exception checks running the given test
|
||
*/
|
||
const shouldValidateLeakSan = (test: string): boolean => {
|
||
return !(skipsForLeaksan.includes(test) || skipsForLeaksan.includes("test/" + test));
|
||
};
|
||
|
||
function getTestModifiers(testPath: string): string[] {
|
||
const ext = extname(testPath);
|
||
const filename = basename(testPath, ext);
|
||
const modifiers = filename.split("-").filter(value => value !== "bun");
|
||
|
||
const os = getOs();
|
||
const arch = getArch();
|
||
modifiers.push(os, arch, `${os}-${arch}`);
|
||
|
||
const distro = getDistro();
|
||
if (distro) {
|
||
modifiers.push(distro, `${os}-${distro}`, `${os}-${arch}-${distro}`);
|
||
const distroVersion = getDistroVersion();
|
||
if (distroVersion) {
|
||
modifiers.push(
|
||
distroVersion,
|
||
`${distro}-${distroVersion}`,
|
||
`${os}-${distro}-${distroVersion}`,
|
||
`${os}-${arch}-${distro}-${distroVersion}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const abi = getAbi();
|
||
if (abi) {
|
||
modifiers.push(abi, `${os}-${abi}`, `${os}-${arch}-${abi}`);
|
||
const abiVersion = getAbiVersion();
|
||
if (abiVersion) {
|
||
modifiers.push(
|
||
abiVersion,
|
||
`${abi}-${abiVersion}`,
|
||
`${os}-${abi}-${abiVersion}`,
|
||
`${os}-${arch}-${abi}-${abiVersion}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return modifiers.map(value => value.toUpperCase());
|
||
}
|
||
|
||
/**
|
||
* Smoke-checks that this agent actually matches the platform the CI step was
|
||
* generated for. The step exports EXPECTED_PLATFORM_* (see getTestBunStep in
|
||
* .buildkite/ci.ts); we compare against the same utils the agent uses to
|
||
* emit its tags. Catches misrouted jobs (e.g. a macOS 26 step landing on a
|
||
* macOS 15 box) before any test runs, instead of producing silently-wrong
|
||
* results for a whole shard.
|
||
*/
|
||
function assertExpectedPlatform() {
|
||
const expectedOs = process.env.EXPECTED_PLATFORM_OS;
|
||
if (!expectedOs) {
|
||
return; // step does not declare expectations (e.g. local runs)
|
||
}
|
||
|
||
const checks: [key: string, expected: string | undefined, actual: string | undefined][] = [
|
||
["os", expectedOs, getOs()],
|
||
["arch", process.env.EXPECTED_PLATFORM_ARCH, getArch()],
|
||
["abi", process.env.EXPECTED_PLATFORM_ABI, getAbi()],
|
||
["distro", process.env.EXPECTED_PLATFORM_DISTRO, getDistro()],
|
||
];
|
||
|
||
const expectedRelease = process.env.EXPECTED_PLATFORM_RELEASE;
|
||
if (expectedRelease) {
|
||
// Major-version prefix match: "26" accepts "26.4", "25.04" accepts "25.04.1".
|
||
const actualRelease = getDistroVersion();
|
||
const ok = actualRelease === expectedRelease || `${actualRelease}.`.startsWith(`${expectedRelease}.`);
|
||
checks.push(["release", expectedRelease, ok ? expectedRelease : actualRelease]);
|
||
}
|
||
|
||
// Fail closed: a declared expectation with no detectable actual value is a
|
||
// mismatch (e.g. a musl step on a box where the ABI probe fails).
|
||
const mismatches = checks.filter(([, expected, actual]) => expected && expected !== actual);
|
||
if (mismatches.length) {
|
||
const details = mismatches
|
||
.map(([k, e, a]) => `${k}: step expects "${e}", agent is ${a ? `"${a}"` : "(undetected)"}`)
|
||
.join("\n ");
|
||
console.error(`Platform smoke check FAILED, this job landed on the wrong agent:\n ${details}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
!isQuiet &&
|
||
console.log(
|
||
"Platform check:",
|
||
checks
|
||
.filter(([, e]) => e)
|
||
.map(([k, e]) => `${k}=${e}`)
|
||
.join(" "),
|
||
"(ok)",
|
||
);
|
||
}
|
||
|
||
type Limit = <T>(fn: () => Promise<T>) => Promise<T>;
|
||
|
||
/**
|
||
* Returns a function that runs at most `concurrency` of the functions passed to it at once.
|
||
* The rest start in the order they were passed, each when an earlier one settles, whether
|
||
* that one resolved or rejected.
|
||
*/
|
||
function createLimit(concurrency: number): Limit {
|
||
const waiting: (() => void)[] = [];
|
||
let running = 0;
|
||
return async fn => {
|
||
if (running < concurrency) {
|
||
running++;
|
||
} else {
|
||
await new Promise<void>(resolve => waiting.push(resolve));
|
||
}
|
||
try {
|
||
return await fn();
|
||
} finally {
|
||
// The slot goes straight to the next in line, so that a later caller cannot take it first.
|
||
const next = waiting.shift();
|
||
if (next) next();
|
||
else running--;
|
||
}
|
||
};
|
||
}
|
||
|
||
async function runTests(): Promise<TestResult[]> {
|
||
assertExpectedPlatform();
|
||
|
||
let execPath: string;
|
||
if (options.step) {
|
||
execPath = await getExecPathFromBuildkite(options.step, options["build-id"]);
|
||
} else {
|
||
execPath = getExecPath(options["exec-path"]);
|
||
}
|
||
!isQuiet && console.log("Bun:", execPath);
|
||
|
||
const revision = getRevision(execPath);
|
||
!isQuiet && console.log("Revision:", revision);
|
||
|
||
const expectations = getTestExpectations();
|
||
const modifiers = getTestModifiers(execPath);
|
||
// "1.2.3+abcdef123", and not "1.2.3-canary.1+abcdef123" or "1.2.3-debug+abcdef123".
|
||
if (/^\d+\.\d+\.\d+(\+|$)/.test(revision)) modifiers.push("STABLE");
|
||
!isQuiet && console.log("Modifiers:", modifiers);
|
||
|
||
const tests = getRelevantTests(testsPath, modifiers, expectations);
|
||
!isQuiet && console.log("Running tests:", tests.length);
|
||
|
||
// Start the docker-service coordinator (test/docker/coordinator.ts). It
|
||
// owns every `docker compose` invocation for this shard — `compose up` is
|
||
// not concurrency-safe, so exactly one process runs it — and prestarts the
|
||
// services this shard's tests need (mysql/postgres/redis/…) in the
|
||
// background while getVendorTests below installs vendor deps. Tests reach
|
||
// it through the unix socket in BUN_DOCKER_COORDINATOR (inherited by every
|
||
// spawned test process); ensure() waits for the coordinator's ready message
|
||
// instead of shelling out to compose itself. Without the env var, ensure()
|
||
// falls back to running compose directly. Linux-only — macOS / Windows CI
|
||
// don't run docker tests. Lifetime is tied to this process via the stdin
|
||
// pipe.
|
||
if (isCI && isLinux && spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0) {
|
||
const coordinatorSocket = join(tmpdir(), `bun-docker-${process.pid}.sock`);
|
||
const coordinator = spawn(execPath, [join(cwd, "test", "docker", "coordinator.ts"), ...tests], {
|
||
stdio: ["pipe", "pipe", "inherit"],
|
||
env: { ...process.env, BUN_DOCKER_COORDINATOR_SOCKET: coordinatorSocket },
|
||
}) as ChildProcessByStdio<Socket, Socket, null>; // see SpawnedProcess
|
||
coordinator.on("error", err => console.warn("docker coordinator spawn failed:", err.message));
|
||
process.once("exit", () => coordinator.kill());
|
||
|
||
// Don't point tests at the socket until it's actually listening; if the
|
||
// coordinator never gets there, tests use the direct-compose fallback.
|
||
const ready = await new Promise<boolean>(resolve => {
|
||
const timer = setTimeout(() => resolve(false), 15_000);
|
||
timer.unref?.();
|
||
let buffered: string | null = "";
|
||
coordinator.stdout.on("data", (chunk: Buffer) => {
|
||
process.stdout.write(chunk);
|
||
if (buffered !== null) {
|
||
buffered += chunk;
|
||
if (buffered.includes("COORDINATOR_READY")) {
|
||
buffered = null;
|
||
clearTimeout(timer);
|
||
resolve(true);
|
||
}
|
||
}
|
||
});
|
||
coordinator.once("exit", () => {
|
||
clearTimeout(timer);
|
||
resolve(false);
|
||
});
|
||
coordinator.once("error", () => {
|
||
clearTimeout(timer);
|
||
resolve(false);
|
||
});
|
||
});
|
||
if (ready) {
|
||
process.env.BUN_DOCKER_COORDINATOR = coordinatorSocket;
|
||
} else {
|
||
console.warn("docker coordinator did not become ready; tests will run compose directly");
|
||
coordinator.kill();
|
||
}
|
||
// Never keep the runner alive on the coordinator's account; it exits on
|
||
// stdin EOF when the runner is gone.
|
||
coordinator.unref();
|
||
coordinator.stdin?.unref?.();
|
||
coordinator.stdout?.unref?.();
|
||
}
|
||
|
||
let vendorTests: VendorTest[] | undefined;
|
||
let vendorTotal = 0;
|
||
if (
|
||
(options.vendor !== undefined && /true|1|yes|on/i.test(options.vendor)) ||
|
||
(isCI && typeof options.vendor === "undefined")
|
||
) {
|
||
vendorTests = await getVendorTests(cwd);
|
||
if (vendorTests.length) {
|
||
vendorTotal = vendorTests.reduce((total, { testPaths }) => total + testPaths.length + 1, 0);
|
||
!isQuiet && console.log("Running vendor tests:", vendorTotal);
|
||
}
|
||
}
|
||
|
||
let i = 0;
|
||
let total = vendorTotal + tests.length + 2;
|
||
|
||
const okResults: TestResult[] = [];
|
||
const flakyResults: TestResult[] = [];
|
||
const flakyResultsTitles: string[] = [];
|
||
const failedResults: TestResult[] = [];
|
||
const failedResultsTitles: string[] = [];
|
||
const maxAttempts = 1 + (parseInt(options.retries) || 0);
|
||
|
||
const parallelism = options.parallel ? availableParallelism() : 1;
|
||
console.log("parallelism", parallelism);
|
||
const limit = createLimit(parallelism);
|
||
|
||
// Test paths that are process-parallel safe by naming convention
|
||
// (js/node/test/parallel/, js/bun/test/parallel/): ~2.8k files with a ~50ms
|
||
// median that spend almost all their wall time in `bun` startup. These run
|
||
// as a separate N-wide phase AFTER every other test in the shard has
|
||
// finished, so no parallel-safe process ever overlaps a serial test (an
|
||
// overlap surfaced new flakes in tight-timeout / port-bind tests such as
|
||
// dns-tcp-bidirectional-poll and test-https-timeout). `--parallel` already
|
||
// widens `limit` above and supersedes this split. macOS is capped at 4: the
|
||
// Intel minis are 6-core i7-8700B (12 HT threads => width 11), and each
|
||
// M2 Ultra host runs two 18-vCPU tart VMs (width 17 apiece, 34 on 24
|
||
// cores when both are busy); both hit the 45-minute job timeout uncapped.
|
||
const parallelSafeCap = isMacOS ? 4 : Infinity;
|
||
const parallelSafeWidth =
|
||
parallelism > 1 ? parallelism : Math.min(parallelSafeCap, Math.max(availableParallelism() - 1, 1));
|
||
const parallelSafeLimit = parallelism > 1 ? limit : createLimit(parallelSafeWidth);
|
||
const isParallelSafeTest = (testPath: string) => {
|
||
const p = testPath.replaceAll("\\", "/");
|
||
if (!p.includes("js/node/test/parallel/") && !p.includes("js/bun/test/parallel/")) return false;
|
||
// test-fs-read-stream-pos.js arms a common.mustCallAtLeast per stream; under
|
||
// I/O-heavy neighbours (e.g. test-fs-read-stream-fd-leak) the 1ms append
|
||
// interval is starved long enough for a stream to catch cur == EOF and get
|
||
// zero 'data' events, failing the mustCallAtLeast check on exit. Run it in
|
||
// the serial phase so the writer keeps its 1ms cadence.
|
||
if (p.endsWith("test-fs-read-stream-pos.js")) return false;
|
||
return true;
|
||
};
|
||
console.log("parallel-safe width", parallelSafeWidth);
|
||
const validationApplies = basename(execPath).includes("asan") || !isCI;
|
||
|
||
/**
|
||
* @param concurrent this call may overlap with other runTest calls
|
||
*/
|
||
const runTest = async (
|
||
title: string,
|
||
fn: (index: number) => Promise<TestResult>,
|
||
concurrent = parallelism > 1,
|
||
): Promise<TestResult | undefined> => {
|
||
const index = ++i;
|
||
|
||
let result: TestResult | undefined, failure: TestResult | undefined, flaky: boolean | undefined;
|
||
let attempt = 1;
|
||
for (; attempt <= maxAttempts; attempt++) {
|
||
if (attempt > 1) {
|
||
await new Promise(resolve => setTimeout(resolve, 5000 + Math.random() * 10_000));
|
||
}
|
||
|
||
let grouptitle = `${getAnsi("gray")}[${index}/${total}]${getAnsi("reset")} ${title}`;
|
||
if (attempt > 1) grouptitle += ` ${getAnsi("gray")}[attempt #${attempt}]${getAnsi("reset")}`;
|
||
|
||
if (concurrent) {
|
||
console.log(grouptitle);
|
||
result = await fn(index);
|
||
} else {
|
||
result = await startGroup(grouptitle, () => fn(index));
|
||
}
|
||
|
||
const { ok, stdoutPreview, error } = result;
|
||
if (ok) {
|
||
if (failure) {
|
||
flakyResults.push(failure);
|
||
flakyResultsTitles.push(title);
|
||
} else {
|
||
okResults.push(result);
|
||
}
|
||
break;
|
||
}
|
||
|
||
const color = attempt >= maxAttempts ? "red" : "yellow";
|
||
const label = `${getAnsi(color)}[${index}/${total}] ${title} - ${error}${getAnsi("reset")}`;
|
||
if (concurrent) {
|
||
// Don't open a group mid-phase: it would re-anchor the log viewer's
|
||
// folding for every concurrent title printed after it.
|
||
console.log(label);
|
||
} else {
|
||
startGroup(label, () => {
|
||
if (!isCI) return;
|
||
process.stderr.write(stdoutPreview);
|
||
});
|
||
}
|
||
|
||
failure ||= result;
|
||
flaky ||= true;
|
||
|
||
if (attempt >= maxAttempts || isAlwaysFailure(error)) {
|
||
flaky = false;
|
||
failedResults.push(failure);
|
||
failedResultsTitles.push(title);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!failure) {
|
||
return result;
|
||
}
|
||
|
||
if (isBuildkite) {
|
||
// Group flaky tests together, regardless of the title; unlisted flakes get their own group above the known ones.
|
||
const newFlaky = flaky && !title.endsWith("package.json") && !isKnownFlakyTest(title);
|
||
const context = flaky ? (newFlaky ? "flaky-new" : "flaky") : title;
|
||
const style = flaky ? "warning" : "error";
|
||
const priority = newFlaky ? 4 : 3;
|
||
if (!flaky) attempt = 1; // no need to show the retries count on failures, we know it maxed out
|
||
|
||
const result = title.startsWith("vendor") ? { ...failure, testPath: title } : failure;
|
||
const content = formatTestToMarkdown(result, false, attempt - 1, newFlaky);
|
||
if (content) {
|
||
reportAnnotationToBuildkite({ context, label: title, content, style, priority });
|
||
}
|
||
}
|
||
|
||
if (isGithubAction) {
|
||
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
||
if (summaryPath) {
|
||
const longMarkdown = formatTestToMarkdown(failure, false, attempt - 1);
|
||
appendFileSync(summaryPath, longMarkdown);
|
||
}
|
||
const shortMarkdown = formatTestToMarkdown(failure, true, attempt - 1);
|
||
appendFileSync("comment.md", shortMarkdown);
|
||
}
|
||
|
||
if (options.bail) {
|
||
markBuildkiteStepReported();
|
||
process.exit(getExitCode("fail"));
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
if (!isQuiet) {
|
||
for (const path of [cwd, testsPath]) {
|
||
const title = relative(cwd, join(path, "package.json")).replace(/\\/g, "/");
|
||
await runTest(title, async () => spawnBunInstall(execPath, { cwd: path }));
|
||
}
|
||
}
|
||
|
||
const isNapiAddonTest = (t: string) => /napi[\\/]node-napi-tests[\\/].*[\\/]do\.test\.ts$/.test(t);
|
||
const napiAddonDirs = [...new Set(tests.filter(isNapiAddonTest).map(t => dirname(join(testsPath, t))))];
|
||
let napiPrebuild: Promise<{ ok: boolean; error: SpawnResult["error"]; output: string; seconds: number }> | null =
|
||
null;
|
||
if (napiAddonDirs.length) {
|
||
let output = "";
|
||
const started = Date.now();
|
||
napiPrebuild = spawnBun(execPath, {
|
||
args: [join(testsPath, "napi/node-napi-tests/prebuild.ts"), ...napiAddonDirs],
|
||
cwd,
|
||
timeout: integrationTimeout,
|
||
stdout: chunk => (output += chunk),
|
||
stderr: chunk => (output += chunk),
|
||
}).then(({ ok, error }) => ({ ok, error, output, seconds: (Date.now() - started) / 1000 }));
|
||
}
|
||
const awaitNapiPrebuild = async (testPath: string) => {
|
||
if (napiPrebuild && isNapiAddonTest(testPath)) {
|
||
const { ok, error, output, seconds } = await napiPrebuild;
|
||
napiPrebuild = null;
|
||
startGroup(`napi prebuild: ${napiAddonDirs.length} addon(s), ${seconds.toFixed(1)}s`);
|
||
process.stdout.write(output);
|
||
if (!ok) console.warn("napi prebuild failed; each do.test.ts builds its own addon:", error);
|
||
}
|
||
};
|
||
|
||
if (!failedResults.length) {
|
||
// TODO: remove windows exclusion here
|
||
if (isCI && !isWindows && (await installCiRemapServer(execPath))) {
|
||
const { promise: portPromise, resolve: portResolve } = Promise.withResolvers<{ port: number }>();
|
||
const { promise: errorPromise, resolve: errorResolve } = Promise.withResolvers<Error | string>();
|
||
let exiting = false;
|
||
|
||
const server = spawn(execPath, ["run", "--silent", "ci-remap-server", execPath, cwd, String(getCommit())], {
|
||
stdio: ["ignore", "pipe", "inherit"],
|
||
cwd: ciRemapServerPath,
|
||
env: { ...process.env, BUN_DEBUG_QUIET_LOGS: "1", NO_COLOR: "1" },
|
||
});
|
||
server.unref();
|
||
server.on("error", errorResolve);
|
||
server.on("exit", (code, signal) => {
|
||
if (!exiting && (code !== 0 || signal !== null)) errorResolve(signal ? signal : "code " + code);
|
||
});
|
||
function onBeforeExit() {
|
||
exiting = true;
|
||
server.removeAllListeners("error");
|
||
server.removeAllListeners("exit");
|
||
server.kill?.();
|
||
}
|
||
process.once("beforeExit", onBeforeExit);
|
||
const lines = createInterface(server.stdout);
|
||
lines.on("line", line => {
|
||
portResolve({ port: parseInt(line) });
|
||
});
|
||
|
||
const result: unknown = await Promise.race([
|
||
portPromise,
|
||
errorPromise.catch((e: unknown) => e),
|
||
setTimeoutPromise(5000, "timeout"),
|
||
]);
|
||
if (typeof result !== "object" || result === null || !("port" in result) || typeof result.port != "number") {
|
||
process.off("beforeExit", onBeforeExit);
|
||
server.kill?.();
|
||
console.warn("ci-remap server did not start:", result);
|
||
} else {
|
||
console.log("crash reports parsed on port", result.port);
|
||
remapPort = result.port;
|
||
}
|
||
}
|
||
|
||
const runOneTest = async (testPath: string, concurrent: boolean) => {
|
||
await awaitNapiPrebuild(testPath);
|
||
const absoluteTestPath = join(testsPath, testPath);
|
||
const title = relative(cwd, absoluteTestPath).replaceAll(sep, "/");
|
||
if (isNodeTest(testPath)) {
|
||
const testContent = readFileSync(absoluteTestPath, "utf-8");
|
||
const flagsMatch = /^\/\/ Flags:[^\S\r\n]+(--[^\r\n]*)$/m.exec(testContent);
|
||
const testFlags = flagsMatch
|
||
? // The group of the pattern is not optional, and split() returns at least one element.
|
||
flagsMatch[1]!.split(/\s+/).filter(flag => resolutionGatingFlags.has(flag.split("=")[0]!))
|
||
: [];
|
||
let runWithBunTest = title.includes("needs-test") || testContent.includes("node:test");
|
||
// don't wanna have a filter for includes("bun:test") but these need our mocks
|
||
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-append-file-flush.js";
|
||
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-write-file-flush.js";
|
||
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-write-stream-flush.js";
|
||
// A file that only drives node:test's run() is the parent of the run,
|
||
// not a test file: Node executes it as a plain script, and under
|
||
// `bun test` a file registering no tests of its own exits before its
|
||
// run() finishes. Files that also register tests at the top level (as
|
||
// opposed to inside a NODE_TEST_CONTEXT child branch) still need
|
||
// `bun test`. run() spawns its own children with `bun test`.
|
||
const importsRun =
|
||
/\brun\b[^\n]*=\s*require\(['"]node:test['"]\)/.test(testContent) ||
|
||
/import\s*{[^}]*\brun\b[^}]*}\s*from\s*['"]node:test['"]/.test(testContent);
|
||
// Registrations behind a NODE_TEST_CONTEXT guard belong to the child
|
||
// run() spawns, not to this process; an unindented one is this file's
|
||
// own and must keep `bun test`, or it silently never runs and the file
|
||
// "passes" having tested nothing. Requiring the guard as well keeps a
|
||
// file whose only registrations are indented for some other reason
|
||
// (inside an `if`, an IIFE) on `bun test`, where the worst case is a
|
||
// real run rather than a vacuous pass.
|
||
const registersAtColumnZero = /^(?:test|it|describe|suite)\s*[.(]/m.test(testContent);
|
||
const registersTests = /(?:^|[^.\w])(?:test|it|describe|suite)\s*[.(]/m.test(testContent);
|
||
const guardsOnTestContext = testContent.includes("NODE_TEST_CONTEXT");
|
||
const isRunDriver = importsRun && !registersAtColumnZero && (!registersTests || guardsOnTestContext);
|
||
// The needs-test filename opt-in wins over this heuristic.
|
||
if (isRunDriver && !title.includes("needs-test")) runWithBunTest = false;
|
||
const subcommand = runWithBunTest ? "test" : "run";
|
||
const env: SpawnEnv = {
|
||
FORCE_COLOR: "0",
|
||
NO_COLOR: "1",
|
||
BUN_DEBUG_QUIET_LOGS: "1",
|
||
// Node parity: a node test process exits only when its event loop
|
||
// drains, and common.mustCall() verifies counts in 'exit' handlers.
|
||
BUN_TEST_DRAIN_EVENT_LOOP: "1",
|
||
};
|
||
if (title.includes("test-util-styletext")) {
|
||
// These assert styleText's own color decisions against a TTY, so they need a
|
||
// color-capable environment they can then override per case. Drop the forced
|
||
// settings above, spawnBun's FORCE_COLOR, and CI (which resolves to "no color"
|
||
// in containers that don't identify the vendor), and pin TERM so every agent
|
||
// agrees.
|
||
env.FORCE_COLOR = undefined;
|
||
env.NO_COLOR = undefined;
|
||
env.NODE_DISABLE_COLORS = undefined;
|
||
env.CI = undefined;
|
||
env.TERM = "xterm-256color";
|
||
}
|
||
if (!isWindows && title.includes("/sequential/")) {
|
||
// Sequential node tests share common.PORT (12346); a cluster worker
|
||
// or child_process subprocess that outlives its test can keep that
|
||
// port bound and flake the next file. --no-orphans wires
|
||
// PR_SET_PDEATHSIG / kqueue parent watch so the whole tree dies with
|
||
// the test process. Scoped to sequential/ because parallel/ has
|
||
// tests that assert a detached grandchild survives its parent
|
||
// (test-child-process-*-detached.js), which this flag defeats.
|
||
env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
|
||
}
|
||
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateExceptions(testPath)) {
|
||
env.BUN_JSC_validateExceptionChecks = "1";
|
||
env.BUN_JSC_dumpSimulatedThrows = "1";
|
||
}
|
||
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateLeakSan(testPath)) {
|
||
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
|
||
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
|
||
// prettier-ignore
|
||
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
|
||
}
|
||
return runTest(
|
||
title,
|
||
async index => {
|
||
const { ok, error, stdout, crashes, exitCode, signalCode, duration } = await spawnBun(execPath, {
|
||
cwd: cwd,
|
||
args: [
|
||
subcommand,
|
||
"--config=" + join(import.meta.dirname, "../bunfig.node-test.toml"),
|
||
...(subcommand === "run" ? testFlags : []),
|
||
absoluteTestPath,
|
||
],
|
||
timeout: getNodeParallelTestTimeout(title),
|
||
env: {
|
||
...env,
|
||
// test/common/tmpdir.js derives `.tmp.${TEST_SERIAL_ID}` from this;
|
||
// a unique value per test file keeps concurrent tmpdir.refresh()
|
||
// calls from wiping each other when parallelSafeWidth > 1.
|
||
TEST_SERIAL_ID: String(index),
|
||
},
|
||
stdout: concurrent ? () => {} : pipeTestStdout(process.stdout),
|
||
stderr: concurrent ? () => {} : pipeTestStdout(process.stderr),
|
||
});
|
||
const mb = 1024 ** 3;
|
||
let stdoutPreview = stdout.slice(0, mb).split("\n").slice(0, 50).join("\n");
|
||
if (crashes) stdoutPreview += crashes;
|
||
return {
|
||
testPath: title,
|
||
ok: ok,
|
||
status: ok ? "pass" : "fail",
|
||
error: error,
|
||
errors: [],
|
||
tests: [],
|
||
stdout: stdout,
|
||
stdoutPreview: stdoutPreview,
|
||
exitCode,
|
||
signalCode,
|
||
duration,
|
||
};
|
||
},
|
||
concurrent,
|
||
);
|
||
} else {
|
||
return runTest(
|
||
title,
|
||
async () =>
|
||
spawnBunTest(execPath, join("test", testPath), {
|
||
cwd,
|
||
stdout: concurrent ? () => {} : pipeTestStdout(process.stdout),
|
||
stderr: concurrent ? () => {} : pipeTestStdout(process.stderr),
|
||
}),
|
||
concurrent,
|
||
);
|
||
}
|
||
};
|
||
|
||
const runParallelBucket = async (bucketFiles: string[]) => {
|
||
for (const t of bucketFiles) await awaitNapiPrebuild(t);
|
||
const width = parallelSafeWidth;
|
||
const label = `${bucketFiles.length} files in parallel (${width}×)`;
|
||
const range = `${getAnsi("gray")}[${i + 1}-${i + bucketFiles.length}/${total}]${getAnsi("reset")}`;
|
||
const junitPath = join(
|
||
cwd,
|
||
`parallel-bucket-junit-${(options.step || getOs()).replace(/[^a-zA-Z0-9._-]/g, "_")}-shard${options.shard ?? 0}.xml`,
|
||
);
|
||
const isAsan = basename(execPath).includes("asan");
|
||
const perTestTimeout = Math.ceil(testTimeout / 2) * (isAsan ? 3 : 1);
|
||
const env: SpawnEnv = {};
|
||
if (validationApplies) {
|
||
env.BUN_JSC_validateExceptionChecks = "1";
|
||
env.BUN_JSC_dumpSimulatedThrows = "1";
|
||
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
|
||
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
|
||
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
|
||
}
|
||
if (isAsan) env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
|
||
|
||
const byPath = new Map(bucketFiles.map(t => [join("test", t).replaceAll("\\", "/"), t]));
|
||
const norm = (p: string) =>
|
||
byPath.get(
|
||
p
|
||
.trim()
|
||
.replace(/:$/, "")
|
||
.replace(/ \(\d+s\)$/, "")
|
||
.replaceAll("\\", "/"),
|
||
);
|
||
|
||
const { ok, stdout, crashes } = await startGroup(`${range} ${label}`, () =>
|
||
spawnBun(execPath, {
|
||
args: [
|
||
"test",
|
||
`--parallel=${width}`,
|
||
`--timeout=${perTestTimeout}`,
|
||
"--dots",
|
||
"--reporter=junit",
|
||
`--reporter-outfile=${junitPath}`,
|
||
...bucketFiles.map(t => join(testsPath, t)),
|
||
],
|
||
cwd,
|
||
timeout: Math.max(
|
||
60_000,
|
||
Math.min(Math.max(10 * 60_000, bucketFiles.length * 5_000), jobBudgetMs() - 5 * 60_000),
|
||
),
|
||
idleTimeout: parseInt(process.env.BUN_RUNNER_BATCH_IDLE_MS || "", 10) || 4 * 60_000,
|
||
gracefulTimeout: true,
|
||
env,
|
||
stdout: pipeTestStdout(process.stdout),
|
||
stderr: pipeTestStdout(process.stderr),
|
||
}),
|
||
);
|
||
if (crashes) process.stderr.write(crashes);
|
||
|
||
let suites = new Map<string, JunitFileSuite>(); // repo-relative path -> { failures, seconds, cases: [{name, message}] }
|
||
try {
|
||
suites = parseJunitFileSuites(readFileSync(junitPath, "utf-8"));
|
||
} catch {}
|
||
if (suites.size && isBuildkite) uploadArtifactsToBuildkite(junitPath);
|
||
else rmSync(junitPath, { force: true });
|
||
|
||
const failed = new Set<string>(); // ran and failed (or hung) — a solo pass is a parallel-mode flake
|
||
const incomplete = new Set<string>(); // never finished/started — re-run quietly
|
||
let evidence = suites.size > 0;
|
||
if (!ok && suites.size) {
|
||
for (const t of bucketFiles) {
|
||
const suite = suites.get(join("test", t).replaceAll("\\", "/"));
|
||
if (suite === undefined) incomplete.add(t);
|
||
else if (suite.failures > 0) failed.add(t);
|
||
}
|
||
} else if (!ok) {
|
||
let list: "running" | "not-started" | null = null; // while inside an interrupt report list
|
||
for (const line of stripAnsi(stdout).split(/\r?\n/)) {
|
||
if (line.startsWith("Interrupted while still running:")) {
|
||
list = "running";
|
||
continue;
|
||
}
|
||
if (/^\d+ file\(s\) had not started:$/.test(line)) {
|
||
list = "not-started";
|
||
continue;
|
||
}
|
||
if (list && /^ {2}\S/.test(line)) {
|
||
const testPath = norm(line);
|
||
if (testPath) (list === "running" ? failed : incomplete).add(testPath);
|
||
continue;
|
||
}
|
||
list = null;
|
||
const ended = /^✗ (.+?) \((worker crashed|aborted:|no live workers)/.exec(line);
|
||
// The groups of the pattern are not optional, and testPath is only set when it matched.
|
||
const testPath = ended && norm(ended[1]!);
|
||
if (testPath) (ended![2] === "worker crashed" ? failed : incomplete).add(testPath);
|
||
}
|
||
evidence = failed.size + incomplete.size > 0;
|
||
}
|
||
const rerun = !ok && !evidence ? [...bucketFiles] : [...failed, ...incomplete];
|
||
const rerunSet = new Set(rerun);
|
||
|
||
for (const t of bucketFiles) {
|
||
if (rerunSet.has(t)) continue;
|
||
const title = join("test", t).replaceAll("\\", "/");
|
||
const seconds = suites.get(title)?.seconds;
|
||
const timing = seconds === undefined ? "" : ` ${getAnsi("gray")}(${seconds.toFixed(2)}s)${getAnsi("reset")}`;
|
||
console.log(`${getAnsi("gray")}[${++i}/${total}]${getAnsi("reset")} ${title}${timing}`);
|
||
okResults.push({
|
||
testPath: title,
|
||
ok: true,
|
||
status: "pass",
|
||
tests: [],
|
||
errors: [],
|
||
stdout: "",
|
||
stdoutPreview: "",
|
||
});
|
||
}
|
||
for (const t of bucketFiles) {
|
||
if (!failed.has(t)) continue;
|
||
const title = join("test", t).replaceAll("\\", "/");
|
||
const suite = suites.get(title);
|
||
if (!suite?.cases.length) continue;
|
||
startGroup(
|
||
`${title} - ${suite.failures} failing in the parallel batch ${getAnsi("gray")}(${suite.seconds.toFixed(2)}s)${getAnsi("reset")}`,
|
||
() => {
|
||
for (const { name, message } of suite.cases) {
|
||
console.log(`${getAnsi("red")}✗${getAnsi("reset")} ${name}`);
|
||
if (message) console.log(message.replace(/^/gm, " "));
|
||
}
|
||
},
|
||
);
|
||
}
|
||
if (rerun.length) {
|
||
console.log(
|
||
`${getAnsi("yellow")}parallel bucket: retrying ${failed.size} failed and ${rerun.length - failed.size} unfinished file(s) one at a time${getAnsi("reset")}`,
|
||
);
|
||
for (const testPath of rerun) {
|
||
const result = await runOneTest(testPath, false);
|
||
if (result?.ok && failed.has(testPath) && isBuildkite) {
|
||
const title = join("test", testPath).replaceAll("\\", "/");
|
||
const cases = suites.get(title)?.cases ?? [];
|
||
const first = cases[0];
|
||
const firstError = first ? `${first.name} — ${first.message.split("\n")[0]}` : "";
|
||
const reason = firstError
|
||
? escapeHtml(firstError.length > 100 ? `${firstError.slice(0, 97)}…` : firstError)
|
||
: "failed in the parallel batch";
|
||
const detail = cases.length
|
||
? `\n\n\`\`\`terminal\n${cases.map(({ name, message }) => `✗ ${name}\n${message}`).join("\n\n")}\n\`\`\`\n\n`
|
||
: "";
|
||
const unlisted = !isKnownFlakyTest(title);
|
||
reportAnnotationToBuildkite({
|
||
context: unlisted ? "flaky-new" : "flaky",
|
||
label: title,
|
||
style: "warning",
|
||
priority: unlisted ? 4 : 3,
|
||
content: `<details><summary><a href="${getFileUrl(title)}"><code>${title}</code></a> - ${reason} <i>(in the parallel batch on ${getBuildLabel()}; passed alone)</i>${unlisted ? " <b>(not in test/flaky-tests.txt)</b>" : ""}</summary>${detail}</details>`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const modifiedTests = new Set(
|
||
allFiles.filter(filename => filename.startsWith("test/")).map(filename => filename.slice("test/".length)),
|
||
);
|
||
const isModified = (t: string) => modifiedTests.has(t.replaceAll("\\", "/"));
|
||
const isBucketCandidate = (t: string) =>
|
||
parallelism === 1 &&
|
||
isTestStrict(t) &&
|
||
!isNodeTest(t) &&
|
||
!/stress/i.test(t) &&
|
||
isParallelAllowlisted(t) &&
|
||
!needsDockerService(t) &&
|
||
(!validationApplies || (shouldValidateExceptions(t) && shouldValidateLeakSan(t)));
|
||
|
||
const serialTests = tests.filter(t => !isParallelSafeTest(t));
|
||
const parallelSafeTests = tests.filter(t => isParallelSafeTest(t));
|
||
const modifiedSerialTests = serialTests.filter(t => isModified(t));
|
||
const bucketCandidates = serialTests.filter(t => !isModified(t) && isBucketCandidate(t));
|
||
const bucketable = bucketCandidates.length >= 8 ? new Set(bucketCandidates) : new Set<string>();
|
||
const restSerialTests = serialTests.filter(t => !isModified(t) && !bucketable.has(t));
|
||
|
||
await Promise.all(modifiedSerialTests.map(t => limit(() => runOneTest(t, parallelism > 1))));
|
||
await Promise.all(restSerialTests.map(t => limit(() => runOneTest(t, parallelism > 1))));
|
||
if (bucketable.size) await runParallelBucket(bucketCandidates);
|
||
// Concurrent tests log their title without opening a group (interleaved
|
||
// output can't nest), so give the phase its own group instead of letting
|
||
// the log viewer fold every line under the last serial test's group.
|
||
if (parallelSafeTests.length && parallelSafeWidth > 1) {
|
||
startGroup(`Running ${parallelSafeTests.length} parallel-safe tests (${parallelSafeWidth}-wide)`);
|
||
}
|
||
await Promise.all(parallelSafeTests.map(t => parallelSafeLimit(() => runOneTest(t, parallelSafeWidth > 1))));
|
||
}
|
||
|
||
if (vendorTests?.length) {
|
||
for (const { cwd: vendorPath, packageManager, testRunner, testPaths } of vendorTests) {
|
||
if (!testPaths.length) {
|
||
continue;
|
||
}
|
||
|
||
const packageJson = join(relative(cwd, vendorPath), "package.json").replace(/\\/g, "/");
|
||
if (packageManager === "bun") {
|
||
const installResult = await runTest(packageJson, () => spawnBunInstall(execPath, { cwd: vendorPath }));
|
||
if (!installResult?.ok) {
|
||
continue;
|
||
}
|
||
} else {
|
||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||
}
|
||
|
||
// build
|
||
const buildResult = await spawnBun(execPath, {
|
||
cwd: vendorPath,
|
||
args: ["run", "build"],
|
||
timeout: 60_000,
|
||
});
|
||
if (!buildResult.ok) {
|
||
throw new Error(`Failed to build vendor: ${buildResult.error}`);
|
||
}
|
||
|
||
for (const testPath of testPaths) {
|
||
const title = join(relative(cwd, vendorPath), testPath).replace(/\\/g, "/");
|
||
|
||
if (testRunner === "bun") {
|
||
await runTest(title, index =>
|
||
spawnBunTest(execPath, testPath, { cwd: vendorPath, env: { TEST_SERIAL_ID: String(index) } }),
|
||
);
|
||
} else {
|
||
const testRunnerPath = join(cwd, "test", "runners", `${testRunner}.ts`);
|
||
if (!existsSync(testRunnerPath)) {
|
||
throw new Error(`Unsupported test runner: ${testRunner}`);
|
||
}
|
||
await runTest(title, () =>
|
||
spawnBunTest(execPath, testPath, {
|
||
cwd: vendorPath,
|
||
args: ["--preload", testRunnerPath],
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// tests are all over, close the group from the final test. any further output should print ungrouped.
|
||
startGroup("End");
|
||
|
||
if (isGithubAction) {
|
||
reportOutputToGitHubAction("failing_tests_count", failedResults.length);
|
||
const markdown = formatTestToMarkdown(failedResults, false, 0);
|
||
reportOutputToGitHubAction("failing_tests", markdown);
|
||
}
|
||
|
||
// Generate and upload JUnit reports if requested
|
||
if (options.junit) {
|
||
const junitTempDir = options["junit-temp-dir"];
|
||
mkdirSync(junitTempDir, { recursive: true });
|
||
|
||
// Generate JUnit reports for tests that don't use bun test
|
||
const nonBunTestResults = [...okResults, ...flakyResults, ...failedResults].filter(result => {
|
||
// Check if this is a test that wasn't run with bun test
|
||
const isNodeTest =
|
||
isJavaScript(result.testPath) && !isTestStrict(result.testPath) && !result.testPath.includes("vendor");
|
||
return isNodeTest;
|
||
});
|
||
|
||
// If we have tests not covered by bun test JUnit reports, generate a report for them
|
||
if (nonBunTestResults.length > 0) {
|
||
const nonBunTestJunitPath = join(junitTempDir, "non-bun-test-results.xml");
|
||
generateJUnitReport(nonBunTestJunitPath, nonBunTestResults);
|
||
!isQuiet &&
|
||
console.log(
|
||
`Generated JUnit report for ${nonBunTestResults.length} non-bun test results at ${nonBunTestJunitPath}`,
|
||
);
|
||
|
||
// Upload this report immediately if we're on BuildKite
|
||
if (isBuildkite && options["junit-upload"]) {
|
||
const uploadSuccess = await uploadJUnitToBuildkite(nonBunTestJunitPath);
|
||
if (uploadSuccess) {
|
||
// Delete the file after successful upload to prevent redundant uploads
|
||
try {
|
||
unlinkSync(nonBunTestJunitPath);
|
||
!isQuiet && console.log(`Uploaded and deleted non-bun test JUnit report`);
|
||
} catch (unlinkError) {
|
||
!isQuiet &&
|
||
console.log(
|
||
`Uploaded but failed to delete non-bun test JUnit report: ${unlinkError instanceof Error ? unlinkError.message : unlinkError}`,
|
||
);
|
||
}
|
||
} else {
|
||
!isQuiet && console.log(`Failed to upload non-bun test JUnit report to BuildKite`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for any JUnit reports that may not have been uploaded yet
|
||
// Since we're deleting files after upload, any remaining files need to be uploaded
|
||
if (isBuildkite && options["junit-upload"]) {
|
||
try {
|
||
// Only process XML files and skip the non-bun test results which we've already uploaded
|
||
const allJunitFiles = readdirSync(junitTempDir).filter(
|
||
file => file.endsWith(".xml") && file !== "non-bun-test-results.xml",
|
||
);
|
||
|
||
if (allJunitFiles.length > 0) {
|
||
!isQuiet && console.log(`Found ${allJunitFiles.length} remaining JUnit reports to upload...`);
|
||
|
||
// Process each remaining JUnit file - these are files we haven't processed yet
|
||
let uploadedCount = 0;
|
||
|
||
for (const file of allJunitFiles) {
|
||
const filePath = join(junitTempDir, file);
|
||
|
||
if (existsSync(filePath)) {
|
||
try {
|
||
const uploadSuccess = await uploadJUnitToBuildkite(filePath);
|
||
if (uploadSuccess) {
|
||
// Delete the file after successful upload
|
||
try {
|
||
unlinkSync(filePath);
|
||
uploadedCount++;
|
||
} catch (unlinkError) {
|
||
!isQuiet &&
|
||
console.log(
|
||
`Uploaded but failed to delete ${file}: ${unlinkError instanceof Error ? unlinkError.message : unlinkError}`,
|
||
);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Error uploading JUnit file ${file}:`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (uploadedCount > 0) {
|
||
!isQuiet && console.log(`Uploaded and deleted ${uploadedCount} remaining JUnit reports`);
|
||
} else {
|
||
!isQuiet && console.log(`No JUnit reports needed to be uploaded`);
|
||
}
|
||
} else {
|
||
!isQuiet && console.log(`No remaining JUnit reports found to upload`);
|
||
}
|
||
} catch (err) {
|
||
console.error(`Error checking for remaining JUnit reports:`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (options["coredump-upload"]) {
|
||
try {
|
||
const coresDirBase = dirname(coresDir!);
|
||
const coresDirName = basename(coresDir!);
|
||
const coreFileNames = readdirSync(coresDir!);
|
||
|
||
if (coreFileNames.length > 0) {
|
||
console.log(`found ${coreFileNames.length} cores in ${coresDir}`);
|
||
let totalBytes = 0;
|
||
let totalBlocks = 0;
|
||
for (const f of coreFileNames) {
|
||
const stat = statSync(join(coresDir!, f));
|
||
totalBytes += stat.size;
|
||
totalBlocks += stat.blocks;
|
||
}
|
||
console.log(`total apparent size = ${totalBytes} bytes`);
|
||
console.log(`total size on disk = ${512 * totalBlocks} bytes`);
|
||
const outdir = mkdtempSync(join(tmpdir(), "cores-upload"));
|
||
const outfileName = `${coresDirName}.tar.gz.age`;
|
||
const outfileAbs = join(outdir, outfileName);
|
||
|
||
// This matches an age identity known by Bun employees. Core dumps from CI have to be kept
|
||
// secret since they will contain API keys.
|
||
const ageRecipient = "age1eunsrgxwjjpzr48hm0y98cw2vn5zefjagt4r0qj4503jg2nxedqqkmz6fu"; // reject external PRs changing this, see above
|
||
|
||
// Run tar in the parent directory of coresDir so that it creates archive entries with
|
||
// coresDirName in them. This way when you extract the tarball you get a folder named
|
||
// bun-cores-XYZ containing core files, instead of a bunch of core files strewn in your
|
||
// current directory
|
||
const before = Date.now();
|
||
const zipAndEncrypt = await spawnWithTimeout({
|
||
command: "bash",
|
||
args: [
|
||
"-c",
|
||
// tar -S: handle sparse files efficiently
|
||
`set -euo pipefail && tar -Sc "$0" | gzip -1 | age -e -r ${ageRecipient} -o "$1"`,
|
||
// $0
|
||
coresDirName,
|
||
// $1
|
||
outfileAbs,
|
||
],
|
||
cwd: coresDirBase,
|
||
stdout: () => {},
|
||
timeout: 60_000,
|
||
});
|
||
const elapsed = Date.now() - before;
|
||
if (!zipAndEncrypt.ok) {
|
||
throw new Error(zipAndEncrypt.error);
|
||
}
|
||
console.log(`saved core dumps to ${outfileAbs} (${statSync(outfileAbs).size} bytes) in ${elapsed} ms`);
|
||
await uploadArtifact(outfileAbs);
|
||
} else {
|
||
console.log(`no cores found in ${coresDir}`);
|
||
}
|
||
} catch (err) {
|
||
console.error("Error collecting and uploading core dumps:", err);
|
||
}
|
||
}
|
||
|
||
if (!isCI && !isQuiet) {
|
||
console.table({
|
||
"Total Tests": okResults.length + failedResults.length + flakyResults.length,
|
||
"Passed Tests": okResults.length,
|
||
"Failing Tests": failedResults.length,
|
||
"Flaky Tests": flakyResults.length,
|
||
});
|
||
|
||
if (failedResults.length) {
|
||
console.log(`${getAnsi("red")}Failing Tests:${getAnsi("reset")}`);
|
||
for (const testPath of failedResultsTitles) {
|
||
console.log(`${getAnsi("red")}- ${testPath}${getAnsi("reset")}`);
|
||
}
|
||
}
|
||
|
||
if (flakyResults.length) {
|
||
console.log(`${getAnsi("yellow")}Flaky Tests:${getAnsi("reset")}`);
|
||
for (const testPath of flakyResultsTitles) {
|
||
console.log(`${getAnsi("yellow")}- ${testPath}${getAnsi("reset")}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dump per-file results as JSON for post-processing (test-fix workflows
|
||
// shard from this). Opt-in via --results-json so CI output is unchanged.
|
||
if (cliOptions["results-json"]) {
|
||
const all = [...okResults, ...flakyResults, ...failedResults].map(r => ({
|
||
testPath: r.testPath,
|
||
ok: r.ok,
|
||
status: r.status,
|
||
error: r.error,
|
||
exitCode: r.exitCode,
|
||
signalCode: r.signalCode,
|
||
duration: r.duration,
|
||
stdoutPreview: r.stdoutPreview?.slice?.(-4000),
|
||
}));
|
||
writeFileSync(cliOptions["results-json"], JSON.stringify(all, null, 2));
|
||
!isQuiet && console.log(`Wrote ${all.length} results to ${cliOptions["results-json"]}`);
|
||
}
|
||
|
||
// Exclude flaky tests from the final results
|
||
return [...okResults, ...failedResults];
|
||
}
|
||
|
||
type SpawnEnv = Record<string, string | undefined>;
|
||
|
||
/** Called per chunk; `end` when the stream closes. */
|
||
type SpawnOutputHandler = ((chunk: string) => void) & { end?: () => void };
|
||
|
||
interface SpawnOptions {
|
||
command: string;
|
||
args: string[];
|
||
cwd?: string | undefined;
|
||
timeout?: number | undefined;
|
||
env?: SpawnEnv | undefined;
|
||
stdout?: SpawnOutputHandler | undefined;
|
||
stderr?: SpawnOutputHandler | undefined;
|
||
/** On timeout send SIGTERM, and SIGKILL 15 seconds later, instead of killing at once (not on Windows). */
|
||
gracefulTimeout?: boolean | undefined;
|
||
/** Milliseconds without any output after which the process counts as timed out ("stalled"). */
|
||
idleTimeout?: number | undefined;
|
||
/** How many times spawning has already been retried after EBUSY or UNKNOWN. */
|
||
retries?: number;
|
||
}
|
||
|
||
interface SpawnResult {
|
||
ok: boolean;
|
||
error: string | undefined;
|
||
spawnError: NodeJS.ErrnoException | undefined;
|
||
exitCode: number | null | undefined;
|
||
signalCode: NodeJS.Signals | null | undefined;
|
||
timestamp: number;
|
||
duration: number;
|
||
stdout: string;
|
||
pid: number | undefined;
|
||
}
|
||
|
||
/**
|
||
* Node creates the "pipe" streams of a child process as net.Socket, which is what has
|
||
* unref(). Its type declarations only say Readable and Writable.
|
||
*/
|
||
type SpawnedProcess = ChildProcessByStdio<null, Socket, Socket>;
|
||
|
||
async function spawnWithTimeout(options: SpawnOptions): Promise<SpawnResult> {
|
||
const {
|
||
command,
|
||
args,
|
||
cwd,
|
||
env,
|
||
timeout = spawnTimeout,
|
||
stdout = process.stdout.write.bind(process.stdout),
|
||
stderr = process.stderr.write.bind(process.stderr),
|
||
retries = 0,
|
||
}: SpawnOptions = options;
|
||
let exitCode: SpawnResult["exitCode"];
|
||
let signalCode: SpawnResult["signalCode"];
|
||
let spawnError: SpawnResult["spawnError"];
|
||
let timestamp: number | undefined;
|
||
let timedOut = false;
|
||
let idledOut = false;
|
||
let duration: number | undefined;
|
||
let subprocess: SpawnedProcess | undefined;
|
||
let timer: NodeJS.Timeout | undefined;
|
||
let idleTimer: NodeJS.Timeout | undefined;
|
||
let armIdleTimer: (() => void) | undefined;
|
||
let buffer = "";
|
||
let doneCalls = 0;
|
||
const beforeDone = (resolve: () => void) => {
|
||
// TODO: wait for stderr as well, spawn.test currently causes it to hang
|
||
if (doneCalls++ === 1) {
|
||
done(resolve);
|
||
}
|
||
};
|
||
const done = (resolve: () => void) => {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
clearTimeout(idleTimer);
|
||
subprocess?.stderr.unref();
|
||
subprocess?.stdout.unref();
|
||
subprocess?.unref();
|
||
if (!signalCode && exitCode === undefined) {
|
||
subprocess?.stdout.destroy();
|
||
subprocess?.stderr.destroy();
|
||
if (!subprocess?.killed) {
|
||
subprocess?.kill(9);
|
||
}
|
||
}
|
||
resolve();
|
||
};
|
||
await new Promise<void>(resolve => {
|
||
try {
|
||
function unsafeBashEscape(str: string | undefined) {
|
||
if (!str) return "";
|
||
if (str.includes(" ")) return JSON.stringify(str);
|
||
return str;
|
||
}
|
||
if (process.env.SHOW_SPAWN_COMMANDS) {
|
||
console.log(
|
||
"SPAWNING COMMAND:\n" +
|
||
[
|
||
"echo -n | " +
|
||
Object.entries(env ?? {})
|
||
.map(([key, value]) => `${unsafeBashEscape(key)}=${unsafeBashEscape(value)}`)
|
||
.join(" "),
|
||
unsafeBashEscape(command),
|
||
...args.map(unsafeBashEscape),
|
||
].join(" ") +
|
||
" | cat",
|
||
);
|
||
}
|
||
subprocess = spawn(command, args, {
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
timeout: options.gracefulTimeout ? undefined : timeout,
|
||
cwd,
|
||
env,
|
||
}) as SpawnedProcess;
|
||
subprocess.on("spawn", () => {
|
||
timestamp = Date.now();
|
||
const expire = () => {
|
||
timedOut = true;
|
||
clearTimeout(idleTimer);
|
||
if (options.gracefulTimeout && !isWindows) {
|
||
subprocess?.kill("SIGTERM");
|
||
timer = setTimeout(() => {
|
||
subprocess?.kill(9);
|
||
done(resolve);
|
||
}, 15_000);
|
||
return;
|
||
}
|
||
done(resolve);
|
||
};
|
||
timer = setTimeout(expire, timeout);
|
||
if (options.idleTimeout) {
|
||
armIdleTimer = () => {
|
||
clearTimeout(idleTimer);
|
||
idleTimer = setTimeout(() => {
|
||
idledOut = true;
|
||
expire();
|
||
}, options.idleTimeout);
|
||
};
|
||
armIdleTimer();
|
||
}
|
||
});
|
||
subprocess.on("error", error => {
|
||
spawnError = error;
|
||
done(resolve);
|
||
});
|
||
subprocess.on("exit", (code, signal) => {
|
||
// "exit" is only emitted after "spawn", which set the timestamp.
|
||
duration = Date.now() - timestamp!;
|
||
exitCode = code;
|
||
signalCode = signal;
|
||
if (signalCode || exitCode !== 0) {
|
||
beforeDone(resolve);
|
||
} else {
|
||
done(resolve);
|
||
}
|
||
});
|
||
subprocess.stdout.on("end", () => {
|
||
beforeDone(resolve);
|
||
});
|
||
subprocess.stdout.on("data", (chunk: Buffer) => {
|
||
armIdleTimer?.();
|
||
const text = chunk.toString("utf-8");
|
||
stdout?.(text);
|
||
buffer += text;
|
||
});
|
||
subprocess.stdout.on("close", () => stdout?.end?.());
|
||
subprocess.stderr.on("data", (chunk: Buffer) => {
|
||
armIdleTimer?.();
|
||
const text = chunk.toString("utf-8");
|
||
stderr?.(text);
|
||
buffer += text;
|
||
});
|
||
subprocess.stderr.on("close", () => stderr?.end?.());
|
||
} catch (error) {
|
||
spawnError = error instanceof Error ? error : new Error(String(error));
|
||
resolve();
|
||
}
|
||
});
|
||
if (spawnError && retries < 5) {
|
||
const { code } = spawnError;
|
||
if (code === "EBUSY" || code === "UNKNOWN") {
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
|
||
return spawnWithTimeout({
|
||
...options,
|
||
retries: retries + 1,
|
||
});
|
||
}
|
||
}
|
||
let error: string | RegExpMatchArray | null | undefined = undefined;
|
||
if (exitCode === 0) {
|
||
// ...
|
||
} else if (spawnError) {
|
||
const { stack, message } = spawnError;
|
||
if (/timed? ?out/.test(message)) {
|
||
error = "timeout";
|
||
} else {
|
||
error = "spawn error";
|
||
buffer = stack || message;
|
||
}
|
||
} else if ((error = buffer.match(/ERROR: Unchecked JS exception:/g))) {
|
||
// Format from JSC's VM::verifyExceptionCheckNeedIsSatisfied / ExceptionEventLocation::printInternal:
|
||
// "{functionName} @ {__FILE__}:{line}"
|
||
// __FILE__ is relative to the cmake build dir (e.g. ../../src/...) — strip leading ../ for a repo-relative path.
|
||
// JSC crashes immediately after printing, so >1 block means multiple subprocesses crashed.
|
||
const count = error.length;
|
||
const repoRel = (p: string | undefined) => p?.replace(/^(?:\.\.?[\\/])+/, "");
|
||
const thrown = /This scope can throw a JS exception: (\S+) @ (\S+)/.exec(buffer);
|
||
const unchecked = /But the exception was unchecked as of this scope: (\S+) @ (\S+)/.exec(buffer);
|
||
error = "unchecked exception";
|
||
if (unchecked) {
|
||
error += ` at ${unchecked[1]} @ ${repoRel(unchecked[2])}`;
|
||
if (thrown) error += ` (thrown from ${repoRel(thrown[2])})`;
|
||
} else if (thrown) {
|
||
error += ` thrown from ${thrown[1]} @ ${repoRel(thrown[2])}`;
|
||
}
|
||
if (count > 1) error += ` +${count - 1} more`;
|
||
} else if (/LeakSanitizer: detected memory leaks/.test(buffer)) {
|
||
error = "leak";
|
||
const leak =
|
||
/(Direct|Indirect) leak of (\d+) byte\(s\) in (\d+) object\(s\) allocated from:\s*\n((?:\s*#\d+[^\n]*\n)+)/.exec(
|
||
buffer,
|
||
);
|
||
if (leak) {
|
||
const [, kind, bytes, objects, stack] = leak;
|
||
error = `${kind!.toLowerCase()} leak of ${bytes}b${objects === "1" ? "" : ` in ${objects} objects`}`;
|
||
const frames = stack!
|
||
.split("\n")
|
||
.map(line => /#\d+ 0x[0-9a-f]+ in ([^\n]+)/i.exec(line)?.[1]?.trim())
|
||
.filter((frame): frame is string => Boolean(frame));
|
||
const isAlloc = (f: string) =>
|
||
/^(?:__interceptor_|__sanitizer|_?malloc\b|_?malloc_zone|calloc|realloc|posix_memalign|operator new|mi_|bmalloc|bun_alloc|WTF::fast(?:Zeroed)?Malloc|WTF::Malloc)/i.test(
|
||
f,
|
||
);
|
||
const ownTree = (f: string) => {
|
||
const loc = /(\S+):\d+$/.exec(f)?.[1];
|
||
return !!loc && loc.replace(/^(?:\.\.?[\\/])+/, "").startsWith("src/");
|
||
};
|
||
const where = frames.find(f => !isAlloc(f) && ownTree(f)) ?? frames.find(f => !isAlloc(f));
|
||
if (where) {
|
||
// Neither group of the pattern is optional, and the fallback has both elements.
|
||
const [, symbol, loc] = /^(.*?)\s+(\S+:\d+)$/.exec(where) ?? [null, where, ""];
|
||
const file = loc!.replace(/^(?:\.\.?[\\/])+/, "");
|
||
error += ` in ${symbol!.replace(/\(.*$/, "")}${file ? ` (${file})` : ""}`.slice(0, 160);
|
||
}
|
||
}
|
||
const leaks = buffer.match(/(?:Direct|Indirect) leak of/g)?.length ?? 1;
|
||
if (leaks > 1) error += ` +${leaks - 1} more`;
|
||
} else if (
|
||
(error = /thread \d+ panic: (.*)(?:\r\n|\r|\n|\\n)/i.exec(buffer)) ||
|
||
(error = /panic\(.*\): (.*)(?:\r\n|\r|\n|\\n)/i.exec(buffer)) ||
|
||
(error = /(Segmentation fault) at address/i.exec(buffer)) ||
|
||
(error = /(Internal assertion failure)/i.exec(buffer)) ||
|
||
(error = /(Illegal instruction) at address/i.exec(buffer)) ||
|
||
(error = /panic: (.*) at address/i.exec(buffer)) ||
|
||
(error = /oh no: Bun has crashed/i.exec(buffer)) ||
|
||
(error = /(ERROR: AddressSanitizer)/.exec(buffer)) ||
|
||
(error = /(SIGABRT)/.exec(buffer))
|
||
) {
|
||
const [, message] = error || [];
|
||
error = message ? message.split("\n")[0]!.toLowerCase() : "crash";
|
||
error = error.indexOf("\\n") !== -1 ? error.substring(0, error.indexOf("\\n")) : error;
|
||
error = `pid ${subprocess?.pid} ${error}`;
|
||
} else if (signalCode) {
|
||
// The "exit" handler that set signalCode set the duration as well.
|
||
if (signalCode === "SIGTERM" && duration! >= timeout) {
|
||
error = "timeout";
|
||
} else {
|
||
error = signalCode;
|
||
}
|
||
} else if (exitCode === 1) {
|
||
const match = buffer.match(/\x1b\[31m\s(\d+) fail/);
|
||
if (match) {
|
||
error = `${match[1]} failing`;
|
||
} else {
|
||
error = "code 1";
|
||
}
|
||
const lines = stripAnsi(buffer).split(/\r?\n/);
|
||
const failAt = lines.findIndex(line => /^\(fail\) /.test(line.trim()));
|
||
if (failAt === -1) {
|
||
// k - 1 is an index of `lines` because k > 0, and so is at - 1.
|
||
const at = lines.findIndex(
|
||
(line, k) => k > 0 && /^\s+at /.test(line) && lines[k - 1]!.trim() && !/^\s+at /.test(lines[k - 1]!),
|
||
);
|
||
if (at !== -1) {
|
||
const message = lines[at - 1]!.trim();
|
||
error += `: ${message.length > 100 ? `${message.slice(0, 97)}…` : message}`;
|
||
}
|
||
} else {
|
||
// failAt and every k below are indices of `lines`.
|
||
const name = lines[failAt]!.trim()
|
||
.replace(/^\(fail\) /, "")
|
||
.replace(/ \[[\d.]+m?s\]$/, "");
|
||
let reason = "";
|
||
for (let k = failAt - 1; k >= Math.max(0, failAt - 40); k--) {
|
||
const m = /^\s*error: (.+)$/.exec(lines[k]!);
|
||
if (m) {
|
||
reason = m[1]!.trim();
|
||
break;
|
||
}
|
||
}
|
||
const first = reason ? `${name} — ${reason}` : name;
|
||
error += `: ${first.length > 100 ? `${first.slice(0, 97)}…` : first}`;
|
||
}
|
||
} else if (exitCode === undefined) {
|
||
error = "timeout";
|
||
} else {
|
||
error = `code ${exitCode}`;
|
||
}
|
||
if (timedOut && (!error || error === signalCode || /^code \d+$/.test(error)))
|
||
error = idledOut ? "stalled" : "timeout";
|
||
return {
|
||
ok: exitCode === 0 && !signalCode && !spawnError,
|
||
error,
|
||
exitCode,
|
||
signalCode,
|
||
spawnError,
|
||
stdout: buffer,
|
||
timestamp: timestamp || Date.now(),
|
||
duration: duration || 0,
|
||
pid: subprocess?.pid,
|
||
};
|
||
}
|
||
|
||
let _combinedPath = "";
|
||
function getCombinedPath(execPath: string): string {
|
||
if (!_combinedPath) {
|
||
_combinedPath = addPath(realpathSync(dirname(execPath)), process.env.PATH);
|
||
// If we're running bun-profile.exe, try to make a symlink to bun.exe so
|
||
// that anything looking for "bun" will find it
|
||
if (isCI && basename(execPath, extname(execPath)).toLowerCase() !== "bun") {
|
||
const existingPath = execPath;
|
||
const newPath = join(dirname(execPath), "bun" + extname(execPath));
|
||
try {
|
||
// On Windows, we might run into permissions issues with symlinks.
|
||
// If that happens, fall back to a regular hardlink.
|
||
symlinkSync(existingPath, newPath, "file");
|
||
} catch (error) {
|
||
try {
|
||
linkSync(existingPath, newPath);
|
||
} catch (error) {
|
||
console.warn(`Failed to link bun`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return _combinedPath;
|
||
}
|
||
|
||
type SpawnBunOptions = Omit<SpawnOptions, "command">;
|
||
|
||
type SpawnBunResult = SpawnResult & { crashes?: string };
|
||
|
||
/** One entry of the response of the ci-remap server to `/traces`. */
|
||
interface RemappedTrace {
|
||
failed_parse?: string;
|
||
failed_remap?: unknown;
|
||
remap?: string;
|
||
}
|
||
|
||
async function spawnBun(
|
||
execPath: string,
|
||
{ args, cwd, timeout, gracefulTimeout, idleTimeout, env, stdout, stderr }: SpawnBunOptions,
|
||
): Promise<SpawnBunResult> {
|
||
const path = getCombinedPath(execPath);
|
||
const tmpdirPath = mkdtempSync(join(tmpdir(), "buntmp-"));
|
||
const { username, homedir } = userInfo();
|
||
const shellPath = getShell();
|
||
const bunEnv: SpawnEnv = {
|
||
...process.env,
|
||
PATH: path,
|
||
TMPDIR: tmpdirPath,
|
||
BUN_TMPDIR: tmpdirPath,
|
||
USER: username,
|
||
USERNAME: isWindows ? username : undefined, // %USERNAME% for ported Windows tests
|
||
HOME: homedir,
|
||
SHELL: shellPath,
|
||
FORCE_COLOR: "1",
|
||
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1",
|
||
BUN_DEBUG_QUIET_LOGS: "1",
|
||
BUN_DISABLE_SLOW_FILESYSTEM_WARNING: "1",
|
||
BUN_GARBAGE_COLLECTOR_LEVEL: "1",
|
||
BUN_JSC_randomIntegrityAuditRate: "1.0",
|
||
BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
|
||
BUN_INSTALL_CACHE_DIR: tmpdirPath,
|
||
SHELLOPTS: isWindows ? "igncr" : undefined, // ignore "\r" on Windows
|
||
TEST_TMPDIR: tmpdirPath, // Used in Node.js tests.
|
||
// test/leaksan.supp names functions, so LeakSanitizer needs a symbolizer to apply it. It looks for one on PATH,
|
||
// which some tests replace.
|
||
ASAN_SYMBOLIZER_PATH: process.env.ASAN_SYMBOLIZER_PATH ?? which(["llvm-symbolizer"]),
|
||
...(typeof remapPort == "number"
|
||
? { BUN_CRASH_REPORT_URL: `http://localhost:${remapPort}` }
|
||
: { BUN_ENABLE_CRASH_REPORTING: "0" }),
|
||
};
|
||
|
||
if (isWindows && bunEnv.Path) {
|
||
delete bunEnv.Path;
|
||
}
|
||
|
||
if (env) {
|
||
Object.assign(bunEnv, env);
|
||
}
|
||
|
||
if (isWindows) {
|
||
delete bunEnv.PATH;
|
||
bunEnv.Path = path;
|
||
for (const tmpdir of ["TMPDIR", "TEMP", "TEMPDIR", "TMP"]) {
|
||
delete bunEnv[tmpdir];
|
||
}
|
||
bunEnv.TEMP = tmpdirPath;
|
||
}
|
||
if (timeout === undefined) {
|
||
timeout = spawnBunTimeout;
|
||
}
|
||
try {
|
||
const existingCores = options["coredump-upload"] ? readdirSync(coresDir!) : [];
|
||
const result: SpawnBunResult = await spawnWithTimeout({
|
||
command: execPath,
|
||
args,
|
||
cwd,
|
||
timeout,
|
||
gracefulTimeout,
|
||
idleTimeout,
|
||
env: bunEnv,
|
||
stdout,
|
||
stderr,
|
||
});
|
||
const newCores = options["coredump-upload"] ? readdirSync(coresDir!).filter(c => !existingCores.includes(c)) : [];
|
||
let crashes = "";
|
||
if (options["coredump-upload"] && (result.signalCode !== null || newCores.length > 0)) {
|
||
// warn if the main PID crashed and we don't have a core
|
||
if (result.signalCode !== null && !newCores.some(c => c.endsWith(`${result.pid}.core`))) {
|
||
crashes += `main process killed by ${result.signalCode} but no core file found\n`;
|
||
}
|
||
|
||
if (newCores.length > 0) {
|
||
result.ok = false;
|
||
if (!isAlwaysFailure(result.error)) result.error = "core dumped";
|
||
}
|
||
|
||
for (const coreName of newCores) {
|
||
const corePath = join(coresDir!, coreName);
|
||
let out = "";
|
||
const gdb = await spawnWithTimeout({
|
||
command: "gdb",
|
||
args: ["-batch", `--eval-command=bt`, "--core", corePath, execPath],
|
||
timeout: 240_000,
|
||
stderr: () => {},
|
||
stdout(text) {
|
||
out += text;
|
||
},
|
||
});
|
||
if (!gdb.ok) {
|
||
crashes += `failed to get backtrace from GDB: ${gdb.error}\n`;
|
||
} else {
|
||
crashes += `======== Stack trace from GDB for ${coreName}: ========\n`;
|
||
for (const line of out.split("\n")) {
|
||
// filter GDB output since it is pretty verbose
|
||
if (
|
||
line.startsWith("Program terminated") ||
|
||
line.startsWith("#") || // gdb backtrace lines start with #0, #1, etc.
|
||
line.startsWith("[Current thread is")
|
||
) {
|
||
crashes += line + "\n";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Skip this if the remap server didn't work or if Bun exited normally
|
||
// (tests in which a subprocess crashed should at least set exit code 1)
|
||
if (typeof remapPort == "number" && result.exitCode !== 0) {
|
||
try {
|
||
// When Bun crashes, it exits before the subcommand it runs to upload the crash report has necessarily finished.
|
||
// So wait a little bit to make sure that the crash report has at least started uploading
|
||
// (once the server sees the /ack request then /traces will wait for any crashes to finish processing)
|
||
// There is a bug that if a test causes crash reports but exits with code 0, the crash reports will instead
|
||
// be attributed to the next test that fails. I'm not sure how to fix this without adding a sleep in between
|
||
// all tests (which would slow down CI a lot).
|
||
await setTimeoutPromise(500);
|
||
const response = await fetch(`http://localhost:${remapPort}/traces`);
|
||
if (!response.ok || response.status !== 200) throw new Error(`server responded with code ${response.status}`);
|
||
const traces = (await response.json()) as RemappedTrace[];
|
||
if (traces.length > 0) {
|
||
result.ok = false;
|
||
if (!isAlwaysFailure(result.error)) result.error = "crash reported";
|
||
|
||
crashes += `${traces.length} crashes reported during this test\n`;
|
||
for (const t of traces) {
|
||
if (t.failed_parse) {
|
||
crashes += "Trace string failed to parse:\n";
|
||
crashes += t.failed_parse + "\n";
|
||
} else if (t.failed_remap) {
|
||
crashes += "Parsed trace failed to remap:\n";
|
||
crashes += JSON.stringify(t.failed_remap, null, 2) + "\n";
|
||
} else {
|
||
crashes += "================\n";
|
||
crashes += t.remap + "\n";
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
crashes += "failed to fetch traces: " + String(e) + "\n";
|
||
}
|
||
}
|
||
if (crashes.length > 0) result.crashes = crashes;
|
||
return result;
|
||
} finally {
|
||
try {
|
||
rmSync(tmpdirPath, { recursive: true, force: true });
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
interface TestResult {
|
||
testPath: string;
|
||
ok: boolean;
|
||
status: string;
|
||
error?: string | undefined;
|
||
errors: TestError[];
|
||
tests: TestEntry[];
|
||
stdout: string;
|
||
stdoutPreview: string;
|
||
// For --results-json and the JUnit report.
|
||
exitCode?: SpawnResult["exitCode"];
|
||
signalCode?: SpawnResult["signalCode"];
|
||
duration?: number;
|
||
}
|
||
|
||
interface TestEntry {
|
||
url?: string | undefined;
|
||
file: string | undefined;
|
||
test: string;
|
||
status: string;
|
||
errors?: TestError[];
|
||
duration?: number | undefined;
|
||
}
|
||
|
||
/** A `::error` workflow command printed by `bun test`; its properties are strings as printed. */
|
||
interface TestError {
|
||
url?: string | undefined;
|
||
file: string | undefined;
|
||
line: string | undefined;
|
||
col: string | undefined;
|
||
name: string | undefined;
|
||
stack: string;
|
||
/** the name of the test the error belongs to */
|
||
test?: string;
|
||
}
|
||
|
||
interface SpawnBunTestOptions {
|
||
cwd: string;
|
||
args?: string[];
|
||
env?: SpawnEnv;
|
||
stdout?: SpawnOutputHandler;
|
||
stderr?: SpawnOutputHandler;
|
||
}
|
||
|
||
async function spawnBunTest(
|
||
execPath: string,
|
||
testPath: string,
|
||
opts: SpawnBunTestOptions = { cwd },
|
||
): Promise<TestResult> {
|
||
const timeout = getTestTimeout(testPath);
|
||
const isAsan = basename(execPath).includes("asan");
|
||
const perTestTimeout = Math.ceil(timeout / 2) * (isAsan ? 3 : 1);
|
||
const absPath = join(opts.cwd, testPath);
|
||
const isReallyTest = isTestStrict(testPath) || absPath.includes("vendor");
|
||
const args = opts.args ?? [];
|
||
|
||
const testArgs = ["test", ...args, `--timeout=${perTestTimeout}`, "--reporter=dots"];
|
||
|
||
// This will be set if a JUnit file is generated
|
||
let junitFilePath: string | null = null;
|
||
|
||
// In CI, we want to use JUnit for all tests
|
||
// Create a unique filename for each test run using a hash of the test path
|
||
// This ensures we can run tests in parallel without file conflicts
|
||
if (cliOptions.junit) {
|
||
const testHash = createHash("sha1").update(testPath).digest("base64url");
|
||
const junitTempDir = cliOptions["junit-temp-dir"];
|
||
|
||
// Create the JUnit file path
|
||
junitFilePath = `${junitTempDir}/test-${testHash}.xml`;
|
||
|
||
// Add JUnit reporter
|
||
testArgs.push("--reporter=junit");
|
||
testArgs.push(`--reporter-outfile=${junitFilePath}`);
|
||
}
|
||
|
||
testArgs.push(absPath);
|
||
|
||
const env: SpawnEnv = {
|
||
GITHUB_ACTIONS: "true", // always true so annotations are parsed
|
||
...opts.env,
|
||
};
|
||
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateExceptions(relative(cwd, absPath))) {
|
||
env.BUN_JSC_validateExceptionChecks = "1";
|
||
env.BUN_JSC_dumpSimulatedThrows = "1";
|
||
}
|
||
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateLeakSan(relative(cwd, absPath))) {
|
||
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
|
||
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
|
||
// prettier-ignore
|
||
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
|
||
}
|
||
if (basename(execPath).includes("asan")) {
|
||
// ASAN test processes are slow and memory-heavy; if the bun test runner is
|
||
// SIGKILLed (timeout, OOM) its spawned subprocesses keep running and pile
|
||
// up, eventually OOM-killing the agent. --no-orphans makes every spawned
|
||
// bun exit when its parent dies AND SIGKILL its own descendants on exit.
|
||
env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
|
||
}
|
||
|
||
const { ok, error, stdout, crashes, exitCode, signalCode, duration } = await spawnBun(execPath, {
|
||
args: isReallyTest ? testArgs : [...args, absPath],
|
||
cwd: opts.cwd,
|
||
// release-asan with debug-assertions on runs every spawned subprocess
|
||
// slower; give each file more headroom so tests with heavy beforeAll
|
||
// setup (napi node-gyp compiles) or many subprocess spawns don't hit
|
||
// the file wall before any individual test times out. Kept below the
|
||
// per-test multiplier so the overall shard stays inside the job timeout.
|
||
timeout: isReallyTest ? Math.ceil(timeout * (isAsan ? 2 : 1)) : 30_000,
|
||
env,
|
||
stdout: opts.stdout ?? pipeTestStdout(process.stdout),
|
||
stderr: opts.stderr ?? pipeTestStdout(process.stderr),
|
||
});
|
||
let { tests, errors, stdout: stdoutPreview } = parseTestStdout(stdout, testPath);
|
||
if (crashes) stdoutPreview += crashes;
|
||
|
||
// If we generated a JUnit file and we're on BuildKite, upload it immediately
|
||
if (junitFilePath && isReallyTest && isBuildkite && cliOptions["junit-upload"]) {
|
||
// Give the file system a moment to finish writing the file
|
||
if (existsSync(junitFilePath)) {
|
||
addToJunitUploadQueue(junitFilePath);
|
||
}
|
||
}
|
||
|
||
return {
|
||
testPath,
|
||
ok,
|
||
status: ok ? "pass" : "fail",
|
||
error,
|
||
errors,
|
||
tests,
|
||
stdout,
|
||
stdoutPreview,
|
||
exitCode,
|
||
signalCode,
|
||
duration,
|
||
};
|
||
}
|
||
|
||
function getTestTimeout(testPath: string): number {
|
||
if (
|
||
/integration|3rd_party|docker|bun-install-registry|bun-security-scanner-matrix|v8|bundler_compile|tonic|test[\\/]napi/i.test(
|
||
testPath,
|
||
)
|
||
) {
|
||
return integrationTimeout;
|
||
}
|
||
return testTimeout;
|
||
}
|
||
|
||
/**
|
||
* Streams the output of one child process stream to `io`, without the workflow
|
||
* commands bun test prints because GITHUB_ACTIONS is set (see createLiveOutputFilter).
|
||
* spawnWithTimeout calls `end()` when the stream closes.
|
||
*/
|
||
function pipeTestStdout(io: NodeJS.WritableStream): ((chunk: string) => void) & { end: () => void } {
|
||
const filter = createLiveOutputFilter();
|
||
const write = (chunk: string) => {
|
||
const text = filter(chunk);
|
||
if (text) io.write(text);
|
||
};
|
||
write.end = () => {
|
||
const text = filter.end();
|
||
if (text) io.write(text);
|
||
};
|
||
return write;
|
||
}
|
||
|
||
interface TestOutput {
|
||
stdout: string;
|
||
tests: TestEntry[];
|
||
errors: TestError[];
|
||
}
|
||
|
||
function parseTestStdout(stdout: string, testPath?: string): TestOutput {
|
||
const tests: TestEntry[] = [];
|
||
const errors: TestError[] = [];
|
||
|
||
let lines: string[] = [];
|
||
let skipCount = 0;
|
||
let testErrors: TestError[] = [];
|
||
let done: boolean | undefined;
|
||
for (const chunk of stdout.split("\n")) {
|
||
const string = stripAnsi(chunk);
|
||
|
||
if (!string.startsWith("::")) {
|
||
lines.push(chunk);
|
||
|
||
if (string.startsWith("✓") || string.startsWith("»") || string.startsWith("✎")) {
|
||
skipCount++;
|
||
} else {
|
||
// If there are more than 3 consecutive non-failing tests,
|
||
// omit the non-failing tests between them.
|
||
if (skipCount > 3) {
|
||
const removeStart = lines.length - skipCount;
|
||
const removeCount = skipCount - 2;
|
||
const omitLine = `${getAnsi("gray")}... omitted ${removeCount} tests ...${getAnsi("reset")}`;
|
||
lines.splice(removeStart, removeCount, omitLine);
|
||
}
|
||
skipCount = 0;
|
||
}
|
||
}
|
||
|
||
// Once the summary is printed, exit early so tests aren't double counted.
|
||
// This needs to be changed if multiple files are run in a single test run.
|
||
if (done || string.startsWith("::endgroup")) {
|
||
done ||= true;
|
||
continue;
|
||
}
|
||
|
||
if (string.startsWith("::error")) {
|
||
const eol = string.indexOf("::", 8);
|
||
const message = unescapeGitHubAction(string.substring(eol + 2));
|
||
const { file, line, col, title }: Record<string, string | undefined> = Object.fromEntries(
|
||
string
|
||
.substring(8, eol)
|
||
.split(",")
|
||
.map(entry => entry.split("=")),
|
||
);
|
||
|
||
const errorPath = file || testPath;
|
||
const error: TestError = {
|
||
url: errorPath === undefined ? undefined : getFileUrl(errorPath, line),
|
||
file: errorPath,
|
||
line,
|
||
col,
|
||
name: title,
|
||
stack: `${title}\n${message}`,
|
||
};
|
||
|
||
errors.push(error);
|
||
testErrors.push(error);
|
||
continue;
|
||
}
|
||
|
||
for (const { emoji, text } of [
|
||
{ emoji: "✓", text: "pass" },
|
||
{ emoji: "✗", text: "fail" },
|
||
{ emoji: "»", text: "skip" },
|
||
{ emoji: "✎", text: "todo" },
|
||
]) {
|
||
if (!string.startsWith(emoji)) {
|
||
continue;
|
||
}
|
||
|
||
const eol = string.lastIndexOf(" [") || undefined;
|
||
const test = string.substring(1 + emoji.length, eol);
|
||
const duration = eol ? string.substring(eol + 2, string.lastIndexOf("]")) : undefined;
|
||
|
||
tests.push({
|
||
url: testPath === undefined ? undefined : getFileUrl(testPath),
|
||
file: testPath,
|
||
test,
|
||
status: text,
|
||
errors: testErrors,
|
||
duration: parseDuration(duration),
|
||
});
|
||
|
||
for (let error of testErrors) {
|
||
error.test = test;
|
||
}
|
||
testErrors = [];
|
||
}
|
||
}
|
||
|
||
let preview;
|
||
const removeCount = lines.length - 100;
|
||
if (removeCount > 10) {
|
||
const omitLine = `${getAnsi("gray")}... omitted ${removeCount} lines ...${getAnsi("reset")}\n`;
|
||
preview = [omitLine, ...lines.slice(-100)].join("\n");
|
||
} else {
|
||
preview = lines.join("\n");
|
||
}
|
||
|
||
return {
|
||
tests,
|
||
errors,
|
||
stdout: preview,
|
||
};
|
||
}
|
||
|
||
async function spawnBunInstall(
|
||
execPath: string,
|
||
options: Partial<SpawnBunOptions> & { cwd: string },
|
||
): Promise<TestResult> {
|
||
// spawnBun sets BUN_INSTALL_CACHE_DIR to a fresh tmpdir so per-test installs
|
||
// are hermetic. This function only runs the runner's own dependency setup
|
||
// (root, test/, scripts/ci-remap-server, vendor), which should hit the
|
||
// image's baked cache when one exists (the `prefetch` tool of
|
||
// scripts/build/ci-images/spec.ts sets BUN_INSTALL_CACHE_DIR machine-wide).
|
||
const cacheDir = process.env.BUN_INSTALL_CACHE_DIR;
|
||
let { ok, error, stdout, duration, crashes } = await spawnBun(execPath, {
|
||
args: ["install"],
|
||
timeout: testTimeout,
|
||
...options,
|
||
env: {
|
||
// A puppeteer postinstall reached through these installs would download
|
||
// Chrome into the agent's shared ~/.cache; a half-extracted download left
|
||
// there on a persistent agent fails every later job's install. Tests that
|
||
// need a browser install one into a per-run cache (getPuppeteerInstallEnv).
|
||
PUPPETEER_SKIP_DOWNLOAD: "1",
|
||
...options.env,
|
||
...(cacheDir && { BUN_INSTALL_CACHE_DIR: cacheDir }),
|
||
},
|
||
});
|
||
if (crashes) stdout += crashes;
|
||
const relativePath = relative(cwd, options.cwd);
|
||
const testPath = join(relativePath, "package.json");
|
||
const status = ok ? "pass" : "fail";
|
||
return {
|
||
testPath,
|
||
ok,
|
||
status,
|
||
error,
|
||
errors: [],
|
||
tests: [
|
||
{
|
||
file: testPath,
|
||
test: "bun install",
|
||
status,
|
||
duration,
|
||
},
|
||
],
|
||
stdout,
|
||
stdoutPreview: stdout,
|
||
duration,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Installs `ci-remap-server`, the bin of bun-tracestrings (github:oven-sh/bun.report).
|
||
* It is pinned in scripts/ci-remap-server/package.json rather than the root
|
||
* package.json because this runner is its only user, and as a github: dependency
|
||
* it would otherwise put GitHub on the critical path of the root `bun install`
|
||
* every GitHub Actions workflow and every build runs. Best-effort, like starting
|
||
* the server itself: without it crash reports are not remapped, the tests still run.
|
||
*/
|
||
async function installCiRemapServer(execPath: string): Promise<boolean> {
|
||
const title = relative(cwd, join(ciRemapServerPath, "package.json")).replaceAll(sep, "/");
|
||
const { ok, error } = await startGroup(title, () => spawnBunInstall(execPath, { cwd: ciRemapServerPath }));
|
||
if (!ok) console.warn(`ci-remap server not installed (${title}: ${error}), crash reports will not be remapped`);
|
||
return ok;
|
||
}
|
||
|
||
function isJavaScript(path: string): boolean {
|
||
return /\.(c|m)?(j|t)sx?$/.test(basename(path));
|
||
}
|
||
|
||
function isJavaScriptTest(path: string): boolean {
|
||
return isJavaScript(path) && /\.test|spec\./.test(basename(path));
|
||
}
|
||
|
||
function isNodeTest(path: string): boolean {
|
||
// Do not run node tests on macOS x64 in CI, those machines are slow and expensive.
|
||
if (isCI && isMacOS && isX64) {
|
||
return false;
|
||
}
|
||
if (!isJavaScript(path)) {
|
||
return false;
|
||
}
|
||
const unixPath = path.replaceAll(sep, "/");
|
||
return (
|
||
unixPath.includes("js/node/test/parallel/") ||
|
||
unixPath.includes("js/node/test/sequential/") ||
|
||
unixPath.includes("js/bun/test/parallel/")
|
||
);
|
||
}
|
||
|
||
function isClusterTest(path: string): boolean {
|
||
const unixPath = path.replaceAll(sep, "/");
|
||
return unixPath.includes("js/node/cluster/test-") && unixPath.endsWith(".ts");
|
||
}
|
||
|
||
function isTest(path: string): boolean {
|
||
return isNodeTest(path) || isClusterTest(path) ? true : isTestStrict(path);
|
||
}
|
||
|
||
function isTestStrict(path: string): boolean {
|
||
return isJavaScript(path) && /\.test|spec\./.test(basename(path));
|
||
}
|
||
|
||
function isHidden(path: string): boolean {
|
||
return /node_modules|node.js/.test(dirname(path)) || /^\./.test(basename(path));
|
||
}
|
||
|
||
function getTests(cwd: string): string[] {
|
||
function* getFiles(cwd: string, path: string): Generator<string> {
|
||
const dirname = join(cwd, path);
|
||
for (const entry of readdirSync(dirname, { encoding: "utf-8", withFileTypes: true })) {
|
||
const { name } = entry;
|
||
const filename = join(path, name);
|
||
if (isHidden(filename)) {
|
||
continue;
|
||
}
|
||
if (entry.isFile()) {
|
||
if (isTest(filename)) {
|
||
yield filename;
|
||
}
|
||
} else if (entry.isDirectory()) {
|
||
yield* getFiles(cwd, filename);
|
||
}
|
||
}
|
||
}
|
||
return [...getFiles(cwd, "")].sort();
|
||
}
|
||
|
||
/** An entry of test/vendor.json. */
|
||
interface Vendor {
|
||
package: string;
|
||
repository: string;
|
||
tag: string;
|
||
packageManager?: string;
|
||
testPath?: string;
|
||
testRunner?: string;
|
||
testExtensions?: string[];
|
||
skipTests?: boolean | Record<string, boolean | string>;
|
||
}
|
||
|
||
interface VendorTest {
|
||
cwd: string;
|
||
packageManager: string;
|
||
testRunner: string;
|
||
testPaths: string[];
|
||
}
|
||
|
||
async function getVendorTests(cwd: string): Promise<VendorTest[]> {
|
||
const vendorPath = join(cwd, "test", "vendor.json");
|
||
if (!existsSync(vendorPath)) {
|
||
throw new Error(`Did not find vendor.json: ${vendorPath}`);
|
||
}
|
||
|
||
const vendors = (JSON.parse(readFileSync(vendorPath, "utf-8")) as Vendor[]).sort(
|
||
(a, b) => a.package.localeCompare(b.package) || a.tag.localeCompare(b.tag),
|
||
);
|
||
|
||
const shardId = parseInt(options.shard);
|
||
const maxShards = parseInt(options["max-shards"]);
|
||
|
||
let relevantVendors: Vendor[] = [];
|
||
if (maxShards > 1) {
|
||
for (let i = 0; i < vendors.length; i++) {
|
||
if (i % maxShards === shardId) {
|
||
relevantVendors.push(vendors[i]!); // i < vendors.length
|
||
}
|
||
}
|
||
} else {
|
||
relevantVendors = vendors.flat();
|
||
}
|
||
|
||
return Promise.all(
|
||
relevantVendors.map(
|
||
async ({ package: name, repository, tag, testPath, testExtensions, testRunner, packageManager, skipTests }) => {
|
||
const vendorPath = join(cwd, "vendor", name);
|
||
|
||
if (!existsSync(vendorPath)) {
|
||
const { ok, error } = await spawnWithTimeout({
|
||
command: "git",
|
||
args: ["clone", "--depth", "1", "--single-branch", repository, vendorPath],
|
||
timeout: testTimeout,
|
||
cwd,
|
||
});
|
||
if (!ok) throw new Error(`failed to git clone vendor '${name}': ${error}`);
|
||
}
|
||
|
||
let { ok, error } = await spawnWithTimeout({
|
||
command: "git",
|
||
args: ["fetch", "--depth", "1", "origin", "tag", tag],
|
||
timeout: testTimeout,
|
||
cwd: vendorPath,
|
||
});
|
||
if (!ok) throw new Error(`failed to fetch tag ${tag} for vendor '${name}': ${error}`);
|
||
|
||
({ ok, error } = await spawnWithTimeout({
|
||
command: "git",
|
||
args: ["checkout", tag],
|
||
timeout: testTimeout,
|
||
cwd: vendorPath,
|
||
}));
|
||
if (!ok) throw new Error(`failed to checkout tag ${tag} for vendor '${name}': ${error}`);
|
||
|
||
const packageJsonPath = join(vendorPath, "package.json");
|
||
if (!existsSync(packageJsonPath)) {
|
||
throw new Error(`Vendor '${name}' does not have a package.json: ${packageJsonPath}`);
|
||
}
|
||
|
||
const testPathPrefix = testPath || "test";
|
||
const testParentPath = join(vendorPath, testPathPrefix);
|
||
if (!existsSync(testParentPath)) {
|
||
throw new Error(`Vendor '${name}' does not have a test directory: ${testParentPath}`);
|
||
}
|
||
|
||
const isTest = (path: string) => {
|
||
if (!isJavaScriptTest(path)) {
|
||
return false;
|
||
}
|
||
|
||
if (typeof skipTests === "boolean") {
|
||
return !skipTests;
|
||
}
|
||
|
||
if (typeof skipTests === "object") {
|
||
for (const [glob, reason] of Object.entries(skipTests)) {
|
||
const pattern = new RegExp(`^${glob.replace(/\*/g, ".*")}$`);
|
||
if (pattern.test(path) && reason) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
const testPaths = readdirSync(testParentPath, { encoding: "utf-8", recursive: true })
|
||
.filter(filename =>
|
||
testExtensions ? testExtensions.some(ext => filename.endsWith(`.${ext}`)) : isTest(filename),
|
||
)
|
||
.map(filename => join(testPathPrefix, filename))
|
||
.filter(
|
||
filename =>
|
||
!filters?.length ||
|
||
filters.some(filter => join(vendorPath, filename).replace(/\\/g, "/").includes(filter)),
|
||
);
|
||
|
||
return {
|
||
cwd: vendorPath,
|
||
packageManager: packageManager || "bun",
|
||
testRunner: testRunner || "bun",
|
||
testPaths,
|
||
};
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Checked-in median wall-clock duration per test file for this lane, from
|
||
* expected-durations.json (see scripts/update-test-durations.mjs).
|
||
*/
|
||
function loadExpectedDurations(cwd: string): Record<string, number> {
|
||
const durations: Record<string, number> = {};
|
||
try {
|
||
const raw = JSON.parse(readFileSync(join(cwd, "expected-durations.json"), "utf8")) as Record<
|
||
string,
|
||
Record<string, unknown>
|
||
>;
|
||
const step = options.step || "";
|
||
// Most specific column first, then the nearest lane, then anything.
|
||
const columns = step.includes("asan")
|
||
? ["asan"]
|
||
: step.includes("musl")
|
||
? ["musl"]
|
||
: step.includes("windows-aarch64") || (isWindows && process.arch === "arm64")
|
||
? ["windows-aarch64", "windows"]
|
||
: isWindows || step.includes("windows")
|
||
? ["windows"]
|
||
: ["default"];
|
||
columns.push("default", "asan", "musl", "windows", "windows-aarch64");
|
||
for (const [path, entry] of Object.entries(raw)) {
|
||
if (path === "_meta") continue;
|
||
const ms = columns.map(column => entry[column]).find(value => typeof value === "number");
|
||
if (typeof ms === "number") durations[path] = ms;
|
||
}
|
||
} catch (e) {
|
||
console.warn("expected-durations.json not loaded:", (e instanceof Error && e.message) || e);
|
||
}
|
||
return durations;
|
||
}
|
||
|
||
function getRelevantTests(cwd: string, testModifiers: string[], testExpectations: TestExpectation[]): string[] {
|
||
let tests = getTests(cwd);
|
||
const availableTests: string[] = [];
|
||
const filteredTests: string[] = [];
|
||
|
||
if (options["node-tests"]) {
|
||
tests = tests.filter(isNodeTest);
|
||
}
|
||
|
||
const isMatch = (testPath: string, filter: string) => {
|
||
return testPath.replace(/\\/g, "/").includes(filter);
|
||
};
|
||
|
||
const getFilter = (filter: string) => {
|
||
return (
|
||
filter
|
||
?.split(",")
|
||
.map(part => part.trim())
|
||
.filter(Boolean) ?? []
|
||
);
|
||
};
|
||
|
||
const includes = options.include?.flatMap(getFilter);
|
||
if (includes?.length) {
|
||
availableTests.push(...tests.filter(testPath => includes.some(filter => isMatch(testPath, filter))));
|
||
!isQuiet && console.log("Including tests:", includes, availableTests.length, "/", tests.length);
|
||
} else {
|
||
availableTests.push(...tests);
|
||
}
|
||
|
||
const excludes = options.exclude?.flatMap(getFilter);
|
||
if (excludes?.length) {
|
||
const excludedTests = availableTests.filter(testPath => excludes.some(filter => isMatch(testPath, filter)));
|
||
if (excludedTests.length) {
|
||
for (const testPath of excludedTests) {
|
||
const index = availableTests.indexOf(testPath);
|
||
if (index !== -1) {
|
||
availableTests.splice(index, 1);
|
||
}
|
||
}
|
||
!isQuiet && console.log("Excluding tests:", excludes, excludedTests.length, "/", availableTests.length);
|
||
}
|
||
}
|
||
|
||
const skipExpectations = testExpectations
|
||
.filter(({ modifiers }) => !modifiers?.length || testModifiers.some(modifier => modifiers?.includes(modifier)))
|
||
.map(({ filename }) => filename.replace("test/", ""));
|
||
if (skipExpectations.length) {
|
||
const skippedTests = availableTests.filter(testPath => skipExpectations.some(filter => isMatch(testPath, filter)));
|
||
if (skippedTests.length) {
|
||
for (const testPath of skippedTests) {
|
||
const index = availableTests.indexOf(testPath);
|
||
if (index !== -1) {
|
||
availableTests.splice(index, 1);
|
||
}
|
||
}
|
||
!isQuiet && console.log("Skipping tests:", skipExpectations, skippedTests.length, "/", availableTests.length);
|
||
}
|
||
}
|
||
|
||
const shardId = parseInt(options.shard);
|
||
const maxShards = parseInt(options["max-shards"]);
|
||
if (filters?.length) {
|
||
filteredTests.push(...availableTests.filter(testPath => filters.some(filter => isMatch(testPath, filter))));
|
||
!isQuiet && console.log("Filtering tests:", filteredTests.length, "/", availableTests.length);
|
||
} else if (options.smoke !== undefined) {
|
||
const smokePercent = parseFloat(options.smoke) || 0.01;
|
||
const smokeCount = Math.ceil(availableTests.length * smokePercent);
|
||
const smokeTests = new Set<string>();
|
||
for (let i = 0; i < smokeCount; i++) {
|
||
const randomIndex = Math.floor(Math.random() * availableTests.length);
|
||
smokeTests.add(availableTests[randomIndex]!); // randomIndex < availableTests.length
|
||
}
|
||
filteredTests.push(...Array.from(smokeTests));
|
||
!isQuiet && console.log("Smoking tests:", filteredTests.length, "/", availableTests.length);
|
||
} else if (maxShards > 1) {
|
||
// Longest-processing-time-first bin packing across shards using the
|
||
// checked-in median wall-clock durations (see scripts/update-test-durations.mjs).
|
||
// Every shard computes the same deterministic assignment from the same
|
||
// sorted input and picks its own bin, so the result is identical across
|
||
// machines without any coordination. Tests absent from the table (new
|
||
// files, or the file failing to load) fall back to the table's median so
|
||
// they spread across shards instead of all landing on shard 0.
|
||
const durations = loadExpectedDurations(cwd);
|
||
const known = Object.values(durations).sort((a, b) => a - b);
|
||
const unknownCost = known.length ? known[Math.floor(known.length / 2)]! : 100;
|
||
const costOf = (testPath: string) => durations[testPath.replaceAll("\\", "/")] ?? unknownCost;
|
||
|
||
// Stable order for equal costs so the packing is reproducible.
|
||
const order = availableTests
|
||
.map((testPath, originalIndex) => ({ testPath, originalIndex, cost: costOf(testPath) }))
|
||
.sort((a, b) => b.cost - a.cost || a.originalIndex - b.originalIndex);
|
||
const load = new Float64Array(maxShards);
|
||
const assigned = Array.from({ length: maxShards }, (): { testPath: string; originalIndex: number }[] => []);
|
||
for (const { testPath, originalIndex, cost } of order) {
|
||
// `load` and `assigned` have maxShards elements, and s and bin are less than that.
|
||
let bin = 0;
|
||
for (let s = 1; s < maxShards; s++) if (load[s]! < load[bin]!) bin = s;
|
||
load[bin]! += cost;
|
||
assigned[bin]!.push({ testPath, originalIndex });
|
||
}
|
||
// Restore the within-shard order the rest of the pipeline expects
|
||
// (docker-last / modified-first sorts below are stable over this).
|
||
// --shard is less than --max-shards: Buildkite numbers parallel jobs from 0.
|
||
assigned[shardId]!.sort((a, b) => a.originalIndex - b.originalIndex);
|
||
for (const { testPath } of assigned[shardId]!) filteredTests.push(testPath);
|
||
!isQuiet &&
|
||
console.log(
|
||
"Sharding tests (LPT):",
|
||
shardId,
|
||
"/",
|
||
maxShards,
|
||
"with tests",
|
||
filteredTests.length,
|
||
"/",
|
||
availableTests.length,
|
||
"est",
|
||
Math.round(load[shardId]! / 1000) + "s",
|
||
"of",
|
||
Math.round(Math.max(...load) / 1000) + "s max",
|
||
);
|
||
} else {
|
||
filteredTests.push(...availableTests);
|
||
}
|
||
|
||
// Run docker-backed tests (the prefixes the coordinator prestarts) last in
|
||
// the shard: the coordinator kicks off `compose up` for their services when
|
||
// the runner starts, but a cold mysqld/postgres takes ~10s to become
|
||
// healthy. Scheduling those files after the non-docker ones lets container
|
||
// init overlap with real test work instead of being paid as wall time
|
||
// inside the first docker test's beforeAll. Stable sort: relative order is
|
||
// otherwise preserved, and sharding above is unaffected.
|
||
filteredTests.sort((a, b) => Number(needsDockerService(a)) - Number(needsDockerService(b)));
|
||
|
||
// Prioritize modified test files
|
||
if (allFiles.length > 0) {
|
||
const modifiedTests = new Set(
|
||
allFiles
|
||
.filter(filename => filename.startsWith("test/") && isTest(filename))
|
||
.map(filename => filename.slice("test/".length)),
|
||
);
|
||
|
||
if (modifiedTests.size > 0) {
|
||
return filteredTests
|
||
.map(testPath => testPath.replaceAll("\\", "/"))
|
||
.sort((a, b) => {
|
||
const aModified = modifiedTests.has(a);
|
||
const bModified = modifiedTests.has(b);
|
||
if (aModified && !bModified) return -1;
|
||
if (!aModified && bModified) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
}
|
||
|
||
return filteredTests;
|
||
}
|
||
|
||
function getExecPath(bunExe: string): string {
|
||
let execPath: string | undefined;
|
||
let error: unknown;
|
||
try {
|
||
const { error, stdout } = spawnSync(bunExe, ["--print", "process.argv[0]"], {
|
||
encoding: "utf-8",
|
||
timeout: spawnTimeout,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
BUN_DEBUG_QUIET_LOGS: "1",
|
||
},
|
||
});
|
||
if (error) {
|
||
throw error;
|
||
}
|
||
execPath = stdout.trim();
|
||
} catch (cause) {
|
||
error = cause;
|
||
}
|
||
|
||
if (execPath) {
|
||
if (isExecutable(execPath)) {
|
||
return execPath;
|
||
}
|
||
error = new Error(`File is not an executable: ${execPath}`);
|
||
}
|
||
|
||
throw new Error(`Could not find executable: ${bunExe}`, { cause: error });
|
||
}
|
||
|
||
async function getExecPathFromBuildkite(target: string, buildId?: string): Promise<string> {
|
||
if (existsSync(target) || target.includes("/")) {
|
||
return getExecPath(target);
|
||
}
|
||
|
||
const releasePath = join(cwd, "release");
|
||
mkdirSync(releasePath, { recursive: true });
|
||
|
||
let zipPath: string | undefined;
|
||
downloadLoop: for (let i = 0; i < 10; i++) {
|
||
const args = ["artifact", "download", "*.zip", releasePath, "--step", target];
|
||
if (buildId) {
|
||
args.push("--build", buildId);
|
||
}
|
||
|
||
const { error } = await spawnWithTimeout({
|
||
command: "buildkite-agent",
|
||
args,
|
||
timeout: 120000,
|
||
});
|
||
if (error === "timeout") {
|
||
throw new Error(
|
||
`buildkite-agent artifact download timed out after 120s for step '${target}'. ` +
|
||
`Refusing to continue with a partial download (would silently fall back to the wrong binary).`,
|
||
);
|
||
}
|
||
|
||
zipPath = readdirSync(releasePath, { recursive: true, encoding: "utf-8" })
|
||
.filter(filename => /^bun.*\.zip$/i.test(filename))
|
||
.map(filename => join(releasePath, filename))
|
||
.sort((a, b) => Number(b.includes("profile")) - Number(a.includes("profile")))
|
||
.at(0);
|
||
|
||
if (zipPath) {
|
||
break downloadLoop;
|
||
}
|
||
|
||
console.warn(`Waiting for ${target}.zip to be available...`);
|
||
await new Promise(resolve => setTimeout(resolve, i * 1000));
|
||
}
|
||
|
||
if (!zipPath) {
|
||
throw new Error(`Could not find ${target}.zip from Buildkite: ${releasePath}`);
|
||
}
|
||
|
||
await unzip(zipPath, releasePath);
|
||
|
||
const releaseFiles = readdirSync(releasePath, { recursive: true, encoding: "utf-8" });
|
||
for (const entry of releaseFiles) {
|
||
const execPath = join(releasePath, entry);
|
||
if (/bun(?:-[a-z]+)?(?:\.exe)?$/i.test(entry) && statSync(execPath).isFile()) {
|
||
return execPath;
|
||
}
|
||
}
|
||
|
||
console.warn(`Found ${releaseFiles.length} files in ${releasePath}:`, releaseFiles);
|
||
throw new Error(`Could not find executable from BuildKite: ${releasePath}`);
|
||
}
|
||
|
||
function getRevision(execPath: string): string {
|
||
try {
|
||
const { error, stdout } = spawnSync(execPath, ["--revision"], {
|
||
encoding: "utf-8",
|
||
timeout: spawnTimeout,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
BUN_DEBUG_QUIET_LOGS: "1",
|
||
},
|
||
});
|
||
if (error) {
|
||
throw error;
|
||
}
|
||
return stdout.trim();
|
||
} catch (error) {
|
||
console.warn(error);
|
||
return "<unknown>";
|
||
}
|
||
}
|
||
|
||
function addPath(...paths: (string | undefined)[]): string {
|
||
if (isWindows) {
|
||
return paths.join(";");
|
||
}
|
||
return paths.join(":");
|
||
}
|
||
|
||
function getTestLabel(): string | undefined {
|
||
return getBuildLabel()?.replace(" - test-bun", "");
|
||
}
|
||
|
||
/**
|
||
* @param unlisted passed on a retry but test/flaky-tests.txt does not list it
|
||
*/
|
||
function formatTestToMarkdown(
|
||
result: TestResult | TestResult[],
|
||
concise: boolean,
|
||
retries: number,
|
||
unlisted = false,
|
||
): string {
|
||
const results = Array.isArray(result) ? result : [result];
|
||
const buildLabel = getTestLabel();
|
||
const buildUrl = getBuildUrl();
|
||
const platform = buildUrl ? `<a href="${buildUrl}">${buildLabel}</a>` : buildLabel;
|
||
|
||
let markdown = "";
|
||
for (const { testPath, ok, tests, error, stdoutPreview: stdout } of results) {
|
||
if (ok || error === "SIGTERM") {
|
||
continue;
|
||
}
|
||
|
||
// An error is reported at the top frame of its stack, which can be in a helper;
|
||
// its line only belongs in a link to the test file when that is its file.
|
||
// The two paths are relative to different directories, so one ends with the other.
|
||
const testFile = testPath.replaceAll("\\", "/");
|
||
const errorLine = tests
|
||
.flatMap(({ errors }) => errors ?? [])
|
||
.find(({ file, line }) => {
|
||
const errorFile = file?.replaceAll("\\", "/");
|
||
return line && errorFile && (testFile.endsWith(errorFile) || errorFile.endsWith(testFile));
|
||
})?.line;
|
||
|
||
const testTitle = testPath.replace(/\\/g, "/");
|
||
const testUrl = getFileUrl(testPath, errorLine);
|
||
|
||
if (concise) {
|
||
markdown += "<li>";
|
||
} else {
|
||
markdown += "<details><summary>";
|
||
}
|
||
|
||
if (testUrl) {
|
||
markdown += `<a href="${testUrl}"><code>${testTitle}</code></a>`;
|
||
} else {
|
||
markdown += `<a><code>${testTitle}</code></a>`;
|
||
}
|
||
if (error) {
|
||
markdown += ` - ${error}`;
|
||
}
|
||
if (platform) {
|
||
markdown += ` on ${platform}`;
|
||
}
|
||
if (retries > 0) {
|
||
markdown += ` (${retries} ${retries === 1 ? "retry" : "retries"})`;
|
||
}
|
||
if (unlisted) {
|
||
markdown += ` <b>(not in test/flaky-tests.txt)</b>`;
|
||
}
|
||
if (newFiles.includes(testTitle)) {
|
||
markdown += ` (new)`;
|
||
}
|
||
|
||
if (concise) {
|
||
markdown += "</li>\n";
|
||
} else {
|
||
markdown += "</summary>\n\n";
|
||
if (isBuildkite) {
|
||
const preview = escapeCodeBlock(stdout);
|
||
markdown += `\`\`\`terminal\n${preview}\n\`\`\`\n`;
|
||
} else {
|
||
const preview = escapeHtml(stripAnsi(stdout));
|
||
markdown += `<pre><code>${preview}</code></pre>\n`;
|
||
}
|
||
markdown += "\n\n</details>\n\n";
|
||
}
|
||
}
|
||
|
||
return markdown;
|
||
}
|
||
|
||
function uploadArtifactsToBuildkite(glob: string): void {
|
||
spawn("buildkite-agent", ["artifact", "upload", glob], {
|
||
stdio: ["ignore", "ignore", "ignore"],
|
||
timeout: spawnTimeout,
|
||
cwd,
|
||
});
|
||
}
|
||
|
||
function reportOutputToGitHubAction(name: string, value: string | number): void {
|
||
const outputPath = process.env.GITHUB_OUTPUT;
|
||
if (!outputPath) {
|
||
return;
|
||
}
|
||
const delimeter = Math.random().toString(36).substring(2, 15);
|
||
const content = `${name}<<${delimeter}\n${value}\n${delimeter}\n`;
|
||
appendFileSync(outputPath, content);
|
||
}
|
||
|
||
function getAnsi(color: string): string {
|
||
switch (color) {
|
||
case "red":
|
||
return "\x1b[31m";
|
||
case "green":
|
||
return "\x1b[32m";
|
||
case "yellow":
|
||
return "\x1b[33m";
|
||
case "blue":
|
||
return "\x1b[34m";
|
||
case "reset":
|
||
return "\x1b[0m";
|
||
case "gray":
|
||
return "\x1b[90m";
|
||
default:
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function parseDuration(duration: string | number | undefined): number | undefined {
|
||
const match = /(\d+\.\d+)(m?s)/.exec(String(duration));
|
||
if (!match) {
|
||
return undefined;
|
||
}
|
||
const [, value, unit] = match;
|
||
return parseFloat(value!) * (unit === "ms" ? 1 : 1000);
|
||
}
|
||
|
||
function isExecutable(execPath: string): boolean {
|
||
if (!existsSync(execPath) || !statSync(execPath).isFile()) {
|
||
return false;
|
||
}
|
||
try {
|
||
accessSync(execPath, fs.X_OK);
|
||
} catch {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function getExitCode(outcome?: "pass" | "fail" | "cancel"): number {
|
||
if (outcome === "pass") {
|
||
return 0;
|
||
}
|
||
if (!isBuildkite) {
|
||
return 1;
|
||
}
|
||
// On Buildkite, you can define a `soft_fail` property to differentiate
|
||
// from failing tests and the runner itself failing.
|
||
if (outcome === "fail") {
|
||
return 2;
|
||
}
|
||
if (outcome === "cancel") {
|
||
return 3;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
// A flaky segfault, sigtrap, or sigkill must never be ignored.
|
||
// If it happens in CI, it will happen to our users.
|
||
// Flaky AddressSanitizer errors cannot be ignored since they still represent real bugs.
|
||
function isAlwaysFailure(error: string | undefined): boolean {
|
||
error = ((error || "") + "").toLowerCase().trim();
|
||
return (
|
||
error.includes("segmentation fault") ||
|
||
error.includes("illegal instruction") ||
|
||
error.includes("unchecked exception") ||
|
||
error.includes("sigtrap") ||
|
||
error.includes("sigabrt") ||
|
||
error.includes("sigkill") ||
|
||
error.includes("error: addresssanitizer") ||
|
||
error.includes("internal assertion failure") ||
|
||
error.includes("core dumped") ||
|
||
error.includes("crash reported")
|
||
);
|
||
}
|
||
|
||
function onExit(signal: NodeJS.Signals): void {
|
||
const label = `${getAnsi("red")}Received ${signal}, exiting...${getAnsi("reset")}`;
|
||
startGroup(label, () => {
|
||
markBuildkiteStepReported();
|
||
process.exit(getExitCode("cancel"));
|
||
});
|
||
}
|
||
|
||
let getBuildkiteAnalyticsToken = () => {
|
||
let token = getSecret("TEST_REPORTING_API", { required: true });
|
||
getBuildkiteAnalyticsToken = () => token;
|
||
return token;
|
||
};
|
||
|
||
interface JUnitTestCase {
|
||
name: string;
|
||
classname: string;
|
||
time: number;
|
||
failure?: { message: string; type: string; content: string | undefined };
|
||
skipped?: { message: string };
|
||
}
|
||
|
||
interface JUnitTestSuite {
|
||
name: string;
|
||
tests: JUnitTestCase[];
|
||
failures: number;
|
||
errors: number;
|
||
skipped: number;
|
||
time: number;
|
||
timestamp: string;
|
||
hostname: string;
|
||
stdout: string;
|
||
}
|
||
|
||
/** Generate a JUnit XML report from test results. */
|
||
function generateJUnitReport(outfile: string, results: TestResult[]): void {
|
||
!isQuiet && console.log(`Generating JUnit XML report: ${outfile}`);
|
||
|
||
// Start the XML document
|
||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||
|
||
// Add an overall testsuite container with metadata
|
||
const totalTests = results.length;
|
||
const totalFailures = results.filter(r => r.status === "fail").length;
|
||
const timestamp = new Date().toISOString();
|
||
|
||
// Calculate total time
|
||
const totalTime = results.reduce((sum, result) => {
|
||
const duration = result.duration || 0;
|
||
return sum + duration / 1000; // Convert ms to seconds
|
||
}, 0);
|
||
|
||
// Create a unique package name to identify this run
|
||
const packageName = `bun.internal.${process.env.BUILDKITE_PIPELINE_SLUG || "tests"}`;
|
||
|
||
xml += `<testsuites name="${escapeXml(packageName)}" tests="${totalTests}" failures="${totalFailures}" time="${totalTime.toFixed(3)}" timestamp="${timestamp}">\n`;
|
||
|
||
// Group results by test file
|
||
const testSuites = new Map<string, JUnitTestSuite>();
|
||
|
||
for (const result of results) {
|
||
const { testPath, status, error, tests, stdoutPreview, stdout, duration = 0 } = result;
|
||
|
||
if (!testSuites.has(testPath)) {
|
||
testSuites.set(testPath, {
|
||
name: testPath,
|
||
tests: [],
|
||
failures: 0,
|
||
errors: 0,
|
||
skipped: 0,
|
||
time: 0,
|
||
timestamp: timestamp,
|
||
hostname: getHostname(),
|
||
stdout: stdout || stdoutPreview || "",
|
||
});
|
||
}
|
||
|
||
const suite = testSuites.get(testPath)!; // set just above
|
||
|
||
// For test suites with granular test information
|
||
if (tests.length > 0) {
|
||
for (const test of tests) {
|
||
const { test: testName, status: testStatus, duration: testDuration = 0, errors: testErrors = [] } = test;
|
||
|
||
suite.time += testDuration / 1000; // Convert to seconds
|
||
|
||
const testCase: JUnitTestCase = {
|
||
name: testName,
|
||
classname: `${packageName}.${testPath.replace(/[\/\\]/g, ".")}`,
|
||
time: testDuration / 1000, // Convert to seconds
|
||
};
|
||
|
||
if (testStatus === "fail") {
|
||
suite.failures++;
|
||
|
||
// Collect error details
|
||
let errorMessage = "Test failed";
|
||
let errorType = "AssertionError";
|
||
let errorContent: string | undefined = "";
|
||
|
||
if (testErrors && testErrors.length > 0) {
|
||
const primaryError = testErrors[0]!; // testErrors.length > 0
|
||
errorMessage = primaryError.name || "Test failed";
|
||
errorType = primaryError.name || "AssertionError";
|
||
errorContent = primaryError.stack || primaryError.name;
|
||
|
||
if (testErrors.length > 1) {
|
||
errorContent +=
|
||
"\n\nAdditional errors:\n" +
|
||
testErrors
|
||
.slice(1)
|
||
.map(e => e.stack || e.name)
|
||
.join("\n");
|
||
}
|
||
} else {
|
||
errorContent = error || "Unknown error";
|
||
}
|
||
|
||
testCase.failure = {
|
||
message: errorMessage,
|
||
type: errorType,
|
||
content: errorContent,
|
||
};
|
||
} else if (testStatus === "skip" || testStatus === "todo") {
|
||
suite.skipped++;
|
||
testCase.skipped = {
|
||
message: testStatus === "skip" ? "Test skipped" : "Test marked as todo",
|
||
};
|
||
}
|
||
|
||
suite.tests.push(testCase);
|
||
}
|
||
} else {
|
||
// For test suites without granular test information (e.g., bun install tests)
|
||
suite.time += duration / 1000; // Convert to seconds
|
||
|
||
const testCase: JUnitTestCase = {
|
||
name: basename(testPath),
|
||
classname: `${packageName}.${testPath.replace(/[\/\\]/g, ".")}`,
|
||
time: duration / 1000, // Convert to seconds
|
||
};
|
||
|
||
if (status === "fail") {
|
||
suite.failures++;
|
||
testCase.failure = {
|
||
message: "Test failed",
|
||
type: "AssertionError",
|
||
content: error || "Unknown error",
|
||
};
|
||
}
|
||
|
||
suite.tests.push(testCase);
|
||
}
|
||
}
|
||
|
||
// Write each test suite to the XML
|
||
for (const [name, suite] of testSuites) {
|
||
xml += ` <testsuite name="${escapeXml(name)}" tests="${suite.tests.length}" failures="${suite.failures}" errors="${suite.errors}" skipped="${suite.skipped}" time="${suite.time.toFixed(3)}" timestamp="${suite.timestamp}" hostname="${escapeXml(suite.hostname)}">\n`;
|
||
|
||
// Include system-out if we have stdout
|
||
if (suite.stdout) {
|
||
xml += ` <system-out><![CDATA[${suite.stdout}]]></system-out>\n`;
|
||
}
|
||
|
||
// Write each test case
|
||
for (const test of suite.tests) {
|
||
xml += ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(test.classname)}" time="${test.time.toFixed(3)}"`;
|
||
|
||
if (test.skipped) {
|
||
xml += `>\n <skipped message="${escapeXml(test.skipped.message)}"/>\n </testcase>\n`;
|
||
} else if (test.failure) {
|
||
xml += `>\n`;
|
||
xml += ` <failure message="${escapeXml(test.failure.message)}" type="${escapeXml(test.failure.type)}"><![CDATA[${test.failure.content}]]></failure>\n`;
|
||
xml += ` </testcase>\n`;
|
||
} else {
|
||
xml += `/>\n`;
|
||
}
|
||
}
|
||
|
||
xml += ` </testsuite>\n`;
|
||
}
|
||
|
||
xml += `</testsuites>`;
|
||
|
||
// Create directory if it doesn't exist
|
||
const dir = dirname(outfile);
|
||
mkdirSync(dir, { recursive: true });
|
||
|
||
// Write to file
|
||
writeFileSync(outfile, xml);
|
||
!isQuiet && console.log(`JUnit XML report written to ${outfile}`);
|
||
}
|
||
|
||
let isUploadingToBuildkite = false;
|
||
const junitUploadQueue: string[] = [];
|
||
async function addToJunitUploadQueue(junitFilePath: string): Promise<void> {
|
||
junitUploadQueue.push(junitFilePath);
|
||
|
||
if (!isUploadingToBuildkite) {
|
||
drainJunitUploadQueue();
|
||
}
|
||
}
|
||
|
||
async function drainJunitUploadQueue(): Promise<void> {
|
||
isUploadingToBuildkite = true;
|
||
while (junitUploadQueue.length > 0) {
|
||
const testPath = junitUploadQueue.shift()!; // junitUploadQueue.length > 0
|
||
await uploadJUnitToBuildkite(testPath)
|
||
.then(uploadSuccess => {
|
||
unlink(testPath, () => {
|
||
if (!uploadSuccess) {
|
||
console.error(`Failed to upload JUnit report for ${testPath}`);
|
||
}
|
||
});
|
||
})
|
||
.catch(err => {
|
||
console.error(`Error uploading JUnit report for ${testPath}:`, err);
|
||
});
|
||
}
|
||
isUploadingToBuildkite = false;
|
||
}
|
||
|
||
/** Upload a JUnit XML report to Buildkite Test Analytics; resolves to whether it was accepted. */
|
||
async function uploadJUnitToBuildkite(junitFile: string): Promise<boolean> {
|
||
const fileName = basename(junitFile);
|
||
!isQuiet && console.log(`Uploading JUnit file "${fileName}" to BuildKite Test Analytics...`);
|
||
|
||
// Get BuildKite environment variables for run_env fields
|
||
const buildId = process.env.BUILDKITE_BUILD_ID;
|
||
const buildUrl = process.env.BUILDKITE_BUILD_URL;
|
||
const branch = getBranch();
|
||
const commit = getCommit();
|
||
const buildNumber = process.env.BUILDKITE_BUILD_NUMBER;
|
||
const jobId = process.env.BUILDKITE_JOB_ID;
|
||
const message = process.env.BUILDKITE_MESSAGE;
|
||
|
||
try {
|
||
// Add a unique test suite identifier to help with correlation in BuildKite
|
||
const testId = fileName.replace(/\.xml$/, "");
|
||
|
||
// Use fetch and FormData instead of curl
|
||
const formData = new FormData();
|
||
|
||
// Add the JUnit file data
|
||
formData.append("data", new Blob([await readFile(junitFile)]), fileName);
|
||
formData.append("format", "junit");
|
||
formData.append("run_env[CI]", "buildkite");
|
||
|
||
// Add additional fields
|
||
if (buildId) formData.append("run_env[key]", buildId);
|
||
if (buildUrl) formData.append("run_env[url]", buildUrl);
|
||
if (branch) formData.append("run_env[branch]", branch);
|
||
if (commit) formData.append("run_env[commit_sha]", commit);
|
||
if (buildNumber) formData.append("run_env[number]", buildNumber);
|
||
if (jobId) formData.append("run_env[job_id]", jobId);
|
||
if (message) formData.append("run_env[message]", message);
|
||
|
||
// Add custom tags
|
||
formData.append("tags[runtime]", "bun");
|
||
formData.append("tags[suite]", testId);
|
||
|
||
// Add additional context information specific to this run
|
||
formData.append("run_env[source]", "junit-import");
|
||
formData.append("run_env[collector]", "bun-runner");
|
||
|
||
const url = "https://analytics-api.buildkite.com/v1/uploads";
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Authorization": `Token token="${getBuildkiteAnalyticsToken()}"`,
|
||
},
|
||
body: formData,
|
||
});
|
||
|
||
if (response.ok) {
|
||
!isQuiet && console.log(`JUnit file "${fileName}" successfully uploaded to BuildKite Test Analytics`);
|
||
|
||
try {
|
||
// Consume the body to ensure Node releases the memory.
|
||
await response.arrayBuffer();
|
||
} catch (error) {
|
||
// Don't care if this fails.
|
||
}
|
||
|
||
return true;
|
||
} else {
|
||
const errorText = await response.text();
|
||
console.error(`Failed to upload JUnit file "${fileName}": HTTP ${response.status}`, errorText);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error uploading JUnit file "${fileName}":`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function escapeXml(str: string): string {
|
||
return str
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||
process.on(signal, () => onExit(signal));
|
||
}
|
||
|
||
if (!isQuiet) {
|
||
printEnvironment();
|
||
}
|
||
|
||
// FIXME: Some DNS tests hang unless we set the DNS server to 8.8.8.8
|
||
// It also appears to hang on 1.1.1.1, which could explain this issue:
|
||
// https://github.com/oven-sh/bun/issues/11136
|
||
if (isWindows && isCI) {
|
||
await spawn("pwsh", [
|
||
"-Command",
|
||
"Set-DnsClientServerAddress -InterfaceAlias 'Ethernet 4' -ServerAddresses ('8.8.8.8','8.8.4.4')",
|
||
]);
|
||
}
|
||
|
||
let doRunTests = true;
|
||
if (isCI) {
|
||
// allFiles can be empty if the GitHub API call failed (bad token, rate
|
||
// limit, non-PR build). [].every() is vacuously true, which would skip the
|
||
// entire suite and exit 0 — so require at least one file before treating
|
||
// the change set as docs-only.
|
||
if (allFiles.length > 0 && allFiles.every(filename => filename.startsWith("docs/"))) {
|
||
doRunTests = false;
|
||
}
|
||
}
|
||
|
||
let ok = true;
|
||
if (doRunTests) {
|
||
const results = await runTests();
|
||
ok = results.every(({ ok }) => ok);
|
||
}
|
||
|
||
let waitForUser = false;
|
||
while (isCI) {
|
||
const userCount = getLoggedInUserCountOrDetails();
|
||
if (!userCount) {
|
||
if (waitForUser) {
|
||
!isQuiet && console.log("No users logged in, exiting runner...");
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (!waitForUser) {
|
||
startGroup("Summary");
|
||
if (typeof userCount === "number") {
|
||
console.warn(`Found ${userCount} users logged in, keeping the runner alive until logout...`);
|
||
} else {
|
||
console.warn(userCount);
|
||
}
|
||
waitForUser = true;
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 60_000));
|
||
}
|
||
|
||
markBuildkiteStepReported();
|
||
process.exit(getExitCode(ok ? "pass" : "fail"));
|
||
}
|
||
|
||
await main();
|