next.js/test/development/mcp-server/mcp-server-get-errors.test.ts
mcp-server-get-errors.test.ts281 lines8.7 KB
import { FileRef, nextTestSetup } from 'e2e-utils'
import path from 'path'
import { retry, debugPrint, getFullUrl } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
import { chromium, firefox, webkit } from 'playwright'
import type { Browser } from 'playwright'

describe('mcp-server get_errors tool', () => {
  const { next } = nextTestSetup({
    files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
  })

  async function callGetErrors(id: string) {
    const response = await fetch(`${next.url}/_next/mcp`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json, text/event-stream',
      },
      body: JSON.stringify({
        jsonrpc: '2.0',
        id,
        method: 'tools/call',
        params: { name: 'get_errors', arguments: {} },
      }),
    })

    const text = await response.text()
    const match = text.match(/data: ({.*})/s)
    const result = JSON.parse(match![1])
    return result.result?.content?.[0]?.text
  }

  it('should handle no browser sessions gracefully', async () => {
    const errorsText = await callGetErrors('test-no-session')
    const errors = JSON.parse(errorsText)
    expect(errors).toMatchInlineSnapshot(`
      {
        "error": "No browser sessions connected. Please open your application in a browser to retrieve error state.",
      }
    `)
  })

  it('should return no errors for clean page', async () => {
    await next.browser('/')
    const errorsText = await callGetErrors('test-1')
    const errors = JSON.parse(errorsText)
    expect(errors).toMatchInlineSnapshot(`
      {
        "configErrors": [],
        "sessionErrors": [],
      }
    `)
  })

  it('should capture runtime errors with source-mapped stack frames', async () => {
    const browser = await next.browser('/')
    await browser.elementByCss('a[href="/runtime-error"]').click()

    let errors: any = null
    await retry(async () => {
      const sessionId = 'test-2-' + Date.now()
      const errorsText = await callGetErrors(sessionId)
      errors = JSON.parse(errorsText)
      expect(errors.sessionErrors).toHaveLength(1)
      expect(errors.sessionErrors[0].runtimeErrors).toHaveLength(1)
    })

    expect(errors.sessionErrors[0]).toMatchObject({
      url: '/runtime-error',
      buildError: null,
      runtimeErrors: [
        {
          type: 'runtime',
          errorName: 'Error',
          message: 'Test runtime error',
          stack: expect.arrayContaining([
            expect.objectContaining({
              file: expect.stringContaining('app/runtime-error/page.tsx'),
              methodName: 'RuntimeErrorPage',
            }),
          ]),
        },
      ],
    })
  })

  it('should capture build errors when directly visiting error page', async () => {
    await next.browser('/build-error')

    let errors: any = null
    await retry(async () => {
      const sessionId = 'test-4-' + Date.now()
      const errorsText = await callGetErrors(sessionId)
      errors = JSON.parse(errorsText)
      expect(errors.sessionErrors).toHaveLength(1)
      expect(errors.sessionErrors[0].buildError).toBeTruthy()
    })

    expect(errors.sessionErrors[0]).toMatchObject({
      url: '/build-error',
      buildError: expect.any(String),
    })

    // Check the build error contains the expected syntax error message
    expect(stripAnsi(errors.sessionErrors[0].buildError)).toContain(
      'Unexpected token. Did you mean'
    )
    expect(stripAnsi(errors.sessionErrors[0].buildError)).toContain(
      'build-error/page.tsx'
    )
  })

  it('should capture errors from multiple browser sessions', async () => {
    // Restart the server
    await next.stop()
    await next.start()

    // Open two independent browser sessions concurrently
    const [s1, s2] = await Promise.all([
      launchStandaloneSession(next.url, '/runtime-error'),
      launchStandaloneSession(next.url, '/runtime-error-2'),
    ])

    try {
      // Wait for server to be ready
      await new Promise((resolve) => setTimeout(resolve, 1000))
      let errors: any = null
      await retry(async () => {
        const sessionId = 'test-multi-' + Date.now()
        const errorsText = await callGetErrors(sessionId)
        errors = JSON.parse(errorsText)
        // Check that we have at least the 2 sessions we created
        expect(errors.sessionErrors.length).toBeGreaterThanOrEqual(2)
        // Ensure both our sessions are present
        const urls = errors.sessionErrors.map((s: any) => s.url)
        expect(urls).toContain('/runtime-error')
        expect(urls).toContain('/runtime-error-2')
      })

      // Find each session's errors
      const session1 = errors.sessionErrors.find(
        (s: any) => s.url === '/runtime-error'
      )
      const session2 = errors.sessionErrors.find(
        (s: any) => s.url === '/runtime-error-2'
      )

      expect(session1).toMatchObject({
        url: '/runtime-error',
        runtimeErrors: [
          {
            type: 'runtime',
            message: 'Test runtime error',
            stack: expect.arrayContaining([
              expect.objectContaining({
                file: expect.stringContaining('app/runtime-error/page.tsx'),
                methodName: 'RuntimeErrorPage',
              }),
            ]),
          },
        ],
      })

      expect(session2).toMatchObject({
        url: '/runtime-error-2',
        runtimeErrors: [
          {
            type: 'runtime',
            message: 'Test runtime error 2',
            stack: expect.arrayContaining([
              expect.objectContaining({
                file: expect.stringContaining('app/runtime-error-2/page.tsx'),
                methodName: 'RuntimeErrorPage',
              }),
            ]),
          },
        ],
      })
    } finally {
      await s1.close()
      await s2.close()
    }
  })

  it('should capture next.config errors and clear when fixed', async () => {
    // Read the original config
    const originalConfig = await next.readFile('next.config.js')

    // Stop server, write invalid config, and restart
    await next.stop()
    await next.patchFile(
      'next.config.js',
      `module.exports = {
  experimental: {
    invalidTestProperty: 'this should cause a validation warning',
  },
}`
    )
    await next.start()

    // Open a browser session
    await next.browser('/')

    // Check that the config error is captured
    let errors: any = null
    await retry(async () => {
      const sessionId = 'test-config-error-' + Date.now()
      const errorsText = await callGetErrors(sessionId)
      errors = JSON.parse(errorsText)
      expect(errors.configErrors.length).toBeGreaterThan(0)
    })

    expect(errors.configErrors[0]).toMatchObject({
      message: expect.stringContaining(
        'Invalid next.config.js options detected'
      ),
    })
    expect(errors.configErrors[0].message).toContain('invalidTestProperty')

    // Stop server, fix the config, and restart
    await next.stop()
    await next.patchFile('next.config.js', originalConfig)
    await next.start()

    // Open a browser session
    await next.browser('/')

    // Verify the config error is now gone
    await retry(async () => {
      const sessionId = 'test-config-fixed-' + Date.now()
      const fixedErrorsText = await callGetErrors(sessionId)
      const fixedErrors = JSON.parse(fixedErrorsText)
      expect(fixedErrors.configErrors).toHaveLength(0)
      expect(fixedErrors.sessionErrors).toHaveLength(0)
    })
  })
})

