fix(47299): allow testing pages with metadata in jsdom test environment (#53578)
### 🧐 What's in there? This is another attempt to allow testing server-only code with Jest. ### 🧪 How to test? There's an integration tests which can be triggered with `pnpm testheadless server-only` Here is a more comprehensive setup: <details> <summary><code>app/lib/index.ts</code></summary> ```ts import 'server-only' export function add(num1: number, num2: number) { return num1 + num2 } ``` </details> <details> <summary><code>app/lib/index.test.ts</code></summary> ```ts import { add } from '.' it('adds two numbers', () => { expect(add(1, 3)).toEqual(4) }) ``` </details> <details> <summary><code>app/client-component.tsx</code></summary> ```ts 'use client' import { useState } from 'react' export default function ClientComponent() { const [text, setText] = useState('not clicked yet') return <button onClick={() => setText('clicked!')}>{text}</button> } ``` </details> <details> <summary><code>app/client-component.test.tsx</code></summary> ```ts import { fireEvent, render, screen } from '@testing-library/react' import ClientComponent from './client-component' it('can be clicked', async () => { render(<ClientComponent />) const button = screen.getByRole('button') expect(button).toHaveTextContent('not clicked yet') await fireEvent.click(button) expect(button).toHaveTextContent('clicked!') }) ``` </details> <details> <summary><code>app/server-component.tsx</code></summary> ```ts import { add } from '@/lib' export default function ServerComponent({ a, b }: { a: number; b: number }) { return ( <code role="comment"> {a} + {b} = {add(a, b)} </code> ) } ``` </details> <details> <summary><code>app/server-component.test.tsx</code></summary> ```ts import { render, screen } from '@testing-library/react' import ServerComponent from './server-component' it('renders', () => { render(<ServerComponent a={2} b={3} />) expect(screen.getByRole('comment')).toHaveTextContent('2 + 3 = 5') }) ``` </details> <details> <summary><code>app/page.tsx</code></summary> ```ts import Link from 'next/link' import ClientComponent from './client-component' import ServerComponent from './server-component' export default function Page() { return ( <> <h1>Hello World</h1> <Link href="/dave">Dave?</Link> <p> <ClientComponent /> </p> <p> <ServerComponent a={5} b={2} /> </p> </> ) } ``` </details> <details> <summary><code>app/page.test.tsx</code></summary> ```ts import { render, screen } from '@testing-library/react' import Page from './page' it('greets', () => { render(<Page />) expect(screen.getByRole('link')).toHaveTextContent('Dave?') expect(screen.getByRole('heading')).toHaveTextContent('Hello World') expect(screen.getByRole('button')).toHaveTextContent('not clicked yet') expect(screen.getByRole('comment')).toHaveTextContent('5 + 2 = 7') }) ``` </details> <details> <summary><code>app/[blog]/page.tsx</code></summary> ```ts import { Metadata } from 'next' import Link from 'next/link' type Props = { params: { blog: string } } export async function generateMetadata({ params: { blog: title }, }: Props): Promise<Metadata> { return { title, description: `A blog post about ${title}` } } export default function Page({ params }: Props) { return ( <> <div> <Link href="/">Back</Link> </div> <h1>All about {params.blog}</h1> </> ) } ``` </details> <details> <summary><code>app/[blog]/page.test.tsx</code></summary> ```ts import { render, screen } from '@testing-library/react' import Page from './page' it('has the appropriate title', () => { const title = 'Jane' render(<Page params={{ blog: title }} />) expect(screen.getByRole('heading')).toHaveTextContent(`All about ${title}`) expect(screen.getByRole('link')).toHaveTextContent('Back') }) ``` </details> <details> <summary><code>app/layout.tsx</code></summary> ```ts export default function RootLayout({ children }) { return ( <html lang="en"> <body>{children}</body> </html> ) } ``` </details> <details> <summary><code>jest.config.js</code></summary> ```ts const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './' }) module.exports = createJestConfig({ testEnvironment: 'jsdom', setupFilesAfterEnv: ['<rootDir>/test-setup.ts'], }) ``` </details> <details> <summary><code>package.json</code></summary> ```ts { "name": "rsc-test", "version": "0.0.0", "private": true, "scripts": { "test": "jest" }, "devDependencies": { "@testing-library/jest-dom": "latest" } } ``` </details> <details> <summary><code>test-setup.ts</code></summary> ```ts import '@testing-library/jest-dom' ``` </details> The app should run and all test should pass. ### ❗ Notes to reviewers #### The problem: 1. next/jest configures jest with a transformer ([jest-transformer](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/swc/jest-transformer.ts)) to compile react code with next -swc 2. the transformers configures next -swc for a given environment: Server or Client, based on jest global environment 3. Based on the environment, next -swc checks for invalid usage of `import('server-only')` `“use client”`, `export const metadata` or `export async function generateMetadata` 4. Because the global test environment is either jsdom or node, the same test suite can not include both client and server components #### Possible mitigations *A. Using jest projects* When configured with [multiple projects](https://jestjs.io/docs/next/configuration/#projects-arraystring--projectconfig), Jest can launch different runners with different environment. This would allow running server-only code in node and client-only code in jsdom. However, it requires user to completely change their jest configuration. It would also require a different setup when scaffolding new app-directory project with create-next. *B. Using doc blocks* Jest allows changing the environment per test file [with docBlock](https://jestjs.io/docs/configuration#testenvironment-string). However, by the time jest is invoking next -swc on a source file to transform it, this information is gone, and next -swc is still invoked with the (wrong) global environment. The PR #52393 provides a workaround for files with `import('server-only')`, but does not allow testing pages with metadata. *C. Always compile for node* Our jest-transformer could always configure next -swc for server: - pass Server-specific validations `import('server-only')` `export const metadata` or `export async function generateMetadata` - does not complain about `"use client"` This is what this PR is about! Fixes #47299 Co-authored-by: Jiachi Liu <4800338+huozhi@users.noreply.github.com>
This commit is contained in:
parent
61e1858241
commit
77acd164d3
10 changed files with 44 additions and 78 deletions
|
@ -242,7 +242,6 @@
|
|||
"image-size": "1.0.0",
|
||||
"is-docker": "2.0.0",
|
||||
"is-wsl": "2.2.0",
|
||||
"jest-docblock": "29.4.3",
|
||||
"jest-worker": "27.0.0-next.5",
|
||||
"json5": "2.2.3",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
|
|
|
@ -29,7 +29,6 @@ DEALINGS IN THE SOFTWARE.
|
|||
import vm from 'vm'
|
||||
import { transformSync } from './index'
|
||||
import { getJestSWCOptions } from './options'
|
||||
import * as docblock from 'next/dist/compiled/jest-docblock'
|
||||
import type {
|
||||
TransformerCreator,
|
||||
TransformOptions,
|
||||
|
@ -77,30 +76,16 @@ function isEsm(
|
|||
)
|
||||
}
|
||||
|
||||
function getTestEnvironment(
|
||||
src: string,
|
||||
jestConfig: Config.ProjectConfig
|
||||
): string {
|
||||
const docblockPragmas = docblock.parse(docblock.extract(src))
|
||||
const pragma = docblockPragmas['jest-environment']
|
||||
const environment =
|
||||
(Array.isArray(pragma) ? pragma[0] : pragma) ?? jestConfig.testEnvironment
|
||||
return environment
|
||||
}
|
||||
|
||||
const createTransformer: TransformerCreator<
|
||||
SyncTransformer<JestTransformerConfig>,
|
||||
JestTransformerConfig
|
||||
> = (inputOptions) => ({
|
||||
process(src, filename, jestOptions) {
|
||||
const jestConfig = getJestConfig(jestOptions)
|
||||
const testEnvironment = getTestEnvironment(src, jestConfig)
|
||||
|
||||
const swcTransformOpts = getJestSWCOptions({
|
||||
// When target is node it's similar to the server option set in SWC.
|
||||
isServer:
|
||||
testEnvironment === 'node' ||
|
||||
testEnvironment.includes('jest-environment-node'),
|
||||
// Always target server when compiling during test, to pass server-only validations and allow testing pages with metadatas
|
||||
isServer: true,
|
||||
filename,
|
||||
jsConfig: inputOptions?.jsConfig,
|
||||
resolvedBaseUrl: inputOptions?.resolvedBaseUrl,
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -1 +0,0 @@
|
|||
(()=>{"use strict";var e={381:e=>{const detectNewline=e=>{if(typeof e!=="string"){throw new TypeError("Expected a string")}const t=e.match(/(?:\r?\n)/g)||[];if(t.length===0){return}const r=t.filter((e=>e==="\r\n")).length;const n=t.length-r;return r>n?"\r\n":"\n"};e.exports=detectNewline;e.exports.graceful=e=>typeof e==="string"&&detectNewline(e)||"\n"},37:e=>{e.exports=require("os")}};var t={};function __nccwpck_require__(r){var n=t[r];if(n!==undefined){return n.exports}var s=t[r]={exports:{}};var c=true;try{e[r](s,s.exports,__nccwpck_require__);c=false}finally{if(c)delete t[r]}return s.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var r={};(()=>{var e=r;Object.defineProperty(e,"__esModule",{value:true});e.extract=extract;e.parse=parse;e.parseWithComments=parseWithComments;e.print=print;e.strip=strip;function _os(){const e=__nccwpck_require__(37);_os=function(){return e};return e}function _detectNewline(){const e=_interopRequireDefault(__nccwpck_require__(381));_detectNewline=function(){return e};return e}function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e}}const t=/\*\/$/;const n=/^\/\*\*?/;const s=/^\s*(\/\*\*?(.|\r?\n)*?\*\/)/;const c=/(^|\s+)\/\/([^\r\n]*)/g;const o=/^(\r?\n)+/;const i=/(?:^|\r?\n) *(@[^\r\n]*?) *\r?\n *(?![^@\r\n]*\/\/[^]*)([^@\r\n\s][^@\r\n]+?) *\r?\n/g;const a=/(?:^|\r?\n) *@(\S+) *([^\r\n]*)/g;const u=/(\r?\n|^) *\* ?/g;const p=[];function extract(e){const t=e.match(s);return t?t[0].trimLeft():""}function strip(e){const t=e.match(s);return t&&t[0]?e.substring(t[0].length):e}function parse(e){return parseWithComments(e).pragmas}function parseWithComments(e){const r=(0,_detectNewline().default)(e)??_os().EOL;e=e.replace(n,"").replace(t,"").replace(u,"$1");let s="";while(s!==e){s=e;e=e.replace(i,`${r}$1 $2${r}`)}e=e.replace(o,"").trimRight();const _=Object.create(null);const l=e.replace(a,"").replace(o,"").trimRight();let f;while(f=a.exec(e)){const e=f[2].replace(c,"");if(typeof _[f[1]]==="string"||Array.isArray(_[f[1]])){_[f[1]]=p.concat(_[f[1]],e)}else{_[f[1]]=e}}return{comments:l,pragmas:_}}function print({comments:e="",pragmas:t={}}){const r=(0,_detectNewline().default)(e)??_os().EOL;const n="/**";const s=" *";const c=" */";const o=Object.keys(t);const i=o.map((e=>printKeyValues(e,t[e]))).reduce(((e,t)=>e.concat(t)),[]).map((e=>`${s} ${e}${r}`)).join("");if(!e){if(o.length===0){return""}if(o.length===1&&!Array.isArray(t[o[0]])){const e=t[o[0]];return`${n} ${printKeyValues(o[0],e)[0]}${c}`}}const a=e.split(r).map((e=>`${s} ${e}`)).join(r)+r;return n+r+(e?a:"")+(e&&o.length?s+r:"")+i+c}function printKeyValues(e,t){return p.concat(t).map((t=>`@${e} ${t}`.trim()))}})();module.exports=r})();
|
|
@ -1 +0,0 @@
|
|||
{"name":"jest-docblock","main":"index.js","license":"MIT"}
|
File diff suppressed because one or more lines are too long
|
@ -2195,15 +2195,6 @@ export async function ncc_https_proxy_agent(task, opts) {
|
|||
.target('src/compiled/https-proxy-agent')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
externals['jest-docblock'] = 'next/dist/compiled/jest-docblock'
|
||||
export async function ncc_jest_docblock(task, opts) {
|
||||
await task
|
||||
.source(relative(__dirname, require.resolve('jest-docblock')))
|
||||
.ncc({ packageName: 'jest-docblock', externals })
|
||||
.target('src/compiled/jest-docblock')
|
||||
}
|
||||
|
||||
export async function precompile(task, opts) {
|
||||
await task.parallel(
|
||||
[
|
||||
|
@ -2338,7 +2329,6 @@ export async function ncc(task, opts) {
|
|||
'ncc_opentelemetry_api',
|
||||
'ncc_http_proxy_agent',
|
||||
'ncc_https_proxy_agent',
|
||||
'ncc_jest_docblock',
|
||||
'ncc_mini_css_extract_plugin',
|
||||
],
|
||||
opts
|
||||
|
|
5
packages/next/types/misc.d.ts
vendored
5
packages/next/types/misc.d.ts
vendored
|
@ -458,8 +458,3 @@ declare module 'next/dist/compiled/@opentelemetry/api' {
|
|||
import * as m from '@opentelemetry/api'
|
||||
export = m
|
||||
}
|
||||
|
||||
declare module 'next/dist/compiled/jest-docblock' {
|
||||
import m from 'jest-docblock'
|
||||
export = m
|
||||
}
|
||||
|
|
|
@ -1117,9 +1117,6 @@ importers:
|
|||
is-wsl:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
jest-docblock:
|
||||
specifier: 29.4.3
|
||||
version: 29.4.3
|
||||
jest-worker:
|
||||
specifier: 27.0.0-next.5
|
||||
version: 27.0.0-next.5
|
||||
|
@ -1233,7 +1230,7 @@ importers:
|
|||
version: 0.13.4
|
||||
sass-loader:
|
||||
specifier: 12.4.0
|
||||
version: 12.4.0(sass@1.54.0)(webpack@5.86.0)
|
||||
version: 12.4.0(webpack@5.86.0)
|
||||
schema-utils2:
|
||||
specifier: npm:schema-utils@2.7.1
|
||||
version: /schema-utils@2.7.1
|
||||
|
@ -16282,13 +16279,6 @@ packages:
|
|||
detect-newline: 3.1.0
|
||||
dev: true
|
||||
|
||||
/jest-docblock@29.4.3:
|
||||
resolution: {integrity: sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
detect-newline: 3.1.0
|
||||
dev: true
|
||||
|
||||
/jest-each@27.5.1:
|
||||
resolution: {integrity: sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
@ -23245,7 +23235,7 @@ packages:
|
|||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
/sass-loader@12.4.0(sass@1.54.0)(webpack@5.86.0):
|
||||
/sass-loader@12.4.0(webpack@5.86.0):
|
||||
resolution: {integrity: sha512-7xN+8khDIzym1oL9XyS6zP6Ges+Bo2B2xbPrjdMHEYyV3AQYhd/wXeru++3ODHF0zMjYmVadblSKrPrjEkL8mg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
peerDependencies:
|
||||
|
@ -23263,7 +23253,6 @@ packages:
|
|||
dependencies:
|
||||
klona: 2.0.4
|
||||
neo-async: 2.6.2
|
||||
sass: 1.54.0
|
||||
webpack: 5.86.0(@swc/core@1.3.55)
|
||||
dev: true
|
||||
|
||||
|
|
|
@ -8,10 +8,6 @@ describe('next/jest', () => {
|
|||
next = await createNext({
|
||||
skipStart: true,
|
||||
files: {
|
||||
'app/page.jsx': `import { PI } from '../lib/util'
|
||||
export default function Home() {
|
||||
return <h1>{PI}</h1>
|
||||
}`,
|
||||
'app/layout.jsx': `export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
@ -19,6 +15,12 @@ describe('next/jest', () => {
|
|||
</html>
|
||||
)
|
||||
}`,
|
||||
|
||||
'app/page.jsx': `import { PI } from '../lib/util'
|
||||
export default function Home() {
|
||||
return <h1>{PI}</h1>
|
||||
}`,
|
||||
|
||||
'app/page.test.jsx': `import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Page from './page'
|
||||
|
@ -27,13 +29,42 @@ describe('next/jest', () => {
|
|||
render(<Page />)
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('3.14')
|
||||
})`,
|
||||
'lib/util.js': `/** @jest-environment node */
|
||||
|
||||
'app/[blog]/page.jsx': `import { Metadata } from 'next'
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { blog: title },
|
||||
}) {
|
||||
return { title, description: 'A blog post about ' + title }
|
||||
}
|
||||
|
||||
export default function Page({ params }) {
|
||||
return <h1>All about {params.blog}</h1>
|
||||
}
|
||||
`,
|
||||
|
||||
'app/[blog]/page.test.jsx': `import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Page from './page'
|
||||
|
||||
describe('Blog Page', () => {
|
||||
it('has the appropriate title', () => {
|
||||
render(<Page params={{ blog: 'Jane' }} />)
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('All about Jane')
|
||||
})
|
||||
})
|
||||
`,
|
||||
|
||||
'lib/util.js': `
|
||||
import 'server-only'
|
||||
export const PI = 3.14;`,
|
||||
'lib/utils.test.ts': `import { PI } from './util'
|
||||
|
||||
'lib/utils.test.ts': `
|
||||
import { PI } from './util'
|
||||
it('works from server-side code', () => {
|
||||
expect(PI).toEqual(3.14)
|
||||
})`,
|
||||
|
||||
'jest.config.js': `module.exports = require('next/jest')({ dir: './' })({ testEnvironment: 'jsdom' })`,
|
||||
},
|
||||
buildCommand: `yarn jest`,
|
||||
|
@ -48,11 +79,11 @@ describe('next/jest', () => {
|
|||
|
||||
afterAll(() => next.destroy())
|
||||
|
||||
it('can run test against server side components', async () => {
|
||||
it('can run test against server server only code', async () => {
|
||||
try {
|
||||
await next.start()
|
||||
} finally {
|
||||
expect(next.cliOutput).toInclude('Tests: 2 passed, 2 total')
|
||||
expect(next.cliOutput).toInclude('Tests: 3 passed, 3 total')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue