diff --git a/packages/nuxi/src/commands/doctor.ts b/packages/nuxi/src/commands/doctor.ts new file mode 100644 index 00000000..10473616 --- /dev/null +++ b/packages/nuxi/src/commands/doctor.ts @@ -0,0 +1,357 @@ +import type { Nuxt } from '@nuxt/schema' + +import process from 'node:process' + +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { colors } from 'consola/utils' +import { resolve } from 'pathe' +import { readPackageJSON } from 'pkg-types' +import { satisfies as semverSatisfies } from 'semver' +import { isBun, isDeno } from 'std-env' + +import { loadKit, tryResolveNuxt } from '../utils/kit' +import { cwdArgs, legacyRootDirArgs, logLevelArgs } from './_shared' + +interface DoctorCheck { + // Required + name: string + status: 'success' | 'warning' | 'error' + message: string + + // Optional - identity/origin + id?: string // programmatic code: "MISSING_PEER_DEP" + source?: string // module name: "@nuxt/ui" + + // Optional - verbose fields + details?: string | string[] + suggestion?: string + url?: string + + // Optional - programmatic + data?: Record +} + +interface DoctorCheckContext { + addCheck: (check: DoctorCheck) => void + nuxt: Nuxt +} + +declare module '@nuxt/schema' { + interface NuxtHooks { + 'doctor:check': (ctx: DoctorCheckContext) => void | Promise + } +} + +const plural = (n: number) => n === 1 ? '' : 's' + +async function resolveNuxtVersion(cwd: string): Promise { + const nuxtPath = tryResolveNuxt(cwd) + for (const pkg of ['nuxt', 'nuxt-nightly', 'nuxt-edge', 'nuxt3']) { + try { + const pkgJson = await readPackageJSON(pkg, { url: nuxtPath || cwd }) + if (pkgJson?.version) + return pkgJson.version + } + catch (err: any) { + // Ignore "not found" errors, log unexpected ones + if (err?.code !== 'ERR_MODULE_NOT_FOUND' && err?.code !== 'ENOENT' && !err?.message?.includes('Cannot find')) + log.warn(`Failed to read ${pkg} version: ${err?.message || err}`) + } + } +} + +export default defineCommand({ + meta: { + name: 'doctor', + description: 'Run diagnostic checks on Nuxt project', + }, + args: { + ...cwdArgs, + ...legacyRootDirArgs, + ...logLevelArgs, + verbose: { + type: 'boolean', + description: 'Show details, suggestions, and URLs', + }, + json: { + type: 'boolean', + description: 'Output results as JSON', + }, + }, + async run(ctx) { + const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) + + if (!ctx.args.json) + intro(colors.cyan('Running diagnostics...')) + + const { loadNuxt } = await loadKit(cwd) + + let nuxt: Nuxt + try { + nuxt = await loadNuxt({ + cwd, + ready: true, + overrides: { + logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose' | undefined, + }, + }) + } + catch (err) { + if (ctx.args.json) { + // eslint-disable-next-line no-console + console.log(JSON.stringify([{ name: 'Nuxt', status: 'error', message: `Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}` }])) + } + else { + log.error(colors.red(`Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}`)) + outro(colors.red('Diagnostics failed')) + } + return process.exit(1) + } + + const checks: DoctorCheck[] = [] + + try { + await runCoreChecks(checks, nuxt, cwd) + + const addCheck = (c: DoctorCheck) => { + const validStatus = c?.status === 'success' || c?.status === 'warning' || c?.status === 'error' + if (!c?.name || !c?.message || !validStatus) { + checks.push({ + id: 'INVALID_DOCTOR_CHECK', + name: 'Doctor', + status: 'error', + message: 'Invalid doctor:check payload from module', + source: c?.source, + data: { received: c }, + }) + return + } + checks.push(c) + } + + try { + await nuxt.callHook('doctor:check', { addCheck, nuxt }) + } + catch (err) { + checks.push({ + id: 'DOCTOR_HOOK_FAILED', + name: 'Doctor', + status: 'error', + message: `doctor:check hook failed: ${err instanceof Error ? err.message : String(err)}`, + }) + } + + displayResults(checks, { verbose: ctx.args.verbose, json: ctx.args.json }) + } + finally { + await nuxt.close() + } + + const hasErrors = checks.some(c => c.status === 'error') + const hasWarnings = checks.some(c => c.status === 'warning') + + if (!ctx.args.json) { + if (hasErrors) + outro(colors.red('Diagnostics complete with errors')) + else if (hasWarnings) + outro(colors.yellow('Diagnostics complete with warnings')) + else + outro(colors.green('All checks passed!')) + } + + if (hasErrors) + process.exit(1) + }, +}) + +async function runCoreChecks(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise { + const runCheck = async (name: string, fn: () => void | Promise) => { + try { + await fn() + } + catch (err) { + checks.push({ name, status: 'error', message: `Check failed: ${err instanceof Error ? err.message : String(err)}` }) + } + } + + await runCheck('Versions', () => checkVersions(checks, cwd)) + await runCheck('Config', () => checkConfig(checks, nuxt)) + await runCheck('Modules', () => checkModuleCompat(checks, nuxt, cwd)) +} + +async function checkVersions(checks: DoctorCheck[], cwd: string): Promise { + const runtime = isBun + // @ts-expect-error Bun global + ? `Bun ${Bun?.version}` + : isDeno + // @ts-expect-error Deno global + ? `Deno ${Deno?.version.deno}` + : `Node ${process.version}` + + const nuxtVersion = await resolveNuxtVersion(cwd) ?? 'unknown' + + // Check Node.js version (if not Bun/Deno) + if (!isBun && !isDeno) { + if (!semverSatisfies(process.versions.node, '>= 18.0.0')) { + checks.push({ + id: 'UNSUPPORTED_NODE', + name: 'Versions', + status: 'error', + message: `${runtime}, Nuxt ${nuxtVersion} - Node.js 18+ required`, + suggestion: 'Upgrade Node.js to v18 or later', + url: 'https://nuxt.com/docs/getting-started/installation#prerequisites', + }) + return + } + } + + checks.push({ + name: 'Versions', + status: 'success', + message: `${runtime}, Nuxt ${nuxtVersion}`, + }) +} + +function checkConfig(checks: DoctorCheck[], nuxt: Nuxt): void { + const issues: string[] = [] + + // Check for common misconfigurations + if (nuxt.options.ssr === false && nuxt.options.nitro?.prerender?.routes?.length) { + issues.push('prerender routes defined but SSR is disabled') + } + + // Check for deprecated options + if ((nuxt.options as any).target) { + issues.push('deprecated "target" option - use ssr + nitro.preset instead') + } + + if ((nuxt.options as any).mode) { + issues.push('deprecated "mode" option - use ssr: true/false instead') + } + + // Check for missing compatibilityDate + if (!nuxt.options.compatibilityDate) { + issues.push('missing "compatibilityDate" - add to nuxt.config.ts for future compat') + } + + if (issues.length > 0) { + checks.push({ + id: 'CONFIG_ISSUES', + name: 'Config', + status: 'warning', + message: `${issues.length} issue${plural(issues.length)} found`, + details: issues, + suggestion: 'Review nuxt.config.ts and fix the issues above', + url: 'https://nuxt.com/docs/getting-started/configuration', + }) + } + else { + checks.push({ + name: 'Config', + status: 'success', + message: 'no issues', + }) + } +} + +async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise { + const nuxtVersion = await resolveNuxtVersion(cwd) + if (!nuxtVersion) { + checks.push({ + name: 'Modules', + status: 'warning', + message: 'could not determine Nuxt version for compatibility check', + }) + return + } + + const installedModules: { meta?: { name?: string, version?: string, compatibility?: { nuxt?: string } } }[] = (nuxt.options as any)._installedModules || [] + const moduleDetails: string[] = [] + const issues: string[] = [] + + for (const mod of installedModules) { + if (!mod.meta?.name) + continue + + const name = mod.meta.name + const version = mod.meta.version ? `@${mod.meta.version}` : '' + const compat = mod.meta.compatibility + + if (compat?.nuxt && !semverSatisfies(nuxtVersion, compat.nuxt, { includePrerelease: true })) { + issues.push(`${name}${version} - requires nuxt ${compat.nuxt}`) + } + else { + moduleDetails.push(`${name}${version}`) + } + } + + if (issues.length > 0) { + checks.push({ + id: 'MODULE_COMPAT', + name: 'Modules', + status: 'warning', + message: `${issues.length} incompatible module${plural(issues.length)}`, + details: issues, + suggestion: 'Update modules to versions compatible with your Nuxt version', + url: 'https://nuxt.com/modules', + }) + } + else if (moduleDetails.length > 0) { + checks.push({ + name: 'Modules', + status: 'success', + message: `${moduleDetails.length} module${plural(moduleDetails.length)} loaded`, + details: moduleDetails, + }) + } + else { + checks.push({ + name: 'Modules', + status: 'success', + message: 'no modules installed', + }) + } +} + +const statusStyles = { + success: { icon: '✓', color: colors.green, detailColor: colors.dim }, + warning: { icon: '!', color: colors.yellow, detailColor: colors.yellow }, + error: { icon: '✗', color: colors.red, detailColor: colors.red }, +} as const + +function displayResults(checks: DoctorCheck[], opts: { verbose?: boolean, json?: boolean }): void { + if (opts.json) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(checks)) + return + } + + for (const check of checks) { + const style = statusStyles[check.status] + const icon = style.color(style.icon) + const source = check.source ? colors.gray(` (via ${check.source})`) : '' + const name = colors.bold(check.name) + const message = check.status === 'success' ? check.message : style.color(check.message) + + let output = `[${icon}] ${name}${source} - ${message}` + + const details = [check.details ?? []].flat() + if (details.length) { + for (const detail of details) + output += `\n ${style.detailColor('→')} ${style.detailColor(detail)}` + } + + // Verbose: show suggestion and url + if (opts.verbose) { + if (check.suggestion) { + output += `\n ${colors.cyan('💡')} ${colors.cyan(check.suggestion)}` + } + if (check.url) { + output += `\n ${colors.blue('🔗')} ${colors.blue(check.url)}` + } + } + + log.message(output) + } +} diff --git a/packages/nuxi/src/commands/index.ts b/packages/nuxi/src/commands/index.ts index 881e3833..c8d545ad 100644 --- a/packages/nuxi/src/commands/index.ts +++ b/packages/nuxi/src/commands/index.ts @@ -8,6 +8,7 @@ export const commands = { 'analyze': () => import('./analyze').then(_rDefault), 'build': () => import('./build').then(_rDefault), 'cleanup': () => import('./cleanup').then(_rDefault), + 'doctor': () => import('./doctor').then(_rDefault), '_dev': () => import('./dev-child').then(_rDefault), 'dev': () => import('./dev').then(_rDefault), 'devtools': () => import('./devtools').then(_rDefault), diff --git a/packages/nuxi/test/unit/commands/doctor.spec.ts b/packages/nuxi/test/unit/commands/doctor.spec.ts new file mode 100644 index 00000000..67c48a97 --- /dev/null +++ b/packages/nuxi/test/unit/commands/doctor.spec.ts @@ -0,0 +1,404 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockLoadNuxt = vi.fn() +const mockCallHook = vi.fn() +const mockClose = vi.fn() + +vi.mock('../../../src/utils/kit', () => ({ + loadKit: () => Promise.resolve({ + loadNuxt: mockLoadNuxt, + }), + tryResolveNuxt: () => '/fake-nuxt-path', +})) + +vi.mock('pkg-types', () => ({ + readPackageJSON: (pkg: string) => { + if (pkg === 'nuxt') { + return Promise.resolve({ version: '3.15.0' }) + } + return Promise.reject(new Error('not found')) + }, +})) + +// Mock @clack/prompts to spy on log.message +const mockLogMessage = vi.fn() +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { message: mockLogMessage, warn: vi.fn(), error: vi.fn() }, +})) + +// Mock process.exit to prevent tests from exiting +const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +// Store original process versions to restore after tests +const originalProcessVersion = process.version +const originalProcessVersionsNode = process.versions.node + +describe('nuxt doctor command', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + // Reset mockCallHook implementation (clearAllMocks only clears history, not implementation) + mockCallHook.mockReset() + + // Restore process versions in case previous test modified them + Object.defineProperty(process, 'version', { value: originalProcessVersion, configurable: true }) + Object.defineProperty(process.versions, 'node', { value: originalProcessVersionsNode, configurable: true }) + + // Default mock for loadNuxt + mockLoadNuxt.mockResolvedValue({ + options: { + ssr: true, + nitro: {}, + _installedModules: [], + compatibilityDate: '2025-01-01', + }, + callHook: mockCallHook, + close: mockClose, + }) + }) + + it('should run core checks successfully', async () => { + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(mockLoadNuxt).toHaveBeenCalled() + expect(mockCallHook).toHaveBeenCalledWith('doctor:check', expect.objectContaining({ + addCheck: expect.any(Function), + nuxt: expect.any(Object), + })) + expect(mockClose).toHaveBeenCalled() + }) + + it('should exit with code 1 when errors are found', async () => { + // Module adds an error check via hook + mockCallHook.mockImplementation(async (hookName, ctx) => { + if (hookName === 'doctor:check') { + ctx.addCheck({ + name: 'Test', + status: 'error', + message: 'Something is broken', + source: 'test-module', + }) + } + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it.each([ + { name: 'warning via hook', options: {}, hook: (ctx: any) => ctx.addCheck({ name: 'Test', status: 'warning', message: 'msg' }) }, + { name: 'deprecated target option', options: { target: 'static' }, hook: null }, + { name: 'deprecated mode option', options: { mode: 'spa' }, hook: null }, + { name: 'module incompatibility', options: { _installedModules: [{ meta: { name: '@old/module', compatibility: { nuxt: '^2.0.0' } } }] }, hook: null }, + ])('should not exit for warning: $name', async ({ options, hook }) => { + mockLoadNuxt.mockResolvedValueOnce({ + options: { ssr: true, nitro: {}, _installedModules: [], compatibilityDate: '2025-01-01', ...options }, + callHook: hook + ? async (hookName: string, ctx: any) => { + if (hookName === 'doctor:check') + hook(ctx) + } + : mockCallHook, + close: mockClose, + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(mockExit).not.toHaveBeenCalled() + }) + + it('should handle hook errors gracefully and still close nuxt', async () => { + mockCallHook.mockRejectedValueOnce(new Error('Hook failed')) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Hook error is caught and converted to error check, so exit(1) is called + expect(mockExit).toHaveBeenCalledWith(1) + expect(mockClose).toHaveBeenCalled() + }) + + it('should handle loadNuxt failure gracefully', async () => { + mockLoadNuxt.mockRejectedValueOnce(new Error('Failed to load nuxt.config')) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(mockExit).toHaveBeenCalledWith(1) + expect(mockClose).not.toHaveBeenCalled() + }) + + it('should error on Node.js < 18', async () => { + // Mock Node version < 18 + Object.defineProperty(process, 'version', { value: 'v16.20.0', configurable: true }) + Object.defineProperty(process.versions, 'node', { value: '16.20.0', configurable: true }) + + mockLoadNuxt.mockResolvedValueOnce({ + options: { + ssr: true, + nitro: {}, + _installedModules: [], + compatibilityDate: '2025-01-01', + }, + callHook: mockCallHook, + close: mockClose, + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Node < 18 is an error + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('should warn when prerender routes defined with SSR disabled', async () => { + mockLoadNuxt.mockResolvedValueOnce({ + options: { + ssr: false, + nitro: { prerender: { routes: ['/'] } }, + _installedModules: [], + compatibilityDate: '2025-01-01', + }, + callHook: mockCallHook, + close: mockClose, + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Check that warning about prerender was shown + const output = mockLogMessage.mock.calls.flat().join('\n') + expect(output).toContain('prerender routes defined but SSR is disabled') + expect(mockExit).not.toHaveBeenCalled() + }) + + it('should warn when compatibilityDate is missing', async () => { + mockLoadNuxt.mockResolvedValueOnce({ + options: { + ssr: true, + nitro: {}, + _installedModules: [], + // no compatibilityDate + }, + callHook: mockCallHook, + close: mockClose, + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Check that warning about compatibilityDate was shown + const output = mockLogMessage.mock.calls.flat().join('\n') + expect(output).toContain('missing "compatibilityDate"') + expect(mockExit).not.toHaveBeenCalled() + }) + + it('should output clean JSON when --json flag is set (no clack prompts)', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockCallHook.mockImplementation(async (hookName, ctx) => { + if (hookName === 'doctor:check') { + ctx.addCheck({ + name: 'Test', + status: 'warning', + message: 'Test warning', + suggestion: 'Fix it', + url: 'https://example.com', + }) + } + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', json: true, _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Should only call console.log once with valid JSON + expect(consoleSpy).toHaveBeenCalledOnce() + expect(consoleSpy.mock.calls[0]).toBeDefined() + const rawOutput = consoleSpy.mock.calls[0]![0] + + // Verify output is valid JSON (no intro/outro pollution) + expect(() => JSON.parse(rawOutput)).not.toThrow() + const output = JSON.parse(rawOutput) + expect(Array.isArray(output)).toBe(true) + expect(output.some((c: any) => c.name === 'Test' && c.suggestion === 'Fix it')).toBe(true) + + consoleSpy.mockRestore() + }) + + it('should output valid JSON on loadNuxt failure with --json flag', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + mockLoadNuxt.mockRejectedValueOnce(new Error('Config parse error')) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', json: true, _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(consoleSpy).toHaveBeenCalledOnce() + expect(consoleSpy.mock.calls[0]).toBeDefined() + const rawOutput = consoleSpy.mock.calls[0]![0] + expect(() => JSON.parse(rawOutput)).not.toThrow() + const output = JSON.parse(rawOutput) + expect(output[0].status).toBe('error') + expect(output[0].message).toContain('Failed to load Nuxt') + + consoleSpy.mockRestore() + }) + + it('should validate JSON output schema has required fields', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockCallHook.mockImplementation(async (hookName, ctx) => { + if (hookName === 'doctor:check') { + ctx.addCheck({ + id: 'TEST_CHECK', + name: 'Test', + status: 'warning', + message: 'Test warning', + source: 'test-module', + details: ['detail1', 'detail2'], + suggestion: 'Fix it', + url: 'https://example.com', + data: { key: 'value' }, + }) + } + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', json: true, _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + expect(consoleSpy.mock.calls[0]).toBeDefined() + const output = JSON.parse(consoleSpy.mock.calls[0]![0]) + const testCheck = output.find((c: any) => c.name === 'Test') + expect(testCheck).toBeDefined() + + // Validate required fields + expect(testCheck).toHaveProperty('name') + expect(testCheck).toHaveProperty('status') + expect(testCheck).toHaveProperty('message') + expect(['success', 'warning', 'error']).toContain(testCheck.status) + + // Validate optional fields are preserved + expect(testCheck.id).toBe('TEST_CHECK') + expect(testCheck.source).toBe('test-module') + expect(testCheck.details).toEqual(['detail1', 'detail2']) + expect(testCheck.suggestion).toBe('Fix it') + expect(testCheck.url).toBe('https://example.com') + expect(testCheck.data).toEqual({ key: 'value' }) + + consoleSpy.mockRestore() + }) + + it('should show verbose fields when --verbose flag is set', async () => { + mockLoadNuxt.mockResolvedValueOnce({ + options: { + ssr: true, + nitro: {}, + _installedModules: [], + compatibilityDate: '2025-01-01', + }, + callHook: mockCallHook, + close: mockClose, + }) + + mockCallHook.mockImplementation(async (hookName, ctx) => { + if (hookName === 'doctor:check') { + ctx.addCheck({ + name: 'Test', + status: 'warning', + message: 'Test warning', + suggestion: 'Fix it', + url: 'https://example.com', + }) + } + }) + + const { default: command } = await import('../../../src/commands/doctor') + + await command.run!({ + args: { cwd: '/fake-dir', rootDir: '/fake-dir', verbose: true, _: [] } as any, + rawArgs: [], + cmd: command, + data: undefined, + }) + + // Verbose mode shows suggestion and url + const output = mockLogMessage.mock.calls.flat().join('\n') + expect(output).toContain('💡') + expect(output).toContain('Fix it') + expect(output).toContain('🔗') + expect(output).toContain('https://example.com') + expect(mockClose).toHaveBeenCalled() + }) +}) diff --git a/packages/nuxt-cli/test/e2e/commands.spec.ts b/packages/nuxt-cli/test/e2e/commands.spec.ts index 8b983a13..3c67d787 100644 --- a/packages/nuxt-cli/test/e2e/commands.spec.ts +++ b/packages/nuxt-cli/test/e2e/commands.spec.ts @@ -49,6 +49,13 @@ describe('commands', () => { expect(res.exitCode).toBe(0) }, 'devtools': 'todo', + 'doctor': async () => { + const res = await x(nuxi, ['doctor'], { + throwOnError: true, + nodeOptions: { stdio: 'pipe', cwd: fixtureDir }, + }) + expect(res.exitCode).toBe(0) + }, 'module': 'todo', 'prepare': async () => { const res = await x(nuxi, ['prepare'], {