Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"changes": { "package.json": "Minor" },
"note": "Add bun-test-env-dom/next, a preload that stands in for every public next/* module and lets tests set the URL, params, headers and cookies and assert on navigation",
"date": "2026-09-23T12:32:26.385Z"
}
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,57 @@ test('custom matchers', () => {
})
```

## Next.js

`bun-test-env-dom/next` replaces every public `next/*` module with a stand-in that needs no Next.js runtime, router or compiler - Next.js does not even have to be installed. Preload it after the DOM:

```toml
[test]
preload = ["bun-test-env-dom", "bun-test-env-dom/next"]
```

Your code keeps importing from `next/*`. Tests set the page and check navigation through `bun-test-env-dom/next`:

```tsx
import { expect, test } from 'bun:test'
import { render, screen, userEvent } from 'bun-test-env-dom'
import { router, setParams, setUrl } from 'bun-test-env-dom/next'

test('opens the next post', async () => {
setUrl('/posts/1?tab=comments')
setParams({ id: '1' })
render(<Post />)
await userEvent.click(screen.getByRole('link', { name: 'Next' }))
expect(router.push).toHaveBeenCalledWith('/posts/2', { scroll: true })
})
```

Everything below is reset before each test:

| Export | What it controls |
| --- | --- |
| `router` | What `useRouter()` from `next/navigation` returns. Its methods are mocks, and `next/link` and `next/form` navigate through them. |
| `setUrl(url)` | `usePathname()`, `useSearchParams()` and the Pages Router's URL. A relative URL resolves against the current one. |
| `setParams(params)` | `useParams()` and the Pages Router's `query`. |
| `setRoute(route)` | The Pages Router's `pathname` and `route`, like `/posts/[id]`. |
| `setSegments(segments)` | `useSelectedLayoutSegments()`, which otherwise splits the path. |
| `setHeaders(init)`, `setCookies(cookies)`, `setDraftMode(enabled)` | `headers()`, `cookies()` and `draftMode()` from `next/headers`. |
| `setOffline(offline)` | `useOffline()` from `next/offline`. |
| `setRootParams(params)` | The getters `next/root-params` exports. |
| `resetNext()` | All of the above, and every mock. |

What the stand-ins do:

- Navigation is recorded, not performed: `router.push` changes neither the URL nor what is rendered. Clicking a `next/link`, or submitting a `next/form` with a string `action`, calls `router.push` (or `replace`) wherever Next.js would navigate client-side. Modified clicks, other targets, downloads and other origins are left to the browser.
- `next/link`, `next/image`, `next/form` and `next/error` render the same markup as Next.js 16, so snapshots survive a change of framework underneath. `next/image` renders images as `unoptimized`: never through `/_next/image`.
- `redirect`, `notFound`, `forbidden` and `unauthorized` throw errors with the `digest` Next.js gives them.
- `next/script` renders nothing and never loads a script. `next/dynamic` is `React.lazy`: wait for it with `findBy*`.
- `next/font` returns stable class names: `Inter()` has the class `__className_inter`.
- `next/cache` functions are mocks, and `unstable_cache` caches nothing.
- `NextRequest` and `NextResponse` from `next/server` keep the `cookie` and `set-cookie` headers that happy-dom's `Request` and `Response` drop.
- `next/router` is the Pages Router, with mock methods. `next/compat/router` returns `null`, as in the App Router, until `setRoute` is called.
- `next/root-params` exports the names given to `setRootParams`. A module's imports are linked when it loads, so for a module that imports them statically, call `setRootParams` in a preload.

## License

Apache-2.0
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[test]
preload = ["./src/index.ts"]
preload = ["./src/index.ts", "./src/next/index.ts"]
coverage = true
coverageSkipTestFiles = true
coverageThreshold = 1.0
Expand Down
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
},
"./next": {
"import": {
"types": "./dist/next/index.d.ts",
"default": "./dist/next.mjs"
},
"require": {
"types": "./dist/next/index.d.ts",
"default": "./dist/next.cjs"
}
}
},
"types": "./dist/index.d.ts",
Expand All @@ -45,11 +55,14 @@
"@testing-library/react": ">=16.3",
"@testing-library/user-event": ">=14.6"
},
"peerDependencies": {
"react": ">=18"
},
"scripts": {
"lint:fix": "biome check --write .",
"lint": "biome check .",
"test": "bun test",
"build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun scripts/write-esm-entry.ts",
"build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/next/index.ts --production --outfile dist/next.cjs --format cjs --packages external && bun scripts/write-esm-entry.ts",
"prepare": "bun run husky"
}
}
30 changes: 30 additions & 0 deletions scripts/generate-google-fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Regenerates `src/next/google-fonts.ts`, the export names of `next/font/google`.
*
* Next.js compiles `next/font/google` calls away at build time, so the module
* has no runtime exports to copy. Its declarations are the only complete list
* of font functions, and a named import only links when the name is listed.
*
* Usage: bun scripts/generate-google-fonts.ts [next version, default latest]
*/
const version = process.argv[2] ?? 'latest'
const response = await fetch(
`https://unpkg.com/next@${version}/dist/compiled/@next/font/dist/google/index.d.ts`,
)
if (!response.ok) {
throw new Error(`Could not download the font list: HTTP ${response.status}`)
}

