diff --git a/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json b/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json new file mode 100644 index 0000000..28d351b --- /dev/null +++ b/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json @@ -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" +} diff --git a/README.md b/README.md index 1c34522..ab239cc 100644 --- a/README.md +++ b/README.md @@ -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() + 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 diff --git a/bun.lock b/bun.lock index d91f188..23a89a2 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,9 @@ "husky": "^9.1.7", "typescript": "latest", }, + "peerDependencies": { + "react": ">=18", + }, }, }, "packages": { diff --git a/bunfig.toml b/bunfig.toml index b3489ab..52ceaef 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,5 @@ [test] -preload = ["./src/index.ts"] +preload = ["./src/index.ts", "./src/next/index.ts"] coverage = true coverageSkipTestFiles = true coverageThreshold = 1.0 diff --git a/package.json b/package.json index 643d7af..07f7443 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } } diff --git a/scripts/generate-google-fonts.ts b/scripts/generate-google-fonts.ts new file mode 100644 index 0000000..5be5033 --- /dev/null +++ b/scripts/generate-google-fonts.ts @@ -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}`) diff --git a/scripts/write-esm-entry.ts b/scripts/write-esm-entry.ts index c155777..1cd3a0a 100644 --- a/scripts/write-esm-entry.ts +++ b/scripts/write-esm-entry.ts @@ -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 @@ -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") diff --git a/src/__tests__/dist-preload.test.ts b/src/__tests__/dist-preload.test.ts index aebe1ec..13c084e 100644 --- a/src/__tests__/dist-preload.test.ts +++ b/src/__tests__/dist-preload.test.ts @@ -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, @@ -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('') @@ -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) diff --git a/src/__tests__/next-components.test.tsx b/src/__tests__/next-components.test.tsx new file mode 100644 index 0000000..ef9647b --- /dev/null +++ b/src/__tests__/next-components.test.tsx @@ -0,0 +1,472 @@ +import { describe, expect, mock, spyOn, test } from 'bun:test' +import { Component, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { fireEvent, render, renderHook, waitFor } from '../index.ts' +import App from '../next/app.tsx' +import Document, { Html } from '../next/document.tsx' +import dynamic, { noSSR } from '../next/dynamic.tsx' +import ErrorPage, { catchError } from '../next/error.tsx' +import localFont, { googleFonts } from '../next/font.ts' +import Form from '../next/form.tsx' +import Head, { defaultHead } from '../next/head.tsx' +import { router, setOffline, setUrl } from '../next/index.ts' +import { notFound } from '../next/navigation.ts' +import { + dispatchOfflineChange, + OfflineProvider, + useOffline, +} from '../next/offline.ts' +import Script, { + handleClientScriptLoad, + initScriptLoader, +} from '../next/script.ts' +import { useReportWebVitals } from '../next/web-vitals.ts' + +describe('next/script', () => { + test('renders nothing and never loads', () => { + const onLoad = mock(() => {}) + const { container } = render( +