diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index b39f97044..f8616e71a 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -231,15 +231,22 @@ const command = defineCommand({ listenOverrides.showURL = false } - const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onFileChange } = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, { + const started = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, { data: ctx.data, listenOverrides, showBanner: !ui, captureUIEvents: ui, onProgress: session && (snapshot => session.reportProgress(snapshot)), onListening: session && (info => session.reportListening(info)), + }).catch((error: unknown) => { + // The panel is mid-startup and holding the terminal, so it has to give it + // back before the error is printed under it. + session?.teardown() + throw error }) + const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onFileChange } = started + /** Feed the dev UI from the server running in this process. */ function attachDevUI(devUI: DevUIController): DevUIController { onLoading(message => devUI.setStatus('building', message)) @@ -393,7 +400,7 @@ const command = defineCommand({ process.exit(1) } logger.error(`Could not restart the dev server, keeping the current one: ${detail}`) - devUI.setStatus('ready') + devUI.settleRestart() onRestart(restart) return } diff --git a/packages/nuxt-cli/src/commands/index.ts b/packages/nuxt-cli/src/commands/index.ts index 27605df50..c7bdba90b 100644 --- a/packages/nuxt-cli/src/commands/index.ts +++ b/packages/nuxt-cli/src/commands/index.ts @@ -1,6 +1,59 @@ -import type { CommandDef } from 'citty' +import type { CommandDef, SubCommandsDef } from 'citty' -const _rDefault = (r: any) => (r.default || r) as Promise +import { asActionableError } from '../utils/errors' + +type Resolvable = T | Promise | (() => T) | (() => Promise) + +function resolve(value: Resolvable): T | Promise { + return typeof value === 'function' ? (value as () => T | Promise)() : value +} + +/** + * Carry the boundary into a command's subcommands, keeping each one lazy so + * wrapping a parent never loads them. + * + * The map is itself resolvable, and enumerating a function or a promise yields + * no entries, so it is resolved before being walked and stays a resolver where + * it began as one. + */ +function withRemedies(subCommands: Resolvable): Resolvable { + const wrapEntries = (resolved: SubCommandsDef): SubCommandsDef => Object.fromEntries( + Object.entries(resolved).map(([name, subCommand]) => [ + name, + async () => withRemedy(await resolve(subCommand)), + ]), + ) + return typeof subCommands === 'function' || subCommands instanceof Promise + ? async () => wrapEntries(await resolve(subCommands)) + : wrapEntries(subCommands) +} + +/** + * One error boundary for every command. + * + * A tagged error can be raised from any of the kit entry points a command + * touches, so the remedy is applied where the command ends rather than at each + * of them. + */ +export function withRemedy(command: CommandDef): CommandDef { + const { run, subCommands } = command + return { + ...command, + ...run && { + async run(context: Parameters[0]) { + try { + return await run(context) + } + catch (error) { + throw asActionableError(error) + } + }, + }, + ...subCommands && { subCommands: withRemedies(subCommands) }, + } +} + +const _rDefault = (r: any) => withRemedy((r.default || r) as CommandDef) as unknown as Promise const commandLoaders = { 'add': () => import('./add').then(_rDefault), diff --git a/packages/nuxt-cli/src/dev/tui/controller.ts b/packages/nuxt-cli/src/dev/tui/controller.ts index 78729ac70..f05853fbf 100644 --- a/packages/nuxt-cli/src/dev/tui/controller.ts +++ b/packages/nuxt-cli/src/dev/tui/controller.ts @@ -13,6 +13,12 @@ export interface DevUIController { /** Whether the interactive UI is active (rather than the plain fallback). */ interactive: boolean setStatus: (status: DevStatus, note?: string) => void + /** + * Report that a restart is over without a new server to show for it, so the + * panel returns to describing the one still running. A failed load keeps its + * error, since that is what the surviving server is. + */ + settleRestart: () => void /** Record a structured log event forwarded from the dev server fork. */ pushServerLog: (log: ForwardedLog) => void /** Record a batch of served requests for the traffic ticker. */ @@ -31,6 +37,7 @@ export interface DevUIController { export const NOOP_CONTROLLER: DevUIController = { interactive: false, setStatus: () => {}, + settleRestart: () => {}, pushServerLog: () => {}, pushRequests: () => {}, setRoutes: () => {}, diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index 2d113f3af..3530e6fac 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -50,6 +50,9 @@ const ACTIVITY_MS = 700 /** How long passing feedback stays on the panel before it is dropped. */ const NOTICE_MS = 4000 +/** Statuses that mean a load is in flight, so the server is not up yet. */ +const LOADING_STATUSES = new Set(['starting', 'building', 'restarting']) + interface UIShortcut { keys: string[] /** Short label for the hint line. Omitted shortcuts live only in the help view. */ @@ -132,6 +135,12 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) let animationInterval = LOGO_FRAME_MS let activityTimer: NodeJS.Timeout | undefined let noticeTimer: NodeJS.Timeout | undefined + /** + * The load in flight raised an error, so the server never came up. Held apart + * from the counts, which are cumulative and so cannot say whether anything is + * wrong *now*. + */ + let loadFailed = false function update(patch: Partial): void { Object.assign(state, patch) @@ -252,6 +261,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) function clearHistory(): void { events.clear() requests.clear() + loadFailed = false // With the history gone, the error badge would point at nothing. update({ failures: 0, ...state.status === 'error' ? { status: 'ready' as DevStatus, note: undefined } : {} }) } @@ -270,7 +280,17 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return } if (event.level <= 0) { - update({ errors: (state.errors ?? 0) + 1, status: state.status === 'ready' ? 'error' : state.status }) + // An error raised while a load is in flight is that load failing, and + // nothing else reports that it has. One raised by a server already up + // belongs to the page it was serving. + loadFailed ||= LOADING_STATUSES.has(state.status) + update({ + errors: (state.errors ?? 0) + 1, + status: 'error', + // The phase note describes work that is no longer happening, so the + // badge's own description takes the line back. + ...state.status === 'error' ? {} : { note: undefined }, + }) } else if (event.level === 1) { // A warning about what the CLI could not do says nothing about the app, so @@ -323,6 +343,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) process.emit('SIGINT' as any) } + const settleRestart = () => update({ status: loadFailed ? 'error' : 'ready', note: undefined }) + const restart = async (options: { clearCache?: boolean } = {}) => { if (!context.restart) { return @@ -343,7 +365,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) showNotice(`could not restart: ${error instanceof Error ? error.message : error}`, 'warn') } finally { - update({ status: 'ready' }) + // A restart that got through, or whose load failed, has already reported + // itself through `setStatus`; only one nothing spoke for is left to settle. + if (state.status === 'restarting') { + settleRestart() + } } } @@ -547,10 +573,12 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return { interactive: true, + settleRestart, setStatus: (status, note) => { // The counts only describe what is currently wrong, so a successful load // supersedes earlier build errors. if (status === 'ready') { + loadFailed = false update({ status, note: undefined, progress: undefined, phaseStartedAt: undefined, phaseElapsedMs: undefined, errors: 0, warnings: 0, failures: 0 }) return } @@ -582,12 +610,17 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) // Nuxt answers a failed render with its error page rather than logging it, // so the response status is the only signal that something is wrong. const failing = (app.at(-1)?.status ?? 0) >= 500 - const recovered = app.length > 0 && !failing && state.status === 'error' && !state.errors + // A page that failed is answered by the next one that does not, but only + // a load that gets through clears a failed load. + const recovered = app.length > 0 && !failing && state.status === 'error' && !loadFailed + // A request that failed because the load did is a symptom of it, and the + // load error is reported in the logs rather than against the request. + const failureNote = failing && !loadFailed ? 'a request failed · press n to trace it' : undefined update({ active: true, failures: (state.failures ?? 0) + failed.length, status: failing ? 'error' : recovered ? 'ready' : state.status, - note: failing ? 'a request failed · press n to trace it' : recovered ? undefined : state.note, + note: failureNote ?? (recovered ? undefined : state.note), }) repaintTicker() clearTimeout(activityTimer) diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 66edacc52..b42478ac3 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -529,9 +529,8 @@ export class NuxtDevServer extends EventEmitter { if (this.#rejectDisallowedHost(req, res)) { return } - if (this.options.captureUIEvents) { - this.#internalResponses.add(res) - } + // The error page answers a request the client made, so it stays in the + // request feed rather than counting as one the CLI answered itself. // The recovery script makes the page reload itself once the next load // starts, so a fixed file shows up without the reader touching anything. await renderError(req, res, this.#loadingError, { inject: RECOVERY_SCRIPT }) diff --git a/packages/nuxt-cli/src/utils/errors.ts b/packages/nuxt-cli/src/utils/errors.ts index d48e41319..812d000d2 100644 --- a/packages/nuxt-cli/src/utils/errors.ts +++ b/packages/nuxt-cli/src/utils/errors.ts @@ -20,6 +20,29 @@ export class ActionableError extends Error { } } +/** + * Re-raise a Nuxt error by the remedy it carries rather than by its stack. + * + * Nuxt tags the errors it raises with `fix`, the command that resolves it, and + * `docs`, where the rest is explained. Those frames are all inside + * `node_modules` or this CLI's own `dist`, so the remedy is the whole of what a + * reader can act on. An untagged error is returned as it came. + */ +export function asActionableError(error: unknown): unknown { + if (!(error instanceof Error)) { + return error + } + const { fix, docs } = error as Error & { fix?: unknown, docs?: unknown } + if (typeof fix !== 'string' || !fix.trim()) { + return error + } + const lines = [error.message, fix] + if (typeof docs === 'string' && docs.trim()) { + lines.push(`See ${docs}`) + } + return new ActionableError(lines.join('\n')) +} + /** * Errors that say something about the other end of a connection rather than * about this process: a broken pipe, a client hanging up mid-request, a tab diff --git a/packages/nuxt-cli/test/unit/command-remedy.spec.ts b/packages/nuxt-cli/test/unit/command-remedy.spec.ts new file mode 100644 index 000000000..eec712790 --- /dev/null +++ b/packages/nuxt-cli/test/unit/command-remedy.spec.ts @@ -0,0 +1,92 @@ +import type { CommandDef } from 'citty' + +import { describe, expect, it } from 'vitest' + +import { withRemedy } from '../../src/commands' +import { ActionableError } from '../../src/utils/errors' + +function tagged(): Error { + return Object.assign(new Error('The module `@nuxt/image` could not be loaded. It may not be installed.'), { + code: 'NUXT_B8017', + fix: 'Run `npm install @nuxt/image` to install it.', + }) +} + +function run(command: CommandDef): Promise { + return (command.run as (context: unknown) => Promise)({ args: {}, cmd: command, rawArgs: [] }) +} + +/** Resolve a command's subcommand map however it was declared, as citty does. */ +async function resolveMap(command: CommandDef): Promise> { + const { subCommands } = command + return await (typeof subCommands === 'function' ? subCommands() : subCommands) as Record +} + +async function subCommand(command: CommandDef, name: string): Promise { + const entry = (await resolveMap(command))[name] + return await (typeof entry === 'function' ? entry() : entry) as CommandDef +} + +describe('command error boundary', () => { + it('should present a tagged error by its remedy however far into the command it was raised', async () => { + const command = withRemedy({ meta: { name: 'build' }, run: async () => { + await Promise.resolve() + throw tagged() + } }) + + await expect(run(command)).rejects.toThrow(ActionableError) + await expect(run(command)).rejects.toThrow(/Run `npm install @nuxt\/image` to install it\./) + }) + + it('should leave an untagged error with its stack', async () => { + const parse = new Error('ParseError: Unexpected token') + const command = withRemedy({ meta: { name: 'build' }, run: () => Promise.reject(parse) }) + + await expect(run(command)).rejects.toBe(parse) + }) + + it('should carry the boundary into subcommands', async () => { + const command = withRemedy({ + meta: { name: 'module' }, + subCommands: { add: () => ({ meta: { name: 'add' }, run: () => Promise.reject(tagged()) }) }, + }) + + await expect(run(await subCommand(command, 'add'))).rejects.toThrow(ActionableError) + }) + + it('should keep the subcommands of a command whose whole map is a resolver', async () => { + const command = withRemedy({ + meta: { name: 'module' }, + subCommands: () => ({ add: { meta: { name: 'add' }, run: () => Promise.reject(tagged()) } }), + }) + + expect(Object.keys(await resolveMap(command))).toEqual(['add']) + await expect(run(await subCommand(command, 'add'))).rejects.toThrow(ActionableError) + }) + + it('should keep the subcommands of a command whose map is a promise', async () => { + const command = withRemedy({ + meta: { name: 'task' }, + subCommands: Promise.resolve({ run: { meta: { name: 'run' }, run: () => Promise.reject(tagged()) } }), + }) + + expect(Object.keys(await resolveMap(command))).toEqual(['run']) + await expect(run(await subCommand(command, 'run'))).rejects.toThrow(ActionableError) + }) + + it('should leave a plain subcommand map as a plain object', async () => { + const command = withRemedy({ + meta: { name: 'module' }, + subCommands: { add: { meta: { name: 'add' }, run: () => Promise.resolve('ok') } }, + }) + + expect(typeof command.subCommands).toBe('object') + await expect(run(await subCommand(command, 'add'))).resolves.toBe('ok') + }) + + it('should pass a resolved command through untouched when it cannot fail', async () => { + const command = withRemedy({ meta: { name: 'info' }, run: () => Promise.resolve('ok') }) + + await expect(run(command)).resolves.toBe('ok') + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 868edaeaa..ca8021a81 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -2283,7 +2283,7 @@ const context = { onReady: () => {}, } -async function withPanel(run: (ui: ReturnType, settle: () => Promise) => Promise): Promise { +async function withPanel(run: (ui: ReturnType, settle: () => Promise) => Promise, overrides: Record = {}): Promise { const chunks: string[] = [] const saved = (['isTTY', 'columns', 'rows'] as const).map(key => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const) const stdin = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') @@ -2298,7 +2298,7 @@ async function withPanel(run: (ui: ReturnType, settle: () => return true }) const session = beginDevUI({ ci: false, test: false, version: '4.5.2' })! - const ui = setupDevUI(context as never, { ci: false, test: false, version: '4.5.2' }) + const ui = setupDevUI({ ...context, ...overrides } as never, { ci: false, test: false, version: '4.5.2' }) try { await run(ui, async () => { // The panel repaints on a trailing timer, so nothing is on screen yet. @@ -2449,6 +2449,141 @@ describe('request failures on the panel', () => { expect(last).toContain('a request failed') }) }) + + it('should return to ready once a request succeeds after a failing one', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + // A failed render is reported as a log as well as a status. + ui.pushServerLog({ level: 0, logType: 'error', message: '/app.vue \u2014 Interpolation end sign was not found.', origin: 'runtime' }) + ui.pushRequests([{ method: 'GET', url: '/', status: 500, duration: 1 }]) + expect(await settle()).toContain('a request failed') + ui.pushRequests([{ method: 'GET', url: '/', status: 200, duration: 1 }]) + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('READY') + expect(last).not.toContain('a request failed') + }) + }) + + it('should report a load that errors rather than leaving its phase on the panel', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('building', 'nuxt.config.ts changed. Reloading Nuxt...') + ui.pushServerLog({ level: 0, logType: 'error', message: 'Cannot restart nuxt: ParseError: Unexpected token', origin: 'build' }) + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('ERROR') + expect(last).toContain('an error was logged') + expect(last).not.toContain('BUILDING') + }) + }) + + it('should count a request the error page answered while a load has failed', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('building', 'nuxt.config.ts changed. Reloading Nuxt...') + ui.pushServerLog({ level: 0, logType: 'error', message: 'Cannot restart nuxt: ParseError: Unexpected token', origin: 'build' }) + await settle() + + ui.pushRequests([{ method: 'GET', url: '/', status: 500, duration: 1 }]) + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('1 failed request') + expect(last).toContain('ERROR') + expect(last).toContain('an error was logged') + expect(last).not.toContain('a request failed') + }) + }) + + it('should keep a failed load on the panel across a restart that does not fix it', async () => { + let restarts = 0 + await withPanel(async (ui, settle) => { + ui.setStatus('building', 'nuxt.config.ts changed. Reloading Nuxt...') + ui.pushServerLog({ level: 0, logType: 'error', message: 'Cannot restart nuxt: ParseError: Unexpected token', origin: 'build' }) + await settle() + + process.stdin.emit('keypress', 'r', { name: 'r', sequence: 'r' }) + await vi.waitFor(() => expect(restarts).toBe(1)) + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('ERROR') + expect(last).not.toContain('READY') + }, { + // `devServer.load` reports a failed reload through the log and resolves. + restart: async () => { + restarts++ + }, + }) + }) + + it('should keep the error when a restart is abandoned and the broken server is kept', async () => { + // `replaceWithFork` keeps the outgoing server when the incoming fork dies. + await withPanel(async (ui, settle) => { + ui.setStatus('building', 'nuxt.config.ts changed. Reloading Nuxt...') + ui.pushServerLog({ level: 0, logType: 'error', message: 'Cannot restart nuxt: ParseError: Unexpected token', origin: 'build' }) + await settle() + + ui.setStatus('restarting', 'restarting') + ui.settleRestart() + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('ERROR') + expect(last).not.toContain('READY') + }) + }) + + it('should return a healthy server to ready when a restart is abandoned', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + ui.setStatus('restarting', 'restarting') + ui.settleRestart() + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('READY') + expect(last).not.toContain('RESTART') + }) + }) + + it('should settle a restart that nothing else spoke for', async () => { + let restarts = 0 + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + await settle() + + process.stdin.emit('keypress', 'r', { name: 'r', sequence: 'r' }) + await vi.waitFor(() => expect(restarts).toBe(1)) + const frames = await settle() + + const last = frames.slice(frames.lastIndexOf('Nuxt 4.5.2')) + expect(last).toContain('READY') + expect(last).not.toContain('RESTART') + }, { + restart: async () => { + restarts++ + }, + }) + }) + + it('should keep a failed load on the panel until a load gets through', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('building', 'nuxt.config.ts changed. Reloading Nuxt...') + ui.pushServerLog({ level: 0, logType: 'error', message: 'Cannot restart nuxt: ParseError: Unexpected token', origin: 'build' }) + await settle() + + // A page answered off the previous build says nothing about the load. + ui.pushRequests([{ method: 'GET', url: '/', status: 200, duration: 1 }]) + const served = await settle() + expect(served.slice(served.lastIndexOf('Nuxt 4.5.2'))).toContain('ERROR') + + ui.setStatus('ready') + const reloaded = await settle() + expect(reloaded.slice(reloaded.lastIndexOf('Nuxt 4.5.2'))).toContain('READY') + }) + }) }) describe('the terminal host on the panel', () => { diff --git a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts index dcbf388e0..a034fbd58 100644 --- a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts @@ -1,5 +1,6 @@ import type { AddressInfo } from 'node:net' +import { EventEmitter } from 'node:events' import { existsSync, readFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -96,6 +97,25 @@ async function get(server: InstanceType, path = '/'): Prom return { status: response.status, body: await response.text() } } +/** Drive `handler` without a socket, for a server that has not listened yet. */ +async function serveLocally(server: InstanceType, path: string): Promise<{ status: number }> { + const res = new EventEmitter() as any + res.statusCode = 200 + res.headersSent = false + res.writableEnded = false + res.setHeader = () => {} + res.end = () => { + res.writableEnded = true + res.headersSent = true + res.emit('close') + } + const closed = new Promise(resolve => res.once('close', resolve)) + const req = { url: path, method: 'GET', headers: { accept: 'text/html', host: '127.0.0.1' }, rawHeaders: [] } as any + await server.handler(req, res) + await closed + return { status: res.statusCode } +} + async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'nuxt-dev-lifecycle-')) tempDirs.push(dir) @@ -186,20 +206,27 @@ describe('dev server startup', () => { }) describe('dev server request feed', () => { - it('should keep its own loading and error responses out of the feed', async () => { + it('should keep its own loading responses out of the feed', async () => { + const server = createServer({ captureUIEvents: true, loadingTemplate: () => 'loading' }) + const seen: Array<{ url: string, status: number }> = [] + server.on('request', event => seen.push({ url: event.url, status: event.status })) + + await expect(serveLocally(server, '/loading')).resolves.toMatchObject({ status: 503 }) + + expect(seen).toEqual([]) + }) + + it('should record a request answered by the error page', async () => { const server = createServer({ captureUIEvents: true }) await server.init() const seen: Array<{ url: string, status: number }> = [] server.on('request', event => seen.push({ url: event.url, status: event.status })) - await expect(get(server, '/app')).resolves.toMatchObject({ status: 200 }) - loadNuxt.mockImplementation(() => Promise.reject(new Error('config exploded'))) await server.load(true, { type: 'config', files: [join(cwd, 'nuxt.config.ts')] }) await expect(get(server, '/broken')).resolves.toMatchObject({ status: 500 }) - await vi.waitFor(() => expect(seen).toContainEqual({ url: '/app', status: 200 })) - expect(seen).not.toContainEqual(expect.objectContaining({ url: '/broken' })) + await vi.waitFor(() => expect(seen).toContainEqual({ url: '/broken', status: 500 })) }) }) diff --git a/packages/nuxt-cli/test/unit/errors.spec.ts b/packages/nuxt-cli/test/unit/errors.spec.ts index b0ba29878..d8e09264f 100644 --- a/packages/nuxt-cli/test/unit/errors.spec.ts +++ b/packages/nuxt-cli/test/unit/errors.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { applySourceMap, stripCwd } from '../../src/dev/error' -import { ActionableError, isRemotePeerError } from '../../src/utils/errors' +import { ActionableError, asActionableError, isRemotePeerError } from '../../src/utils/errors' describe('actionableError', () => { it('should print its advice instead of a stack trace', () => { @@ -17,6 +17,41 @@ describe('actionableError', () => { }) }) +describe('asActionableError', () => { + it('should lead with the remedy a nuxt error carries instead of its frames', () => { + const error = Object.assign(new Error('The module `@nuxt/image` could not be loaded. It may not be installed.'), { + code: 'NUXT_B8017', + fix: 'Run `npm install @nuxt/image` to install it.', + }) + + const actionable = asActionableError(error) as Error + + expect(actionable).toBeInstanceOf(ActionableError) + expect(actionable.stack).toBe(actionable.message) + expect(actionable.message).toBe('The module `@nuxt/image` could not be loaded. It may not be installed.\nRun `npm install @nuxt/image` to install it.') + expect(actionable.stack).not.toContain(' at ') + }) + + it('should point at the docs a nuxt error references', () => { + const error = Object.assign(new Error('Something is misconfigured.'), { + fix: 'Set `compatibilityDate`.', + docs: 'https://nuxt.com/docs', + }) + + expect((asActionableError(error) as Error).message).toBe('Something is misconfigured.\nSet `compatibilityDate`.\nSee https://nuxt.com/docs') + }) + + it('should leave an untagged error alone, since its stack is all there is', () => { + const error = new Error('boom') + + const blank = Object.assign(new Error('boom'), { fix: ' ' }) + + expect(asActionableError(error)).toBe(error) + expect(asActionableError(blank)).toBe(blank) + expect(asActionableError('not an error')).toBe('not an error') + }) +}) + describe('isRemotePeerError', () => { it('should detect errors from the other end of a connection', () => { expect(isRemotePeerError(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))).toBe(true)