mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
304 lines
9.5 KiB
TypeScript
304 lines
9.5 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
||
import { retry } from 'next-test-utils'
|
||
import stripAnsi from 'strip-ansi'
|
||
|
||
/**
|
||
* Strips the dynamic test directory prefix from paths so snapshots are stable.
|
||
* Handles both absolute paths (/full/path/test/tmp/next-test-XXX/) and
|
||
* relative paths (./test/tmp/next-test-XXX/).
|
||
*/
|
||
function normalizePaths(output: string, testDir: string): string {
|
||
let result = output.replaceAll(testDir + '/', '')
|
||
const relMatch = testDir.match(/(test\/tmp\/next-test-[^/]+)/)
|
||
if (relMatch) {
|
||
result = result.replaceAll('./' + relMatch[1] + '/', './')
|
||
result = result.replaceAll(relMatch[1] + '/', '')
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Extracts a single error block from CLI output by searching for a title string.
|
||
*
|
||
* Start boundary: the nearest `⨯` marker (dev) or file-path line (prod)
|
||
* before the title.
|
||
* End boundary: the first blank line after an "Import trace" section that
|
||
* isn't followed by continuation content (indented lines or file paths).
|
||
*/
|
||
function extractErrorBlock(output: string, errorTitle: string): string {
|
||
const titleIdx = output.indexOf(errorTitle)
|
||
if (titleIdx === -1) return ''
|
||
|
||
const beforeTitle = output.substring(0, titleIdx)
|
||
|
||
// In dev mode, errors start with a `⨯` marker on the same line as the file
|
||
// path (e.g. `⨯ ./app/file.js`). In prod mode, errors start with a bare
|
||
// file-path line above the title. Match `⨯ ./` specifically so we don't latch
|
||
// onto unrelated `⨯` markers in the startup output, such as the experimental
|
||
// features list (e.g. `⨯ exampleFlag (disabled by ...)`).
|
||
const markerIdx = beforeTitle.lastIndexOf('⨯ ./')
|
||
let startIdx: number
|
||
if (markerIdx !== -1) {
|
||
startIdx = markerIdx
|
||
} else {
|
||
// Go back two lines: title is preceded by a file-path line (e.g. ./file:3:1)
|
||
const lines = beforeTitle.split('\n')
|
||
startIdx =
|
||
lines.length >= 2
|
||
? beforeTitle.length -
|
||
lines[lines.length - 1].length -
|
||
lines[lines.length - 2].length -
|
||
1
|
||
: 0
|
||
}
|
||
|
||
// Scan forward to find the end of the Import trace section
|
||
const block = output.substring(startIdx)
|
||
const lines = block.split('\n')
|
||
let inImportTrace = false
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^Import trace/.test(lines[i])) {
|
||
inImportTrace = true
|
||
}
|
||
// A blank line after the import trace ends the block, unless the next
|
||
// line is a continuation (indented or a file path)
|
||
if (inImportTrace && lines[i].trim() === '' && i > 0) {
|
||
const nextLine = lines[i + 1]
|
||
if (
|
||
!nextLine ||
|
||
(!nextLine.startsWith(' ') && !nextLine.startsWith('./'))
|
||
) {
|
||
return lines.slice(0, i).join('\n')
|
||
}
|
||
}
|
||
}
|
||
return block.trimEnd()
|
||
}
|
||
|
||
describe('webpack-loader-parse-error (development)', () => {
|
||
const { next, isTurbopack, isNextDev, skipped } = nextTestSetup({
|
||
files: __dirname,
|
||
skipDeployment: true,
|
||
skipStart: true,
|
||
})
|
||
if (skipped) return
|
||
|
||
if (!isNextDev) {
|
||
it('skipped in production mode', () => {})
|
||
return
|
||
}
|
||
|
||
beforeAll(() => next.start())
|
||
|
||
it('should show parse error for JS loader that returns broken code', async () => {
|
||
const outputIndex = next.cliOutput.length
|
||
|
||
// Fetch the page to trigger the error
|
||
await next.fetch('/')
|
||
|
||
// Check that the CLI output contains the parse error
|
||
// Turbopack: "Parsing ecmascript source code failed"
|
||
// Webpack: "Syntax Error"
|
||
await retry(async () => {
|
||
expect(next.cliOutput).toMatch(
|
||
/Parsing ecmascript source code failed|Syntax Error/
|
||
)
|
||
})
|
||
|
||
if (isTurbopack) {
|
||
const output = normalizePaths(
|
||
stripAnsi(next.cliOutput.slice(outputIndex)),
|
||
next.testDir
|
||
)
|
||
const errorBlock = extractErrorBlock(
|
||
output,
|
||
'Parsing ecmascript source code failed'
|
||
)
|
||
expect(errorBlock).toMatchInlineSnapshot(`
|
||
"⨯ ./app/data.broken.js:3:1
|
||
Error: Expected '</', got '{'
|
||
1 | // This file will be processed by broken-js-loader
|
||
2 | // The loader will return invalid JavaScript with a source map
|
||
> 3 | export default function Data() {
|
||
| ^
|
||
4 | return <div>original source content</div>
|
||
5 | }
|
||
6 |
|
||
|
||
Parsing ecmascript source code failed
|
||
|
||
Generated code of loaders [broken-js-loader.js] transform of file content of app/data.broken.js:
|
||
./app/data.broken.js:3:46
|
||
1 | // Generated by broken-js-loader
|
||
2 | export default function Page() {
|
||
> 3 | return <div>this is intentionally broken {{{ invalid jsx
|
||
| ^
|
||
4 | }
|
||
5 |
|
||
|
||
Import trace:
|
||
Server Component:
|
||
./app/data.broken.js
|
||
./app/page.js"
|
||
`)
|
||
}
|
||
})
|
||
|
||
it('should show parse error for CSS loader that returns broken code', async () => {
|
||
const outputIndex = next.cliOutput.length
|
||
|
||
// Fetch the page to trigger the CSS error
|
||
await next.fetch('/css-page')
|
||
|
||
// Check that the CLI output contains the CSS parse error
|
||
// Turbopack: "Parsing CSS source code failed"
|
||
// Webpack: "Unknown word"
|
||
await retry(async () => {
|
||
expect(next.cliOutput).toMatch(
|
||
/Parsing CSS source code failed|Unknown word/
|
||
)
|
||
})
|
||
|
||
if (isTurbopack) {
|
||
const output = normalizePaths(
|
||
stripAnsi(next.cliOutput.slice(outputIndex)),
|
||
next.testDir
|
||
)
|
||
const errorBlock = extractErrorBlock(
|
||
output,
|
||
'Parsing CSS source code failed'
|
||
)
|
||
expect(errorBlock).toMatchInlineSnapshot(`
|
||
"⨯ ./app/css-page/styles.broken.css:2:3
|
||
Error: Parsing CSS source code failed
|
||
1 | .page {
|
||
> 2 | color: blue;
|
||
| ^
|
||
3 | font-size: 16px;
|
||
4 | }
|
||
5 |
|
||
|
||
Unexpected token CurlyBracketBlock
|
||
|
||
Generated code of PostCSS transform of loaders [broken-css-loader.js] transform of file content of app/css-page/styles.broken.css:
|
||
./app/css-page/styles.broken.css:5:15
|
||
3 | color: red
|
||
4 | @@@ THIS IS NOT VALID CSS @@@;
|
||
> 5 | background: {{{ invalid
|
||
| ^
|
||
6 | }
|
||
7 |
|
||
|
||
Import trace:
|
||
Client Component Browser:
|
||
./app/css-page/styles.broken.css [Client Component Browser]
|
||
./app/css-page/page.js [Server Component]"
|
||
`)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('webpack-loader-parse-error (production)', () => {
|
||
const { next, isNextStart, isTurbopack } = nextTestSetup({
|
||
files: __dirname,
|
||
skipDeployment: true,
|
||
skipStart: true,
|
||
})
|
||
|
||
if (!isNextStart) {
|
||
it('skipped in development mode', () => {})
|
||
return
|
||
}
|
||
|
||
it('should fail the build with parse errors from loaders', async () => {
|
||
await expect(next.start()).rejects.toThrow(
|
||
'next build failed with code/signal 1'
|
||
)
|
||
|
||
const output = normalizePaths(stripAnsi(next.cliOutput), next.testDir)
|
||
|
||
if (isTurbopack) {
|
||
const jsError = extractErrorBlock(output, "Expected '</', got '{'")
|
||
expect(jsError).toMatchInlineSnapshot(`
|
||
"./app/data.broken.js:3:1
|
||
Error: Expected '</', got '{'
|
||
1 | // This file will be processed by broken-js-loader
|
||
2 | // The loader will return invalid JavaScript with a source map
|
||
> 3 | export default function Data() {
|
||
| ^
|
||
4 | return <div>original source content</div>
|
||
5 | }
|
||
6 |
|
||
|
||
Parsing ecmascript source code failed
|
||
|
||
Generated code of loaders [broken-js-loader.js] transform of file content of app/data.broken.js:
|
||
./app/data.broken.js:3:46
|
||
1 | // Generated by broken-js-loader
|
||
2 | export default function Page() {
|
||
> 3 | return <div>this is intentionally broken {{{ invalid jsx
|
||
| ^
|
||
4 | }
|
||
5 |
|
||
|
||
Import trace:
|
||
Server Component:
|
||
./app/data.broken.js
|
||
./app/page.js"
|
||
`)
|
||
|
||
const cssError = extractErrorBlock(
|
||
output,
|
||
'Parsing CSS source code failed'
|
||
)
|
||
expect(cssError).toMatchInlineSnapshot(`
|
||
"./app/css-page/styles.broken.css:5:15
|
||
Error: Parsing CSS source code failed
|
||
3 | color: red
|
||
4 | @@@ THIS IS NOT VALID CSS @@@;
|
||
> 5 | background: {{{ invalid
|
||
| ^
|
||
6 | }
|
||
7 |
|
||
|
||
Unexpected token CurlyBracketBlock
|
||
|
||
Generated code of PostCSS transform of loaders [broken-css-loader.js] transform of file content of app/css-page/styles.broken.css:
|
||
./app/css-page/styles.broken.css:5:15
|
||
3 | color: red
|
||
4 | @@@ THIS IS NOT VALID CSS @@@;
|
||
> 5 | background: {{{ invalid
|
||
| ^
|
||
6 | }
|
||
7 |
|
||
|
||
Import trace:
|
||
Client Component Browser:
|
||
./app/css-page/styles.broken.css [Client Component Browser]
|
||
./app/css-page/page.js [Server Component]"
|
||
`)
|
||
} else {
|
||
// Webpack stops at the first error (JS), showing the generated code
|
||
const jsError = extractErrorBlock(output, "Expected '</', got '{'")
|
||
expect(jsError).toMatchInlineSnapshot(`
|
||
"./app/data.broken.js
|
||
Error: x Expected '</', got '{'
|
||
,-[3:1]
|
||
1 | // Generated by broken-js-loader
|
||
2 | export default function Page() {
|
||
3 | return <div>this is intentionally broken {{{ invalid jsx
|
||
: ^
|
||
4 | }
|
||
\`----
|
||
|
||
Caused by:
|
||
Syntax Error
|
||
|
||
Import trace for requested module:
|
||
./app/data.broken.js
|
||
./app/page.js"
|
||
`)
|
||
}
|
||
})
|
||
})
|