const names = [
...(await response.text()).matchAll(/export declare function (\w+)/g),
].map(([, name]) => name)
if (names.length === 0) {
throw new Error(`No font functions in ${response.url}`)
}

const resolved = response.url.match(/next@([^/]+)/)?.[1] ?? version
await Bun.write(
`${import.meta.dirname}/../src/next/google-fonts.ts`,
`// Generated by scripts/generate-google-fonts.ts from next@${resolved}. Do not edit.\nexport const GOOGLE_FONTS = [\n${names.map((name) => ` '${name}',`).join('\n')}\n]\n`,
)
console.info(`${names.length} fonts from next@${resolved}`)
7 changes: 5 additions & 2 deletions scripts/write-esm-entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Emits `dist/index.mjs` as a re-export of `dist/index.cjs`.
* Emits `dist/index.mjs` and `dist/next.mjs` as re-exports of their CJS
* builds.
*
* The ESM entry cannot be bundled from `src/index.ts` on its own. bun evaluates
* the CJS dependencies of a module graph while it is being linked, before any
Expand All @@ -10,6 +11,8 @@
*
* Re-exporting the CJS build keeps a real ESM entry point while leaving the
* ordering to `require`, which is an ordinary runtime call and therefore
* happens in source order.
* happens in source order. It also means a preload and a test that import
* the same entry share one copy of it.
*/
await Bun.write('dist/index.mjs', "export * from './index.cjs'\n")
await Bun.write('dist/next.mjs', "export * from './next.cjs'\n")
154 changes: 98 additions & 56 deletions src/__tests__/dist-preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,35 @@ const spec = (path: string) => JSON.stringify(path.replaceAll('\\', '/'))
* came out as user-event's whole module namespace (1.0.4), where
* `userEvent.type` is undefined. So the fixture also types through the
* `userEvent` it imports from the built entry.
*
* `bun-test-env-dom/next` is checked the same way, from a directory without
* Next.js: the stand-ins have to resolve `next/*` on their own.
*/

