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.
// In prod mode, errors start with a bare file-path line above the title.
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 } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})
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
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
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
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
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"
`)
}
})
})