diff --git a/.github/workflows/build-darwin.yml b/.github/workflows/build-darwin.yml index 4f6b0800a0..d35edfab45 100644 --- a/.github/workflows/build-darwin.yml +++ b/.github/workflows/build-darwin.yml @@ -307,4 +307,4 @@ jobs: @${{ github.actor }}, the build for bun-${{ inputs.tag }} failed. - **[View logs](${{ github.event.workflow_run.html_url }})** + **[View logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})** diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7517225d31..c1bde9271c 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -61,4 +61,4 @@ jobs: @${{ github.actor }}, the build for bun-${{ inputs.tag }} failed. - **[View logs](${{ github.event.workflow_run.html_url }})** + **[View logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})** diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 93392ef458..3ed47a4c39 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -334,4 +334,4 @@ jobs: @${{ github.actor }}, the build for bun-${{ inputs.tag }} failed. - **[View logs](${{ github.event.workflow_run.html_url }})** + **[View logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec95a0b43..16ea3d011f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ permissions: actions: write concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run-id || github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.run-id || github.ref }} cancel-in-progress: true on: @@ -15,13 +15,21 @@ on: type: string description: The workflow ID to download artifacts (skips the build step) pull_request: + paths-ignore: + - .vscode/**/* + - docs/**/* + - examples/**/* push: branches: - main + paths-ignore: + - .vscode/**/* + - docs/**/* + - examples/**/* jobs: format: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Format uses: ./.github/workflows/run-format.yml secrets: inherit @@ -30,12 +38,12 @@ jobs: permissions: contents: write lint: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Lint uses: ./.github/workflows/run-lint.yml secrets: inherit linux-x64: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build linux-x64 uses: ./.github/workflows/build-linux.yml secrets: inherit @@ -46,7 +54,7 @@ jobs: cpu: haswell canary: true linux-x64-baseline: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build linux-x64-baseline uses: ./.github/workflows/build-linux.yml secrets: inherit @@ -57,7 +65,7 @@ jobs: cpu: nehalem canary: true linux-aarch64: - if: ${{ !github.event.inputs.run-id && github.repository_owner == 'oven-sh' }} + if: ${{ !inputs.run-id && github.repository_owner == 'oven-sh' }} name: Build linux-aarch64 uses: ./.github/workflows/build-linux.yml secrets: inherit @@ -68,7 +76,7 @@ jobs: cpu: native canary: true darwin-x64: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build darwin-x64 uses: ./.github/workflows/build-darwin.yml secrets: inherit @@ -79,7 +87,7 @@ jobs: cpu: haswell canary: true darwin-x64-baseline: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build darwin-x64-baseline uses: ./.github/workflows/build-darwin.yml secrets: inherit @@ -90,7 +98,7 @@ jobs: cpu: nehalem canary: true darwin-aarch64: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build darwin-aarch64 uses: ./.github/workflows/build-darwin.yml secrets: inherit @@ -101,7 +109,7 @@ jobs: cpu: native canary: true windows-x64: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build windows-x64 uses: ./.github/workflows/build-windows.yml secrets: inherit @@ -112,7 +120,7 @@ jobs: cpu: haswell canary: true windows-x64-baseline: - if: ${{ !github.event.inputs.run-id }} + if: ${{ !inputs.run-id }} name: Build windows-x64-baseline uses: ./.github/workflows/build-windows.yml secrets: inherit @@ -123,7 +131,7 @@ jobs: cpu: nehalem canary: true linux-x64-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test linux-x64 needs: linux-x64 uses: ./.github/workflows/run-test.yml @@ -134,7 +142,7 @@ jobs: runs-on: ${{ github.repository_owner == 'oven-sh' && 'namespace-profile-bun-ci-linux-x64' || 'ubuntu-latest' }} tag: linux-x64 linux-x64-baseline-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test linux-x64-baseline needs: linux-x64-baseline uses: ./.github/workflows/run-test.yml @@ -145,7 +153,7 @@ jobs: runs-on: ${{ github.repository_owner == 'oven-sh' && 'namespace-profile-bun-ci-linux-x64' || 'ubuntu-latest' }} tag: linux-x64-baseline linux-aarch64-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' && github.repository_owner == 'oven-sh'}} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' && github.repository_owner == 'oven-sh'}} name: Test linux-aarch64 needs: linux-aarch64 uses: ./.github/workflows/run-test.yml @@ -156,7 +164,7 @@ jobs: runs-on: namespace-profile-bun-ci-linux-aarch64 tag: linux-aarch64 darwin-x64-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test darwin-x64 needs: darwin-x64 uses: ./.github/workflows/run-test.yml @@ -167,7 +175,7 @@ jobs: runs-on: ${{ github.repository_owner == 'oven-sh' && 'macos-13-large' || 'macos-13' }} tag: darwin-x64 darwin-x64-baseline-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test darwin-x64-baseline needs: darwin-x64-baseline uses: ./.github/workflows/run-test.yml @@ -178,7 +186,7 @@ jobs: runs-on: ${{ github.repository_owner == 'oven-sh' && 'macos-13-large' || 'macos-13' }} tag: darwin-x64-baseline darwin-aarch64-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test darwin-aarch64 needs: darwin-aarch64 uses: ./.github/workflows/run-test.yml @@ -189,7 +197,7 @@ jobs: runs-on: ${{ github.repository_owner == 'oven-sh' && 'namespace-profile-bun-ci-darwin-aarch64' || 'macos-13' }} tag: darwin-aarch64 windows-x64-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test windows-x64 needs: windows-x64 uses: ./.github/workflows/run-test.yml @@ -200,7 +208,7 @@ jobs: runs-on: windows tag: windows-x64 windows-x64-baseline-test: - if: ${{ github.event.inputs.run-id && always() || github.event_name == 'pull_request' }} + if: ${{ inputs.run-id && always() || github.event_name == 'pull_request' }} name: Test windows-x64-baseline needs: windows-x64-baseline uses: ./.github/workflows/run-test.yml diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index db74ae59b3..44cdda3963 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -37,7 +37,7 @@ jobs: else echo -e "✅ @${{ github.actor }}, all tests passed!" > comment.md fi - echo -e "\n**[View logs](${{ github.event.workflow_run.html_url }})**" >> comment.md + echo -e "\n**[View logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})**" >> comment.md echo -e "" >> comment.md - name: Find Comment id: comment diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 8b7cb96b00..b98d3d2ebf 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -22,7 +22,7 @@ on: jobs: test: - name: Run Tests + name: Tests runs-on: ${{ inputs.runs-on }} steps: - if: ${{ runner.os == 'Windows' }} @@ -129,3 +129,79 @@ jobs: run: | echo "There are ${{ steps.test.outputs.failing_tests_count || 'some' }} failing tests on bun-${{ inputs.tag }}." exit 1 + test-node: + name: Node.js Tests + runs-on: ${{ inputs.runs-on }} + steps: + - if: ${{ runner.os == 'Windows' }} + name: Setup Git + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + test/node.js + - name: Setup Environment + shell: bash + run: | + echo "${{ inputs.pr-number }}" > pr-number.txt + - name: Download Bun + uses: actions/download-artifact@v4 + with: + name: bun-${{ inputs.tag }} + path: bun + github-token: ${{ github.token }} + run-id: ${{ inputs.run-id || github.run_id }} + - if: ${{ runner.os != 'Windows' }} + name: Setup Bun + shell: bash + run: | + unzip bun/bun-*.zip + cd bun-* + pwd >> $GITHUB_PATH + - if: ${{ runner.os == 'Windows' }} + name: Setup Cygwin + uses: secondlife/setup-cygwin@v3 + with: + packages: bash + - if: ${{ runner.os == 'Windows' }} + name: Setup Bun (Windows) + run: | + unzip bun/bun-*.zip + cd bun-* + pwd >> $env:GITHUB_PATH + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Checkout Tests + shell: bash + working-directory: test/node.js + run: | + node runner.mjs --pull + - name: Install Dependencies + timeout-minutes: 5 + shell: bash + working-directory: test/node.js + run: | + bun install + - name: Run Tests + timeout-minutes: 10 # Increase when more tests are added + shell: bash + working-directory: test/node.js + env: + TMPDIR: ${{ runner.temp }} + BUN_GARBAGE_COLLECTOR_LEVEL: "0" + BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "true" + run: | + node runner.mjs + - name: Upload Results + uses: actions/upload-artifact@v4 + with: + name: bun-${{ inputs.tag }}-node-tests + path: | + test/node.js/summary/*.json + if-no-files-found: error + overwrite: true diff --git a/.gitmodules b/.gitmodules index 51f5cdc69f..1da9798dc9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -84,9 +84,17 @@ depth = 1 shallow = true fetchRecurseSubmodules = false [submodule "zig"] - path = src/deps/zig - url = https://github.com/oven-sh/zig - branch = bun - depth = 1 - shallow = true - fetchRecurseSubmodules = false +path = src/deps/zig +url = https://github.com/oven-sh/zig +branch = bun +depth = 1 +shallow = true +fetchRecurseSubmodules = false +[submodule "test/node.js/upstream"] + path = test/node.js/upstream + url = https://github.com/nodejs/node.git +ignore = dirty +depth = 1 +update = none +shallow = true +fetchRecurseSubmodules = false diff --git a/.vscode/settings.json b/.vscode/settings.json index 4961329465..677970ece7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ ".git": true, "src/bun.js/WebKit": true, "src/deps/*/**": true, + "test/node.js/upstream": true, }, "search.followSymlinks": false, "search.useIgnoreFiles": true, diff --git a/test/node.js/.gitignore b/test/node.js/.gitignore new file mode 100644 index 0000000000..edad843264 --- /dev/null +++ b/test/node.js/.gitignore @@ -0,0 +1,6 @@ +# Paths copied from Node.js repository +upstream/ + +# Paths for test runner +summary/ +summary.md diff --git a/test/node.js/.prettierignore b/test/node.js/.prettierignore new file mode 100644 index 0000000000..42b5527ca1 --- /dev/null +++ b/test/node.js/.prettierignore @@ -0,0 +1 @@ +upstream/ diff --git a/test/node.js/bunfig.toml b/test/node.js/bunfig.toml new file mode 100644 index 0000000000..e630e9b8b5 --- /dev/null +++ b/test/node.js/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./common/preload.js"] diff --git a/test/node.js/common/assert.js b/test/node.js/common/assert.js new file mode 100644 index 0000000000..e38fe9c7c6 --- /dev/null +++ b/test/node.js/common/assert.js @@ -0,0 +1,273 @@ +import { expect } from "bun:test"; + +function deepEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).toEqual(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function deepStrictEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).toStrictEqual(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function doesNotMatch(string, regexp, message) { + if (isIgnored(regexp, message)) { + return; + } + try { + expect(string).not.toMatch(regexp); + } catch (cause) { + throwError(cause, message); + } +} + +function doesNotReject(asyncFn, error, message) { + if (isIgnored(error, message)) { + return; + } + try { + expect(asyncFn).rejects.toThrow(error); + } catch (cause) { + throwError(cause, message); + } +} + +function doesNotThrow(fn, error, message) { + if (isIgnored(error, message)) { + return; + } + todo("doesNotThrow"); +} + +function equal(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).toBe(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function fail(actual, expected, message, operator, stackStartFn) { + if (isIgnored(expected, message)) { + return; + } + todo("fail"); +} + +function ifError(value) { + if (isIgnored(value)) { + return; + } + todo("ifError"); +} + +function match(string, regexp, message) { + if (isIgnored(regexp, message)) { + return; + } + try { + expect(string).toMatch(regexp); + } catch (cause) { + throwError(cause, message); + } +} + +function notDeepEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + todo("notDeepEqual"); +} + +function notDeepStrictEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + todo("notDeepStrictEqual"); +} + +function notEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).not.toBe(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function notStrictEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).not.toStrictEqual(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function ok(value, message) { + if (isIgnored(message)) { + return; + } + equal(!!value, true, message); +} + +function rejects(asyncFn, error, message) { + if (isIgnored(error, message)) { + return; + } + todo("rejects"); +} + +function strictEqual(actual, expected, message) { + if (isIgnored(expected, message)) { + return; + } + try { + expect(actual).toBe(expected); + } catch (cause) { + throwError(cause, message); + } +} + +function throws(fn, error, message) { + try { + let result; + try { + result = fn(); + } catch (cause) { + const matcher = toErrorMatcher(error); + expect(cause).toEqual(matcher); + return; + } + expect(result).toBe("Expected function to throw an error, instead it returned"); + } catch (cause) { + throwError(cause, message); + } +} + +function toErrorMatcher(expected) { + let message; + if (typeof expected === "string") { + message = expected; + } else if (expected instanceof RegExp) { + message = expected.source; + } else if (typeof expected === "object") { + message = expected.message; + } + + for (const [expected, actual] of similarErrors) { + if (message && expected.test(message)) { + message = actual; + break; + } + } + + if (!message) { + return expect.anything(); + } + + if (typeof expected === "object") { + return expect.objectContaining({ + ...expected, + message: expect.stringMatching(message), + }); + } + + return expect.stringMatching(message); +} + +const similarErrors = [ + [/Invalid typed array length/i, /length too large/i], + [/Unknown encoding/i, /Invalid encoding/i], + [ + /The ".*" argument must be of type string or an instance of Buffer or ArrayBuffer/i, + /Invalid input, must be a string, Buffer, or ArrayBuffer/i, + ], + [/The ".*" argument must be an instance of Buffer or Uint8Array./i, /Expected Buffer/i], + [/The ".*" argument must be an instance of Array./i, /Argument must be an array/i], + [/The value of ".*" is out of range./i, /Offset is out of bounds/i], + [/Attempt to access memory outside buffer bounds/i, /Out of bounds access/i], +]; + +const ignoredExpectations = [ + // Reason: Bun has a nicer format for `Buffer.inspect()`. + /^ { + if (calls !== n) { + throw new Error(`function should be called exactly ${n} times:\n ${callSite}`); + } + }); + + return mustCallFn; +} + +function mustNotCall() { + const callSite = getCallSite(mustNotCall); + + return function mustNotCall(...args) { + const argsInfo = args.length > 0 ? `\ncalled with arguments: ${args.map(arg => inspect(arg)).join(", ")}` : ""; + assert.fail(`${msg || "function should not have been called"} at ${callSite}` + argsInfo); + }; +} + +function printSkipMessage(message) { + console.warn(message); +} + +function skip(message) { + printSkipMessage(message); + process.exit(0); +} + +function expectsError(validator, exact) { + return mustCall((...args) => { + if (args.length !== 1) { + // Do not use `assert.strictEqual()` to prevent `inspect` from + // always being called. + assert.fail(`Expected one argument, got ${inspect(args)}`); + } + const error = args.pop(); + // The error message should be non-enumerable + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(error, "message"), false); + + assert.throws(() => { + throw error; + }, validator); + return true; + }, exact); +} + +function expectWarning(name, code, message) { + // Do nothing +} + +function invalidArgTypeHelper(input) { + return ` Received: ${inspect(input)}`; +} + +function getCallSite(fn) { + const originalStackFormatter = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => `${stack[0].getFileName()}:${stack[0].getLineNumber()}`; + const error = new Error(); + Error.captureStackTrace(error, fn); + error.stack; // With the V8 Error API, the stack is not formatted until it is accessed + Error.prepareStackTrace = originalStackFormatter; + return error.stack; +} + +export { + hasIntl, + hasCrypto, + hasOpenSSL3, + hasOpenSSL31, + hasQuic, + // ... + isWindows, + isSunOS, + isFreeBSD, + isOpenBSD, + isLinux, + isOSX, + isAsan, + isPi, + // ... + isDumbTerminal, + // ... + mustCall, + mustNotCall, + printSkipMessage, + skip, + expectsError, + expectWarning, + // ... + inspect, + invalidArgTypeHelper, +}; diff --git a/test/node.js/common/preload.js b/test/node.js/common/preload.js new file mode 100644 index 0000000000..8f3b714f19 --- /dev/null +++ b/test/node.js/common/preload.js @@ -0,0 +1,10 @@ +const { mock } = require("bun:test"); +const assert = require("./assert"); + +mock.module("assert", () => { + return assert; +}); + +mock.module("internal/test/binding", () => { + return {}; +}); diff --git a/test/node.js/metadata.mjs b/test/node.js/metadata.mjs new file mode 100644 index 0000000000..16a4fcf7de --- /dev/null +++ b/test/node.js/metadata.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; + +const isBun = !!process.isBun; +const os = process.platform === "win32" ? "windows" : process.platform; +const arch = process.arch === "arm64" ? "aarch64" : process.arch; +const version = isBun ? Bun.version : process.versions.node; +const revision = isBun ? Bun.revision : undefined; +const baseline = (() => { + if (!isBun || arch !== "x64") { + return undefined; + } + const { stdout } = spawnSync(process.execPath, ["--print", "Bun.unsafe.segfault()"], { + encoding: "utf8", + timeout: 5_000, + }); + if (stdout.includes("baseline")) { + return true; + } + return undefined; +})(); +const name = baseline ? `bun-${os}-${arch}-baseline` : `${isBun ? "bun" : "node"}-${os}-${arch}`; + +console.log( + JSON.stringify({ + name, + os, + arch, + version, + revision, + baseline, + }), +); diff --git a/test/node.js/package.json b/test/node.js/package.json new file mode 100644 index 0000000000..5136aaa87d --- /dev/null +++ b/test/node.js/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "scripts": { + "test": "node runner.mjs --exec-path $(which bun-debug || which bun)" + } +} diff --git a/test/node.js/runner.mjs b/test/node.js/runner.mjs new file mode 100644 index 0000000000..3254de137b --- /dev/null +++ b/test/node.js/runner.mjs @@ -0,0 +1,428 @@ +import { parseArgs } from "node:util"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import readline from "node:readline/promises"; + +const testPath = new URL("./", import.meta.url); +const nodePath = new URL("upstream/", testPath); +const nodeTestPath = new URL("test/", nodePath); +const metadataScriptPath = new URL("metadata.mjs", testPath); +const testJsonPath = new URL("tests.json", testPath); +const summariesPath = new URL("summary/", testPath); +const summaryMdPath = new URL("summary.md", testPath); +const cwd = new URL("../../", testPath); + +async function main() { + const { values, positionals } = parseArgs({ + options: { + help: { + type: "boolean", + short: "h", + }, + baseline: { + type: "boolean", + }, + interactive: { + type: "boolean", + short: "i", + }, + "exec-path": { + type: "string", + }, + pull: { + type: "boolean", + }, + summary: { + type: "boolean", + }, + }, + }); + + if (values.help) { + printHelp(); + return; + } + + if (values.summary) { + printSummary(); + return; + } + + if (values.pull) { + pullTests(true); + return; + } + + pullTests(); + const summary = await runTests(values); + const regressedTests = appendSummary(summary); + printSummary(summary, regressedTests); + + process.exit(regressedTests?.length ? 1 : 0); +} + +function printHelp() { + console.log(`Usage: ${process.argv0} ${basename(import.meta.filename)} [options]`); + console.log(); + console.log("Options:"); + console.log(" -h, --help Show this help message"); + console.log(" -e, --exec-path Path to the bun executable to run"); + console.log(" -i, --interactive Pause and wait for input after a failing test"); + console.log(" -s, --summary Print a summary of the tests (does not run tests)"); +} + +function pullTests(force) { + if (!force && existsSync(nodeTestPath)) { + return; + } + + console.log("Pulling tests..."); + const { status, error, stderr } = spawnSync( + "git", + ["submodule", "update", "--init", "--recursive", "--progress", "--depth=1", "--checkout", "upstream"], + { + cwd: testPath, + stdio: "inherit", + }, + ); + + if (error || status !== 0) { + throw error || new Error(stderr); + } + + for (const { filename, status } of getTests(nodeTestPath)) { + if (status === "TODO") { + continue; + } + + const src = new URL(filename, nodeTestPath); + const dst = new URL(filename, testPath); + + try { + writeFileSync(dst, readFileSync(src)); + } catch (error) { + if (error.code === "ENOENT") { + mkdirSync(new URL(".", dst), { recursive: true }); + writeFileSync(dst, readFileSync(src)); + } else { + throw error; + } + } + } +} + +async function runTests(options) { + const { interactive } = options; + const bunPath = process.isBun ? process.execPath : "bun"; + const execPath = options["exec-path"] || bunPath; + + let reader; + if (interactive) { + reader = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + } + + const results = []; + const tests = getTests(testPath); + for (const { label, filename, status: filter } of tests) { + if (filter !== "OK") { + results.push({ label, filename, status: filter }); + continue; + } + + const { pathname: filePath } = new URL(filename, testPath); + const tmp = mkdtempSync(join(tmpdir(), "bun-")); + const timestamp = Date.now(); + const { + status: exitCode, + signal: signalCode, + error: spawnError, + } = spawnSync(execPath, ["test", filePath], { + cwd: testPath, + stdio: "inherit", + env: { + PATH: process.env.PATH, + HOME: tmp, + TMPDIR: tmp, + TZ: "Etc/UTC", + FORCE_COLOR: "1", + BUN_DEBUG_QUIET_LOGS: "1", + BUN_GARBAGE_COLLECTOR_LEVEL: "1", + BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0", + GITHUB_ACTIONS: "false", // disable for now + }, + timeout: 30_000, + }); + + const duration = Math.ceil(Date.now() - timestamp); + const status = exitCode === 0 ? "PASS" : "FAIL"; + let error; + if (signalCode) { + error = signalCode; + } else if (spawnError) { + const { message } = spawnError; + if (message.includes("timed out") || message.includes("timeout")) { + error = "TIMEOUT"; + } else { + error = message; + } + } else if (exitCode !== 0) { + error = `code ${exitCode}`; + } + results.push({ label, filename, status, error, timestamp, duration }); + + if (reader && status === "FAIL") { + const answer = await reader.question("Continue? [Y/n] "); + if (answer.toUpperCase() !== "Y") { + break; + } + } + } + + reader?.close(); + return { + v: 1, + metadata: getMetadata(execPath), + tests: results, + }; +} + +function getTests(filePath) { + const tests = []; + const testData = JSON.parse(readFileSync(testJsonPath, "utf8")); + + for (const filename of readdirSync(filePath, { recursive: true })) { + if (!isJavaScript(filename) || !isTest(filename)) { + continue; + } + + let match; + for (const { label, pattern, skip: skipList = [], todo: todoList = [] } of testData) { + if (!filename.startsWith(pattern)) { + continue; + } + + if (skipList.some(({ file }) => filename.endsWith(file))) { + tests.push({ label, filename, status: "SKIP" }); + } else if (todoList.some(({ file }) => filename.endsWith(file))) { + tests.push({ label, filename, status: "TODO" }); + } else { + tests.push({ label, filename, status: "OK" }); + } + + match = true; + break; + } + + if (!match) { + tests.push({ filename, status: "TODO" }); + } + } + + return tests; +} + +function appendSummary(summary) { + const { metadata, tests, ...extra } = summary; + const { name } = metadata; + + const summaryPath = new URL(`${name}.json`, summariesPath); + const summaryData = { + metadata, + tests: tests.map(({ label, filename, status, error }) => ({ label, filename, status, error })), + ...extra, + }; + + const regressedTests = []; + if (existsSync(summaryPath)) { + const previousData = JSON.parse(readFileSync(summaryPath, "utf8")); + const { v } = previousData; + if (v === 1) { + const { tests: previousTests } = previousData; + for (const { label, filename, status, error } of tests) { + if (status !== "FAIL") { + continue; + } + const previousTest = previousTests.find(({ filename: file }) => file === filename); + if (previousTest) { + const { status: previousStatus } = previousTest; + if (previousStatus !== "FAIL") { + regressedTests.push({ label, filename, error }); + } + } + } + } + } + + if (regressedTests.length) { + return regressedTests; + } + + const summaryText = JSON.stringify(summaryData, null, 2); + try { + writeFileSync(summaryPath, summaryText); + } catch (error) { + if (error.code === "ENOENT") { + mkdirSync(summariesPath, { recursive: true }); + writeFileSync(summaryPath, summaryText); + } else { + throw error; + } + } +} + +function printSummary(summaryData, regressedTests) { + let metadataInfo = {}; + let testInfo = {}; + let labelInfo = {}; + let errorInfo = {}; + + const summaryList = []; + if (summaryData) { + summaryList.push(summaryData); + } else { + for (const filename of readdirSync(summariesPath)) { + if (!filename.endsWith(".json")) { + continue; + } + + const summaryPath = new URL(filename, summariesPath); + const summaryData = JSON.parse(readFileSync(summaryPath, "utf8")); + summaryList.push(summaryData); + } + } + + for (const summaryData of summaryList) { + const { v, metadata, tests } = summaryData; + if (v !== 1) { + continue; + } + + const { name, version, revision } = metadata; + if (revision) { + metadataInfo[name] = + `${version}-[\`${revision.slice(0, 7)}\`](https://github.com/oven-sh/bun/commit/${revision})`; + } else { + metadataInfo[name] = `${version}`; + } + + for (const test of tests) { + const { label, filename, status, error } = test; + if (label) { + labelInfo[label] ||= { pass: 0, fail: 0, skip: 0, todo: 0, total: 0 }; + labelInfo[label][status.toLowerCase()] += 1; + labelInfo[label].total += 1; + } + testInfo[name] ||= { pass: 0, fail: 0, skip: 0, todo: 0, total: 0 }; + testInfo[name][status.toLowerCase()] += 1; + testInfo[name].total += 1; + if (status === "FAIL") { + errorInfo[filename] ||= {}; + errorInfo[filename][name] = error; + } + } + } + + let summaryMd = `## Node.js tests +`; + + if (!summaryData) { + summaryMd += ` +| Platform | Conformance | Passed | Failed | Skipped | Total | +| - | - | - | - | - | - | +`; + + for (const [name, { pass, fail, skip, total }] of Object.entries(testInfo)) { + testInfo[name].coverage = (((pass + fail + skip) / total) * 100).toFixed(2); + testInfo[name].conformance = ((pass / total) * 100).toFixed(2); + } + + for (const [name, { conformance, pass, fail, skip, total }] of Object.entries(testInfo)) { + summaryMd += `| \`${name}\` ${metadataInfo[name]} | ${conformance} % | ${pass} | ${fail} | ${skip} | ${total} |\n`; + } + } + + summaryMd += ` +| API | Conformance | Passed | Failed | Skipped | Total | +| - | - | - | - | - | - | +`; + + for (const [label, { pass, fail, skip, total }] of Object.entries(labelInfo)) { + labelInfo[label].coverage = (((pass + fail + skip) / total) * 100).toFixed(2); + labelInfo[label].conformance = ((pass / total) * 100).toFixed(2); + } + + for (const [label, { conformance, pass, fail, skip, total }] of Object.entries(labelInfo)) { + summaryMd += `| \`${label}\` | ${conformance} % | ${pass} | ${fail} | ${skip} | ${total} |\n`; + } + + if (!summaryData) { + writeFileSync(summaryMdPath, summaryMd); + } + + const githubSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (githubSummaryPath) { + appendFileSync(githubSummaryPath, summaryMd); + } + + console.log("=".repeat(process.stdout.columns)); + console.log("Summary by platform:"); + console.table(testInfo); + console.log("Summary by label:"); + console.table(labelInfo); + if (regressedTests?.length) { + const isTty = process.stdout.isTTY; + if (isTty) { + process.stdout.write("\x1b[31m"); + } + const { name } = summaryData.metadata; + console.log(`Regressions found in ${regressedTests.length} tests for ${name}:`); + console.table(regressedTests); + if (isTty) { + process.stdout.write("\x1b[0m"); + } + } +} + +function isJavaScript(filename) { + return /\.(m|c)?js$/.test(filename); +} + +function isTest(filename) { + return /^test-/.test(basename(filename)); +} + +function getMetadata(execPath) { + const { pathname: filePath } = metadataScriptPath; + const { status: exitCode, stdout } = spawnSync(execPath, [filePath], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + env: { + PATH: process.env.PATH, + BUN_DEBUG_QUIET_LOGS: "1", + }, + timeout: 5_000, + }); + + if (exitCode === 0) { + try { + return JSON.parse(stdout); + } catch { + // Ignore + } + } + + return { + os: process.platform, + arch: process.arch, + }; +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/test/node.js/tests.json b/test/node.js/tests.json new file mode 100644 index 0000000000..18c8e225cd --- /dev/null +++ b/test/node.js/tests.json @@ -0,0 +1,22 @@ +[ + { + "label": "node:buffer", + "pattern": "parallel/test-buffer", + "skip": [ + { + "file": "backing-arraybuffer.js", + "reason": "Internal binding checks if the buffer is on the heap" + } + ], + "todo": [ + { + "file": "constants.js", + "reason": "Hangs" + }, + { + "file": "tostring-rangeerror.js", + "reason": "Hangs" + } + ] + } +] diff --git a/test/node.js/tsconfig.json b/test/node.js/tsconfig.json new file mode 100644 index 0000000000..b2ad667c9f --- /dev/null +++ b/test/node.js/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [".", "../../packages/bun-types/index.d.ts"], + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "experimentalDecorators": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "resolveJsonModule": true, + "noImplicitThis": false, + "paths": { + "assert": ["./common/assert.js"] + } + }, + "exclude": [] +} diff --git a/test/node.js/upstream b/test/node.js/upstream new file mode 160000 index 0000000000..311504125f --- /dev/null +++ b/test/node.js/upstream @@ -0,0 +1 @@ +Subproject commit 311504125f749328e2f31d68b6d2460b2832040f