From dcb160f4dd1cb85e724109d55a374a8a26c4af68 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:14:06 -0400 Subject: [PATCH] run the unit tests in Bun instead of mocking the runtime --- .github/workflows/ci.yml | 5 + packages/adapter-bun/package.json | 5 +- packages/adapter-bun/test/adapter.spec.ts | 201 +++++++++++----------- packages/adapter-bun/test/env.spec.ts | 13 +- packages/adapter-bun/test/handler.spec.ts | 42 ++--- packages/adapter-bun/test/mocks.ts | 28 +++ packages/adapter-bun/test/routes.spec.ts | 28 +-- packages/adapter-bun/test/start.spec.ts | 100 ++++++----- packages/adapter-bun/tsconfig.json | 1 - packages/adapter-bun/vitest.config.js | 7 - pnpm-lock.yaml | 3 - 11 files changed, 232 insertions(+), 201 deletions(-) create mode 100644 packages/adapter-bun/test/mocks.ts delete mode 100644 packages/adapter-bun/vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8866e60d52a5..cd1d37f16747 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,11 @@ jobs: - uses: ./.github/actions/node-setup with: node-version: ${{ matrix.node-version }} + # the adapter-bun unit tests run under `bun test` + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: matrix.script == 'test:others' + with: + bun-version: 1.4.0 - run: pnpm run sync-all - run: pnpm ${{ matrix.script }} e2e: diff --git a/packages/adapter-bun/package.json b/packages/adapter-bun/package.json index 039deda58c7d..c6918efe2349 100644 --- a/packages/adapter-bun/package.json +++ b/packages/adapter-bun/package.json @@ -33,7 +33,7 @@ "ambient.d.ts" ], "scripts": { - "test": "vitest run", + "test": "bun test .spec", "check": "tsc", "lint": "prettier --check .", "format": "pnpm lint --write" @@ -42,8 +42,7 @@ "@playwright/test": "catalog:", "@sveltejs/kit": "workspace:^", "@types/node": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "typescript": "catalog:" }, "dependencies": { "bun-types": "^1.3.14" diff --git a/packages/adapter-bun/test/adapter.spec.ts b/packages/adapter-bun/test/adapter.spec.ts index 5a3234cc4afb..a5963fa9f94f 100644 --- a/packages/adapter-bun/test/adapter.spec.ts +++ b/packages/adapter-bun/test/adapter.spec.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test, type Mock } from 'bun:test'; import adapter from '../index.js'; const package_dir = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); @@ -10,53 +10,48 @@ const routes_file = `${package_dir}/src/routes.js`; const options_file = `${package_dir}/src/options.js`; const start_file = `${package_dir}/src/start.js`; -vi.mock('node:fs', async (import_original) => { - const actual = await import_original(); - const mocked = { - ...actual, - readdirSync: vi.fn(), - existsSync: vi.fn(), - rmSync: vi.fn(), - readFileSync: vi.fn() - }; - return { ...mocked, default: mocked }; -}); +const entrypoint = '// generated server entrypoint'; + +let bun_build: Mock<(options: any) => Promise>; +let read_dir: Mock; +let exists: Mock; +let read_file: Mock; -const bun = vi.hoisted(() => ({ - entrypoint: '// generated server entrypoint', - build: vi.fn(async (_options: any): Promise => ({ success: true, logs: [], outputs: [] })), - file: vi.fn((_path: string) => ({ - text: async () => '// generated server entrypoint', +// the real Bun.build would bundle and the real hashers would read assets off +// disk, so the build APIs stay test doubles even under Bun +beforeEach(() => { + bun_build = spyOn(Bun, 'build').mockImplementation((async (_options: any): Promise => ({ + success: true, + logs: [], + outputs: [] + })) as any) as any; + spyOn(Bun, 'file').mockImplementation(((_path: string) => ({ + text: async () => entrypoint, stream: () => new Blob([]).stream(), lastModified: 0 - })), - CryptoHasher: class { - update() {} - digest() { - return 'abc'; - } - }, - hash: (input: string) => { + })) as never); + spyOn(Bun, 'CryptoHasher').mockImplementation(function () { + return { + update() {}, + digest() { + return 'abc'; + } + }; + } as never); + spyOn(Bun, 'hash').mockImplementation(((input: string) => { let hash = 0n; for (const char of input) hash = hash * 31n + BigInt(char.charCodeAt(0)); return hash; - } -})); + }) as never); -beforeEach(() => { - vi.stubGlobal('Bun', { - build: bun.build, - file: bun.file, - CryptoHasher: bun.CryptoHasher, - hash: bun.hash - }); - vi.mocked(fs.readdirSync).mockReturnValue([]); - vi.mocked(fs.existsSync).mockReturnValue(true); + read_dir = spyOn(fs, 'readdirSync').mockReturnValue([]) as any; + exists = spyOn(fs, 'existsSync').mockReturnValue(true); + spyOn(fs, 'rmSync').mockImplementation(() => {}); + read_file = spyOn(fs, 'readFileSync').mockImplementation((() => undefined) as any) as any; }); afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); + mock.restore(); }); describe('adapter contract', () => { @@ -68,10 +63,21 @@ describe('adapter contract', () => { expect(instance.supports?.instrumentation?.()).toBe(true); }); - test('requires the SvelteKit build to run in Bun', async () => { - vi.stubGlobal('Bun', undefined); - - await expect(adapter().adapt(create_builder())).rejects.toThrow( + test('requires the SvelteKit build to run in Bun', () => { + // the Bun global cannot be unset inside Bun itself, so run the guard under Node + const result = Bun.spawnSync([ + 'node', + '--input-type=module', + '-e', + `import adapter from ${JSON.stringify(`${package_dir}/index.js`)};\n` + + 'await adapter().adapt({}).then(\n' + + '\t() => process.exit(0),\n' + + '\t(error) => { console.error(error.message); process.exit(1); }\n' + + ');' + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain( 'adapter-bun requires running the SvelteKit build with Bun' ); }); @@ -85,7 +91,7 @@ describe('Bun build configuration', () => { expect(fs.rmSync).toHaveBeenCalledWith('build', { recursive: true, force: true }); expect(builder.log.minor).toHaveBeenCalledWith('Building server'); - const options = bun.build.mock.calls[0][0]; + const options = bun_build.mock.calls[0][0]; expect(options).toMatchObject({ entrypoints: [index_file], outdir: 'build', @@ -113,7 +119,7 @@ describe('Bun build configuration', () => { serverOptions: { hostname: '127.0.0.1', port: 4000, development: true } }).adapt(builder); - const files = bun.build.mock.calls[0][0].files; + const files = bun_build.mock.calls[0][0].files; expect(files[manifest_file]).toBe( 'export const manifest = {"appDir":"_app"};\n' + 'export const base = "/docs";\n' + @@ -129,8 +135,8 @@ describe('Bun build configuration', () => { test('resolves generated runtime modules through the Bun plugin', async () => { await adapter().adapt(create_builder()); - const on_resolve = vi.fn(); - bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: vi.fn() }); + const on_resolve = mock((_options: any, _callback: any) => {}); + bun_build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: mock() }); expect(on_resolve).toHaveBeenCalledWith( { filter: /^(SERVER|MANIFEST|ROUTES|SERVER_OPTIONS)$/ }, @@ -150,9 +156,9 @@ describe('Bun build configuration', () => { mock_chunks(chunks_dir, { 'events.js': "import './other.js';\nexport {};\n" }); await adapter().adapt(create_builder()); - const on_resolve = vi.fn(); - const on_load = vi.fn(); - bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: on_load }); + const on_resolve = mock((_options: any, _callback: any) => {}); + const on_load = mock((_options: any, _callback: any) => {}); + bun_build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: on_load }); const resolve_chunk = on_resolve.mock.calls[1][1]; const load_chunk = on_load.mock.calls.find( @@ -190,10 +196,10 @@ describe('Bun build configuration', () => { }); await adapter().adapt(create_builder()); - const on_resolve = vi.fn(); - bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: vi.fn() }); + const on_resolve = mock((_options: any, _callback: any) => {}); + bun_build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: mock() }); - expect(on_resolve).toHaveBeenCalledOnce(); + expect(on_resolve).toHaveBeenCalledTimes(1); }); test('keeps virtual entrypoints resolvable when a chunk shares their name', async () => { @@ -201,8 +207,8 @@ describe('Bun build configuration', () => { mock_chunks(chunks_dir, { 'start.js': "import './other.js';\nexport {};\n" }); await adapter().adapt(create_builder({ instrumentation: true })); - const on_resolve = vi.fn(); - bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: vi.fn() }); + const on_resolve = mock((_options: any, _callback: any) => {}); + bun_build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: mock() }); // resolving nothing here would send Bun to the filesystem, where start.js does not exist expect(on_resolve.mock.calls[1][1]({ path: start_file, resolveDir: package_dir })).toEqual({ @@ -222,7 +228,7 @@ describe('Bun build configuration', () => { } }).adapt(create_builder()); - expect(bun.build.mock.calls[0][0]).toMatchObject({ + expect(bun_build.mock.calls[0][0]).toMatchObject({ outdir: 'dist', target: 'bun', format: 'esm', @@ -248,33 +254,33 @@ describe('Bun build configuration', () => { ] as const)('normalizes compile option %j', async (compile, expected) => { await adapter({ buildOptions: { compile } }).adapt(create_builder()); - expect(bun.build.mock.calls[0][0].compile).toEqual(expected); + expect(bun_build.mock.calls[0][0].compile).toEqual(expected); }); test('loads instrumentation before the generated server entrypoint', async () => { const builder = create_builder({ instrumentation: true }); await adapter().adapt(builder); - const files = bun.build.mock.calls[0][0].files; + const files = bun_build.mock.calls[0][0].files; expect(files[index_file]).toBe( `import ".svelte-kit/output/server/instrumentation.server.js";\nawait import(${JSON.stringify(start_file)});` ); - expect(files[start_file]).toBe(bun.entrypoint); + expect(files[start_file]).toBe(entrypoint); expect(builder.instrument).not.toHaveBeenCalled(); // start.js must be its own entrypoint so asset paths resolve from the output root - expect(bun.build.mock.calls[0][0].entrypoints).toEqual([index_file, start_file]); + expect(bun_build.mock.calls[0][0].entrypoints).toEqual([index_file, start_file]); }); test('keeps a single entrypoint when compiling with instrumentation', async () => { const builder = create_builder({ instrumentation: true }); await adapter({ buildOptions: { compile: true } }).adapt(builder); - expect(bun.build.mock.calls[0][0].entrypoints).toEqual([index_file]); + expect(bun_build.mock.calls[0][0].entrypoints).toEqual([index_file]); }); test('reports every Bun diagnostic before failing the build', async () => { - bun.build.mockResolvedValueOnce({ + bun_build.mockResolvedValueOnce({ success: false, logs: [ { level: 'error', message: 'broken' }, @@ -308,7 +314,7 @@ describe('generated routes', () => { await adapter().adapt(builder); expect(builder.findServerAssets).toHaveBeenCalledWith([dynamic]); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).toContain('...client_asset("data.json", undefined, {"hash":"abc","mtime":0})'); expect(source).toContain( '...client_asset("_app/immutable/read.txt", undefined, {"hash":"abc","mtime":0})' @@ -332,7 +338,7 @@ describe('generated routes', () => { }) ); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).toContain( '...prerendered_page("/base/page/", "page/index.html", {"hash":"abc","mtime":0})' ); @@ -354,7 +360,7 @@ describe('generated routes', () => { }) ); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).toContain("with { type: 'file' }"); expect(source).toContain('...client_asset("data.json", asset_0, {"hash":"abc","mtime":0})'); expect(source).toContain( @@ -379,7 +385,7 @@ describe('generated routes', () => { await expect(adapter({ buildOptions: { compile } }).adapt(builder)).rejects.toThrow( 'Bun treats literal `*` characters in route paths as wildcards' ); - expect(bun.build).not.toHaveBeenCalled(); + expect(bun_build).not.toHaveBeenCalled(); }); test('precompresses assets and marks the variants in the generated routes', async () => { @@ -389,7 +395,7 @@ describe('generated routes', () => { expect(builder.compress).toHaveBeenCalledWith('build/client'); expect(builder.compress).toHaveBeenCalledWith('build/prerendered'); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).toContain( '...client_asset("app.js", undefined, {"hash":"abc","mtime":0,"br":true,"gz":true})' ); @@ -421,7 +427,7 @@ describe('generated routes', () => { await adapter().adapt(builder); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).not.toContain('.env'); expect(source).toContain( '...client_asset(".well-known/security.txt", undefined, {"hash":"abc","mtime":0})' @@ -430,13 +436,13 @@ describe('generated routes', () => { }); test('embedded builds tolerate absent output directories but propagate readdir errors', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + exists.mockReturnValue(false); await adapter({ buildOptions: { compile: true } }).adapt(create_builder()); - expect(bun.build).toHaveBeenCalledOnce(); - expect(fs.readdirSync).not.toHaveBeenCalled(); + expect(bun_build).toHaveBeenCalledTimes(1); + expect(read_dir).not.toHaveBeenCalled(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readdirSync).mockImplementation(() => { + exists.mockReturnValue(true); + read_dir.mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }); await expect( @@ -449,7 +455,7 @@ describe('generated routes', () => { await adapter({ buildOptions: { compile: true } }).adapt(create_builder()); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).not.toContain('.secret'); expect(source).toContain('...client_asset("public.txt", asset_0, {"hash":"abc","mtime":0})'); }); @@ -458,7 +464,7 @@ describe('generated routes', () => { const builder = create_builder({ client_files: [':tag.txt'] }); await expect(adapter().adapt(builder)).rejects.toThrow('starts with `:`'); - expect(bun.build).not.toHaveBeenCalled(); + expect(bun_build).not.toHaveBeenCalled(); }); test('embedded assets with the same relative path keep distinct imports', async () => { @@ -468,7 +474,7 @@ describe('generated routes', () => { create_builder({ prerendered_pages: [['/page/', { file: 'page.html' }]] }) ); - const source = bun.build.mock.calls[0][0].files[routes_file]; + const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).toContain('...client_asset("page.html", asset_0, {"hash":"abc","mtime":0})'); expect(source).toContain('...prerendered_page("/page/", asset_1, {"hash":"abc","mtime":0})'); }); @@ -481,7 +487,7 @@ describe('generated routes', () => { await expect(adapter().adapt(builder)).rejects.toThrow( 'Bun treats literal `*` characters in route paths as wildcards' ); - expect(bun.build).not.toHaveBeenCalled(); + expect(bun_build).not.toHaveBeenCalled(); }); test('fails when a prerendered page is absent from compiled build output', async () => { @@ -502,17 +508,16 @@ describe('generated routes', () => { }); function mock_chunks(chunks_dir: string, sources: Record) { - vi.mocked(fs.readdirSync).mockImplementation((directory) => + read_dir.mockImplementation(((directory: unknown) => String(directory) === chunks_dir - ? (Object.keys(sources).map((name) => ({ + ? Object.keys(sources).map((name) => ({ name, parentPath: chunks_dir, isFile: () => true - })) as unknown as ReturnType) - : [] - ); - vi.mocked(fs.readFileSync).mockImplementation( - (file) => sources[path.basename(String(file))] as unknown as ReturnType + })) + : []) as unknown as typeof fs.readdirSync); + read_file.mockImplementation( + ((file: unknown) => sources[path.basename(String(file))]) as unknown as typeof fs.readFileSync ); } @@ -527,8 +532,8 @@ function mock_files({ dependencies?: string[]; data?: string[]; }) { - vi.mocked(fs.readdirSync).mockImplementation((path) => { - const directory = String(path); + read_dir.mockImplementation(((dir: unknown) => { + const directory = String(dir); const files = directory.endsWith('/client') ? client : directory.endsWith('/prerendered/pages') @@ -539,14 +544,14 @@ function mock_files({ return files.map((file) => { const segments = file.split('/'); - const name = /** @type {string} */ segments.pop(); + const name = segments.pop(); return { name, parentPath: [directory, ...segments].join('/'), isFile: () => true }; - }) as unknown as ReturnType; - }); + }); + }) as unknown as typeof fs.readdirSync); } function create_builder({ @@ -578,18 +583,18 @@ function create_builder({ redirects: new Map(prerendered_redirects) }, log: { - minor: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - info: vi.fn() + minor: mock((_message: string) => {}), + error: mock((_message: string) => {}), + warn: mock((_message: string) => {}), + info: mock((_message: string) => {}) }, getServerDirectory: () => '.svelte-kit/output/server', - writeClient: vi.fn(() => client_files), - writePrerendered: vi.fn(() => prerendered_files), - compress: vi.fn(async () => {}), - findServerAssets: vi.fn(() => server_assets), - generateManifest: vi.fn(() => '{"appDir":"_app"}'), + writeClient: mock(() => client_files), + writePrerendered: mock(() => prerendered_files), + compress: mock(async (_directory: string) => {}), + findServerAssets: mock(() => server_assets), + generateManifest: mock(() => '{"appDir":"_app"}'), hasServerInstrumentationFile: () => instrumentation, - instrument: vi.fn() + instrument: mock(() => {}) } as any; } diff --git a/packages/adapter-bun/test/env.spec.ts b/packages/adapter-bun/test/env.spec.ts index 413e46070ca4..ab9e15be32c9 100644 --- a/packages/adapter-bun/test/env.spec.ts +++ b/packages/adapter-bun/test/env.spec.ts @@ -1,13 +1,13 @@ import process from 'node:process'; -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { mock_manifest } from './mocks.js'; const changed = new Set(); +let instance = 0; afterEach(() => { for (const name of changed) delete process.env[name]; changed.clear(); - vi.resetModules(); - vi.doUnmock('MANIFEST'); }); describe('env', () => { @@ -148,9 +148,10 @@ describe('bytes_env', () => { }); async function load_env(prefix = '') { - vi.resetModules(); - vi.doMock('MANIFEST', () => ({ env_prefix: prefix })); - return import('../src/env.js'); + mock_manifest({ env_prefix: prefix }); + // a fresh query string re-runs the module-level prefix check + const specifier = `../src/env.js?instance=${++instance}`; + return (await import(specifier)) as typeof import('../src/env.js'); } function set_env(name: string, value: string) { diff --git a/packages/adapter-bun/test/handler.spec.ts b/packages/adapter-bun/test/handler.spec.ts index 26603cce6563..bc1bd272827f 100644 --- a/packages/adapter-bun/test/handler.spec.ts +++ b/packages/adapter-bun/test/handler.spec.ts @@ -1,17 +1,14 @@ import process from 'node:process'; -import { afterEach, expect, test, vi } from 'vitest'; +import { afterEach, expect, mock, spyOn, test } from 'bun:test'; +import { mock_manifest, mock_routes } from './mocks.js'; const environment = new Set(); +let instance = 0; afterEach(() => { for (const name of environment) delete process.env[name]; environment.clear(); - vi.resetModules(); - vi.doUnmock('SERVER'); - vi.doUnmock('MANIFEST'); - vi.doUnmock('ROUTES'); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + mock.restore(); }); test('initializes SvelteKit with Bun environment variables and server-readable assets', async () => { @@ -19,7 +16,7 @@ test('initializes SvelteKit with Bun environment variables and server-readable a expect(loaded.construct).toHaveBeenCalledWith(loaded.manifest); expect(loaded.init).toHaveBeenCalledWith({ - env: loaded.bun_env, + env: Bun.env, read: expect.any(Function) }); const { read } = loaded.init.mock.calls[0][0]; @@ -75,7 +72,7 @@ test.each([ ])('returns 400 for an invalid origin from %s', async (name, value, headers, message) => { set_env(name, value); const loaded = await load_handler({ envPrefix: 'APP_' }); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = spyOn(console, 'error').mockImplementation(() => {}); const response = await loaded.handler( new Request('http://internal/path', { headers }), @@ -90,7 +87,7 @@ test.each([ test('rejects a present but empty Host header', async () => { const loaded = await load_handler(); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = spyOn(console, 'error').mockImplementation(() => {}); const request = new Request('http://internal/path'); request.headers.set('host', ''); @@ -199,14 +196,12 @@ async function load_handler({ envPrefix = '', response = new Response('ok') }: { origin?: string; envPrefix?: string; response?: Response } = {}) { - vi.resetModules(); const manifest = { appDir: '_app' }; - const bun_env = { PUBLIC_VALUE: 'available' }; const stream = new ReadableStream(); - const asset = { stream: vi.fn(() => stream) }; - const construct = vi.fn(); - const init = vi.fn(async (_options: any) => {}); - const respond = vi.fn(async (_request: Request, _options: any) => response); + const asset = { stream: mock(() => stream) }; + const construct = mock((_value: unknown) => {}); + const init = mock(async (_options: any) => {}); + const respond = mock(async (_request: Request, _options: any) => response); class Server { constructor(value: unknown) { @@ -216,24 +211,23 @@ async function load_handler({ respond = respond; } - vi.doMock('SERVER', () => ({ Server })); - vi.doMock('MANIFEST', () => ({ manifest, origin, env_prefix: envPrefix })); - vi.doMock('ROUTES', () => ({ server_assets: new Map([['asset.txt', asset]]) })); - vi.stubGlobal('Bun', { env: bun_env }); + mock.module('SERVER', () => ({ Server })); + mock_manifest({ manifest, origin, env_prefix: envPrefix }); + mock_routes({ server_assets: new Map([['asset.txt', asset]]) }); - const request_ip = vi.fn((_request: Request): any => ({ + const request_ip = mock((_request: Request): any => ({ address: '127.0.0.1', port: 5000, family: 'IPv4' })); - const timeout = vi.fn((_request: Request, _seconds: number) => {}); + const timeout = mock((_request: Request, _seconds: number) => {}); const bun_server = { requestIP: request_ip, timeout } as any; - const { handler } = await import('../src/handler.js'); + const specifier = `../src/handler.js?instance=${++instance}`; + const { handler } = (await import(specifier)) as typeof import('../src/handler.js'); return { handler, manifest, - bun_env, stream, construct, init, diff --git a/packages/adapter-bun/test/mocks.ts b/packages/adapter-bun/test/mocks.ts new file mode 100644 index 000000000000..150e00cbd4d4 --- /dev/null +++ b/packages/adapter-bun/test/mocks.ts @@ -0,0 +1,28 @@ +import { mock } from 'bun:test'; + +// bun:test fixes a mocked module's export names on first registration and only +// updates their values afterwards, so every mock must supply the union of the +// exports that src modules pull from these build-generated specifiers + +export function mock_manifest({ + manifest, + base, + embed, + origin, + env_prefix = '' +}: { + manifest?: unknown; + base?: string; + embed?: boolean; + origin?: string; + env_prefix?: string; +} = {}) { + mock.module('MANIFEST', () => ({ manifest, base, embed, origin, env_prefix })); +} + +export function mock_routes({ + routes, + server_assets +}: { routes?: unknown; server_assets?: unknown } = {}) { + mock.module('ROUTES', () => ({ routes, server_assets })); +} diff --git a/packages/adapter-bun/test/routes.spec.ts b/packages/adapter-bun/test/routes.spec.ts index 55d1d7240785..1c736217457c 100644 --- a/packages/adapter-bun/test/routes.spec.ts +++ b/packages/adapter-bun/test/routes.spec.ts @@ -1,15 +1,15 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterEach, expect, test, vi } from 'vitest'; +import { afterEach, expect, mock, spyOn, test } from 'bun:test'; +import { mock_manifest } from './mocks.js'; const meta = { hash: 'abc', mtime: 0 }; -// the module resolves assets from its own directory, which is src/ under vitest +// the module resolves assets from its own directory, which is src/ under bun test const dir = path.dirname(fileURLToPath(new URL('../src/routes-util.js', import.meta.url))); +let instance = 0; afterEach(() => { - vi.resetModules(); - vi.doUnmock('MANIFEST'); - vi.unstubAllGlobals(); + mock.restore(); }); test('client assets use the configured base and URL-encode path segments', async () => { @@ -218,7 +218,7 @@ test('embedded routes use the imported asset instead of a filesystem path', asyn expect(file).toHaveBeenNthCalledWith(1, '/embedded/client.txt'); expect(file).toHaveBeenNthCalledWith(2, '/embedded/prerendered.txt'); expect(file).toHaveBeenNthCalledWith(3, '/embedded/server.txt'); - expect(server_file).toMatchObject({ path: '/embedded/server.txt' }); + expect(server_file.name).toBe('/embedded/server.txt'); }); test('server assets resolve from the client output in regular builds', async () => { @@ -227,12 +227,11 @@ test('server assets resolve from the client output in regular builds', async () const result = routes.server_asset('nested/read.txt'); expect(file).toHaveBeenCalledWith(`${dir}/client/nested/read.txt`); - expect(result).toMatchObject({ path: `${dir}/client/nested/read.txt` }); + expect(result.name).toBe(`${dir}/client/nested/read.txt`); }); test('prerendered assets use the base path and preserve their content type', async () => { - const { routes, file } = await load_routes({ base: '/base' }); - file.mockImplementationOnce((path) => ({ path, type: 'image/x-icon' })); + const { routes } = await load_routes({ base: '/base' }); const [[path, handler]] = routes.prerendered_asset('icon.ico', undefined, meta); @@ -285,10 +284,11 @@ test('prerendered redirects retain their status and location', async () => { }); async function load_routes({ base = '/', embed = false, appDir = '_app' } = {}) { - vi.resetModules(); - vi.doMock('MANIFEST', () => ({ manifest: { appDir }, base, embed })); - const file = vi.fn((path: string) => ({ path, type: 'text/plain;charset=utf-8' })); - vi.stubGlobal('Bun', { file }); + mock_manifest({ manifest: { appDir }, base, embed }); + // the real Bun.file runs, with the spy recording resolved paths; the files it + // points at need not exist because nothing reads their contents + const file = spyOn(Bun, 'file'); - return { routes: await import('../src/routes-util.js'), file }; + const specifier = `../src/routes-util.js?instance=${++instance}`; + return { routes: (await import(specifier)) as typeof import('../src/routes-util.js'), file }; } diff --git a/packages/adapter-bun/test/start.spec.ts b/packages/adapter-bun/test/start.spec.ts index 0e020f6e7673..091dc07c07ba 100644 --- a/packages/adapter-bun/test/start.spec.ts +++ b/packages/adapter-bun/test/start.spec.ts @@ -1,15 +1,19 @@ -import { afterEach, expect, test, vi } from 'vitest'; +import fs from 'node:fs'; +import process from 'node:process'; +import { afterAll, afterEach, expect, jest, mock, spyOn, test } from 'bun:test'; +import { mock_manifest, mock_routes } from './mocks.js'; + +// the const captures the real module object before any test swaps the live binding +const real_process = process; +let instance = 0; afterEach(() => { - vi.resetModules(); - vi.doUnmock('node:fs'); - vi.doUnmock('node:process'); - vi.doUnmock('MANIFEST'); - vi.doUnmock('ROUTES'); - vi.doUnmock('SERVER_OPTIONS'); - vi.doUnmock('../src/handler.js'); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + jest.useRealTimers(); + mock.restore(); +}); + +afterAll(() => { + mock.module('node:process', () => ({ default: real_process })); }); test('starts Bun with production defaults and generated request routes', async () => { @@ -84,13 +88,12 @@ test('a Unix socket takes precedence over TCP-only options', async () => { }); test('removes a stale socket file before listening', async () => { - const statSync = vi.fn(() => ({ size: 0 })); - const rmSync = vi.fn(); - vi.doMock('node:fs', () => ({ default: { statSync, rmSync } })); + spyOn(fs, 'statSync').mockReturnValue({ size: 0 } as ReturnType); + const rm = spyOn(fs, 'rmSync').mockImplementation(() => {}); await load_start({ env: { SOCKET_PATH: '/tmp/application.sock' } }); - expect(rmSync).toHaveBeenCalledWith('/tmp/application.sock'); + expect(rm).toHaveBeenCalledWith('/tmp/application.sock'); }); test.each([ @@ -102,12 +105,21 @@ test.each([ }); test('refuses to start on a Bun older than 1.4', async () => { - await expect(load_start({ bunVersion: '1.3.14' })).rejects.toThrow('requires Bun 1.4'); + spyOn(Bun.semver, 'order').mockReturnValue(-1); + await expect(load_start()).rejects.toThrow('requires Bun 1.4'); }); -test.each(['1.4.1', '1.5.0-canary.1', '2.0.0'])('starts on Bun %s', async (bunVersion) => { - const loaded = await load_start({ bunVersion }); - expect(loaded.serve).toHaveBeenCalled(); +// every other test proves the guard admits the running Bun; these pin the real +// comparator's verdicts for the release shapes the guard must order correctly, +// canaries being the reason it uses order() rather than satisfies() +test.each([ + ['1.3.14', -1], + ['1.4.0', 0], + ['1.4.1', 1], + ['1.5.0-canary.1', 1], + ['2.0.0', 1] +] as const)('Bun.semver orders %s against the 1.4.0 floor as %d', (version, expected) => { + expect(Bun.semver.order(version, '1.4.0')).toBe(expected); }); test.each(['SIGINT', 'SIGTERM'] as const)( @@ -117,7 +129,7 @@ test.each(['SIGINT', 'SIGTERM'] as const)( await loaded.listeners.get(signal)?.(); - expect(loaded.stop).toHaveBeenCalledOnce(); + expect(loaded.stop).toHaveBeenCalledTimes(1); expect(loaded.emit).toHaveBeenCalledWith('sveltekit:shutdown', signal); expect(loaded.log).toHaveBeenCalledWith( expect.stringContaining('Waiting for 2 requests to finish before shutting down...') @@ -126,7 +138,7 @@ test.each(['SIGINT', 'SIGTERM'] as const)( ); test('force-closes lingering connections after SHUTDOWN_TIMEOUT', async () => { - vi.useFakeTimers(); + jest.useFakeTimers(); try { let finish_force: (() => void) | undefined; const loaded = await load_start({ @@ -138,7 +150,8 @@ test('force-closes lingering connections after SHUTDOWN_TIMEOUT', async () => { }); const shutdown = loaded.listeners.get('SIGTERM')?.(); - await vi.advanceTimersByTimeAsync(5000); + jest.advanceTimersByTime(5000); + await flush_microtasks(); expect(loaded.stop).toHaveBeenCalledTimes(2); expect(loaded.stop).toHaveBeenLastCalledWith(true); expect(loaded.emit).not.toHaveBeenCalled(); @@ -147,7 +160,7 @@ test('force-closes lingering connections after SHUTDOWN_TIMEOUT', async () => { await shutdown; expect(loaded.emit).toHaveBeenCalledWith('sveltekit:shutdown', 'SIGTERM'); } finally { - vi.useRealTimers(); + jest.useRealTimers(); } }); @@ -165,56 +178,53 @@ test('a second shutdown signal forces the process to exit', async () => { await first; }); +// bun:test has no async advanceTimersByTime, so drain the promise chains that a +// synchronously-fired timer callback unblocks +async function flush_microtasks() { + for (let i = 0; i < 10; i += 1) await Promise.resolve(); +} + async function load_start({ serverOptions = {}, env = {}, envPrefix = '', pendingRequests = 0, - bunVersion = '1.4.0', stop: stop_implementation }: { serverOptions?: Record; env?: Record; envPrefix?: string; pendingRequests?: number; - bunVersion?: string; stop?: (force?: boolean) => Promise; } = {}) { - vi.resetModules(); const listeners = new Map Promise | void>(); - const emit = vi.fn(); - const exit = vi.fn(); + const emit = mock((_name: string, _detail: unknown) => {}); + const exit = mock((_code: number) => {}); const fake_process = { env, - on: vi.fn((name: string, callback: () => Promise | void) => - listeners.set(name, callback) - ), + on: mock((name: string, callback: () => Promise | void) => listeners.set(name, callback)), emit, exit }; - vi.doMock('node:process', () => ({ default: fake_process })); - vi.doMock('MANIFEST', () => ({ env_prefix: envPrefix })); + mock.module('node:process', () => ({ default: fake_process })); + mock_manifest({ env_prefix: envPrefix }); const routes = { '/asset': { GET: new Response('asset') } }; - const handler = vi.fn(); - vi.doMock('ROUTES', () => ({ routes })); - vi.doMock('SERVER_OPTIONS', () => ({ default: serverOptions })); - vi.doMock('../src/handler.js', () => ({ handler })); + const handler = mock(() => {}); + mock_routes({ routes }); + mock.module('SERVER_OPTIONS', () => ({ default: serverOptions })); + mock.module('../src/handler.js', () => ({ handler })); - const stop = vi.fn(stop_implementation ?? (async () => {})); + const stop = mock(stop_implementation ?? (async () => {})); const server = { url: new URL('http://localhost:3000'), pendingRequests, stop }; - const serve = vi.fn((_options: any) => server); - // vitest runs under Node, so a numeric compare stands in for Bun.semver.order - const order = (a: string, b: string) => - a.replace('-', '.').localeCompare(b.replace('-', '.'), undefined, { numeric: true }); - vi.stubGlobal('Bun', { serve, version: bunVersion, semver: { order } }); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await import('../src/index.js'); + const serve = spyOn(Bun, 'serve').mockImplementation((_options: any) => server as any); + const log = spyOn(console, 'log').mockImplementation(() => {}); + + await import(`../src/index.js?instance=${++instance}`); return { listeners, emit, exit, routes, handler, stop, serve, log }; } diff --git a/packages/adapter-bun/tsconfig.json b/packages/adapter-bun/tsconfig.json index d97024f0a4a0..345223b1bc72 100644 --- a/packages/adapter-bun/tsconfig.json +++ b/packages/adapter-bun/tsconfig.json @@ -14,7 +14,6 @@ }, "include": [ "index.js", - "vitest.config.js", "src/**/*.js", "test/*.js", "test/*.ts", diff --git a/packages/adapter-bun/vitest.config.js b/packages/adapter-bun/vitest.config.js deleted file mode 100644 index 34663efc6c8b..000000000000 --- a/packages/adapter-bun/vitest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['test/*.spec.ts'] - } -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4569db15bd4c..8fabcf3cf415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,9 +163,6 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 - vitest: - specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) packages/adapter-bun/test/apps/basic: devDependencies: