/**
* E2E test for the turbopack trace server MCP API and CLI.
*
* Flow:
* 1. Start the Next.js app with `NEXT_TURBOPACK_TRACING=1`.
* - In dev mode: fetches `/` to trigger compilation so trace data is written.
* - In start mode: the build already produced the trace file.
* 2. Wait for the trace file to appear.
* 3. Spawn `next internal trace <file> --mcp-port <port>` in the background.
* 4. Wait for the MCP HTTP server to be ready.
* 5. Run MCP HTTP and CLI queries and verify the response structure.
*/
import { nextTestSetup, isNextDeploy } from 'e2e-utils'
import { existsSync } from 'fs'
import path from 'path'
import spawn from 'cross-spawn'
import type { ChildProcess } from 'child_process'
import treeKill from 'tree-kill'
import { findPort, retry } from 'next-test-utils'
// ─── helpers ─────────────────────────────────────────────────────────────────
const nextBin = path.join(
path.dirname(require.resolve('next/package')),
'dist/bin/next'
)
/**
* POST a JSON-RPC `tools/call` to the MCP server and return the text content.
* The Streamable-HTTP transport responds with Server-Sent Events; we find the
* first `data:` line that contains a `result.content[].text` field.
*/
async function callMcpTool(
port: number,
toolName: string,
args: Record<string, unknown>
): Promise<string> {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: { name: toolName, arguments: args },
id: 1,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
const body = await res.text()
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue
const msg = JSON.parse(line.slice('data: '.length)) as {
result?: { content?: Array<{ type: string; text?: string }> }
error?: unknown
}
if (msg.error) throw new Error(`MCP error: ${JSON.stringify(msg.error)}`)
const text = msg.result?.content?.find((c) => c.type === 'text')?.text
if (text !== undefined) return text
}
throw new Error(`No text content in MCP response:\n${body}`)
}
type SpanData = {
id: string
name: string
[key: string]: unknown
}
type QuerySpansResult = {
spans: SpanData[]
page: number
totalPages: number
totalCount: number
}
/** Call query_spans with outputType:'json' and return the parsed result. */
async function querySpansJson(
port: number,
args: Record<string, unknown> = {}
): Promise<QuerySpansResult> {
const text = await callMcpTool(port, 'query_spans', {
...args,
outputType: 'json',
})
return JSON.parse(text)
}
/**
* Run `next internal query-trace` with the given extra arguments.
* Returns captured stdout, stderr, and exit code.
*/
function runQueryTraceCli(
extraArgs: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve) => {
const proc = spawn(
'node',
['--no-deprecation', nextBin, 'internal', 'query-trace', ...extraArgs],
{ stdio: 'pipe' }
)
let stdout = ''
let stderr = ''
proc.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString()))
proc.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString()))
proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }))
})
}
// ─── test suite ──────────────────────────────────────────────────────────────
describe('turbopack-trace-server', () => {
if (isNextDeploy) {
it('skipped for deploy mode', () => {})
return
}
const { next, isTurbopack, isNextDev, skipped } = nextTestSetup({
files: __dirname,
env: { NEXT_TURBOPACK_TRACING: '1' },
skipDeployment: true,
})
if (skipped) return
// Error-path test: does not need turbopack or a running trace server.
it('CLI: should show an error when the trace server is not running', async () => {
const unusedPort = await findPort()
const { stderr, exitCode } = await runQueryTraceCli([
'--port',
String(unusedPort),
])
expect(exitCode).toBe(1)
expect(stderr).toContain(
`Could not connect to trace server on port ${unusedPort}`
)
expect(stderr).toContain('next internal trace <file>')
expect(stderr).toContain('next internal query-trace --help')
})
// Skip turbopack-specific tests for non-turbopack builds.
if (!isTurbopack) return
let traceServerProcess: ChildProcess | undefined
let mcpPort: number
beforeAll(async () => {
// In dev mode, trigger compilation so the trace file has real span data.
if (isNextDev) {
const res = await next.fetch('/')
if (res.status !== 200) {
throw new Error(`Dev server returned ${res.status} for /`)
}
}
// Wait for the trace file to appear.
// Since Next.js 16.2.1-canary.25, trace is written to .next-profiles/ instead of distDir.
// Fall back to distDir for compatibility with older locally-installed binaries.
const traceFileNewPath = path.join(
next.testDir,
'.next-profiles',
'trace-turbopack'
)
const traceFileOldPath = path.join(
next.testDir,
next.distDir,
'trace-turbopack'
)
let traceFile: string = traceFileNewPath
await retry(
async () => {
if (existsSync(traceFileNewPath)) {
traceFile = traceFileNewPath
} else if (existsSync(traceFileOldPath)) {
traceFile = traceFileOldPath
} else {
throw new Error(
`Trace file not found yet: tried ${traceFileNewPath} and ${traceFileOldPath}`
)
}
},
15_000,
500
)
// Allocate a port and start the trace server with an MCP endpoint.
mcpPort = await findPort()
traceServerProcess = spawn(
'node',
[
'--no-deprecation',
nextBin,
'internal',
'trace',
traceFile,
'--mcp-port',
String(mcpPort),
],
{ stdio: 'inherit' }
)
// Wait for the MCP HTTP server to be ready.
await retry(
async () => {
const res = await fetch(`http://127.0.0.1:${mcpPort}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 0,
}),
})
if (res.status >= 500) {
throw new Error(`MCP server not ready (HTTP ${res.status})`)
}
},
30_000,
500
)
}, 120_000)
afterAll(async () => {
if (traceServerProcess?.pid) {
await new Promise<void>((resolve) => {
treeKill(traceServerProcess!.pid!, 'SIGKILL', () => resolve())
})
traceServerProcess = undefined
}
})
// ─── MCP HTTP API tests ──────────────────────────────────────────────────
it('should return root-level spans in markdown format', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', {})
expect(md).toContain('## Spans at root level')
expect(md).toMatch(/ID: `[a-z0-9-]+`/)
expect(md).toMatch(/CPU Duration|Corrected Duration/)
})
it('should support aggregated mode (default) grouping spans by name', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', { aggregated: true })
expect(md).toContain('## Spans at root level')
expect(md).toMatch(/###/)
})
it('should support pagination', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', { page: 1 })
expect(md).toMatch(/Page \d+ of \d+/)
expect(md).toMatch(/\d+ total/)
})
it('should drill into children of a span using its ID', async () => {
const { spans } = await querySpansJson(mcpPort, { sort: 'value' })
const spanId = spans[0].id
const childMd = await callMcpTool(mcpPort, 'query_spans', {
parent: spanId,
})
expect(childMd).toContain(`children of ID \`${spanId}\``)
expect(childMd).toMatch(/Page \d+ of \d+/)
})
it('should return no results for an impossible search term', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', {
search: 'zzz_unlikely_span_name_zzz',
})
expect(md).toMatch(/\b0 total/)
})
it('should return results when searching for a real span name', async () => {
// Get a real span name from the root level via JSON output.
const { spans } = await querySpansJson(mcpPort, { sort: 'value' })
const searchTerm = spans[0].name.slice(0, 20)
const md = await callMcpTool(mcpPort, 'query_spans', {
search: searchTerm,
})
// Should find at least the span we took the name from.
expect(md).not.toMatch(/\b0 total/)
expect(md).toMatch(/###/)
})
it('should support sort by value', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', { sort: 'value' })
expect(md).toContain('## Spans at root level')
expect(md).toMatch(/###/)
})
it('should support sort by name', async () => {
const md = await callMcpTool(mcpPort, 'query_spans', { sort: 'name' })
expect(md).toContain('## Spans at root level')
expect(md).toMatch(/###/)
})
it('should return JSON when outputType is json', async () => {
const data = await querySpansJson(mcpPort)
expect(Array.isArray(data.spans)).toBe(true)
expect(data.spans.length).toBeGreaterThan(0)
expect(data).toHaveProperty('page')
expect(data).toHaveProperty('totalPages')
expect(data).toHaveProperty('totalCount')
// Each span should have the expected fields.
const span = data.spans[0]
expect(span).toHaveProperty('id')
expect(span).toHaveProperty('name')
expect(span).toHaveProperty('cpuDuration')
expect(span).toHaveProperty('correctedDuration')
expect(span).toHaveProperty('isAggregated')
})
// ─── CLI tests ───────────────────────────────────────────────────────────
it('CLI: should return root-level spans', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
])
expect(exitCode).toBe(0)
expect(stdout).toContain('## Spans at root level')
expect(stdout).toMatch(/ID: `[a-z0-9-]+`/)
expect(stdout).toMatch(/CPU Duration|Corrected Duration/)
})
it('CLI: should support --sort value flag', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--sort',
'value',
])
expect(exitCode).toBe(0)
expect(stdout).toContain('## Spans at root level')
expect(stdout).toMatch(/###/)
})
it('CLI: should support --sort name flag', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--sort',
'name',
])
expect(exitCode).toBe(0)
expect(stdout).toContain('## Spans at root level')
expect(stdout).toMatch(/###/)
})
it('CLI: should support --search flag with a real match', async () => {
// Get a real span name to search for via JSON output.
const { spans } = await querySpansJson(mcpPort, { sort: 'value' })
const searchTerm = spans[0].name.slice(0, 20)
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--search',
searchTerm,
])
expect(exitCode).toBe(0)
expect(stdout).not.toMatch(/\b0 total/)
expect(stdout).toMatch(/###/)
})
it('CLI: should support --search flag with no match', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--search',
'zzz_unlikely_span_name_zzz',
])
expect(exitCode).toBe(0)
expect(stdout).toMatch(/\b0 total/)
})
it('CLI: should support --no-aggregated flag', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--no-aggregated',
])
expect(exitCode).toBe(0)
expect(stdout).toContain('## Spans at root level')
// Raw span IDs are plain numbers (no "a" prefix).
expect(stdout).toMatch(/ID: `\d+`/)
})
it('CLI: should support --parent to drill into children', async () => {
const { spans } = await querySpansJson(mcpPort, { sort: 'value' })
const spanId = spans[0].id
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--parent',
spanId,
])
expect(exitCode).toBe(0)
expect(stdout).toContain(`children of ID \`${spanId}\``)
expect(stdout).toMatch(/Page \d+ of \d+/)
})
it('CLI: should support --json flag', async () => {
const { stdout, exitCode } = await runQueryTraceCli([
'--port',
String(mcpPort),
'--json',
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data).toHaveProperty('spans')
expect(data).toHaveProperty('page')
expect(data).toHaveProperty('totalPages')
expect(data).toHaveProperty('totalCount')
expect(Array.isArray(data.spans)).toBe(true)
expect(data.spans.length).toBeGreaterThan(0)
})
})