Repository: vercel/next.js
Pull request: bundle-analyzer: fix regression and add basic smoke test

#86886 broke `next experimental-analyze` since it depended on the external esm api which does not work in Node.js since it references extensionless imports.

This corrects that as well as adds a basic smoke test just to verify that `next experimental-analyze` can actually run without failing.

Changed files:
filename: packages/next/src/build/turbopack-analyze/index.ts
patch: @@ -1,3 +1,6 @@
+import type { NextConfigComplete } from '../../server/config-shared'
+import type { __ApiPreviewProps } from '../../server/api-utils'
+
 import path from 'path'
 import { validateTurboNextConfig } from '../../lib/turbopack-warning'
 import { isFileSystemCacheEnabledForBuild } from '../../shared/lib/turbopack/utils'
@@ -6,9 +9,7 @@ import { isCI } from '../../server/ci-info'
 import { backgroundLogCompilationEvents } from '../../shared/lib/turbopack/compilation-events'
 import { getSupportedBrowsers } from '../utils'
 import { normalizePath } from '../../lib/normalize-path'
-import type { NextConfigComplete } from '../../server/config-shared'
-import type { __ApiPreviewProps } from '../../server/api-utils'
-import { PHASE_PRODUCTION_BUILD } from '../../api/constants'
+import { PHASE_PRODUCTION_BUILD } from '../../shared/lib/constants'
 
 export type AnalyzeContext = {
   config: NextConfigComplete

filename: test/e2e/next-analyze/app/page.tsx
patch: @@ -0,0 +1,3 @@
+export default function Page() {
+  return <div>Hello World</div>
+}

filename: test/e2e/next-analyze/next-analyze.test.ts
patch: @@ -0,0 +1,103 @@
+import { nextTestSetup } from 'e2e-utils'
+import { runNextCommand, shouldUseTurbopack } from 'next-test-utils'
+import path from 'node:path'
+import { ChildProcess, spawn } from 'node:child_process'
+
+describe('next experimental-analyze', () => {
+  if (!shouldUseTurbopack()) {
+    it('skips in non-Turbopack tests', () => {})
+    return
+  }
+
+  const { next, skipped } = nextTestSetup({
+    files: __dirname,
+    skipStart: true,
+    skipDeployment: true,
+  })
+
+  if (skipped) {
+    it('is skipped', () => {})
+    return
+  }
+
+  it('runs successfully without errors', async () => {
+    const { code, stderr } = await runNextCommand(['experimental-analyze'], {
+      cwd: next.testDir,
+      stderr: true,
+    })
+
+    expect(code).toBe(0)
+    expect(stderr).not.toContain('Error')
+
+    const nextDir = path.dirname(require.resolve('next/package'))
+    const nextBin = path.join(nextDir, 'dist/bin/next')
+
+    const serveProcess = spawn(
+      'node',
+      [nextBin, 'experimental-analyze', '--serve', '--port', '0'],
+      {
+        cwd: next.testDir,
+        stdio: ['ignore', 'pipe', 'pipe'],
+      }
+    )
+
+    try {
+      const url = await waitForServer(serveProcess)
+      const response = await fetch(url)
+      expect(response.status).toBe(200)
+      expect(await response.text()).toContain(
+        '<title>Next.js Bundle Analyzer</title>'
+      )
+    } finally {
+      serveProcess.kill()
+    }
+  })
+})
+
+function waitForServer(process: ChildProcess, timeoutMs: number = 30000) {
+  const serverUrlPromise = new Promise<string>((resolve, reject) => {
+    const timeout = setTimeout(() => {
+      process.stdout.off('data', onStdout)
+      process.off('error', onError)
+      process.off('exit', onExit)
+      reject(new Error('Server did not start within timeout'))
+    }, timeoutMs)
+
+    function onStdout(data: Buffer) {
+      const urlMatch = data.toString().match(/http:\/\/[^\s]+/)
+      if (urlMatch) {
+        clearTimeout(timeout)
+        process.stdout.off('data', onStdout)
+        process.off('error', onError)
+        process.off('exit', onExit)
+        resolve(urlMatch[0])
+      }
+    }
+
+    function onError(error: Error) {
+      clearTimeout(timeout)
+      process.stdout.off('data', onStdout)
+      process.off('error', onError)
+      process.off('exit', onExit)
+      reject(error)
+    }
+
+    function onExit(code: number) {
+      clearTimeout(timeout)
+      process.stdout.off('data', onStdout)
+      process.off('error', onError)
+      process.off('exit', onExit)
+      reject(
+        new Error(
+          `Server process exited with code ${code} before URL was emitted`
+        )
+      )
+    }
+
+    process.stdout.on('data', onStdout)
+    process.on('error', onError)
+    process.on('exit', onExit)
+  })
+
+  return serverUrlPromise
+}

filename: test/e2e/next-analyze/next.config.js
patch: @@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {}
+
+module.exports = nextConfig