/**
 * Minimal standalone browser session launcher for testing multiple concurrent browser tabs.
 * The standard test harness (next.browser) uses a singleton browser instance which doesn't
 * support concurrent tabs needed for testing errors across multiple browser sessions.
 */
async function launchStandaloneSession(
  appPortOrUrl: string | number,
  url: string
) {
  const headless = !!process.env.HEADLESS
  const browserName = (process.env.BROWSER_NAME || 'chrome').toLowerCase()

  let browser: Browser
  if (browserName === 'safari') {
    browser = await webkit.launch({ headless })
  } else if (browserName === 'firefox') {
    browser = await firefox.launch({ headless })
  } else {
    browser = await chromium.launch({ headless })
  }

  const context = await browser.newContext()
  const page = await context.newPage()

  const fullUrl = getFullUrl(appPortOrUrl, url)
  debugPrint(`Loading standalone browser with ${fullUrl}`)

  page.on('pageerror', (error) => debugPrint('Standalone page error', error))

  await page.goto(fullUrl, { waitUntil: 'load' })
  debugPrint(`Loaded standalone browser with ${fullUrl}`)

  return {
    page,
    close: async () => {
      await page.close().catch(() => {})
      await context.close().catch(() => {})
      await browser.close().catch(() => {})
    },
  }
}
Quest for Codev2.0.0
/
SIGN IN