next.js/test/development/browser-logs/browser-logs.test.ts
browser-logs.test.ts419 lines13.6 KB
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'e2e-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
import stripAnsi from 'strip-ansi'
import { retry } from 'next-test-utils'

const bundlerName = process.env.IS_TURBOPACK_TEST ? 'Turbopack' : 'Webpack'
const enableNewScrollHandler =
  process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER === 'true'
const innerScrollAndMaybeFocusHandlerName = enableNewScrollHandler
  ? 'InnerScrollHandlerNew'
  : 'InnerScrollAndFocusHandlerOld'

function setupLogCapture() {
  const logs: string[] = []
  const originalStdout = process.stdout.write
  const originalStderr = process.stderr.write

  const capture = (chunk: any) => {
    logs.push(stripAnsi(chunk.toString()))
    return true
  }

  process.stdout.write = function (chunk: any) {
    capture(chunk)
    return originalStdout.call(this, chunk)
  }

  process.stderr.write = function (chunk: any) {
    capture(chunk)
    return originalStderr.call(this, chunk)
  }

  const restore = () => {
    process.stdout.write = originalStdout
    process.stderr.write = originalStderr
  }

  const clearLogs = () => {
    logs.length = 0
  }

  return { logs, restore, clearLogs }
}

describe(`Terminal Logging (${bundlerName})`, () => {
  describe('Pages Router', () => {
    let next: NextInstance
    let logs: string[] = []
    let logCapture: ReturnType<typeof setupLogCapture>
    let browser = null

    beforeAll(async () => {
      logCapture = setupLogCapture()
      logs = logCapture.logs

      next = await createNext({
        files: {
          pages: new FileRef(join(__dirname, 'fixtures/pages')),
          'next.config.js': new FileRef(
            join(__dirname, 'fixtures/next.config.js')
          ),
        },
      })
    })

    afterAll(async () => {
      logCapture.restore()
      await next.destroy()
    })

    beforeEach(() => {
      logCapture.clearLogs()
    })

    afterEach(async () => {
      if (browser) {
        await browser.close()
        browser = null
      }
    })

    it('should forward client component logs', async () => {
      browser = await webdriver(next.url, '/pages-client-log')
      await browser.waitForElementByCss('#log-button')
      await browser.elementByCss('#log-button').click()

      await retry(() => {
        const logOutput = logs.join('')
        expect(logOutput).toContain(
          '[browser] Log from pages router client component'
        )
      })
    })

    it('should handle circular references safely', async () => {
      browser = await webdriver(next.url, '/circular-refs')
      await browser.waitForElementByCss('#circular-button')
      await browser.elementByCss('#circular-button').click()

      await retry(() => {
        const logOutput = logs.join('\n')
        expect(logOutput).toContain('[browser] Circular object:')
        expect(logOutput).toContain('[Circular]')
      })
    })

    it('should respect default depth limit', async () => {
      browser = await webdriver(next.url, '/deep-objects')
      await browser.waitForElementByCss('#deep-button')
      await browser.elementByCss('#deep-button').click()

      await retry(() => {
        const logOutput = logs.join('\n')
        expect(logOutput).toContain('[browser] Deep object: {')
        expect(logOutput).toContain('level1: {')
        expect(logOutput).toContain('level2: { level3: { level4: { level5:')
        expect(logOutput).toContain("'[Object]'")
      })
    })

    it('should show source-mapped errors in pages router', async () => {
      browser = await webdriver(next.url, '/pages-client-error')
      await browser.waitForElementByCss('#error-button')

      logCapture.clearLogs()

      await browser.elementByCss('#error-button').click()

      await retry(() => {
        const logOutput = logs.join('\n')
        const browserErrorPattern =
          /\[browser\] Uncaught Error: Client error in pages router\n\s+at throwClientError \(pages\/pages-client-error\.js:2:\d+\)\n\s+at callClientError \(pages\/pages-client-error\.js:6:\d+\)/
        expect(logOutput).toMatch(browserErrorPattern)
      })
    })

    it('should show source-mapped errors for server errors from pages router ', async () => {
      const outputIndex = logs.length

      browser = await webdriver(next.url, '/pages-server-error')

      await retry(() => {
        const newLogs = logs.slice(outputIndex).join('\n')

        const browserErrorPattern =
          /\[browser\] Uncaught Error: Server error in pages router\n\s+at throwPagesServerError \(pages\/pages-server-error\.js:2:\d+\)\n\s+at callPagesServerError \(pages\/pages-server-error\.js:6:\d+\)/
        expect(newLogs).toMatch(browserErrorPattern)
      })
    })
  })

  describe('App Router - Server Components', () => {
    let next: NextInstance
    let logs: string[] = []
    let logCapture: ReturnType<typeof setupLogCapture>

    beforeAll(async () => {
      logCapture = setupLogCapture()
      logs = logCapture.logs

      next = await createNext({
        files: {
          app: new FileRef(join(__dirname, 'fixtures/app')),
          'next.config.js': new FileRef(
            join(__dirname, 'fixtures/next.config.js')
          ),
        },
      })
    })

    afterAll(async () => {
      logCapture.restore()
      await next.destroy()
    })

    beforeEach(() => {
      logCapture.clearLogs()
    })

    it('should not re-log server component logs', async () => {
      const outputIndex = logs.length
      await next.render('/server-log')

      await retry(() => {
        const newLogs = logs.slice(outputIndex).join('')
        expect(newLogs).toContain('Server component console.log')
      }, 2000)

      const newLogs = logs.slice(outputIndex).join('')

      expect(newLogs).not.toContain('[browser] Server component console.log')
      expect(newLogs).not.toContain('[browser] Server component console.error')
    })

    it('should show source-mapped errors for server components', async () => {
      const outputIndex = logs.length

      const browser = await webdriver(next.url, '/server-error')

      await retry(() => {
        const newLogs = logs.slice(outputIndex).join('\n')

        const browserErrorPattern =
          /\[browser\] Uncaught Error: Server component error in app router\n\s+at throwServerError \(app\/server-error\/page\.js:2:\d+\)\n\s+at callServerError \(app\/server-error\/page\.js:6:\d+\)\n\s+at ServerErrorPage \(app\/server-error\/page\.js:10:\d+\)/
        expect(newLogs).toMatch(browserErrorPattern)
      })

      await browser.close()
    })
  })

  describe('App Router - Client Components', () => {
    let next: NextInstance
    let logs: string[] = []
    let logCapture: ReturnType<typeof setupLogCapture>

    beforeAll(async () => {
      logCapture = setupLogCapture()
      logs = logCapture.logs

      next = await createNext({
        files: {
          app: new FileRef(join(__dirname, 'fixtures/app')),
          'next.config.js': new FileRef(
            join(__dirname, 'fixtures/next.config.js')
          ),
        },
      })
    })

    afterAll(async () => {
      logCapture.restore()
      await next.destroy()
    })

    beforeEach(() => {
      logCapture.clearLogs()
    })

    it('should forward client component logs in app router', async () => {
      const browser = await webdriver(next.url, '/client-log')
      await browser.waitForElementByCss('#log-button')
      await browser.elementByCss('#log-button').click()

      await retry(() => {
        const logOutput = logs.join('')
        expect(logOutput).toContain(
          '[browser] Client component log from app router'
        )
      })

      await browser.close()
    })

    it('should show source-mapped errors for client components', async () => {
      const browser = await webdriver(next.url, '/client-error')
      await browser.waitForElementByCss('#error-button')

      logCapture.clearLogs()

      await browser.elementByCss('#error-button').click()

      await retry(() => {
        const logOutput = logs.join('\n')
        const browserErrorPattern =
          /\[browser\] Uncaught Error: Client component error in app router\n\s+at throwError \(app\/client-error\/page\.js:4:\d+\)\n\s+at callError \(app\/client-error\/page\.js:8:\d+\)/
        expect(logOutput).toMatch(browserErrorPattern)
      })

      await browser.close()
    })
  })

  describe('App Router - Hydration Errors', () => {
    let next: NextInstance
    let logs: string[] = []
    let logCapture: ReturnType<typeof setupLogCapture>

    beforeAll(async () => {
      logCapture = setupLogCapture()
      logs = logCapture.logs

      next = await createNext({
        files: {
          app: new FileRef(join(__dirname, 'fixtures/app')),
          'next.config.js': new FileRef(
            join(__dirname, 'fixtures/next.config.js')
          ),
        },
      })
    })

    afterAll(async () => {
      logCapture.restore()
      await next.destroy()
    })

    beforeEach(() => {
      logCapture.clearLogs()
    })

    it('should show hydration errors with owner stack trace', async () => {
      const browser = await webdriver(next.url, '/hydration-error')

      let hydrationErrorLog = ''
      await retry(() => {
        const logOutput = logs.join('\n')
        // Find the hydration error log entry
        // Stop at: another [browser] log, status indicators (○ ⨯),
        // or timestamp-prefixed logs (e.g. "[12:34:56.789Z] Browser Log: ...")
        const hydrationMatch = logOutput.match(
          /\[browser\].*Hydration[\s\S]*?(?=\n\[browser\]|\n *○|\n *⨯|\n *\[\d|$)/
        )
        expect(hydrationMatch).not.toBeNull()
        hydrationErrorLog = hydrationMatch![0]
        // Verify the Page component is in the forwarded stack trace with source location
        expect(hydrationErrorLog).toMatch(/Page/)
        expect(hydrationErrorLog).toMatch(/app\/hydration-error\/page/)
      })

      // Assert the entire hydration error message including owner stack trace
      expect(hydrationErrorLog).toMatchInlineSnapshot(`
       "[browser] Uncaught Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

       - A server/client branch \`if (typeof window !== 'undefined')\`.
       - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
       - Date formatting in a user's locale which doesn't match the server.
       - External changing data without sending a snapshot of it along with the HTML.
       - Invalid HTML tag nesting.

       It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.

       https://react.dev/link/hydration-mismatch

         ...
           <RenderFromTemplateContext>
             <ScrollAndMaybeFocusHandler cacheNode={{rsc:{...}, ...}}>
               <${innerScrollAndMaybeFocusHandlerName} focusAndScrollRef={{scrollRef:null, ...}} cacheNode={{rsc:{...}, ...}}>
                 <ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
                   <LoadingBoundary name="hydration-..." loading={null}>
                     <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
                       <RedirectBoundary>
                         <RedirectErrorBoundary router={{...}}>
                           <InnerLayoutRouter url="/hydration..." tree={[...]} params={{}} cacheNode={{rsc:{...}, ...}} ...>
                             <SegmentViewNode type="page" pagePath="hydration-...">
                               <SegmentTrieNode>
                               <ClientPageRoot Component={function Page} serverProvidedParams={{...}}>
                                 <Page params={Promise} searchParams={Promise}>
                                   <div>
                                     <p>
       +                               client
       -                               server
                             ...
                           ...
                 ...

           at <unknown> (https://react.dev/link/hydration-mismatch)
           at p (<anonymous>)
           at Page (app/hydration-error/page.js:7:7)
          5 |   return (
          6 |     <div>
       >  7 |       <p>{isClient ? 'client' : 'server'}</p>
            |       ^
          8 |     </div>
          9 |   )
         10 | }
       "
      `)

      await browser.close()
    })
  })

  describe('App Router - Edge Runtime', () => {
    let next: NextInstance
    let logs: string[] = []
    let logCapture: ReturnType<typeof setupLogCapture>

    beforeAll(async () => {
      logCapture = setupLogCapture()
      logs = logCapture.logs

      next = await createNext({
        files: {
          app: new FileRef(join(__dirname, 'fixtures/app')),
          'next.config.js': new FileRef(
            join(__dirname, 'fixtures/next.config.js')
          ),
        },
      })
    })

    afterAll(async () => {
      logCapture.restore()
      await next.destroy()
    })

    beforeEach(() => {
      logCapture.clearLogs()
    })

    it('should handle edge runtime errors with source mapping', async () => {
      const browser = await webdriver(next.url, '/edge-deep-stack')

      await retry(() => {
        const logOutput = logs.join('\n')

        const browserErrorPattern =
          /\[browser\] Uncaught Error: Deep stack error during render\n\s+at functionA \(app\/edge-deep-stack\/page\.js:6:\d+\)\n\s+at functionB \(app\/edge-deep-stack\/page\.js:10:\d+\)\n\s+at functionC \(app\/edge-deep-stack\/page\.js:14:\d+\)\n\s+at EdgeDeepStackPage \(app\/edge-deep-stack\/page\.js:18:\d+\)/
        expect(logOutput).toMatch(browserErrorPattern)
      })

      await browser.close()
    })
  })
})
Quest for Codev2.0.0
/
SIGN IN