diff options
author | 2023-05-04 15:27:12 -0700 | |
---|---|---|
committer | 2023-05-04 15:27:12 -0700 | |
commit | 1183dd1a3fd073de40823b6f3b44a890e89d5ffd (patch) | |
tree | 9e488cb615460e517b638d779ae75aab7243b52b | |
parent | 8e18229d5da2a24b89791b87fb5a8ec4e5a0db1b (diff) | |
download | bun-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
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 Binary files differindex ed9a220c7..b6006363b 100755 --- a/packages/bun-internal-test/bun.lockb +++ b/packages/bun-internal-test/bun.lockb 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, "+") + .replace(/\-/g, "-") + .replace(/\*/g, "*"); +} + +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; +} |