function buildArtifacts() {
const bundle = Bun.spawnSync(
[
'bun',
'build',
'--target',
'node',
'src/index.ts',
'--production',
'--outfile',
'dist/index.cjs',
'--format',
'cjs',
'--packages',
'external',
],
{ cwd: root, stdout: 'pipe', stderr: 'pipe' },
)
expect(bundle.exitCode).toBe(0)
for (const [source, outfile] of [
['src/index.ts', 'dist/index.cjs'],
['src/next/index.ts', 'dist/next.cjs'],
]) {
const bundle = Bun.spawnSync(
[
'bun',
'build',
'--target',
'node',
source as string,
'--production',
'--outfile',
outfile as string,
'--format',
'cjs',
'--packages',
'external',
],
{ cwd: root, stdout: 'pipe', stderr: 'pipe' },
)
expect(bundle.exitCode).toBe(0)
}

const esmEntry = Bun.spawnSync(['bun', 'scripts/write-esm-entry.ts'], {
cwd: root,
Expand All @@ -56,26 +64,52 @@ function buildArtifacts() {
expect(esmEntry.exitCode).toBe(0)
}

function preloadAndType(entry: string) {
/**
* Runs `source` as a test file that preloads `preload`. The fixture lives
* outside the package so this suite never collects it, which rules out bare
* specifiers of installed packages and JSX - both resolve from the file's own
* directory. Absolute specifiers and `createElement` keep it portable.
*/
function runFixture(preload: string[], source: string) {
const dir = mkdtempSync(join(tmpdir(), 'bun-test-env-dom-'))
try {
// The fixture lives outside the package so this suite never collects it,
// which rules out bare specifiers and JSX - both resolve from the file's
// own directory. Absolute specifiers and `createElement` keep it portable.
const reactPath = Bun.resolveSync('react', root)
const rtlPath = Bun.resolveSync('@testing-library/react', root)
const entryPath = resolve(root, entry)

writeFileSync(
join(dir, 'bunfig.toml'),
`[test]\npreload = [${spec(entryPath)}]\ncoverage = false\n`,
`[test]\npreload = [${preload.map((entry) => spec(resolve(root, entry))).join(', ')}]\ncoverage = false\n`,
)
writeFileSync(
join(dir, 'order.test.ts'),
`import { expect, mock, test } from 'bun:test'
import { fireEvent, render } from ${spec(rtlPath)}
import { createElement, useState } from ${spec(reactPath)}
import { userEvent } from ${spec(entryPath)}
writeFileSync(join(dir, 'fixture.test.ts'), source)
const run = Bun.spawnSync(
[
'bun',
`--config=${join(dir, 'bunfig.toml')}`,
'test',
join(dir, 'fixture.test.ts'),
],
{ cwd: root, stdout: 'pipe', stderr: 'pipe' },
)
return {
output: decode(run.stderr) + decode(run.stdout),
exitCode: run.exitCode,
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}

const reactPath = spec(Bun.resolveSync('react', root))
const rtlPath = spec(Bun.resolveSync('@testing-library/react', root))

test.each([
'dist/index.cjs',
'dist/index.mjs',
])('%s registers a DOM first and re-exports a usable userEvent', (entry) => {
buildArtifacts()
const { output, exitCode } = runFixture(
[entry],
`import { expect, mock, test } from 'bun:test'
import { fireEvent, render } from ${rtlPath}
import { createElement, useState } from ${reactPath}
import { userEvent } from ${spec(resolve(root, entry))}

function Field({ onSubmit }: { onSubmit: (value: string) => void }) {
const [value, setValue] = useState('')
Expand Down Expand Up @@ -114,32 +148,40 @@ test('the re-exported userEvent types into the React field', async () => {
expect(onSubmit).toHaveBeenCalledWith('typed')
})
`,
)

const run = Bun.spawnSync(
[
'bun',
`--config=${join(dir, 'bunfig.toml')}`,
'test',
join(dir, 'order.test.ts'),
],
{ cwd: root, stdout: 'pipe', stderr: 'pipe' },
)
return {
output: decode(run.stderr) + decode(run.stdout),
exitCode: run.exitCode,
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
)
expect(output).toContain('2 pass')
expect(output).toContain('0 fail')
expect(exitCode).toBe(0)
})

test.each([
'dist/index.cjs',
'dist/index.mjs',
])('%s registers a DOM first and re-exports a usable userEvent', (entry) => {
'dist/next.cjs',
'dist/next.mjs',
])('%s stands in for next/* where Next.js is not installed', (entry) => {
buildArtifacts()
const { output, exitCode } = preloadAndType(entry)
const { output, exitCode } = runFixture(
['dist/index.cjs', entry],
`import { expect, test } from 'bun:test'
import { fireEvent, render } from ${rtlPath}
import { createElement } from ${reactPath}
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { router, setUrl } from ${spec(resolve(root, entry))}

test('next/link navigates through the router a test imports', () => {
const view = render(createElement(Link, { href: '/about' }, 'About'))
fireEvent.click(view.getByText('About'))
expect(router.push).toHaveBeenCalledWith('/about', { scroll: true })
expect(useRouter()).toBe(router)
})

test('next/navigation reads the URL a test sets, from a clean state', () => {
expect(router.push).not.toHaveBeenCalled()
setUrl('/posts?page=2')
expect(usePathname()).toBe('/posts')
})
`,
)
expect(output).toContain('2 pass')
expect(output).toContain('0 fail')
expect(exitCode).toBe(0)
Expand Down
Loading
Loading