aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Ashcon Partovi <ashcon@partovi.net> 2023-05-04 15:27:12 -0700
committerGravatar GitHub <noreply@github.com> 2023-05-04 15:27:12 -0700
commit1183dd1a3fd073de40823b6f3b44a890e89d5ffd (patch)
tree9e488cb615460e517b638d779ae75aab7243b52b
parent8e18229d5da2a24b89791b87fb5a8ec4e5a0db1b (diff)
downloadbun-1183dd1a3fd073de40823b6f3b44a890e89d5ffd.tar.gz
bun-1183dd1a3fd073de40823b6f3b44a890e89d5ffd.tar.zst
bun-1183dd1a3fd073de40823b6f3b44a890e89d5ffd.zip
Add initial ecosystem tests (#2801)
* Add initial ecosystem tests * Run ecosystem tests every morning, after canary release
-rw-r--r--.github/scripts/get-revision.js1
-rw-r--r--.github/scripts/package.json3
-rw-r--r--.github/scripts/test-runner.ts687
-rw-r--r--.github/workflows/bun-ecosystem-test.yml48
-rw-r--r--packages/bun-internal-test/.gitignore1
-rw-r--r--packages/bun-internal-test/README.md3
-rwxr-xr-xpackages/bun-internal-test/bun.lockbbin2704 -> 1991 bytes
-rw-r--r--packages/bun-internal-test/package.json12
-rw-r--r--packages/bun-internal-test/resources/packages.json131
-rw-r--r--packages/bun-internal-test/runners/bun/__snapshots__/runner.test.ts.snap552
-rw-r--r--packages/bun-internal-test/runners/bun/package.json5
-rw-r--r--packages/bun-internal-test/runners/bun/runner.test.ts303
-rw-r--r--packages/bun-internal-test/runners/bun/runner.ts714
-rw-r--r--packages/bun-internal-test/runners/qunit/assert.ts233
-rw-r--r--packages/bun-internal-test/runners/qunit/package.json5
-rw-r--r--packages/bun-internal-test/runners/qunit/qunit.d.ts115
-rw-r--r--packages/bun-internal-test/runners/qunit/qunit.test.ts239
-rw-r--r--packages/bun-internal-test/runners/qunit/qunit.ts327
-rw-r--r--packages/bun-internal-test/runners/tap/index.ts335
-rw-r--r--packages/bun-internal-test/scripts/html.ts75
-rw-r--r--packages/bun-internal-test/scripts/run-ecosystem-tests.ts191
-rw-r--r--packages/bun-internal-test/tsconfig.json26
-rw-r--r--packages/bun-internal-test/types/bun-test.d.ts20
23 files changed, 3319 insertions, 707 deletions
diff --git a/.github/scripts/get-revision.js b/.github/scripts/get-revision.js
deleted file mode 100644
index 09905e5bf..000000000
--- a/.github/scripts/get-revision.js
+++ /dev/null
@@ -1 +0,0 @@
-console.log(Bun.revision);
diff --git a/.github/scripts/package.json b/.github/scripts/package.json
deleted file mode 100644
index 3dbc1ca59..000000000
--- a/.github/scripts/package.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "type": "module"
-}
diff --git a/.github/scripts/test-runner.ts b/.github/scripts/test-runner.ts
deleted file mode 100644
index cd049e822..000000000
--- a/.github/scripts/test-runner.ts
+++ /dev/null
@@ -1,687 +0,0 @@
-// This file parses the output of `bun test` and outputs
-// a markdown summary and Github Action annotations.
-//
-// In the future, a version of this will be built-in to Bun.
-
-import { join, basename } from "node:path";
-import { readdirSync, writeSync, fsyncSync, appendFileSync } from "node:fs";
-import { spawn, spawnSync } from "node:child_process";
-
-export { parseTest, runTest, formatTest };
-
-export type ParseTestOptions = {
- cwd?: string;
- paths?: string[];
-};
-
-export type ParseTestResult = {
- info: TestInfo;
- files: TestFile[];
- summary: TestSummary;
-};
-
-export type RunTestOptions = ParseTestOptions & {
- args?: string[];
- timeout?: number;
- isolate?: boolean;
-};
-
-export type RunTestResult = ParseTestResult & {
- exitCode: number | null;
- stdout: string;
- stderr: string;
-};
-
-export type TestInfo = {
- name: string;
- version: string;
- revision: string;
- os: string;
- arch: string;
-};
-
-export type TestFile = {
- file: string;
- status: TestStatus;
- tests: Test[];
- summary: TestSummary;
- errors?: TestError[];
-};
-
-export type TestError = {
- name: string;
- message: string;
- stack?: TestErrorStack[];
-};
-
-export type TestErrorStack = {
- file: string;
- function?: string;
- line: number;
- column?: number;
-};
-
-export type TestStatus = "pass" | "fail" | "skip";
-
-export type Test = {
- name: string;
- status: TestStatus;
- errors?: TestError[];
-};
-
-export type TestSummary = {
- pass: number;
- fail: number;
- skip: number;
- tests: number;
- files: number;
- duration: number;
-};
-
-function parseTest(lines: string[], options?: ParseTestOptions): ParseTestResult {
- let i = 0;
- const isDone = () => {
- return i >= lines.length;
- };
- const readLine = () => {
- return lines[i++];
- };
- function readUntil<V>(cb: (line: string) => V | undefined): V | undefined {
- while (!isDone()) {
- const line = readLine();
- const result = cb(line);
- if (result) {
- return result;
- }
- }
- }
- const { cwd, paths = cwd ? Array.from(listFiles(cwd, "")) : [] } = options ?? {};
- const info = readUntil(parseInfo);
- if (!info) {
- throw new Error("No tests found");
- }
- const files: TestFile[] = [];
- let file: TestFile | undefined;
- let test: Test | undefined;
- let error: TestError | undefined;
- let errorStart: number | undefined;
- let summary: TestSummary | undefined;
- const reset = () => {
- if (error) {
- if (file) {
- if (test) {
- if (!test?.errors) {
- test.errors = [error];
- } else {
- test.errors.push(error);
- }
- } else {
- if (!file.errors) {
- file.errors = [error];
- } else {
- file.errors.push(error);
- }
- }
- }
- error = undefined;
- errorStart = undefined;
- }
- };
- while (!isDone()) {
- const line = readLine();
- if (error) {
- const newStack = parseStack(line, cwd);
- if (newStack) {
- if (errorStart !== undefined) {
- for (let j = errorStart; j < i - 1; j++) {
- error.message += `\n${lines[j]}`;
- }
- errorStart = undefined;
- }
- if (!error.stack) {
- error.stack = [newStack];
- } else {
- error.stack.push(newStack);
- }
- continue;
- }
- }
- const newFile = parseFile(line, paths);
- if (newFile) {
- reset();
- file = newFile;
- files.push(file);
- continue;
- }
- const newTest = parseStatus(line);
- if (newTest) {
- if (newTest.status === "skip" && error) {
- continue;
- }
- test = newTest;
- if (file) {
- file.tests.push(test);
- file.summary[test.status]++;
- file.summary.tests++;
- if (test.status === "fail") {
- file.status = "fail";
- }
- }
- file?.tests.push(test);
- reset();
- continue;
- }
- const newError = parseError(line);
- if (newError) {
- reset();
- error = newError;
- errorStart = i;
- continue;
- }
- const newSkip = parseSkip(line);
- if (newSkip) {
- reset();
- i += parseSkip(line);
- continue;
- }
- const newSummary = parseSummary(line);
- if (newSummary) {
- summary = newSummary;
- break;
- }
- }
- summary = {
- tests: files.reduce((n, file) => n + file.tests.length, 0),
- files: files.length,
- duration: 0,
- ...summary,
- pass: files.reduce((n, file) => n + file.tests.filter(test => test.status === "pass").length, 0),
- fail: files.reduce((n, file) => n + file.tests.filter(test => test.status === "fail").length, 0),
- skip: files.reduce((n, file) => n + file.tests.filter(test => test.status === "skip").length, 0),
- };
- if (files.length === 1) {
- files[0].summary = summary;
- }
- return {
- info,
- files,
- summary,
- };
-}
-
-function getRevision(): string {
- if ("Bun" in globalThis) {
- return Bun.revision;
- }
- const { stdout } = spawnSync("bun", ["get-revision.js"], {
- cwd: new URL(".", import.meta.url),
- stdio: "pipe",
- encoding: "utf-8",
- });
- return stdout.trim();
-}
-
-function parseInfo(line: string): TestInfo | undefined {
- const match = /^(bun (?:wip)?test) v([0-9\.]+) \(([0-9a-z]+)\)$/.exec(line);
- if (!match) {
- return undefined;
- }
- const [, name, version, sha] = match;
- return {
- name,
- version,
- revision: getRevision(),
- os: process.platform,
- arch: process.arch,
- };
-}
-
-function parseFile(line: string, paths?: string[]): TestFile | undefined {
- const match = /^([a-z0-9_-]+\.(?:test|spec)\.(?:c|m)?(?:j|t)sx?)\:$/.exec(line);
- if (!match) {
- return undefined;
- }
- let [, file] = match;
- for (const path of paths ?? []) {
- if (path.endsWith(file)) {
- file = path;
- break;
- }
- }
- return {
- file,
- tests: [],
- status: "pass",
- summary: {
- files: 1,
- tests: 0,
- pass: 0,
- fail: 0,
- skip: 0,
- duration: 0,
- },
- };
-}
-
-function parseStatus(line: string): Test | undefined {
- const match = /^(✓|✗|-) (.*)$/.exec(line);
- if (!match) {
- return undefined;
- }
- const [, icon, name] = match;
- return {
- name,
- status: icon === "✓" ? "pass" : icon === "✗" ? "fail" : "skip",
- };
-}
-
-function parseError(line: string): TestError | undefined {
- const match = /^(.*error)\: (.*)$/i.exec(line);
- if (!match) {
- return undefined;
- }
- const [, name, message] = match;
- return {
- name: name === "error" ? "Error" : name,
- message,
- };
-}
-
-function parseStack(line: string, cwd?: string): TestErrorStack | undefined {
- let match = /^\s*at (.*) \((.*)\:([0-9]+)\:([0-9]+)\)$/.exec(line);
- if (!match) {
- match = /^\s*at (.*)\:([0-9]+)\:([0-9]+)$/.exec(line);
- if (!match) {
- return undefined;
- }
- }
- const [columnNo, lineNo, path, func] = match.reverse();
- let file = path;
- if (cwd && path.startsWith(cwd)) {
- file = path.slice(cwd.length);
- if (file.startsWith("/")) {
- file = file.slice(1);
- }
- }
- return {
- file,
- function: func !== line ? func : undefined,
- line: parseInt(lineNo),
- column: parseInt(columnNo),
- };
-}
-
-function parseSkip(line: string): number {
- const match = /^([0-9]+) tests (?:skipped|failed)\:$/.exec(line);
- if (match) {
- return parseInt(match[1]);
- }
- return 0;
-}
-
-function parseSummary(line: string): TestSummary | undefined {
- const match = /^Ran ([0-9]+) tests across ([0-9]+) files \[([0-9]+\.[0-9]+)(m?s)\]$/.exec(line);
- if (!match) {
- return undefined;
- }
- const [, tests, files, duration, unit] = match;
- return {
- pass: 0,
- fail: 0,
- skip: 0,
- tests: parseInt(tests),
- files: parseInt(files),
- duration: parseFloat(duration) * (unit === "s" ? 1000 : 1),
- };
-}
-
-function* listFiles(cwd: string, dir: string): Generator<string> {
- const dirents = readdirSync(join(cwd, dir), { withFileTypes: true });
- for (const dirent of dirents) {
- const { name } = dirent;
- if (name === "node_modules" || name.startsWith(".")) {
- continue;
- }
- const path = join(dir, name);
- if (dirent.isDirectory()) {
- yield* listFiles(cwd, path);
- } else if (dirent.isFile()) {
- yield path;
- }
- }
-}
-
-function stripAnsi(string: string): string {
- return string.replace(/\x1b\[[0-9;]*m/g, "");
-}
-
-function print(buffer: string | Uint8Array) {
- if (typeof buffer === "string") {
- buffer = new TextEncoder().encode(buffer);
- }
- let offset = 0;
- let length = buffer.byteLength;
- while (offset < length) {
- try {
- const n = writeSync(1, buffer);
- offset += n;
- if (offset < length) {
- try {
- fsyncSync(1);
- } catch {}
- buffer = buffer.slice(n);
- }
- } catch (error) {
- // @ts-ignore
- if (error.code === "EAGAIN") {
- continue;
- }
- throw error;
- }
- }
-}
-
-// FIXME: there is a bug that causes annotations to be duplicated
-const seen = new Set<string>();
-
-function annotate(type: string, arg?: string, args?: Record<string, unknown>): void {
- let line = `::${type}`;
- if (args) {
- line += " ";
- line += Object.entries(args)
- .map(([key, value]) => `${key}=${value}`)
- .join(",");
- }
- line += "::";
- if (arg) {
- line += arg;
- }
- line = line.replace(/\n/g, "%0A");
- if (seen.has(line)) {
- return;
- }
- seen.add(line);
- print(`\n${line}\n`);
-}
-
-async function* runTest(options: RunTestOptions): AsyncGenerator<RunTestResult, ParseTestResult> {
- const {
- cwd = process.cwd(),
- args = [],
- timeout = 60_000, // 1 min
- isolate = false,
- } = options;
- const paths: string[] = Array.from(listFiles(cwd, ""));
- const files: string[] = [];
- for (const path of paths) {
- if (!path.includes(".test.")) {
- continue;
- }
- if (!args.length) {
- files.push(path);
- continue;
- }
- for (const arg of args) {
- if (
- (arg.endsWith("/") && path.startsWith(arg)) ||
- (arg.includes(".") && path.endsWith(arg)) ||
- (!arg.endsWith("/") && !arg.includes(".") && path.includes(arg))
- ) {
- files.push(path);
- break;
- }
- }
- }
- const runSingleTest = async (args: string[]) => {
- const runner = spawn("bun", ["test", ...args], {
- cwd,
- env: {
- ...process.env,
- "FORCE_COLOR": "1",
- },
- stdio: "pipe",
- timeout,
- });
- let stderr = "";
- let stdout = "";
- const exitCode = await new Promise<number | null>(resolve => {
- runner.stdout.on("data", (data: Buffer) => {
- stdout += data.toString("utf-8");
- });
- runner.stderr.on("data", (data: Buffer) => {
- stderr += data.toString("utf-8");
- });
- runner.on("error", ({ name, message }) => {
- stderr += `${name}: ${message}`;
- resolve(null);
- });
- runner.on("exit", exitCode => {
- resolve(exitCode);
- });
- });
- const lines = stderr.split("\n").map(stripAnsi);
- const result = parseTest(lines, { cwd, paths });
- return {
- exitCode,
- stdout,
- stderr,
- ...result,
- };
- };
- if (!isolate) {
- const result = await runSingleTest(args);
- yield result;
- return result;
- }
- const tests = files.map(file => runSingleTest([file]));
- const results: RunTestResult[] = [];
- for (const test of tests) {
- const result = await test;
- results.push(result);
- yield result;
- }
- if (!results.length) {
- throw new Error("No tests found");
- }
- return {
- info: results.map(result => result.info).pop()!,
- files: results.flatMap(result => result.files),
- summary: results
- .map(result => result.summary)
- .reduce((summary, result) => {
- summary.pass += result.pass;
- summary.fail += result.fail;
- summary.skip += result.skip;
- summary.tests += result.tests;
- summary.files += result.files;
- summary.duration += result.duration;
- return summary;
- }),
- };
-}
-
-export type FormatTestOptions = {
- debug?: boolean;
- baseUrl?: string;
-};
-
-function formatTest(result: ParseTestResult, options?: FormatTestOptions): string {
- const { debug, baseUrl } = options ?? {};
- const count = (n: number, label?: string) => {
- return n ? (label ? `${n} ${label}` : `${n}`) : "";
- };
- const code = (content: string, lang?: string) => {
- return `\`\`\`${lang ?? ""}\n${content}\n\`\`\`\n`;
- };
- const link = (title: string, href?: string) => {
- if (href && baseUrl) {
- href = `${new URL(href, baseUrl)}`;
- }
- return href ? `[${title}](${href})` : title;
- };
- const table = (headers: string[], rows: unknown[][]) => {
- return [headers, headers.map(() => "-"), ...rows].map(row => `| ${row.join(" | ")} |`).join("\n");
- };
- const header = (level: number, content: string) => {
- return `${"#".repeat(level)} ${content}\n`;
- };
- const icon = {
- pass: "✅",
- fail: "❌",
- skip: "⏭️",
- };
- const files = table(
- ["File", "Status", "Pass", "Fail", "Skip", "Tests", "Duration"],
- result.files
- .filter(({ status }) => debug || status !== "pass")
- .sort((a, b) => {
- if (a.status === b.status) {
- return a.file.localeCompare(b.file);
- }
- return a.status.localeCompare(b.status);
- })
- .map(({ file, status, summary }) => [
- link(basename(file), file),
- icon[status],
- count(summary.pass),
- count(summary.fail),
- count(summary.skip),
- count(summary.tests),
- count(summary.duration, "ms"),
- ]),
- );
- const errors = result.files
- .filter(({ status }) => status === "fail")
- .sort((a, b) => a.file.localeCompare(b.file))
- .flatMap(({ file, tests }) => {
- return [
- header(2, link(basename(file), file)),
- ...tests
- .filter(({ status }) => status === "fail")
- .map(({ name, errors }) => {
- let url = "";
- let content = "";
- if (errors) {
- content += errors
- .map(({ name, message, stack }) => {
- let preview = code(`${name}: ${message}`, "diff");
- if (stack?.length && baseUrl) {
- const { file, line } = stack[0];
- if (!is3rdParty(file) && !url) {
- const { href } = new URL(`${file}?plain=1#L${line}`, baseUrl);
- url = href;
- }
- }
- return preview;
- })
- .join("\n");
- } else {
- content += code("See logs for details");
- }
- return `${header(3, link(name, url))}\n${content}`;
- }),
- ];
- })
- .join("\n");
- return `${header(1, "Files")}
-${files}
-
-${header(1, "Errors")}
-${errors}`;
-}
-
-function is3rdParty(file?: string): boolean {
- return !file || file.startsWith("/") || file.includes(":") || file.includes("..") || file.includes("node_modules/");
-}
-
-function printTest(result: RunTestResult): void {
- const isAction = process.env["GITHUB_ACTIONS"] === "true";
- const isSingle = result.files.length === 1;
- if (isSingle) {
- const { file, status } = result.files[0];
- if (isAction) {
- annotate("group", `${status.toUpperCase()} - ${file}`);
- } else {
- print(`\n${file}:\n`);
- }
- }
- print(result.stderr);
- print(result.stdout);
- if (!isAction) {
- return;
- }
- result.files
- .filter(({ status }) => status === "fail")
- .flatMap(({ tests }) => tests)
- .filter(({ status }) => status === "fail")
- .flatMap(({ name: title, errors }) =>
- errors?.forEach(({ name, message, stack }) => {
- const { file, line } = stack?.[0] ?? {};
- if (is3rdParty(file)) {
- return;
- }
- annotate("error", `${name}: ${message}`, {
- file,
- line,
- title,
- });
- }),
- );
- if (isSingle) {
- annotate("endgroup");
- }
-}
-
-async function main() {
- let args = [...process.argv.slice(2)];
- let timeout;
- let isolate;
- let quiet;
- for (let i = 0; i < args.length; i++) {
- const arg = args[i];
- if (arg.startsWith("--timeout=")) {
- timeout = parseInt(arg.split("=").pop()!);
- } else if (arg.startsWith("--isolate")) {
- isolate = true;
- } else if (arg.startsWith("--quiet")) {
- quiet = true;
- }
- }
- args = args.filter(arg => !arg.startsWith("--"));
- const results = runTest({
- args,
- timeout,
- isolate,
- });
- let result: ParseTestResult;
- while (true) {
- const { value, done } = await results.next();
- if (done) {
- result = value;
- break;
- } else if (!quiet) {
- printTest(value);
- }
- }
- const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
- if (!summaryPath) {
- return;
- }
- const sha = process.env["GITHUB_SHA"] ?? result.info.revision;
- const repo = process.env["GITHUB_REPOSITORY"] ?? "oven-sh/bun";
- const serverUrl = process.env["GITHUB_SERVER_URL"] ?? "https://github.com";
- const summary = formatTest(result, {
- debug: process.env["ACTIONS_STEP_DEBUG"] === "true",
- baseUrl: `${serverUrl}/${repo}/blob/${sha}/`,
- });
- appendFileSync(summaryPath, summary, "utf-8");
- process.exit(0);
-}
-
-function isMain() {
- return import.meta.main || import.meta.url === `file://${process.argv[1]}`;
-}
-
-if (isMain()) {
- await main();
-}
diff --git a/.github/workflows/bun-ecosystem-test.yml b/.github/workflows/bun-ecosystem-test.yml
new file mode 100644
index 000000000..a8b7af4e2
--- /dev/null
+++ b/.github/workflows/bun-ecosystem-test.yml
@@ -0,0 +1,48 @@
+name: Ecosystem Test
+
+on:
+ schedule:
+ - cron: "0 15 * * *" # every day at 7am PST
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "The version of Bun to run"
+ required: true
+ default: "canary"
+ type: string
+jobs:
+ test:
+ name: ${{ matrix.tag }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ tag: linux-x64
+ url: linux/x64?avx2=true
+ - os: ubuntu-latest
+ tag: linux-x64-baseline
+ url: linux/x64?baseline=true
+ # FIXME: runner fails with "No tests found"?
+ #- os: macos-latest
+ # tag: darwin-x64
+ # url: darwin/x64?avx2=true
+ - os: macos-latest
+ tag: darwin-x64-baseline
+ url: darwin/x64?baseline=true
+ steps:
+ - id: checkout
+ name: Checkout
+ uses: actions/checkout@v3
+ with:
+ submodules: false
+ - id: setup
+ name: Setup
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-download-url: https://bun.sh/download/${{ github.event.inputs.version }}/${{ matrix.url }}
+ - id: test
+ name: Test
+ run: bun run test:ecosystem
diff --git a/packages/bun-internal-test/.gitignore b/packages/bun-internal-test/.gitignore
index 1f7591603..40f49ecdd 100644
--- a/packages/bun-internal-test/.gitignore
+++ b/packages/bun-internal-test/.gitignore
@@ -2,3 +2,4 @@
.env
node_modules
failing-tests.txt
+packages/
diff --git a/packages/bun-internal-test/README.md b/packages/bun-internal-test/README.md
deleted file mode 100644
index 5d5471101..000000000
--- a/packages/bun-internal-test/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# bun-test
-
-Scripts to run Bun's tests using `bun wiptest`.
diff --git a/packages/bun-internal-test/bun.lockb b/packages/bun-internal-test/bun.lockb
index ed9a220c7..b6006363b 100755
--- a/packages/bun-internal-test/bun.lockb
+++ b/packages/bun-internal-test/bun.lockb
Binary files differ
diff --git a/packages/bun-internal-test/package.json b/packages/bun-internal-test/package.json
index accc76f77..b7d3ada30 100644
--- a/packages/bun-internal-test/package.json
+++ b/packages/bun-internal-test/package.json
@@ -1,14 +1,18 @@
{
"private": true,
+ "name": "bun-internal-test",
"type": "module",
- "dependencies": {
- "@actions/core": "^1.10.0"
- },
+ "workspaces": [
+ "runners/bun",
+ "runners/qunit"
+ ],
+ "dependencies": {},
"devDependencies": {
"bun-types": "canary",
"prettier": "^2.8.2"
},
"scripts": {
- "test": "node src/runner.node.mjs"
+ "test": "node src/runner.node.mjs",
+ "test:ecosystem": "bun scripts/run-ecosystem-tests.ts"
}
}
diff --git a/packages/bun-internal-test/resources/packages.json b/packages/bun-internal-test/resources/packages.json
new file mode 100644
index 000000000..49bd24821
--- /dev/null
+++ b/packages/bun-internal-test/resources/packages.json
@@ -0,0 +1,131 @@
+[
+ {
+ "name": "lodash",
+ "repository": {
+ "github": "lodash/lodash"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "test/"
+ }
+ },
+ {
+ "name": "express",
+ "repository": {
+ "github": "expressjs/express"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "test/"
+ }
+ },
+ {
+ "name": "moment",
+ "repository": {
+ "github": "moment/moment"
+ },
+ "test": {
+ "runner": "qunit",
+ "path": "src/test/moment/"
+ }
+ },
+ {
+ "name": "underscore",
+ "repository": {
+ "github": "jashkenas/underscore"
+ },
+ "test": {
+ "runner": "qunit",
+ "path": "test/"
+ }
+ },
+ {
+ "name": "glob",
+ "repository": {
+ "github": "isaacs/node-glob"
+ },
+ "test": {
+ "runner": "tap",
+ "path": "test/"
+ }
+ },
+ {
+ "name": "uuid",
+ "repository": {
+ "github": "uuidjs/uuid"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "test/unit/"
+ }
+ },
+ {
+ "name": "elysia",
+ "repository": {
+ "github": "elysiajs/elysia"
+ },
+ "test": {
+ "runner": "bun",
+ "path": "test/"
+ }
+ },
+ {
+ "name": "hono",
+ "repository": {
+ "github": "honojs/hono"
+ },
+ "test": {
+ "runner": "bun",
+ "env": {
+ "NAME": "Bun"
+ },
+ "args": [
+ "--jsx-import-source",
+ "src/middleware/jsx/jsx-dev-runtime"
+ ],
+ "path": "runtime_tests/bun/index.test.tsx"
+ }
+ },
+ {
+ "name": "shumai",
+ "repository": {
+ "github": "facebookresearch/shumai"
+ },
+ "test": {
+ "runner": "bun",
+ "path": "test/",
+ "skip": "TODO: handle shumai's external dependencies"
+ }
+ },
+ {
+ "name": "zod",
+ "repository": {
+ "github": "colinhacks/zod"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "src/__tests__/"
+ }
+ },
+ {
+ "name": "fs-extra",
+ "repository": {
+ "github": "jprichardson/node-fs-extra"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "lib/*/__tests__/*"
+ }
+ },
+ {
+ "name": "graphql-yoga",
+ "repository": {
+ "github": "dotansimha/graphql-yoga"
+ },
+ "test": {
+ "runner": "jest",
+ "path": "packages/graphql-yoga/__tests__/",
+ "skip": "TODO: bun install does not work"
+ }
+ }
+]
diff --git a/packages/bun-internal-test/runners/bun/__snapshots__/runner.test.ts.snap b/packages/bun-internal-test/runners/bun/__snapshots__/runner.test.ts.snap
new file mode 100644
index 000000000..e6d6c1a51
--- /dev/null
+++ b/packages/bun-internal-test/runners/bun/__snapshots__/runner.test.ts.snap
@@ -0,0 +1,552 @@
+// Bun Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`runTests() can run all tests 1`] = `
+{
+ "exitCode": 0,
+ "files": [
+ {
+ "file": "path/to/example4.test.ts",
+ "status": "skip",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 1,
+ "tests": 0,
+ },
+ "tests": [
+ {
+ "name": "this should skip",
+ "status": "skip",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 1,
+ "tests": 0,
+ },
+}
+`;
+
+exports[`runTests() can run all tests 2`] = `
+{
+ "exitCode": 0,
+ "files": [
+ {
+ "file": "example2.spec.js",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "name": "this should pass",
+ "status": "pass",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+}
+`;
+
+exports[`runTests() can run all tests 3`] = `
+{
+ "exitCode": 1,
+ "files": [
+ {
+ "file": "example3.test.mjs",
+ "status": "fail",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "errors": [
+ {
+ "message": "expect(received).toBe(expected)\n\nExpected: false\nReceived: true\n",
+ "name": "Error",
+ "preview": "1 | \n2 | import { test, expect } from \"bun:test\";\n3 | \n4 | test(\"this should fail\", () => {\n5 | expect(true).toBe(false);\n ^",
+ "stack": [
+ {
+ "column": 8,
+ "file": "example3.test.mjs",
+ "function": undefined,
+ "line": 5,
+ },
+ ],
+ },
+ ],
+ "name": "this should fail",
+ "status": "fail",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 1,
+ },
+}
+`;
+
+exports[`runTests() can run all tests 4`] = `
+{
+ "exitCode": 0,
+ "files": [
+ {
+ "file": "example1.test.ts",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 0,
+ },
+ "tests": [],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 0,
+ },
+}
+`;
+
+exports[`runTests() can run all tests 5`] = `
+{
+ "files": [
+ {
+ "file": "path/to/example4.test.ts",
+ "status": "skip",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 1,
+ "tests": 0,
+ },
+ "tests": [
+ {
+ "name": "this should skip",
+ "status": "skip",
+ },
+ ],
+ },
+ {
+ "file": "example2.spec.js",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "name": "this should pass",
+ "status": "pass",
+ },
+ ],
+ },
+ {
+ "file": "example3.test.mjs",
+ "status": "fail",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "errors": [
+ {
+ "message": "expect(received).toBe(expected)\n\nExpected: false\nReceived: true\n",
+ "name": "Error",
+ "preview": "1 | \n2 | import { test, expect } from \"bun:test\";\n3 | \n4 | test(\"this should fail\", () => {\n5 | expect(true).toBe(false);\n ^",
+ "stack": [
+ {
+ "column": 8,
+ "file": "example3.test.mjs",
+ "function": undefined,
+ "line": 5,
+ },
+ ],
+ },
+ ],
+ "name": "this should fail",
+ "status": "fail",
+ },
+ ],
+ },
+ {
+ "file": "example1.test.ts",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 0,
+ },
+ "tests": [],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 4,
+ "pass": 1,
+ "skip": 1,
+ "tests": 2,
+ },
+}
+`;
+
+exports[`runTest() can run a test 1`] = `
+{
+ "exitCode": 0,
+ "files": [
+ {
+ "file": "example2.test.ts",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "name": "this should pass",
+ "status": "pass",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+}
+`;
+
+exports[`runTest() can run a test with a symlink 1`] = `
+{
+ "exitCode": 1,
+ "files": [
+ {
+ "file": "example1.ts",
+ "status": "fail",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 1,
+ "skip": 1,
+ "tests": 2,
+ },
+ "tests": [
+ {
+ "name": "this should pass",
+ "status": "pass",
+ },
+ {
+ "errors": [
+ {
+ "message": "expect(received).toBe(expected)\n\nExpected: false\nReceived: true\n",
+ "name": "Error",
+ "preview": "4 | test(\"this should pass\", () => {\n5 | expect(true).toBe(true);\n6 | });\n7 | \n8 | test(\"this should fail\", () => {\n9 | expect(true).toBe(false);\n ^",
+ "stack": [
+ {
+ "column": 8,
+ "file": "example1.ts",
+ "function": undefined,
+ "line": 9,
+ },
+ ],
+ },
+ ],
+ "name": "this should fail",
+ "status": "fail",
+ },
+ {
+ "name": "this should skip",
+ "status": "skip",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 1,
+ "skip": 1,
+ "tests": 2,
+ },
+}
+`;
+
+exports[`runTest() can run a test with a preload 1`] = `
+{
+ "exitCode": 0,
+ "files": [
+ {
+ "file": "preload.test.ts",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+ "tests": [
+ {
+ "name": "test should have preloaded",
+ "status": "pass",
+ },
+ ],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "stderr": "",
+ "stdout": "",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 1,
+ },
+}
+`;
+
+exports[`parseTest() can parse test results 1`] = `
+{
+ "files": [
+ {
+ "file": "example1.test.ts",
+ "status": "fail",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 1,
+ "skip": 1,
+ "tests": 3,
+ },
+ "tests": [
+ {
+ "name": "this should pass",
+ "status": "pass",
+ },
+ {
+ "errors": [
+ {
+ "message": "expect(received).toBe(expected)\n\nExpected: false\nReceived: true\n",
+ "name": "Error",
+ "preview": "4 | test(\"this should pass\", () => {\n5 | expect(true).toBe(true);\n6 | });\n7 | \n8 | test(\"this should fail\", () => {\n9 | expect(true).toBe(false);\n ^",
+ "stack": [
+ {
+ "column": 8,
+ "file": "example1.test.ts",
+ "function": undefined,
+ "line": 9,
+ },
+ ],
+ },
+ ],
+ "name": "this should fail",
+ "status": "fail",
+ },
+ {
+ "name": "this should skip",
+ "status": "skip",
+ },
+ ],
+ },
+ {
+ "file": "example3.spec.tsx",
+ "status": "fail",
+ "summary": {
+ "duration": 0,
+ "fail": 1,
+ "files": 1,
+ "pass": 1,
+ "skip": 0,
+ "tests": 2,
+ },
+ "tests": [
+ {
+ "name": "tests > this should pass",
+ "status": "pass",
+ },
+ {
+ "errors": [
+ {
+ "message": "Oops!",
+ "name": "TypeError",
+ "preview": "10 | throw new TypeError(\"Oops!\");\n ^",
+ "stack": [
+ {
+ "column": 20,
+ "file": "path/to/example3.spec.tsx",
+ "function": undefined,
+ "line": 10,
+ },
+ ],
+ },
+ ],
+ "name": "tests > this should fail",
+ "status": "fail",
+ },
+ ],
+ },
+ {
+ "file": "example2.test.js",
+ "status": "pass",
+ "summary": {
+ "duration": 0,
+ "fail": 0,
+ "files": 1,
+ "pass": 0,
+ "skip": 0,
+ "tests": 0,
+ },
+ "tests": [],
+ },
+ ],
+ "info": {
+ "arch": undefined,
+ "name": "bun test",
+ "os": undefined,
+ "revision": "",
+ "version": "",
+ },
+ "summary": {
+ "duration": 0,
+ "fail": 2,
+ "files": 3,
+ "pass": 2,
+ "skip": 1,
+ "tests": 4,
+ },
+}
+`;
diff --git a/packages/bun-internal-test/runners/bun/package.json b/packages/bun-internal-test/runners/bun/package.json
new file mode 100644
index 000000000..5831719c6
--- /dev/null
+++ b/packages/bun-internal-test/runners/bun/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "bun",
+ "module": "runner.ts"
+}
diff --git a/packages/bun-internal-test/runners/bun/runner.test.ts b/packages/bun-internal-test/runners/bun/runner.test.ts
new file mode 100644
index 000000000..a3e2c8235
--- /dev/null
+++ b/packages/bun-internal-test/runners/bun/runner.test.ts
@@ -0,0 +1,303 @@
+import { describe, test, expect } from "bun:test";
+import { tmpdir } from "node:os";
+import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import type { FindTestOptions, ParseTestResult, RunTestResult } from "./runner";
+import { bunSpawn, nodeSpawn, findTests, parseTest, runTest, runTests } from "./runner";
+
+describe("runTests()", () => {
+ const cwd = createFs({
+ "example1.test.ts": "",
+ "example2.spec.js": `
+ import { test, expect } from "bun:test";
+
+ test("this should pass", () => {
+ expect(true).toBe(true);
+ });
+ `,
+ "example3.test.mjs": `
+ import { test, expect } from "bun:test";
+
+ test("this should fail", () => {
+ expect(true).toBe(false);
+ });
+ `,
+ "path": {
+ "to": {
+ "example4.test.ts": `
+ import { test, expect } from "bun:test";
+
+ test.skip("this should skip", () => {
+ expect(true).toBe(true);
+ });
+ `
+ }
+ }
+ });
+ test("can run all tests", async () => {
+ const results = runTests({ cwd });
+ while (true) {
+ const { value, done } = await results.next();
+ toMatchResult(value);
+ if (done) {
+ break;
+ }
+ }
+ });
+});
+
+describe("runTest()", () => {
+ const cwd = createFs({
+ "example1.ts": `
+ import { test, expect } from "bun:test";
+
+ test("this should pass", () => {
+ expect(true).toBe(true);
+ });
+
+ test("this should fail", () => {
+ expect(true).toBe(false);
+ });
+
+ test.skip("this should skip", () => {
+ expect(true).toBe(true);
+ });
+ `,
+ "path": {
+ "to": {
+ "example2.test.ts": `
+ import { test, expect } from "bun:test";
+
+ test("this should pass", () => {
+ expect(true).toBe(true);
+ });
+ `
+ }
+ },
+ "preload": {
+ "preload.test.ts": `
+ import { test, expect } from "bun:test";
+
+ test("test should have preloaded", () => {
+ expect(globalThis.preload).toBe(true);
+ });
+ `,
+ "preload.ts": `
+ globalThis.preload = true;
+ `
+ }
+ });
+ test("can run a test", async () => {
+ const result = await runTest({
+ cwd,
+ path: "path/to/example2.ts",
+ });
+ toMatchResult(result);
+ });
+ test("can run a test with a symlink", async () => {
+ const result = await runTest({
+ cwd,
+ path: "example1.ts",
+ });
+ toMatchResult(result);
+ });
+ test("can run a test with a preload", async () => {
+ const result = await runTest({
+ cwd,
+ path: "preload/preload.test.ts",
+ preload: ["./preload/preload.ts"]
+ });
+ toMatchResult(result);
+ });
+});
+
+describe("parseTest()", () => {
+ const cwd = createFs({
+ "example1.test.ts": `
+ import { test, expect } from "bun:test";
+
+ test("this should pass", () => {
+ expect(true).toBe(true);
+ });
+
+ test("this should fail", () => {
+ expect(true).toBe(false);
+ });
+
+ test.skip("this should skip", () => {
+ expect(true).toBe(true);
+ });
+ `,
+ "path": {
+ "to": {
+ "example2.test.js": "",
+ "example3.spec.tsx": `
+ import { describe, test, expect } from "bun:test";
+
+ describe("tests", () => {
+ test("this should pass", () => {
+ expect(true).toBe(true);
+ });
+
+ test("this should fail", () => {
+ throw new TypeError("Oops!");
+ });
+ });
+ `,
+ }
+ }
+ });
+ test("can parse test results", async () => {
+ const { stderr } = await bunSpawn({
+ cwd,
+ cmd: "bun",
+ args: ["test"],
+ });
+ const result = parseTest(stderr, { cwd });
+ toMatchResult(result);
+ });
+});
+
+function toMatchResult(result: ParseTestResult | RunTestResult): void {
+ result.summary.duration = 0;
+ result.info.revision = "";
+ result.info.version = "";
+ result.info.os = undefined;
+ result.info.arch = undefined;
+ for (const file of result.files) {
+ file.summary.duration = 0;
+ }
+ if ("stderr" in result) {
+ result.stderr = "";
+ result.stdout = "";
+ }
+ expect(result).toMatchSnapshot();
+}
+
+describe("findTests()", () => {
+ const cwd = createFs({
+ "readme.md": "",
+ "package.json": "",
+ "path": {
+ "to": {
+ "example1.js": "",
+ "example2.test.ts": "",
+ "example3.spec.js": "",
+ "example.txt": "",
+ },
+ "example4.js.map": "",
+ "example4.js": "",
+ "example5.test.ts": "",
+ }
+ });
+ const find = (options: FindTestOptions = {}) => {
+ const results = findTests({ cwd, ...options });
+ return [...results].sort();
+ };
+ test("can find all tests", () => {
+ const results = find();
+ expect(results).toEqual([
+ "path/example4.js",
+ "path/example5.test.ts",
+ "path/to/example1.js",
+ "path/to/example2.test.ts",
+ "path/to/example3.spec.js",
+ ]);
+ });
+ test("can find tests that match a directory", () => {
+ const results = find({
+ filters: ["path/to/"],
+ });
+ expect(results).toEqual([
+ "path/to/example1.js",
+ "path/to/example2.test.ts",
+ "path/to/example3.spec.js",
+ ]);
+ });
+ test("can find tests that match a file", () => {
+ const results = find({
+ filters: [
+ "example1.js",
+ "example5.test.ts"
+ ],
+ });
+ expect(results).toEqual([
+ "path/example5.test.ts",
+ "path/to/example1.js",
+ ]);
+ });
+ test("can find tests that match a glob", () => {
+ const results = find({
+ filters: [
+ "path/to/*.js",
+ "*.spec.*",
+ ],
+ });
+ expect(results).toEqual([
+ "path/to/example1.js",
+ "path/to/example3.spec.js",
+ ]);
+ });
+ test("can find no tests", () => {
+ const results = find({
+ filters: [
+ "path/to/nowhere/*",
+ ],
+ });
+ expect(results).toEqual([]);
+ });
+});
+
+describe("bunSpawn()", () => {
+ testSpawn(bunSpawn);
+});
+
+describe("nodeSpawn()", () => {
+ testSpawn(nodeSpawn);
+});
+
+function testSpawn(spawn: typeof bunSpawn): void {
+ test("can run a command", async () => {
+ const { exitCode, stdout, stderr } = await spawn({
+ cmd: "echo",
+ args: ["hello world"],
+ });
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe("hello world\n");
+ expect(stderr).toBe("");
+ });
+ test("can timeout a command", async () => {
+ const { exitCode, stdout, stderr } = await spawn({
+ cmd: "sleep",
+ args: ["60"],
+ timeout: 1,
+ });
+ expect(exitCode).toBe(null);
+ expect(stdout).toBe("");
+ expect(stderr).toBe("");
+ });
+}
+
+type FsTree = {
+ [path: string]: FsTree | string;
+};
+
+function createFs(tree: FsTree): string {
+ let cwd = mkdtempSync(join(tmpdir(), "bun-internal-test-"));
+ if (cwd.startsWith("/var/folders")) {
+ cwd = join("/private", cwd); // HACK: macOS
+ }
+ const traverse = (tree: FsTree, path: string) => {
+ for (const [name, content] of Object.entries(tree)) {
+ const newPath = join(path, name);
+ if (typeof content === "string") {
+ writeFileSync(newPath, content);
+ } else {
+ mkdirSync(newPath);
+ traverse(content, newPath);
+ }
+ }
+ };
+ traverse(tree, cwd);
+ return cwd;
+}
diff --git a/packages/bun-internal-test/runners/bun/runner.ts b/packages/bun-internal-test/runners/bun/runner.ts
new file mode 100644
index 000000000..9553fa611
--- /dev/null
+++ b/packages/bun-internal-test/runners/bun/runner.ts
@@ -0,0 +1,714 @@
+// This file parses the output of `bun test` and outputs
+// a markdown summary and Github Action annotations.
+//
+// In the future, a version of this will be built-in to Bun.
+
+import { join } from "node:path";
+import { readdirSync, writeSync, fsyncSync, symlinkSync, unlinkSync } from "node:fs";
+import { spawn } from "node:child_process";
+
+export type TestInfo = {
+ name: string;
+ version: string;
+ revision: string;
+ os?: string;
+ arch?: string;
+};
+
+export type TestFile = {
+ file: string;
+ status: TestStatus;
+ tests: Test[];
+ summary: TestSummary;
+ errors?: TestError[];
+};
+
+export type TestError = {
+ name: string;
+ message: string;
+ preview?: string;
+ stack?: TestErrorStack[];
+};
+
+export type TestErrorStack = {
+ file: string;
+ function?: string;
+ line: number;
+ column?: number;
+};
+
+export type TestStatus = "pass" | "fail" | "skip";
+
+export type Test = {
+ name: string;
+ status: TestStatus;
+ errors?: TestError[];
+};
+
+export type TestSummary = {
+ pass: number;
+ fail: number;
+ skip: number;
+ tests: number;
+ files: number;
+ duration: number;
+};
+
+export type RunTestsOptions = ParseTestOptions & {
+ filters?: string[];
+ preload?: string[];
+ env?: Record<string, string>;
+ args?: string[];
+ timeout?: number;
+};
+
+export async function* runTests(options: RunTestsOptions = {}): AsyncGenerator<RunTestResult, ParseTestResult> {
+ const { cwd = process.cwd(), filters, timeout, preload, env, args } = options;
+ const knownPaths = [...listFiles(cwd)];
+ const paths = [...findTests({ cwd, knownPaths, filters })];
+ if (!paths.length) {
+ throw new Error(`No tests found; ${knownPaths.length} files did not match: ${filters}`);
+ }
+ const startTest = (path: string) => runTest({
+ cwd,
+ path,
+ knownPaths,
+ preload,
+ timeout,
+ env,
+ args,
+ });
+ const results: RunTestResult[] = [];
+ const batchSize = 10;
+ for (let i = 0; i < paths.length; i += batchSize) {
+ for (const test of paths.slice(i, i + batchSize).map(startTest)) {
+ const result = await test;
+ results.push(result);
+ yield result;
+ }
+ }
+ return {
+ info: results.map(result => result.info).pop()!,
+ files: results.flatMap(result => result.files),
+ summary: results
+ .map(result => result.summary)
+ .reduce((summary, result) => {
+ summary.pass += result.pass;
+ summary.fail += result.fail;
+ summary.skip += result.skip;
+ summary.tests += result.tests;
+ summary.files += result.files;
+ summary.duration += result.duration;
+ return summary;
+ }),
+ };
+}
+
+export type RunTestOptions = ParseTestOptions & {
+ path: string;
+ preload?: string[];
+ timeout?: number;
+ env?: Record<string, string>;
+ args?: string[];
+};
+
+export type RunTestResult = ParseTestResult & {
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+};
+
+export async function runTest(options: RunTestOptions): Promise<RunTestResult> {
+ const { cwd = process.cwd(), path, knownPaths, preload = [], timeout, env = {}, args = [] } = options;
+ let file = path;
+ if (!isTestJavaScript(file)) {
+ const i = file.lastIndexOf(".");
+ file = `${file.substring(0, i)}.test.${file.substring(i + 1)}`;
+ try {
+ symlinkSync(join(cwd, path), join(cwd, file));
+ } catch { }
+ }
+ const { exitCode, stdout, stderr } = await bunSpawn({
+ cwd,
+ cmd: "bun",
+ args: ["test", ...args, ...preload.flatMap(path => ["--preload", path]), file],
+ env: {
+ ...process.env,
+ ...env,
+ "FORCE_COLOR": "1",
+ },
+ timeout,
+ });
+ if (file !== path) {
+ try {
+ unlinkSync(join(cwd, file));
+ } catch { }
+ }
+ const result = parseTest(stderr, { cwd, knownPaths });
+ result.info.os ||= process.platform;
+ result.info.arch ||= process.arch;
+ if ("Bun" in globalThis && Bun.revision.startsWith(result.info.revision)) {
+ result.info.revision = Bun.revision;
+ }
+ if (exitCode !== 0 && !result.summary.fail) {
+ result.summary.fail = 1;
+ result.files[0].summary.fail = 1;
+ result.files[0].status = "fail";
+ }
+ return {
+ exitCode,
+ stdout,
+ stderr,
+ ...result,
+ };
+}
+
+export function printTest(result: ParseTestResult | RunTestResult): void {
+ const isAction = process.env["GITHUB_ACTIONS"] === "true";
+ const isSingle = result.files.length === 1;
+ if (isSingle) {
+ const { file, status } = result.files[0];
+ if (isAction) {
+ printAnnotation("group", `${status.toUpperCase()} - ${file}`);
+ } else {
+ print(`\n${file}:\n`);
+ }
+ }
+ if ("stderr" in result) {
+ print(result.stderr);
+ print(result.stdout);
+ }
+ if (!isAction) {
+ print("\n");
+ return;
+ }
+ result.files
+ .filter(({ status }) => status === "fail")
+ .flatMap(({ tests }) => tests)
+ .filter(({ status }) => status === "fail")
+ .flatMap(({ name: title, errors }) =>
+ errors?.forEach(({ name, message, stack }) => {
+ const { file, line } = stack?.[0] ?? {};
+ if (is3rdParty(file)) {
+ return;
+ }
+ printAnnotation("error", `${name}: ${message}`, {
+ file,
+ line,
+ title,
+ });
+ }),
+ );
+ if (isSingle) {
+ printAnnotation("endgroup");
+ }
+}
+
+function print(buffer: string | Uint8Array) {
+ if (typeof buffer === "string") {
+ buffer = new TextEncoder().encode(buffer);
+ }
+ let offset = 0;
+ let length = buffer.byteLength;
+ while (offset < length) {
+ try {
+ const n = writeSync(1, buffer);
+ offset += n;
+ if (offset < length) {
+ try {
+ fsyncSync(1);
+ } catch {}
+ buffer = buffer.slice(n);
+ }
+ } catch (error) {
+ // @ts-ignore
+ if (error.code === "EAGAIN") {
+ continue;
+ }
+ throw error;
+ }
+ }
+}
+
+// FIXME: there is a bug that causes annotations to be duplicated
+const annotations = new Set<string>();
+
+function printAnnotation(type: string, arg?: string, args?: Record<string, unknown>): void {
+ let line = `::${type}`;
+ if (args) {
+ line += " ";
+ line += Object.entries(args)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(",");
+ }
+ line += "::";
+ if (arg) {
+ line += arg;
+ }
+ line = line.replace(/\n/g, "%0A");
+ if (annotations.has(line)) {
+ return;
+ }
+ annotations.add(line);
+ print(`\n${line}\n`);
+}
+
+function is3rdParty(file?: string): boolean {
+ return !file || file.startsWith("/") || file.includes(":") || file.includes("..") || file.includes("node_modules/");
+}
+
+export type ParseTestOptions = {
+ cwd?: string;
+ knownPaths?: string[];
+};
+
+export type ParseTestResult = {
+ info: TestInfo;
+ files: TestFile[];
+ summary: TestSummary;
+};
+
+export function parseTest(stderr: string, options: ParseTestOptions = {}): ParseTestResult {
+ const { cwd, knownPaths } = options;
+ const linesAnsi = stderr.split("\n");
+ const lines = linesAnsi.map(stripAnsi);
+ let info: TestInfo | undefined;
+ const parseInfo = (line: string): TestInfo | undefined => {
+ const match = /^(bun (?:wip)?test) v([0-9\.]+) \(([0-9a-z]+)\)$/.exec(line);
+ if (!match) {
+ return undefined;
+ }
+ const [, name, version, sha] = match;
+ return {
+ name,
+ version,
+ revision: sha,
+ };
+ };
+ let files: TestFile[] = [];
+ let file: TestFile | undefined;
+ const parseFile = (line: string): TestFile | undefined => {
+ let file = line.slice(0, -1);
+ if (!isJavaScript(file) || !line.endsWith(":")) {
+ return undefined;
+
+ }
+ for (const path of knownPaths ?? []) {
+ if (path.endsWith(file)) {
+ file = path;
+ break;
+ }
+ }
+ return {
+ file,
+ tests: [],
+ status: "pass",
+ summary: {
+ files: 1,
+ tests: 0,
+ pass: 0,
+ fail: 0,
+ skip: 0,
+ duration: 0,
+ },
+ };
+ };
+ const parseTestLine = (line: string): Test | undefined => {
+ const match = /^(✓|‚úì|✗|‚úó|-) (.*)$/.exec(line);
+ if (!match) {
+ return undefined;
+ }
+ const [, icon, name] = match;
+ return {
+ name,
+ status: icon === "✓" || icon === "‚úì" ? "pass" : icon === "✗" || icon === "‚úó" ? "fail" : "skip",
+ };
+ };
+ let errors: TestError[] = [];
+ let error: TestError | undefined;
+ const parseError = (line: string): TestError | undefined => {
+ const match = /^(.*error)\: (.*)$/i.exec(line);
+ if (!match) {
+ return undefined;
+ }
+ const [, name, message] = match;
+ return {
+ name: name === "error" ? "Error" : name,
+ message,
+ };
+ };
+ const parseErrorStack = (line: string): TestErrorStack | undefined => {
+ let match = /^\s*at (.*) \((.*)\:([0-9]+)\:([0-9]+)\)$/.exec(line);
+ if (!match) {
+ match = /^\s*at (.*)\:([0-9]+)\:([0-9]+)$/.exec(line);
+ if (!match) {
+ return undefined;
+ }
+ }
+ const [columnNo, lineNo, path, func] = match.reverse();
+ let file = path;
+ if (cwd && path.startsWith(cwd)) {
+ file = path.slice(cwd.length);
+ if (file.startsWith("/")) {
+ file = file.slice(1);
+ }
+ }
+ return {
+ file,
+ function: func !== line ? func : undefined,
+ line: parseInt(lineNo),
+ column: parseInt(columnNo),
+ };
+ };
+ const parseErrorPreview = (line: string): string | undefined => {
+ if (line.endsWith("^") || /^[0-9]+ \| /.test(line)) {
+ return line;
+ }
+ return undefined;
+ }
+ let summary: TestSummary | undefined;
+ const parseSummary = (line: string): TestSummary | undefined => {
+ const match = /^Ran ([0-9]+) tests across ([0-9]+) files \[([0-9]+\.[0-9]+)(m?s)\]$/.exec(line);
+ if (!match) {
+ return undefined;
+ }
+ const [, tests, files, duration, unit] = match;
+ return {
+ pass: 0,
+ fail: 0,
+ skip: 0,
+ tests: parseInt(tests),
+ files: parseInt(files),
+ duration: parseFloat(duration) * (unit === "s" ? 1000 : 1),
+ };
+ }
+ const createSummary = (files: TestFile[]): TestSummary => {
+ const summary = {
+ pass: 0,
+ fail: 0,
+ skip: 0,
+ tests: 0,
+ files: 0,
+ duration: 0,
+ };
+ for (const file of files) {
+ summary.files++;
+ summary.duration += file.summary.duration;
+ for (const test of file.tests) {
+ summary.tests++;
+ summary[test.status]++;
+ }
+ if (file.errors?.length) {
+ summary.fail++;
+ }
+ }
+ return summary;
+ };
+ const parseSkip = (line: string): number => {
+ const match = /^([0-9]+) tests (?:skipped|failed)\:$/.exec(line);
+ if (match) {
+ return parseInt(match[1]);
+ }
+ return 0;
+ };
+ const endOfFile = (file?: TestFile): void => {
+ if (file && !file.tests.length && errors.length) {
+ file.errors = errors;
+ errors = [];
+ }
+ };
+ let errorStart = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (!info && !(info = parseInfo(line))) {
+ continue;
+ }
+ const newFile = parseFile(line);
+ if (newFile) {
+ endOfFile(file);
+ files.push(file = newFile);
+ continue;
+ }
+ const newError = parseError(line);
+ if (newError) {
+ errorStart = i;
+ errors.push(error = newError);
+ for (let j = 1; j < 8 && i - j >= 0; j++) {
+ const line = lines[i - j];
+ const preview = parseErrorPreview(line);
+ if (!preview) {
+ break;
+ }
+ if (error.preview) {
+ error.preview = preview + "\n" + error.preview;
+ } else {
+ error.preview = preview;
+ }
+ }
+ continue;
+ }
+ const newStack = parseErrorStack(line);
+ if (newStack) {
+ if (error) {
+ error.stack ||= [];
+ error.stack.push(newStack);
+ for (let j = errorStart + 1; j < i && error.stack.length === 1; j++) {
+ error.message += "\n" + lines[j];
+ }
+ } else {
+ // TODO: newStack and !error
+ }
+ continue;
+ }
+ const newTest = parseTestLine(line);
+ if (newTest) {
+ if (error && newTest.status === "skip") {
+ continue; // Likely a false positive from error message
+ }
+ if (error) {
+ for (let j = errorStart + 1; j < i - 1 && !error.stack?.length; j++) {
+ error.message += "\n" + lines[j];
+ }
+ error = undefined;
+ }
+ if (errors.length) {
+ newTest.errors = errors;
+ errors = [];
+ }
+ file!.tests.push(newTest);
+ continue;
+ }
+ const newSummary = parseSummary(line);
+ if (newSummary) {
+ summary = newSummary;
+ break;
+ }
+ i += parseSkip(line);
+ }
+ endOfFile(file);
+ if (!info) {
+ throw new Error("No tests found; did the test runner crash?");
+ }
+ summary ||= createSummary(files);
+ const count = (status: TestStatus): number => {
+ return files.reduce((n, file) => n + file.tests.filter(test => test.status === status).length, 0);
+ };
+ summary.pass ||= count("pass");
+ summary.fail ||= count("fail");
+ summary.skip ||= count("skip");
+ const getStatus = (summary: TestSummary) => {
+ return summary.fail ? "fail" : !summary.pass && summary.skip ? "skip" : "pass";
+ };
+ if (files.length === 1) {
+ files[0].summary = { ...summary };
+ files[0].status = getStatus(summary);
+ } else {
+ for (const file of files) {
+ const summary = createSummary([file]);
+ file.summary = summary;
+ file.status = getStatus(summary);
+ }
+ }
+ return {
+ info,
+ files,
+ summary,
+ };
+}
+
+function stripAnsi(string: string): string {
+ return string.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
+export type FindTestOptions = {
+ cwd?: string;
+ knownPaths?: string[];
+ filters?: string[];
+};
+
+export function* findTests(options: FindTestOptions = {}): Generator<string> {
+ const { cwd = process.cwd(), knownPaths, filters = [] } = options;
+ const paths = knownPaths ?? listFiles(cwd);
+ for (const path of paths) {
+ if (!isJavaScript(path)) {
+ continue;
+ }
+ let match = filters.length === 0;
+ for (const filter of filters) {
+ if (isGlob(filter)) {
+ match = isGlobMatch(filter, path);
+ } else if (filter.endsWith("/")) {
+ match = path.startsWith(filter);
+ } else if (isJavaScript(filter)) {
+ match = path.endsWith(filter);
+ } else {
+ match = path.includes(filter);
+ }
+ if (match) {
+ break;
+ }
+ }
+ if (!match) {
+ continue;
+ }
+ yield path;
+ }
+}
+
+function* listFiles(cwd: string, dir: string = ""): Generator<string> {
+ const dirents = readdirSync(join(cwd, dir), { withFileTypes: true });
+ for (const dirent of dirents) {
+ const { name } = dirent;
+ if (name === "node_modules" || name.startsWith(".")) {
+ continue;
+ }
+ const path = join(dir, name);
+ if (dirent.isDirectory()) {
+ yield* listFiles(cwd, path);
+ } else if (dirent.isFile()) {
+ yield path;
+ }
+ }
+}
+
+function isJavaScript(path: string): boolean {
+ return /\.(c|m)?(t|j)sx?$/.test(path);
+}
+
+function isTestJavaScript(path: string): boolean {
+ return /\.(test|spec)\.(c|m)?(t|j)sx?$/.test(path);
+}
+
+function isGlob(path: string): boolean {
+ return path.includes("*");
+}
+
+function isGlobMatch(glob: string, path: string): boolean {
+ return new RegExp(`^${glob.replace(/\*/g, ".*")}$`).test(path);
+}
+
+export type SpawnOptions = {
+ cmd: string;
+ args?: string[];
+ cwd?: string;
+ env?: Record<string, string>;
+ timeout?: number;
+};
+
+export type SpawnResult = {
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+};
+
+export async function nodeSpawn(options: SpawnOptions): Promise<SpawnResult> {
+ const { cmd, args = [], cwd, env, timeout } = options;
+ const subprocess = spawn(cmd, args, {
+ cwd,
+ env,
+ timeout,
+ stdio: "pipe",
+ });
+ let stderr = "";
+ let stdout = "";
+ subprocess.stdout.on("data", (data: Buffer) => {
+ stdout += data.toString("utf-8");
+ });
+ subprocess.stderr.on("data", (data: Buffer) => {
+ stderr += data.toString("utf-8");
+ });
+ const exitCode = await new Promise<number | null>(resolve => {
+ subprocess.on("error", ({ name, message }) => {
+ stderr += `${name}: ${message}`;
+ resolve(null);
+ });
+ subprocess.on("exit", exitCode => {
+ resolve(exitCode);
+ });
+ });
+ return {
+ exitCode,
+ stdout,
+ stderr,
+ };
+}
+
+export async function bunSpawn(options: SpawnOptions): Promise<SpawnResult> {
+ const { cmd, args = [], cwd, env, timeout } = options;
+ const subprocess = Bun.spawn({
+ cwd,
+ env,
+ cmd: [cmd, ...args],
+ stdout: "pipe",
+ stderr: "pipe",
+ lazy: false,
+ });
+ const consume = async (stream?: ReadableStream) => {
+ let result = "";
+ const decoder = new TextDecoder();
+ for await (const chunk of stream ?? []) {
+ result += decoder.decode(chunk);
+ }
+ return result;
+ };
+ const exitCode = await Promise.race([
+ timeout
+ ? Bun.sleep(timeout).then(() => null)
+ : subprocess.exited,
+ subprocess.exited,
+ ]);
+ if (!subprocess.killed) {
+ subprocess.kill();
+ }
+ const [stdout, stderr] = await Promise.all([
+ consume(subprocess.stdout),
+ consume(subprocess.stderr),
+ ]);
+ return {
+ exitCode,
+ stdout,
+ stderr,
+ };
+}
+
+async function main() {
+ let filters = [...process.argv.slice(2)];
+ let timeout;
+ let isolate;
+ let quiet;
+ for (let i = 0; i < filters.length; i++) {
+ const filter = filters[i];
+ if (filter.startsWith("--timeout=")) {
+ timeout = parseInt(filter.split("=").pop()!);
+ } else if (filter.startsWith("--isolate")) {
+ isolate = true;
+ } else if (filter.startsWith("--quiet")) {
+ quiet = true;
+ }
+ }
+ filters = filters.filter(filter => !filter.startsWith("--"));
+ const results = runTests({
+ filters,
+ timeout,
+ });
+ let result;
+ while (true) {
+ const { value, done } = await results.next();
+ if (done) {
+ result = value;
+ break;
+ } else if (!quiet) {
+ printTest(value);
+ }
+ }
+ process.exit(0);
+}
+
+function isMain() {
+ // @ts-ignore
+ return import.meta.main || import.meta.url === `file://${process.argv[1]}`;
+}
+
+if (isMain()) {
+ await main();
+}
diff --git a/packages/bun-internal-test/runners/qunit/assert.ts b/packages/bun-internal-test/runners/qunit/assert.ts
new file mode 100644
index 000000000..007b34a55
--- /dev/null
+++ b/packages/bun-internal-test/runners/qunit/assert.ts
@@ -0,0 +1,233 @@
+import type { Assert } from "./qunit.d";
+import type { BunExpect } from "bun-test";
+
+export { $Assert as Assert };
+
+class $Assert implements Assert {
+ #$expect: BunExpect;
+ #assertions = 0;
+ #assertionsExpected: number | undefined;
+ #asyncs = 0;
+ #asyncsExpected: number | undefined;
+ #promises: Promise<unknown>[] | undefined;
+ #steps: string[] | undefined;
+ #timeout: number | undefined;
+ #abort: AbortController | undefined;
+
+ constructor(expect: BunExpect) {
+ this.#$expect = expect;
+ }
+
+ get #expect() {
+ this.#assertions++;
+ return this.#$expect;
+ }
+
+ async(count?: number): () => void {
+ const expected = Math.max(0, count ?? 1);
+ if (this.#asyncsExpected === undefined) {
+ this.#asyncsExpected = expected;
+ } else {
+ this.#asyncsExpected += expected;
+ }
+ let actual = 0;
+ return () => {
+ this.#asyncs++;
+ if (actual++ > expected) {
+ throw new Error(`Expected ${expected} calls to async(), but got ${actual} instead`);
+ }
+ };
+ }
+
+ deepEqual<T>(actual: T, expected: T, message?: string): void {
+ this.#expect(actual).toStrictEqual(expected);
+ }
+
+ equal(actual: any, expected: any, message?: string): void {
+ this.#expect(actual == expected).toBe(true);
+ }
+
+ expect(amount: number): void {
+ // If falsy, then the test can pass without any assertions.
+ this.#assertionsExpected = Math.max(0, amount);
+ }
+
+ false(state: any, message?: string): void {
+ this.#expect(state).toBe(false);
+ }
+
+ notDeepEqual(actual: any, expected: any, message?: string): void {
+ this.#expect(actual).not.toStrictEqual(expected);
+ }
+
+ notEqual(actual: any, expected: any, message?: string): void {
+ this.#expect(actual == expected).toBe(false);
+ }
+
+ notOk(state: any, message?: string): void {
+ this.#expect(state).toBeFalsy();
+ }
+
+ notPropContains(actual: any, expected: any, message?: string): void {
+ throw new Error("Method not implemented.");
+ }
+
+ notPropEqual(actual: any, expected: any, message?: string): void {
+ throw new Error("Method not implemented.");
+ }
+
+ notStrictEqual(actual: any, expected: any, message?: string): void {
+ this.#expect(actual).not.toBe(expected);
+ }
+
+ ok(state: any, message?: string): void {
+ this.#expect(state).toBeTruthy();
+ }
+
+ propContains(actual: any, expected: any, message?: string): void {
+ throw new Error("Method not implemented.");
+ }
+
+ propEqual(actual: any, expected: any, message?: string): void {
+ throw new Error("Method not implemented.");
+ }
+
+ pushResult(assertResult: { result: boolean; actual: any; expected: any; message?: string; source?: string }): void {
+ throw new Error("Method not implemented.");
+ }
+
+ async rejects(promise: unknown, expectedMatcher?: unknown, message?: unknown): Promise<void> {
+ if (!(promise instanceof Promise)) {
+ throw new Error(`Expected a promise, but got ${promise} instead`);
+ }
+ let passed = true;
+ const result = promise
+ .then(value => {
+ passed = false;
+ throw new Error(`Expected promise to reject, but it resolved with ${value}`);
+ })
+ .catch(error => {
+ if (passed && expectedMatcher !== undefined) {
+ // @ts-expect-error
+ this.#$expect(() => {
+ throw error;
+ }).toThrow(expectedMatcher);
+ }
+ })
+ .finally(() => {
+ this.#assertions++;
+ });
+ if (this.#promises === undefined) {
+ this.#promises = [result];
+ } else {
+ this.#promises.push(result);
+ }
+ }
+
+ timeout(duration: number): void {
+ if (this.#timeout !== undefined) {
+ clearTimeout(this.#timeout);
+ }
+ if (this.#abort === undefined) {
+ this.#abort = new AbortController();
+ }
+ const error = new Error(`Test timed out after ${duration}ms`);
+ const onAbort = () => {
+ this.#abort!.abort(error);
+ };
+ hideFromStack(onAbort);
+ this.#timeout = +setTimeout(onAbort, Math.max(0, duration));
+ }
+
+ step(value: string): void {
+ if (this.#steps) {
+ this.#steps.push(value);
+ } else {
+ this.#steps = [value];
+ }
+ }
+
+ strictEqual<T>(actual: T, expected: T, message?: string): void {
+ this.#expect(actual).toBe(expected);
+ }
+
+ throws(block: () => void, expected?: any, message?: any): void {
+ if (expected === undefined) {
+ this.#expect(block).toThrow();
+ } else {
+ this.#expect(block).toThrow(expected);
+ }
+ }
+
+ raises(block: () => void, expected?: any, message?: any): void {
+ if (expected === undefined) {
+ this.#expect(block).toThrow();
+ } else {
+ this.#expect(block).toThrow(expected);
+ }
+ }
+
+ true(state: any, message?: string): void {
+ this.#expect(state).toBe(true);
+ }
+
+ verifySteps(steps: string[], message?: string): void {
+ const actual = this.#steps ?? [];
+ try {
+ this.#expect(actual).toStrictEqual(steps);
+ } finally {
+ this.#steps = undefined;
+ }
+ }
+
+ async close(timeout: number): Promise<void> {
+ const newError = (reason: string) => {
+ const message = this.#abort?.signal?.aborted ? `${reason} (timed out after ${timeout}ms)` : reason;
+ return new Error(message);
+ };
+ hideFromStack(newError);
+ const assert = () => {
+ if (this.#assertions === 0 && this.#assertionsExpected !== 0) {
+ throw newError("Test completed without any assertions");
+ }
+ if (this.#assertionsExpected && this.#assertionsExpected !== this.#assertions) {
+ throw newError(`Expected ${this.#assertionsExpected} assertions, but got ${this.#assertions} instead`);
+ }
+ if (this.#asyncsExpected && this.#asyncsExpected !== this.#asyncs) {
+ throw newError(`Expected ${this.#asyncsExpected} calls to async(), but got ${this.#asyncs} instead`);
+ }
+ };
+ hideFromStack(assert);
+ if (this.#promises === undefined && this.#asyncsExpected === undefined) {
+ assert();
+ return;
+ }
+ if (this.#timeout === undefined) {
+ this.timeout(timeout);
+ }
+ const { signal } = this.#abort!;
+ const onTimeout = new Promise((_, reject) => {
+ signal.onabort = () => {
+ reject(signal.reason);
+ };
+ });
+ await Promise.race([onTimeout, Promise.all(this.#promises ?? [])]);
+ assert();
+ }
+}
+
+function hideFromStack(object: any): void {
+ if (typeof object === "function") {
+ Object.defineProperty(object, "name", {
+ value: "::bunternal::",
+ });
+ return;
+ }
+ for (const name of Object.getOwnPropertyNames(object)) {
+ Object.defineProperty(object[name], "name", {
+ value: "::bunternal::",
+ });
+ }
+}
+
+hideFromStack($Assert.prototype);
diff --git a/packages/bun-internal-test/runners/qunit/package.json b/packages/bun-internal-test/runners/qunit/package.json
new file mode 100644
index 000000000..11749e337
--- /dev/null
+++ b/packages/bun-internal-test/runners/qunit/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "qunit",
+ "module": "qunit.ts"
+}
diff --git a/packages/bun-internal-test/runners/qunit/qunit.d.ts b/packages/bun-internal-test/runners/qunit/qunit.d.ts
new file mode 100644
index 000000000..616eef304
--- /dev/null
+++ b/packages/bun-internal-test/runners/qunit/qunit.d.ts
@@ -0,0 +1,115 @@
+export type Fn = (assert: Assert) => Promise<void> | void;
+export type TestFn = (name: string, fn?: Fn) => void;
+export type EachFn = (assert: Assert, value: unknown) => Promise<void> | void;
+export type TestEachFn = (name: string, data: DataInit, fn?: EachFn) => void;
+export type TestOrEachFn = TestFn & { each: TestEachFn };
+export type ModuleFn = (name: string, hooks?: Hooks | HooksFn, fn?: HooksFn) => void;
+
+/**
+ * @link https://api.qunitjs.com/
+ */
+export type QUnit = {
+ start(): void;
+ config: {
+ [key: string]: unknown;
+ };
+ test: TestOrEachFn & {
+ skip: TestOrEachFn;
+ todo: TestOrEachFn;
+ only: TestOrEachFn;
+ };
+ skip: TestFn;
+ todo: TestFn;
+ only: TestFn;
+ module: ModuleFn & {
+ skip: ModuleFn;
+ todo: ModuleFn;
+ only: ModuleFn;
+ };
+ hooks: {
+ beforeEach(fn: Fn): void;
+ afterEach(fn: Fn): void;
+ };
+ assert: Assert;
+ begin(fn: UnknownFn): void;
+ done(fn: UnknownFn): void;
+ log(fn: UnknownFn): void;
+ moduleDone(fn: UnknownFn): void;
+ moduleStart(fn: UnknownFn): void;
+ on(fn: UnknownFn): void;
+ testDone(fn: UnknownFn): void;
+ testStart(fn: UnknownFn): void;
+ extend(target: unknown, mixin: unknown): unknown;
+ push(result: ResultInit): void;
+ stack(offset?: number): string;
+ onUncaughtException(fn: ErrorFn): void;
+ equiv(a: unknown, b: unknown): boolean;
+ dump: {
+ maxDepth: number;
+ parse(value: unknown): string;
+ };
+};
+
+/**
+ * @link https://api.qunitjs.com/QUnit/module/#options-object
+ */
+export type Hooks = {
+ before?: Fn;
+ beforeEach?: Fn;
+ after?: Fn;
+ afterEach?: Fn;
+};
+
+export type NestedHooks = {
+ before: (fn: Fn) => void;
+ beforeEach: (fn: Fn) => void;
+ after: (fn: Fn) => void;
+ afterEach: (fn: Fn) => void;
+};
+
+export type HooksFn = (hooks: NestedHooks) => void;
+
+/**
+ * @link https://api.qunitjs.com/assert/
+ */
+export type Assert = {
+ async(count?: number): EmptyFn;
+ deepEqual(actual: unknown, expected: unknown, message?: string): void;
+ equal(actual: unknown, expected: unknown, message?: string): void;
+ expect(count: number): void;
+ false(actual: unknown, message?: string): void;
+ notDeepEqual(actual: unknown, expected: unknown, message?: string): void;
+ notEqual(actual: unknown, expected: unknown, message?: string): void;
+ notOk(actual: unknown, message?: string): void;
+ notPropContains(actual: unknown, prop: string, expected: unknown, message?: string): void;
+ notPropEqual(actual: unknown, prop: string, expected: unknown, message?: string): void;
+ notStrictEqual(actual: unknown, expected: unknown, message?: string): void;
+ ok(actual: unknown, message?: string): void;
+ propContains(actual: unknown, prop: string, expected: unknown, message?: string): void;
+ propEqual(actual: unknown, prop: string, expected: unknown, message?: string): void;
+ pushResult(result: ResultInit): void;
+ rejects(promise: Promise<unknown>, expected?: ErrorInit, message?: string): Promise<void>;
+ step(message: string): void;
+ strictEqual(actual: unknown, expected: unknown, message?: string): void;
+ throws(fn: () => unknown, expected?: ErrorInit, message?: string): void;
+ timeout(ms: number): void;
+ true(actual: unknown, message?: string): void;
+ verifySteps(steps: string[], message?: string): void;
+};
+
+export type ResultInit = {
+ result: boolean;
+ actual: unknown;
+ expected: unknown;
+ message?: string;
+};
+
+export type DataInit = unknown[] | Record<string, unknown>;
+
+export type ErrorInit = Error | string | RegExp | ErrorConstructor;
+
+export type EmptyFn = () => void;
+
+export type ErrorFn = (error?: unknown) => void;
+
+export type UnknownFn = (...args: unknown[]) => unknown;
diff --git a/packages/bun-internal-test/runners/qunit/qunit.test.ts b/packages/bun-internal-test/runners/qunit/qunit.test.ts
new file mode 100644
index 000000000..b89fef2e5
--- /dev/null
+++ b/packages/bun-internal-test/runners/qunit/qunit.test.ts
@@ -0,0 +1,239 @@
+import { QUnit } from "qunit";
+
+const { module, test } = QUnit;
+const { todo, skip } = test;
+
+const pass = test;
+const fail = test;
+
+module("assert.async()", () => {
+ pass("1 complete task", assert => {
+ const done = assert.async();
+ done();
+ });
+ pass("2 complete tasks", assert => {
+ const done1 = assert.async();
+ const done2 = assert.async(2);
+ done1();
+ done2();
+ done2();
+ });
+ fail("1 incomplete task", assert => {
+ const done = assert.async(2);
+ done();
+ });
+});
+
+module("assert.deepEqual()", () => {
+ pass("equal objects", assert => {
+ assert.deepEqual({ a: 1, b: { c: "d" } }, { a: 1, b: { c: "d" } });
+ assert.deepEqual([1, 2, "three"], [1, 2, "three"]);
+ });
+ fail("unequal objects", assert => {
+ assert.deepEqual({ a: 1, b: "d" }, { a: 1, b: { c: "d" } });
+ });
+});
+
+module("assert.equal()", () => {
+ pass("equal values", assert => {
+ assert.equal(1, 1);
+ assert.equal(1, "1");
+ assert.equal(0, "");
+ });
+ fail("unequal values", assert => {
+ assert.equal(null, false);
+ });
+});
+
+module("assert.expect()", () => {
+ pass("no assertions", assert => {
+ assert.expect(0);
+ });
+ pass("expected number of assertions", assert => {
+ assert.expect(1);
+ assert.ok(true);
+ });
+ fail("unexpected number of assertions", assert => {
+ assert.expect(3);
+ assert.ok(true);
+ assert.ok(true);
+ });
+});
+
+module("assert.false()", () => {
+ pass("false", assert => {
+ assert.false(false);
+ });
+ fail("falsey", assert => {
+ assert.false(0);
+ });
+ fail("true", assert => {
+ assert.false(true);
+ });
+});
+
+module("assert.notDeepEqual()", () => {
+ pass("unequal objects", assert => {
+ assert.notDeepEqual({ a: 1, b: "d" }, { a: 1, b: { c: "d" } });
+ });
+ fail("equal objects", assert => {
+ assert.notDeepEqual({ a: 1, b: { c: "d" } }, { a: 1, b: { c: "d" } });
+ });
+});
+
+module("assert.notEqual()", () => {
+ pass("unequal values", assert => {
+ assert.notEqual(null, false);
+ });
+ fail("equal values", assert => {
+ assert.notEqual(1, 1);
+ });
+});
+
+module("assert.notOk()", () => {
+ pass("false", assert => {
+ assert.notOk(false);
+ });
+ pass("falsey", assert => {
+ assert.notOk("");
+ });
+ fail("truthy", assert => {
+ assert.notOk(1);
+ });
+});
+
+module.todo("assert.notPropContains()");
+
+todo("assert.notPropEqual()");
+
+module("assert.notStrictEqual()", () => {
+ pass("unequal values", assert => {
+ assert.notStrictEqual(1, "1");
+ });
+ fail("equal values", assert => {
+ assert.notStrictEqual(1, 1);
+ });
+});
+
+module("assert.ok()", () => {
+ pass("true", assert => {
+ assert.ok(true);
+ });
+ pass("truthy", assert => {
+ assert.ok(1);
+ });
+ fail("false", assert => {
+ assert.ok(false);
+ });
+ fail("falsey", assert => {
+ assert.ok("");
+ });
+});
+
+module.todo("assert.propContains()");
+
+module.todo("assert.propEqual()");
+
+module.todo("assert.pushResult()");
+
+module("assert.rejects()", () => {
+ skip("rejected promise", assert => {
+ assert.rejects(Promise.reject()); // segfault?
+ });
+ pass("rejected promise", assert => {
+ assert.rejects(Promise.reject(new Error("foo")), new Error("foo"));
+ assert.rejects(Promise.reject(new TypeError("foo")), TypeError);
+ assert.rejects(Promise.reject(new Error("foo")), "foo");
+ assert.rejects(Promise.reject(new Error("foo")), /foo/);
+ });
+ fail("resolved promise", assert => {
+ assert.rejects(Promise.resolve());
+ });
+ fail("rejected promise with unexpected error", assert => {
+ assert.rejects(Promise.reject(new Error("foo")), "bar");
+ });
+});
+
+module("assert.step()", () => {
+ pass("correct steps", assert => {
+ assert.step("foo");
+ assert.step("bar");
+ assert.verifySteps(["foo", "bar"]);
+ });
+ fail("incorrect steps", assert => {
+ assert.step("foo");
+ assert.verifySteps(["bar"]);
+ });
+});
+
+module("assert.strictEqual()", () => {
+ pass("equal values", assert => {
+ assert.strictEqual(1, 1);
+ });
+ fail("unequal values", assert => {
+ assert.strictEqual(1, "1");
+ });
+});
+
+module("assert.throws()", () => {
+ pass("thrown error", assert => {
+ assert.throws(() => {
+ throw new Error("foo");
+ }, new Error("foo"));
+ assert.throws(() => {
+ throw new TypeError("foo");
+ }, TypeError);
+ assert.throws(() => {
+ throw new Error("foo");
+ }, "foo");
+ assert.throws(() => {
+ throw new Error("foo");
+ }, /foo/);
+ });
+ fail("no error thrown", assert => {
+ assert.throws(() => {});
+ });
+ fail("unexpected error thrown", assert => {
+ assert.throws(() => {
+ throw new Error("foo");
+ }, "bar");
+ });
+});
+
+module("assert.timeout()", () => {
+ pass("no timeout", assert => {
+ assert.timeout(0);
+ });
+ fail("early timeout", assert => {
+ const done = assert.async();
+ assert.timeout(1);
+ setTimeout(done, 2);
+ });
+});
+
+module("assert.true()", () => {
+ pass("true", assert => {
+ assert.true(true);
+ });
+ fail("truthy", assert => {
+ assert.true(1);
+ });
+ fail("false", assert => {
+ assert.true(false);
+ });
+});
+
+module("assert.verifySteps()", () => {
+ pass("correct steps", assert => {
+ assert.step("foo");
+ assert.verifySteps(["foo"]);
+ assert.step("bar");
+ assert.verifySteps(["bar"]);
+ assert.verifySteps([]);
+ });
+ fail("incorrect steps", assert => {
+ assert.step("foo");
+ assert.verifySteps(["foo", "bar"]);
+ assert.step("bar");
+ });
+});
diff --git a/packages/bun-internal-test/runners/qunit/qunit.ts b/packages/bun-internal-test/runners/qunit/qunit.ts
new file mode 100644
index 000000000..33b95f6c0
--- /dev/null
+++ b/packages/bun-internal-test/runners/qunit/qunit.ts
@@ -0,0 +1,327 @@
+import type { DataInit, EachFn, Fn, Hooks, HooksFn, ModuleFn, TestEachFn, TestFn, TestOrEachFn } from "./qunit.d";
+import type { TestContext } from "bun-test";
+import { inspect, deepEquals } from "bun";
+import { Assert } from "./assert";
+
+type Status = "todo" | "skip" | "only" | undefined;
+
+type Module = {
+ name: string;
+ status: Status;
+ before: Fn[];
+ beforeEach: Fn[];
+ afterEach: Fn[];
+ after: Fn[];
+ addHooks(hooks?: Hooks | HooksFn): void;
+ addTest(name: string, status: Status, fn?: Fn): void;
+ addTests(name: string, status: Status, data: DataInit, fn?: EachFn): void;
+};
+
+function newModule(context: TestContext, moduleName: string, moduleStatus?: Status): Module {
+ const before: Fn[] = [];
+ const beforeEach: Fn[] = [];
+ const afterEach: Fn[] = [];
+ const after: Fn[] = [];
+ let tests = 0;
+ const addTest = (name: string, status: Status, fn?: Fn) => {
+ const runTest = async () => {
+ if (fn === undefined) {
+ return;
+ }
+ const assert = new Assert(context.expect);
+ if (tests++ === 1) {
+ for (const fn of before) {
+ await fn(assert);
+ }
+ }
+ for (const fn of beforeEach) {
+ await fn(assert);
+ }
+ try {
+ await fn(assert);
+ } finally {
+ for (const fn of afterEach) {
+ await fn(assert);
+ }
+ // TODO: need a way to know when module is done
+ if (false) {
+ for (const fn of after) {
+ await fn(assert);
+ }
+ }
+ // TODO: configurable timeout
+ await assert.close(100);
+ }
+ };
+ hideFromStack(runTest);
+ const addTest = () => {
+ if (moduleStatus !== undefined) {
+ status = moduleStatus;
+ }
+ if (status === undefined) {
+ context.test(name, runTest);
+ } else if (status === "skip" || status === "todo") {
+ context.test.skip(name, runTest);
+ } else {
+ context.test.only(name, runTest);
+ }
+ };
+ hideFromStack(addTest);
+ if (moduleName) {
+ context.describe(moduleName, addTest);
+ } else {
+ addTest();
+ }
+ };
+ hideFromStack(addTest);
+ if (moduleStatus === "skip" || moduleStatus === "todo") {
+ context.test.skip(moduleName, () => {});
+ }
+ return {
+ name: moduleName,
+ status: moduleStatus,
+ before,
+ beforeEach,
+ afterEach,
+ after,
+ addHooks(hooks) {
+ if (hooks === undefined) {
+ return;
+ }
+ if (typeof hooks === "object") {
+ if (hooks.before !== undefined) {
+ before.push(hooks.before);
+ }
+ if (hooks.beforeEach !== undefined) {
+ beforeEach.push(hooks.beforeEach);
+ }
+ if (hooks.afterEach !== undefined) {
+ afterEach.push(hooks.afterEach);
+ }
+ if (hooks.after !== undefined) {
+ after.push(hooks.after);
+ }
+ } else {
+ hooks({
+ before(fn) {
+ before.push(fn);
+ },
+ beforeEach(fn) {
+ beforeEach.push(fn);
+ },
+ afterEach(fn) {
+ afterEach.push(fn);
+ },
+ after(fn) {
+ after.push(fn);
+ },
+ });
+ }
+ },
+ addTest,
+ addTests(name, status, data, fn) {
+ let entries: [string, unknown][];
+ if (Array.isArray(data)) {
+ entries = data.map(value => [inspect(value), value]);
+ } else {
+ entries = Object.entries(data);
+ }
+ for (const [key, value] of entries) {
+ context.describe(name, () => {
+ addTest(key, status, fn ? assert => fn(assert, value) : undefined);
+ });
+ }
+ },
+ };
+}
+hideFromStack(newModule);
+
+function hideFromStack(object: any): void {
+ if (typeof object === "function") {
+ Object.defineProperty(object, "name", {
+ value: "::bunternal::",
+ });
+ return;
+ }
+ for (const name of Object.getOwnPropertyNames(object)) {
+ Object.defineProperty(object[name], "name", {
+ value: "::bunternal::",
+ });
+ }
+}
+
+function todo(name: string) {
+ const todo = () => {
+ throw new Error(`Not implemented: QUnit.${name}`);
+ };
+ hideFromStack(todo);
+ return todo;
+}
+
+function newCallable<C, O>(callable: C, object: O): C & O {
+ // @ts-expect-error
+ return Object.assign(callable, object);
+}
+
+function newQUnit(context: TestContext): import("./qunit.d").QUnit {
+ let module: Module = newModule(context, "");
+ let modules: Module[] = [module];
+ const addModule = (name: string, status?: Status, hooks?: Hooks | HooksFn, fn?: HooksFn) => {
+ module = newModule(context, name, status);
+ modules.push(module);
+ module.addHooks(hooks);
+ module.addHooks(fn);
+ };
+ hideFromStack(addModule);
+ return {
+ assert: Assert.prototype,
+ hooks: {
+ beforeEach(fn) {
+ for (const module of modules) {
+ module.beforeEach.push(fn);
+ }
+ },
+ afterEach(fn) {
+ for (const module of modules) {
+ module.afterEach.push(fn);
+ }
+ },
+ },
+ start() {},
+ module: newCallable<
+ ModuleFn,
+ {
+ skip: ModuleFn;
+ todo: ModuleFn;
+ only: ModuleFn;
+ }
+ >(
+ (name, hooks, fn) => {
+ addModule(name, undefined, hooks, fn);
+ },
+ {
+ skip(name, hooks, fn) {
+ addModule(name, "skip", hooks, fn);
+ },
+ todo(name, hooks, fn) {
+ addModule(name, "todo", hooks, fn);
+ },
+ only(name, hooks, fn) {
+ addModule(name, "only", hooks, fn);
+ },
+ },
+ ),
+ test: newCallable<
+ TestFn,
+ {
+ each: TestEachFn;
+ skip: TestOrEachFn;
+ todo: TestOrEachFn;
+ only: TestOrEachFn;
+ }
+ >(
+ (name, fn) => {
+ module.addTest(name, undefined, fn);
+ },
+ {
+ each: (name, data, fn) => {
+ module.addTests(name, undefined, data, fn);
+ },
+ skip: newCallable<
+ TestFn,
+ {
+ each: TestEachFn;
+ }
+ >(
+ (name, fn) => {
+ module.addTest(name, "skip", fn);
+ },
+ {
+ each(name, data, fn) {
+ module.addTests(name, "skip", data, fn);
+ },
+ },
+ ),
+ todo: newCallable<
+ TestFn,
+ {
+ each: TestEachFn;
+ }
+ >(
+ (name, fn) => {
+ module.addTest(name, "todo", fn);
+ },
+ {
+ each(name, data, fn) {
+ module.addTests(name, "todo", data, fn);
+ },
+ },
+ ),
+ only: newCallable<
+ TestFn,
+ {
+ each: TestEachFn;
+ }
+ >(
+ (name, fn) => {
+ module.addTest(name, "only", fn);
+ },
+ {
+ each(name, data, fn) {
+ module.addTests(name, "only", data, fn);
+ },
+ },
+ ),
+ },
+ ),
+ skip(name, fn) {
+ module.addTest(name, "skip", fn);
+ },
+ todo(name, fn) {
+ module.addTest(name, "todo", fn);
+ },
+ only(name, fn) {
+ module.addTest(name, "only", fn);
+ },
+ dump: {
+ maxDepth: Infinity,
+ parse(data) {
+ return inspect(data);
+ },
+ },
+ extend(target: any, mixin) {
+ return Object.assign(target, mixin);
+ },
+ equiv(a, b) {
+ return deepEquals(a, b);
+ },
+ config: {},
+ testDone: todo("testDone"),
+ testStart: todo("testStart"),
+ moduleDone: todo("moduleDone"),
+ moduleStart: todo("moduleStart"),
+ begin: todo("begin"),
+ done: todo("done"),
+ log: todo("log"),
+ onUncaughtException: todo("onUncaughtException"),
+ push: todo("push"),
+ stack: todo("stack"),
+ on: todo("on"),
+ };
+}
+
+const { expect, describe, test, beforeAll, beforeEach, afterEach, afterAll } = Bun.jest(import.meta.path);
+
+export const QUnit = newQUnit({
+ expect,
+ describe,
+ test,
+ beforeAll,
+ beforeEach,
+ afterEach,
+ afterAll,
+});
+export { Assert };
+
+// @ts-expect-error
+globalThis.QUnit = QUnit;
diff --git a/packages/bun-internal-test/runners/tap/index.ts b/packages/bun-internal-test/runners/tap/index.ts
new file mode 100644
index 000000000..ad3a72c39
--- /dev/null
+++ b/packages/bun-internal-test/runners/tap/index.ts
@@ -0,0 +1,335 @@
+// Not working yet, WIP
+
+import { callerSourceOrigin } from "bun:jsc";
+
+type EventEmitter = import("node:events").EventEmitter;
+type Expect = (value: unknown) => import("bun:test").Expect;
+type Fn = () => unknown;
+type Future = Promise<unknown> | (() => Promise<unknown>);
+type Extra = {
+ [key: string | number | symbol]: unknown;
+ todo?: boolean | string;
+ skip?: boolean | string;
+};
+
+export function test(name: string, options?: Extra, fn?: (t: Tap) => unknown): Promise<void> {
+ // @ts-expect-error
+ const { expect } = Bun.jest(callerSourceOrigin());
+ const tap = new Tap({
+ expect: expect,
+ name,
+ context: {},
+ parent: null,
+ before: [],
+ after: [],
+ });
+ return tap.test(name, options, fn);
+}
+
+/**
+ * @link https://node-tap.org/docs/api/
+ */
+class Tap {
+ #_expect: Expect;
+
+ #name: string;
+ #context: unknown;
+
+ #parent: Tap | null;
+ #children: Tap[];
+
+ #before: Fn[];
+ #beforeEach: Fn[];
+ #after: Fn[];
+ #afterEach: Fn[];
+
+ #abort: AbortController;
+ #aborted: Promise<void>;
+ #timeout: number | null;
+ #passing: boolean;
+ #plan: number | null;
+ #count: number;
+
+ constructor({
+ name,
+ context,
+ parent,
+ before,
+ after,
+ expect,
+ }: {
+ name?: string;
+ context?: unknown;
+ parent?: Tap | null;
+ before?: Fn[];
+ after?: Fn[];
+ expect: Expect;
+ }) {
+ this.#_expect = expect;
+ this.#name = name ?? "";
+ this.#context = context ?? {};
+ this.#parent = parent ?? null;
+ this.#children = [];
+ this.#before = before ? [...before] : [];
+ this.#beforeEach = [];
+ this.#after = after ? [...after] : [];
+ this.#afterEach = [];
+ this.#abort = new AbortController();
+ this.#aborted = new Promise(resolve => {
+ this.#abort.signal.addEventListener("abort", () => resolve());
+ });
+ this.#timeout = null;
+ this.#passing = true;
+ this.#plan = null;
+ this.#count = 0;
+ }
+
+ get name(): string {
+ return this.#name;
+ }
+
+ get context(): unknown {
+ return this.#context;
+ }
+
+ set context(value: unknown) {
+ this.#context = value;
+ }
+
+ get passing(): boolean {
+ return this.#passing;
+ }
+
+ #expect(value: unknown) {
+ this.#count++;
+ return this.#_expect(value);
+ }
+
+ async test(name: string, options?: Extra, fn?: (t: Tap) => unknown): Promise<void> {
+ if (typeof options === "function") {
+ fn = options;
+ options = {};
+ }
+ if (fn === undefined) {
+ throw new Error("Missing test function");
+ }
+ const test = new Tap({
+ expect: this.#_expect,
+ name,
+ context: this.#context,
+ parent: this,
+ before: [...this.#before, ...this.#beforeEach],
+ after: [...this.#after, ...this.#afterEach],
+ });
+ this.#children.push(test);
+ try {
+ for (const fn of this.#before) {
+ fn();
+ }
+ await fn(test);
+ } catch (error) {
+ test.#passing = false;
+ test.#abort.abort(error);
+ }
+ }
+
+ async todo(name: string, options?: Extra, fn?: (t: Tap) => unknown): Promise<void> {
+ console.warn("TODO", name);
+ }
+
+ async skip(name: string, options?: Extra, fn?: (t: Tap) => unknown): Promise<void> {
+ console.warn("SKIP", name);
+ }
+
+ beforeEach(fn: Fn): void {
+ this.#beforeEach.push(fn);
+ }
+
+ afterEach(fn: Fn): void {
+ this.#afterEach.push(fn);
+ }
+
+ before(fn: Fn): void {
+ this.#before.push(fn);
+ }
+
+ teardown(fn: Fn): void {
+ this.#after.push(fn);
+ }
+
+ setTimeout(timeout: number): void {
+ if (timeout === 0) {
+ if (this.#timeout !== null) {
+ clearTimeout(this.#timeout);
+ }
+ } else {
+ const fn = () => {
+ this.#abort.abort(new Error("Timed out"));
+ };
+ this.#timeout = +setTimeout(fn, timeout);
+ }
+ }
+
+ pragma(options: Record<string, unknown>): void {
+ throw new TODO("pragma");
+ }
+
+ plan(count: number, comment?: string): void {
+ if (this.#plan !== null) {
+ throw new Error("Plan already set");
+ }
+ this.#plan = count;
+ }
+
+ pass(message?: string, extra?: Extra): void {
+ // TODO
+ }
+
+ fail(message?: string, extra?: Extra): void {
+ // TODO
+ }
+
+ end(): void {
+ if (this.#abort.signal.aborted) {
+ throw new Error("Test already ended");
+ }
+ this.#abort.abort();
+ }
+
+ endAll(): void {
+ for (const child of this.#children) {
+ child.endAll();
+ }
+ this.end();
+ }
+
+ autoend(value: boolean): void {
+ throw new TODO("autoend");
+ }
+
+ bailout(reason?: string): void {
+ throw new TODO("bailout");
+ }
+
+ ok(value: unknown, message?: string, extra?: Extra): void {
+ this.#expect(value).toBeTruthy();
+ }
+
+ notOk(value: unknown, message?: string, extra?: Extra): void {
+ this.#expect(value).toBeFalsy();
+ }
+
+ error(value: unknown, message?: string, extra?: Extra): void {
+ this.#expect(value).toBeInstanceOf(Error);
+ }
+
+ async emits(eventEmitter: EventEmitter, event: string, message?: string, extra?: Extra): Promise<void> {
+ throw new TODO("emits");
+ }
+
+ async rejects(value: Future, expectedError?: Error, message?: string, extra?: Extra): Promise<void> {
+ throw new TODO("rejects");
+ }
+
+ async resolves(value: Future, message?: string, extra?: Extra): Promise<void> {
+ throw new TODO("resolves");
+ }
+
+ async resolveMatch(value: Future, expected: unknown, message?: string, extra?: Extra): Promise<void> {
+ throw new TODO("resolveMatch");
+ }
+
+ async resolveMatchSnapshot(value: Future, message?: string, extra?: Extra): Promise<void> {
+ throw new TODO("resolveMatchSnapshot");
+ }
+
+ throws(fn: Fn, expectedError?: Error, message?: string, extra?: Extra): void {
+ this.#expect(fn).toThrow(expectedError);
+ }
+
+ doesNotThrow(fn: Fn, message?: string, extra?: Extra): void {
+ throw new TODO("doesNotThrow");
+ }
+
+ expectUncaughtException(expectedError?: Error, message?: string, extra?: Extra): void {
+ throw new TODO("expectUncaughtException");
+ }
+
+ equal(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).toBe(expected);
+ }
+
+ not(expected: unknown, actual: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).not.toBe(expected);
+ }
+
+ same(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).toEqual(expected);
+ }
+
+ notSame(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).not.toEqual(expected);
+ }
+
+ strictSame(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).toStrictEqual(expected);
+ }
+
+ strictNotSame(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ this.#expect(actual).not.toStrictEqual(expected);
+ }
+
+ hasStrict(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("hasStrict");
+ }
+
+ notHasStrict(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("notHasStrict");
+ }
+
+ has(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("has");
+ }
+
+ notHas(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("notHas");
+ }
+
+ hasProp(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("hasProp");
+ }
+
+ hasProps(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("hasProps");
+ }
+
+ hasOwnProp(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("hasOwnProp");
+ }
+
+ hasOwnProps(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("hasOwnProps");
+ }
+
+ match(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("match");
+ }
+
+ notMatch(actual: unknown, expected: unknown, message?: string, extra?: Extra): void {
+ throw new TODO("notMatch");
+ }
+
+ type(actual: unknown, type: string, message?: string, extra?: Extra): void {
+ const types = ["string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint"];
+ if (type in types) {
+ return this.#expect(typeof actual).toBe(type);
+ }
+ this.#expect(actual?.constructor?.name).toBe(type);
+ }
+}
+
+class TODO extends Error {
+ constructor(message?: string) {
+ super(message);
+ }
+}
diff --git a/packages/bun-internal-test/scripts/html.ts b/packages/bun-internal-test/scripts/html.ts
new file mode 100644
index 000000000..a4e6c86a6
--- /dev/null
+++ b/packages/bun-internal-test/scripts/html.ts
@@ -0,0 +1,75 @@
+import { escapeHTML } from "bun";
+
+export function table(headers: unknown[], rows: unknown[][]): string {
+ return "<table>"
+ + headers.reduce((html, header) => html + `<th>${header}</th>`, "<tr>") + "</tr>"
+ + rows.reduce((html, row) => html + row.reduce((html, cell) => html + `<td>${cell}</td>`, "<tr>") + "</tr>", "")
+ + "</table>";
+}
+
+export function h(level: number, content: string): string {
+ return `<h${level}>${content}</h${level}>`;
+}
+
+export function ul(items: unknown[]): string {
+ return items.reduce((html, item) => html + `<li>${item}</li>`, "<ul>") + "</ul>";
+}
+
+export function a(content: string, baseUrl?: string, url?: string): string {
+ const href = baseUrl && url ? `${baseUrl}/${url}` : baseUrl;
+ return href ? `<a href="${href}">${escape(content)}</a>` : escape(content);
+}
+
+export function br(n: number = 1): string {
+ return "<br/>".repeat(n);
+}
+
+export function details(summary: string, details: string): string {
+ return `<details><summary>${summary}</summary>${details}</details>`;
+}
+
+export function code(content: string, lang: string = ""): string {
+ return `<pre lang="${lang}"><code>${escape(content)}</code></pre>`;
+}
+
+export function escape(content: string): string {
+ return escapeHTML(content)
+ .replace(/\+/g, "&#43;")
+ .replace(/\-/g, "&#45;")
+ .replace(/\*/g, "&#42;");
+}
+
+export function percent(numerator: number, demonimator: number): number {
+ const percent = Math.floor(numerator / demonimator * 100);
+ if (isNaN(percent) || percent < 0) {
+ return 0;
+ }
+ if (percent >= 100) {
+ return numerator >= demonimator ? 100 : 99;
+ }
+ return percent;
+}
+
+export function count(n: number): string {
+ return n ? `${n}` : "";
+}
+
+export function duration(milliseconds: number): string {
+ const seconds = Math.floor(milliseconds / 1000);
+ if (seconds === 0) {
+ return "< 1s";
+ }
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ let result = [];
+ if (hours) {
+ result.push(`${hours}h`);
+ }
+ if (minutes) {
+ result.push(`${minutes % 60}m`);
+ }
+ if (seconds) {
+ result.push(`${seconds % 60}s`);
+ }
+ return result.join(" ");
+}
diff --git a/packages/bun-internal-test/scripts/run-ecosystem-tests.ts b/packages/bun-internal-test/scripts/run-ecosystem-tests.ts
new file mode 100644
index 000000000..d3c02f5ea
--- /dev/null
+++ b/packages/bun-internal-test/scripts/run-ecosystem-tests.ts
@@ -0,0 +1,191 @@
+import { readFileSync, existsSync, appendFileSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { TestSummary, TestError, TestStatus, TestFile, Test, printTest } from "runner";
+import { runTests } from "runner";
+import { a, br, code, count, details, duration, h, percent, table, ul } from "html";
+
+const [filter] = process.argv.slice(2);
+const packagesText = readFileSync(resolve("resources/packages.json"), "utf8");
+const packagesList: Package[] = JSON.parse(packagesText);
+const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
+
+type Package = {
+ name: string;
+ repository: {
+ github: string;
+ commit?: string;
+ };
+ test?: {
+ runner: "bun" | "jest" | "qunit" | "mocha" | "tap";
+ path: string;
+ skip?: boolean | string;
+ env?: Record<string, string>;
+ args?: string[];
+ };
+};
+
+let summary = h(2, "Summary");
+let summaries: string[][] = [];
+let errors = h(2, "Errors");
+
+for (const pkg of packagesList) {
+ const { name, test } = pkg;
+ if (filter && !name.includes(filter)) {
+ continue;
+ }
+ const cwd = gitClone(pkg);
+ if (!test || test.skip) {
+ continue;
+ }
+ const { runner, path, args, env } = test;
+ const preload: string[] = [];
+ if (runner === "qunit") {
+ preload.push(resolve("runners/qunit/qunit.ts"));
+ }
+ if (runner === "tap" || runner === "mocha") {
+ continue; // TODO
+ }
+ const tests = runTests({
+ cwd,
+ filters: [path],
+ preload,
+ args,
+ env,
+ timeout: 5000,
+ });
+ let result;
+ while (true) {
+ const { value, done } = await tests.next();
+ if (done) {
+ result = value;
+ break;
+ } else if (filter || value.summary.fail) {
+ printTest(value)
+ }
+ }
+ if (!summaryPath) {
+ continue;
+ }
+ const { summary, files } = result;
+ const baseUrl = htmlUrl(pkg);
+ summaries.push([
+ a(name, baseUrl),
+ htmlStatus(summary),
+ count(summary.pass),
+ count(summary.fail),
+ count(summary.skip),
+ duration(summary.duration),
+ ]);
+ let breakdown = "";
+ const isFailed = ({ status }: { status: TestStatus }) => status === "fail";
+ for (const file of files.filter(isFailed)) {
+ breakdown += h(3, a(file.file, htmlLink(baseUrl, file)));
+ for (const error of file.errors ?? []) {
+ breakdown += htmlError(error);
+ }
+ let entries: string[] = [];
+ for (const test of file.tests.filter(isFailed)) {
+ let entry = a(test.name, htmlLink(baseUrl, file, test));
+ if (!test.errors?.length) {
+ entries.push(entry);
+ continue;
+ }
+ entry += br(2);
+ for (const error of test.errors) {
+ entry += htmlError(error);
+ }
+ entries.push(entry);
+ }
+ if (!entries.length && !file.errors?.length) {
+ breakdown += code("Test failed, but no errors were found.");
+ } else {
+ breakdown += ul(entries);
+ }
+ }
+ if (breakdown) {
+ errors += details(a(name, baseUrl), breakdown);
+ }
+}
+
+if (summaryPath) {
+ let html = summary
+ + table(
+ ["Package", "Status", "Passed", "Failed", "Skipped", "Duration"],
+ summaries,
+ )
+ + errors;
+ appendFileSync(summaryPath, html, "utf-8");
+}
+
+function htmlLink(baseUrl: string, file: TestFile, test?: Test): string {
+ const url = new URL(file.file, baseUrl);
+ const errors = (test ? test.errors : file.errors) ?? [];
+ loop: for (const { stack } of errors) {
+ for (const location of stack ?? []) {
+ if (test || location.file.endsWith(file.file)) {
+ url.hash = `L${location.line}`;
+ break loop;
+ }
+ }
+ }
+ return url.toString();
+}
+
+function htmlStatus(summary: TestSummary): string {
+ const ratio = percent(summary.pass, summary.tests);
+ if (ratio >= 95) {
+ return `✅ ${ratio}%`;
+ }
+ if (ratio >= 75) {
+ return `⚠️ ${ratio}%`;
+ }
+ return `❌ ${ratio}%`;
+}
+
+function htmlError(error: TestError): string {
+ const { name, message, preview } = error;
+ let result = code(`${name}: ${message}`, "diff");
+ if (preview) {
+ result += code(preview, "typescript");
+ }
+ return result;
+}
+
+function htmlUrl(pkg: Package): string {
+ const { repository } = pkg;
+ const { github, commit } = repository;
+ return `https://github.com/${github}/tree/${commit}/`;
+}
+
+function gitClone(pkg: Package): string {
+ const { name, repository } = pkg;
+ const path = resolve(`packages/${name}`);
+ if (!existsSync(path)) {
+ const url = `git@github.com:${repository.github}.git`;
+ spawnSync("git", [
+ "clone",
+ "--single-branch",
+ "--depth=1",
+ url,
+ path
+ ], {
+ stdio: "inherit",
+ });
+ spawnSync("bun", [
+ "install"
+ ], {
+ cwd: path,
+ stdio: "inherit",
+ });
+ }
+ const { stdout } = spawnSync("git", ["rev-parse", "HEAD"], {
+ cwd: path,
+ stdio: "pipe",
+ });
+ repository.commit = stdout.toString().trim();
+ return path;
+}
+
+function resolve(path: string): string {
+ return new URL(`../${path}`, import.meta.url).pathname;
+}
diff --git a/packages/bun-internal-test/tsconfig.json b/packages/bun-internal-test/tsconfig.json
index 860a9d365..670bc3968 100644
--- a/packages/bun-internal-test/tsconfig.json
+++ b/packages/bun-internal-test/tsconfig.json
@@ -4,14 +4,22 @@
"lib": ["ESNext"],
"module": "ESNext",
"target": "ESNext",
- "moduleResolution": "node",
- "types": ["bun-types"],
- "esModuleInterop": true,
- "allowJs": true,
+ "moduleResolution": "bundler",
"strict": true,
- "resolveJsonModule": true
- },
- "include": [
- "src"
- ]
+ "downlevelIteration": true,
+ "skipLibCheck": true,
+ "jsx": "preserve",
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "allowJs": true,
+ "types": ["bun-types"],
+ "baseUrl": ".",
+ "paths": {
+ "packages": ["resources/packages.json"],
+ "qunit": ["runners/qunit/qunit.ts"],
+ "bun-test": ["types/bun-test.d.ts"],
+ "runner": ["runners/bun/runner.ts"],
+ "html": ["scripts/html.ts"]
+ }
+ }
}
diff --git a/packages/bun-internal-test/types/bun-test.d.ts b/packages/bun-internal-test/types/bun-test.d.ts
new file mode 100644
index 000000000..13d3b06ba
--- /dev/null
+++ b/packages/bun-internal-test/types/bun-test.d.ts
@@ -0,0 +1,20 @@
+import type { Expect, test, describe, beforeAll, beforeEach, afterAll, afterEach } from "bun:test";
+
+export type BunExpect = (value: unknown) => Expect;
+export type BunDescribe = typeof describe;
+export type BunTest = typeof test;
+export type BunHook = typeof beforeAll | typeof beforeEach | typeof afterAll | typeof afterEach;
+
+export type TestContext = {
+ expect: BunExpect;
+ describe: BunDescribe;
+ test: BunTest;
+ beforeAll: BunHook;
+ beforeEach: BunHook;
+ afterAll: BunHook;
+ afterEach: BunHook;
+};
+
+declare module "bun" {
+ function jest(path: string): TestContext;
+}