next.js/test/e2e/webpack-loader-parse-error/webpack-loader-parse-error.test.ts
webpack-loader-parse-error.test.ts300 lines9.2 KB
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"
      `)
    }
  })
})
Quest for Codev2.0.0
/
SIGN IN