File size: 12,004 Bytes
b91e262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
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 errors = await callGetErrors('test-no-session')
    expect(stripAnsi(errors)).toMatchInlineSnapshot(
      `"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 errors = await callGetErrors('test-1')
    expect(stripAnsi(errors)).toMatchInlineSnapshot(
      `"No errors detected in 1 browser session(s)."`
    )
  })

  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: string = ''
    await retry(async () => {
      const sessionId = 'test-2-' + Date.now()
      errors = await callGetErrors(sessionId)
      expect(errors).toContain('Runtime Errors')
      expect(errors).toContain('Found errors in 1 browser session')
    })

    const strippedErrors = stripAnsi(errors)
      // Replace dynamic port with placeholder
      .replace(/localhost:\d+/g, 'localhost:PORT')

    // Verify proper URL display in session header (now shows pathname only)
    expect(strippedErrors).toContain('Session: /runtime-error')

    expect(strippedErrors).toMatchInlineSnapshot(`
      "# Found errors in 1 browser session(s)

      ## Session: /runtime-error

      **1 error(s) found**

      ### Runtime Errors

      #### Error 1 (Type: runtime)

      **Error**: Test runtime error

      \`\`\`
        at RuntimeErrorPage (app/runtime-error/page.tsx:2:9)
      \`\`\`

      ---"
    `)
  })

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

    let errors: string = ''
    await retry(async () => {
      const sessionId = 'test-4-' + Date.now()
      errors = await callGetErrors(sessionId)
      expect(errors).toContain('Build Error')
      expect(errors).toContain('Found errors in 1 browser session')
    })

    let strippedErrors = stripAnsi(errors)
      // Replace dynamic port with placeholder
      .replace(/localhost:\d+/g, 'localhost:PORT')

    // Verify proper URL display in session header (now shows pathname only)
    expect(strippedErrors).toContain('Session: /build-error')

    const isTurbopack = process.env.IS_TURBOPACK_TEST === '1'

    const isRspack = !!process.env.NEXT_RSPACK

    // Normalize paths in turbopack output to remove temp directory prefix
    if (isTurbopack) {
      strippedErrors = strippedErrors.replace(/\.\/test\/tmp\/[^/]+\//g, './')
    }

    if (isTurbopack) {
      // Turbopack output
      expect(strippedErrors).toMatchInlineSnapshot(`
       "# Found errors in 1 browser session(s)

       ## Session: /build-error

       **2 error(s) found**

       ### Build Error

       \`\`\`
       ./app/build-error/page.tsx:4:1
       Parsing ecmascript source code failed
         2 |   // Syntax error - missing closing brace
         3 |   return <div>Page
       > 4 | }
           | ^

       Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
       \`\`\`

       ### Runtime Errors

       #### Error 1 (Type: runtime)

       **Error**: ./app/build-error/page.tsx:4:1
       Parsing ecmascript source code failed
         2 |   // Syntax error - missing closing brace
         3 |   return <div>Page
       > 4 | }
           | ^

       Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?



       \`\`\`
         at <unknown> (Error: ./app/build-error/page.tsx:4:1)
         at <unknown> (Error: (./app/build-error/page.tsx:4:1)
       \`\`\`

       ---"
      `)
    } else if (isRspack) {
      // Webpack output
      expect(strippedErrors).toMatchInlineSnapshot(`
       "# Found errors in 1 browser session(s)

       ## Session: /build-error

       **1 error(s) found**

       ### Build Error

       \`\`\`
       ./app/build-error/page.tsx
         ╰─▢   Γ— Error:   x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
               β”‚    ,-[4:1]
               β”‚  1 | export default function BuildErrorPage() {
               β”‚  2 |   // Syntax error - missing closing brace
               β”‚  3 |   return <div>Page
               β”‚  4 | }
               β”‚    : ^
               β”‚    \`----
               β”‚   x Expected '</', got '<eof>'
               β”‚    ,-[4:1]
               β”‚  1 | export default function BuildErrorPage() {
               β”‚  2 |   // Syntax error - missing closing brace
               β”‚  3 |   return <div>Page
               β”‚  4 | }
               β”‚    \`----
               β”‚
               β”‚
               β”‚ Caused by:
               β”‚     Syntax Error
       \`\`\`

       ---"
      `)
    } else {
      expect(strippedErrors).toMatchInlineSnapshot(`
       "# Found errors in 1 browser session(s)

       ## Session: /build-error

       **1 error(s) found**

       ### Build Error

       \`\`\`
       ./app/build-error/page.tsx
       Error:   x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
          ,-[4:1]
        1 | export default function BuildErrorPage() {
        2 |   // Syntax error - missing closing brace
        3 |   return <div>Page
        4 | }
          : ^
          \`----
         x Expected '</', got '<eof>'
          ,-[4:1]
        1 | export default function BuildErrorPage() {
        2 |   // Syntax error - missing closing brace
        3 |   return <div>Page
        4 | }
          \`----

       Caused by:
           Syntax Error
       \`\`\`

       ---"
      `)
    }
  })

  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: string = ''
      await retry(async () => {
        const sessionId = 'test-multi-' + Date.now()
        errors = await callGetErrors(sessionId)
        // Check that we have at least the 2 sessions we created
        expect(errors).toMatch(/Found errors in \d+ browser session/)
        // Ensure both our sessions are present
        expect(errors).toContain('/runtime-error')
        expect(errors).toContain('/runtime-error-2')
      })

      const strippedErrors = stripAnsi(errors).replace(
        /localhost:\d+/g,
        'localhost:PORT'
      )

      // Extract each session's content to check them independently
      const session1Match = strippedErrors.match(
        /## Session: \/runtime-error\n[\s\S]*?(?=---)/
      )
      const session2Match = strippedErrors.match(
        /## Session: \/runtime-error-2\n[\s\S]*?(?=---)/
      )

      expect(session1Match).toBeTruthy()
      expect(session2Match).toBeTruthy()

      expect(session1Match?.[0]).toMatchInlineSnapshot(`
        "## Session: /runtime-error

        **1 error(s) found**

        ### Runtime Errors

        #### Error 1 (Type: runtime)

        **Error**: Test runtime error

        \`\`\`
          at RuntimeErrorPage (app/runtime-error/page.tsx:2:9)
        \`\`\`

        "
      `)

      expect(session2Match?.[0]).toMatchInlineSnapshot(`
        "## Session: /runtime-error-2

        **1 error(s) found**

        ### Runtime Errors

        #### Error 1 (Type: runtime)

        **Error**: Test runtime error 2

        \`\`\`
          at RuntimeErrorPage (app/runtime-error-2/page.tsx:2:9)
        \`\`\`

        "
      `)
    } 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: string = ''
    await retry(async () => {
      const sessionId = 'test-config-error-' + Date.now()
      errors = await callGetErrors(sessionId)
      expect(errors).toContain('Next.js Configuration Errors')
      expect(errors).toContain('error(s) found in next.config')
    })

    const strippedErrors = stripAnsi(errors)
    expect(strippedErrors).toContain('Next.js Configuration Errors')
    expect(strippedErrors).toContain('Invalid next.config.js options detected')
    expect(strippedErrors).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 fixedErrors = await callGetErrors(sessionId)
      const strippedFixed = stripAnsi(fixedErrors)
      expect(strippedFixed).not.toContain('Next.js Configuration Errors')
      expect(strippedFixed).not.toContain('invalidTestProperty')
      expect(strippedFixed).toContain('No errors detected')
    })
  })
})

/**
 * 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(() => {})
    },
  }
}