Files
bun.sh/test/harness.ts

669 lines
18 KiB
TypeScript

import { gc as bunGC, unsafe, which } from "bun";
import { describe, test, expect, afterAll, beforeAll } from "bun:test";
import { readlink, readFile, writeFile } from "fs/promises";
import { isAbsolute, join, dirname } from "path";
import fs, { openSync, closeSync } from "node:fs";
import os from "node:os";
import { heapStats } from "bun:jsc";
type Awaitable<T> = T | Promise<T>;
export const isMacOS = process.platform === "darwin";
export const isLinux = process.platform === "linux";
export const isPosix = isMacOS || isLinux;
export const isWindows = process.platform === "win32";
export const isIntelMacOS = isMacOS && process.arch === "x64";
export const bunEnv: NodeJS.ProcessEnv = {
...process.env,
GITHUB_ACTIONS: "false",
BUN_DEBUG_QUIET_LOGS: "1",
NO_COLOR: "1",
FORCE_COLOR: undefined,
TZ: "Etc/UTC",
CI: "1",
BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1",
BUN_GARBAGE_COLLECTOR_LEVEL: process.env.BUN_GARBAGE_COLLECTOR_LEVEL || "0",
};
if (isWindows) {
bunEnv.SHELLOPTS = "igncr"; // Ignore carriage return
}
for (let key in bunEnv) {
if (bunEnv[key] === undefined) {
delete bunEnv[key];
}
if (key.startsWith("BUN_DEBUG_") && key !== "BUN_DEBUG_QUIET_LOGS") {
delete bunEnv[key];
}
}
export function bunExe() {
if (isWindows) return process.execPath.replaceAll("\\", "/");
return process.execPath;
}
export function nodeExe(): string | null {
return which("node") || null;
}
export function gc(force = true) {
bunGC(force);
}
/**
* The garbage collector is not 100% deterministic
*
* We want to assert that SOME of the objects are collected
* But we cannot reliably assert that ALL of them are collected
*
* Therefore, we check that the count is less than or equal to the expected count
*
* @param type
* @param count
* @param maxWait
* @returns
*/
export async function expectMaxObjectTypeCount(
expect: typeof import("bun:test").expect,
type: string,
count: number,
maxWait = 1000,
) {
var { heapStats } = require("bun:jsc");
gc();
if (heapStats().objectTypeCounts[type] <= count) return;
gc(true);
for (const wait = 20; maxWait > 0; maxWait -= wait) {
if (heapStats().objectTypeCounts[type] <= count) break;
await Bun.sleep(wait);
gc();
}
expect(heapStats().objectTypeCounts[type]).toBeLessThanOrEqual(count);
}
// we must ensure that finalizers are run
// so that the reference-counting logic is exercised
export function gcTick(trace = false) {
trace && console.trace("");
// console.trace("hello");
gc();
return Bun.sleep(0);
}
export function withoutAggressiveGC(block: () => unknown) {
if (!unsafe.gcAggressionLevel) return block();
const origGC = unsafe.gcAggressionLevel();
unsafe.gcAggressionLevel(0);
try {
return block();
} finally {
unsafe.gcAggressionLevel(origGC);
}
}
export function hideFromStackTrace(block: CallableFunction) {
Object.defineProperty(block, "name", {
value: "::bunternal::",
configurable: true,
enumerable: true,
writable: true,
});
}
type DirectoryTree = {
[name: string]:
| string
| Buffer
| DirectoryTree
| ((opts: { root: string }) => Awaitable<string | Buffer | DirectoryTree>);
};
export function tempDirWithFiles(basename: string, files: DirectoryTree): string {
async function makeTree(base: string, tree: DirectoryTree) {
for (const [name, raw_contents] of Object.entries(tree)) {
const contents = typeof raw_contents === "function" ? await raw_contents({ root: base }) : raw_contents;
const joined = join(base, name);
if (name.includes("/")) {
const dir = dirname(name);
fs.mkdirSync(join(base, dir), { recursive: true });
}
if (typeof contents === "object" && contents && !Buffer.isBuffer(contents)) {
fs.mkdirSync(joined);
makeTree(joined, contents);
continue;
}
fs.writeFileSync(joined, contents);
}
}
const base = fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), basename + "_"));
makeTree(base, files);
return base;
}
export function bunRun(file: string, env?: Record<string, string>) {
var path = require("path");
const result = Bun.spawnSync([bunExe(), file], {
cwd: path.dirname(file),
env: {
...bunEnv,
NODE_ENV: undefined,
...env,
},
});
if (!result.success) throw new Error(result.stderr.toString("utf8"));
return {
stdout: result.stdout.toString("utf8").trim(),
stderr: result.stderr.toString("utf8").trim(),
};
}
export function bunTest(file: string, env?: Record<string, string>) {
var path = require("path");
const result = Bun.spawnSync([bunExe(), "test", path.basename(file)], {
cwd: path.dirname(file),
env: {
...bunEnv,
NODE_ENV: undefined,
...env,
},
});
if (!result.success) throw new Error(result.stderr.toString("utf8"));
return {
stdout: result.stdout.toString("utf8").trim(),
stderr: result.stderr.toString("utf8").trim(),
};
}
export function bunRunAsScript(
dir: string,
script: string,
env?: Record<string, string | undefined>,
execArgv?: string[],
) {
const result = Bun.spawnSync([bunExe(), ...(execArgv ?? []), `run`, `${script}`], {
cwd: dir,
env: {
...bunEnv,
NODE_ENV: undefined,
...env,
},
});
if (!result.success) throw new Error(result.stderr.toString("utf8"));
return {
stdout: result.stdout.toString("utf8").trim(),
stderr: result.stderr.toString("utf8").trim(),
};
}
export function randomLoneSurrogate() {
const n = randomRange(0, 2);
if (n === 0) return randomLoneHighSurrogate();
return randomLoneLowSurrogate();
}
export function randomInvalidSurrogatePair() {
const low = randomLoneLowSurrogate();
const high = randomLoneHighSurrogate();
return `${low}${high}`;
}
// Generates a random lone high surrogate (from the range D800-DBFF)
export function randomLoneHighSurrogate() {
return String.fromCharCode(randomRange(0xd800, 0xdbff));
}
// Generates a random lone high surrogate (from the range DC00-DFFF)
export function randomLoneLowSurrogate() {
return String.fromCharCode(randomRange(0xdc00, 0xdfff));
}
function randomRange(low: number, high: number): number {
return low + Math.floor(Math.random() * (high - low));
}
export function runWithError(cb: () => unknown): Error | undefined {
try {
cb();
} catch (e) {
return e as Error;
}
return undefined;
}
export async function runWithErrorPromise(cb: () => unknown): Promise<Error | undefined> {
try {
await cb();
} catch (e) {
return e as Error;
}
return undefined;
}
export function fakeNodeRun(dir: string, file: string | string[], env?: Record<string, string>) {
var path = require("path");
const result = Bun.spawnSync([bunExe(), "--bun", "node", ...(Array.isArray(file) ? file : [file])], {
cwd: dir ?? path.dirname(file),
env: {
...bunEnv,
NODE_ENV: undefined,
...env,
},
});
if (!result.success) throw new Error(result.stderr.toString("utf8"));
return {
stdout: result.stdout.toString("utf8").trim(),
stderr: result.stderr.toString("utf8").trim(),
};
}
export function randomPort(): number {
return 1024 + Math.floor(Math.random() * (65535 - 1024));
}
expect.extend({
toRun(cmds: string[], optionalStdout?: string) {
const result = Bun.spawnSync({
cmd: [bunExe(), ...cmds],
env: bunEnv,
stdio: ["inherit", "pipe", "inherit"],
});
if (result.exitCode !== 0) {
return {
pass: false,
message: () => `Command ${cmds.join(" ")} failed:` + "\n" + result.stdout.toString("utf-8"),
};
}
if (optionalStdout) {
return {
pass: result.stdout.toString("utf-8") === optionalStdout,
message: () =>
`Expected ${cmds.join(" ")} to output ${optionalStdout} but got ${result.stdout.toString("utf-8")}`,
};
}
return {
pass: true,
message: () => `Expected ${cmds.join(" ")} to fail`,
};
},
});
export function ospath(path: string) {
if (isWindows) {
return path.replace(/\//g, "\\");
}
return path;
}
export async function toHaveBins(actual: string[], expectedBins: string[]) {
const message = () => `Expected ${actual} to be package bins ${expectedBins}`;
if (isWindows) {
for (var i = 0; i < actual.length; i += 2) {
if (!actual[i].includes(expectedBins[i / 2]) || !actual[i + 1].includes(expectedBins[i / 2])) {
return { pass: false, message };
}
}
return { pass: true, message };
}
return { pass: actual.every((bin, i) => bin === expectedBins[i]), message };
}
export async function toBeValidBin(actual: string, expectedLinkPath: string) {
const message = () => `Expected ${actual} to be a link to ${expectedLinkPath}`;
if (isWindows) {
const contents = await readFile(actual + ".bunx", "utf16le");
const expected = expectedLinkPath.slice(3);
return { pass: contents.includes(expected), message };
}
return { pass: (await readlink(actual)) === expectedLinkPath, message };
}
export async function toBeWorkspaceLink(actual: string, expectedLinkPath: string) {
const message = () => `Expected ${actual} to be a link to ${expectedLinkPath}`;
if (isWindows) {
// junctions on windows will have an absolute path
const pass = isAbsolute(actual) && actual.includes(expectedLinkPath.split("..").at(-1)!);
return { pass, message };
}
const pass = actual === expectedLinkPath;
return { pass, message };
}
export function getMaxFD(): number {
const maxFD = openSync("/dev/null", "r");
closeSync(maxFD);
return maxFD;
}
// This is extremely frowned upon but I think it's easier to deal with than
// remembering to do this manually everywhere
declare global {
interface Buffer {
/**
* **INTERNAL USE ONLY, NOT An API IN BUN**
*/
toUnixString(): string;
}
}
Buffer.prototype.toUnixString = function () {
return this.toString("utf-8").replaceAll("\r\n", "\n");
};
export function dockerExe(): string | null {
return which("docker") || which("podman") || null;
}
export async function waitForPort(port: number, timeout: number = 60_000): Promise<void> {
let deadline = Date.now() + Math.max(1, timeout);
let error: unknown;
while (Date.now() < deadline) {
error = await new Promise(resolve => {
Bun.connect({
hostname: "localhost",
port,
socket: {
data: socket => {
resolve(undefined);
socket.end();
},
end: () => resolve(new Error("Socket closed")),
error: (_, cause) => resolve(new Error("Socket error", { cause })),
connectError: (_, cause) => resolve(new Error("Socket connect error", { cause })),
},
});
});
if (error) {
await Bun.sleep(1000);
} else {
return;
}
}
throw error;
}
export async function describeWithContainer(
label: string,
{
image,
env = {},
args = [],
archs,
}: {
image: string;
env?: Record<string, string>;
args?: string[];
archs?: NodeJS.Architecture[];
},
fn: (port: number) => void,
) {
describe(label, () => {
const docker = dockerExe();
if (!docker) {
test.skip(`docker is not installed, skipped: ${image}`, () => {});
return;
}
const { arch, platform } = process;
if ((archs && !archs?.includes(arch)) || platform === "win32") {
test.skip(`docker image is not supported on ${platform}/${arch}, skipped: ${image}`, () => {});
return false;
}
let containerId: string;
{
const envs = Object.entries(env).map(([k, v]) => `-e${k}=${v}`);
const { exitCode, stdout, stderr } = Bun.spawnSync({
cmd: [docker, "run", "--rm", "-dPit", ...envs, image, ...args],
stdout: "pipe",
stderr: "pipe",
});
if (exitCode !== 0) {
process.stderr.write(stderr);
test.skip(`docker container for ${image} failed to start`, () => {});
return false;
}
containerId = stdout.toString("utf-8").trim();
}
let port: number;
{
const { exitCode, stdout, stderr } = Bun.spawnSync({
cmd: [docker, "port", containerId],
stdout: "pipe",
stderr: "pipe",
});
if (exitCode !== 0) {
process.stderr.write(stderr);
test.skip(`docker container for ${image} failed to find a port`, () => {});
return false;
}
const [firstPort] = stdout
.toString("utf-8")
.trim()
.split("\n")
.map(line => parseInt(line.split(":").pop()!));
port = firstPort;
}
beforeAll(async () => {
await waitForPort(port);
});
afterAll(() => {
Bun.spawnSync({
cmd: [docker, "rm", "-f", containerId],
stdout: "ignore",
stderr: "ignore",
});
});
fn(port);
});
}
export function osSlashes(path: string) {
return isWindows ? path.replace(/\//g, "\\") : path;
}
import * as child_process from "node:child_process";
class WriteBlockedError extends Error {
constructor(time) {
super("Write blocked for " + (time | 0) + "ms");
this.name = "WriteBlockedError";
}
}
function failTestsOnBlockingWriteCall() {
const prop = Object.getOwnPropertyDescriptor(child_process.ChildProcess.prototype, "stdin");
const didAttachSymbol = Symbol("kDidAttach");
if (prop) {
Object.defineProperty(child_process.ChildProcess.prototype, "stdin", {
...prop,
get() {
const actual = prop.get.call(this);
if (actual?.write && !actual.__proto__[didAttachSymbol]) {
actual.__proto__[didAttachSymbol] = true;
attachWriteMeasurement(actual);
}
return actual;
},
});
}
function attachWriteMeasurement(stream) {
const prop = Object.getOwnPropertyDescriptor(stream.__proto__, "write");
if (prop) {
Object.defineProperty(stream.__proto__, "write", {
...prop,
value(chunk, encoding, cb) {
const start = performance.now();
const rc = prop.value.apply(this, arguments);
const end = performance.now();
if (end - start > 8) {
const err = new WriteBlockedError(end - start);
throw err;
}
return rc;
},
});
}
}
}
failTestsOnBlockingWriteCall();
export function dumpStats() {
const stats = heapStats();
const { objectTypeCounts, protectedObjectTypeCounts } = stats;
console.log({
objects: Object.fromEntries(Object.entries(objectTypeCounts).sort()),
protected: Object.fromEntries(Object.entries(protectedObjectTypeCounts).sort()),
});
}
export function fillRepeating(dstBuffer: NodeJS.TypedArray, start: number, end: number) {
let len = dstBuffer.length, // important: use indices length, not byte-length
sLen = end - start,
p = sLen; // set initial position = source sequence length
// step 2: copy existing data doubling segment length per iteration
while (p < len) {
if (p + sLen > len) sLen = len - p; // if not power of 2, truncate last segment
dstBuffer.copyWithin(p, start, sLen); // internal copy
p += sLen; // add current length to offset
sLen <<= 1; // double length for next segment
}
}
function makeFlatPropertyMap(opts: object) {
// return all properties of opts as paths for nested objects with dot notation
// like { a: { b: 1 } } => { "a.b": 1 }
// combining names of nested objects with dot notation
// infinitely deep
const ret: any = {};
function recurse(obj: object, path = "") {
for (const [key, value] of Object.entries(obj)) {
if (value === undefined) continue;
if (value && typeof value === "object") {
recurse(value, path ? `${path}.${key}` : key);
} else {
ret[path ? `${path}.${key}` : key] = value;
}
}
}
recurse(opts);
return ret;
}
export function toTOMLString(opts: object) {
// return a TOML string of the given options
const props = makeFlatPropertyMap(opts);
let ret = "";
for (const [key, value] of Object.entries(props)) {
if (value === undefined) continue;
ret += `${key} = ${JSON.stringify(value)}` + "\n";
}
return ret;
}
const shebang_posix = (program: string) => `#!/usr/bin/env ${program}
`;
const shebang_windows = (program: string) => `0</* :{
@echo off
${program} %~f0 %*
exit /b %errorlevel%
:} */0;
`;
export function writeShebangScript(path: string, program: string, data: string) {
if (!isWindows) {
return writeFile(path, shebang_posix(program) + "\n" + data, { mode: 0o777 });
} else {
return writeFile(path + ".cmd", shebang_windows(program) + "\n" + data);
}
}
export async function* forEachLine(iter: AsyncIterable<NodeJS.TypedArray | ArrayBufferLike>) {
var decoder = new (require("string_decoder").StringDecoder)("utf8");
var str = "";
for await (const chunk of iter) {
str += decoder.write(chunk);
let i = str.indexOf("\n");
while (i >= 0) {
yield str.slice(0, i);
str = str.slice(i + 1);
i = str.indexOf("\n");
}
}
str += decoder.end();
{
let i = str.indexOf("\n");
while (i >= 0) {
yield str.slice(0, i);
str = str.slice(i + 1);
i = str.indexOf("\n");
}
}
if (str.length > 0) {
yield str;
}
}
export function joinP(...paths: string[]) {
return join(...paths).replaceAll("\\", "/");
}
/**
* TODO: see if this is the default behavior of node child_process APIs if so,
* we need to do case-insensitive stuff within our Bun.spawn implementation
*
* Windows has case-insensitive environment variables, so sometimes an
* object like { Path: "...", PATH: "..." } will be passed. Bun lets
* the first one win, but we really want the LAST one to win.
*
* This is mostly needed if you want to override env vars, such like:
* env: {
* ...bunEnv,
* PATH: "my path override here",
* }
* becomes
* env: mergeWindowEnvs([
* bunEnv,
* {
* PATH: "my path override here",
* },
* ])
*/
export function mergeWindowEnvs(envs: Record<string, string | undefined>[]) {
const keys: Record<string, string | undefined> = {};
const flat: Record<string, string | undefined> = {};
for (const env of envs) {
for (const key in env) {
if (!env[key]) continue;
const normalized = keys[key.toUpperCase()] ?? key;
flat[normalized] = env[key];
}
}
return flat;
}
export function tmpdirSync(pattern: string) {
return fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), pattern));
}