Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 11 additions & 26 deletions packages/nuxt-cli/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import { readPackageJSON, readTSConfig } from 'pkg-types'
import { hasTTY } from 'std-env'
import { x } from 'tinyexec'

import { resolveDotenvFileNames } from '../utils/args'
import { formatDuration } from '../utils/formatting'
import { loadKit } from '../utils/kit'
import { logger } from '../utils/logger'
import { resolveRootDir } from '../utils/paths'
import { withNodePath } from '../utils/resolve-nuxt'
Expand Down Expand Up @@ -147,11 +145,19 @@ export default defineCommand({
return
}

// Overrides may not be serialisable, so they are applied in this process.
const hasOverrides = Object.keys(ctx.data?.overrides ?? {}).length > 0
const { writeTypes, writeTypesInChild } = await import('../utils/write-types')
const [tsConfig] = await Promise.all([
readTSConfig(cwd).catch(() => ({} as TSConfig)),
writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', {
...ctx.data?.overrides,
...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }),
(hasOverrides ? writeTypes : writeTypesInChild)({
cwd,
dotenv: ctx.args.dotenv,
logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose',
overrides: {
...ctx.data?.overrides,
...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }),
},
}),
])

Expand Down Expand Up @@ -370,24 +376,3 @@ async function installMissingPackages(options: {
logger.info(`You can install ${plural ? 'them' : 'it'} manually with:\n\n ${styleText('bold', installCommand)}\n`)
return false
}

async function writeTypes(cwd: string, dotenv?: string[], logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record<string, any>) {
const { loadNuxt, buildNuxt, writeTypes } = await loadKit(cwd)
const nuxt = await loadNuxt({
cwd,
dotenv: { cwd, fileName: resolveDotenvFileNames(dotenv) },
overrides: {
_prepare: true,
logLevel,
...overrides,
},
})

try {
await writeTypes(nuxt)
await buildNuxt(nuxt)
}
finally {
await nuxt.close()
}
}
57 changes: 57 additions & 0 deletions packages/nuxt-cli/src/utils/write-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import process from 'node:process'
import { fileURLToPath } from 'node:url'

import { resolveDotenvFileNames } from './args'
import { ActionableError } from './errors'
import { loadKit } from './kit'

export interface WriteTypesOptions {
cwd: string
dotenv?: string[]
logLevel?: 'silent' | 'info' | 'verbose'
overrides?: Record<string, any>
}

/** Load Nuxt in this process, write its types and build its templates. */
export async function writeTypes(options: WriteTypesOptions): Promise<void> {
const { cwd, dotenv, logLevel, overrides } = options
const { loadNuxt, buildNuxt, writeTypes } = await loadKit(cwd)
const nuxt = await loadNuxt({
cwd,
dotenv: { cwd, fileName: resolveDotenvFileNames(dotenv) },
overrides: {
_prepare: true,
logLevel,
...overrides,
},
})

try {
await writeTypes(nuxt)
await buildNuxt(nuxt)
}
finally {
await nuxt.close()
}
}

/**
* Run {@link writeTypes} in a short-lived Node.js process, so this one does not
* keep Nuxt in memory afterwards. `options` must be JSON-serialisable.
*/
export async function writeTypesInChild(options: WriteTypesOptions): Promise<void> {
const entry = fileURLToPath(new URL('./write-types.mjs', import.meta.url))
const child = spawn(process.execPath, [entry, JSON.stringify(options)], {
cwd: options.cwd,
stdio: 'inherit',
})
const [code, signal] = await once(child, 'exit') as [number | null, NodeJS.Signals | null]
if (signal) {
throw new ActionableError(`Generating types was terminated by ${signal}.`)
}
if (code !== 0) {
throw new ActionableError(`Generating types failed with exit code ${code}. Fix the error above and try again.`)
}
}
20 changes: 20 additions & 0 deletions packages/nuxt-cli/src/write-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { WriteTypesOptions } from './utils/write-types'

import process from 'node:process'

import { setupGlobalConsole } from './utils/console'
import { asActionableError } from './utils/errors'
import { writeTypes } from './utils/write-types'

setupGlobalConsole()

writeTypes(JSON.parse(process.argv[2]!) as WriteTypesOptions)
.catch((error) => {
console.error(asActionableError(error), '\n')
process.exitCode = 1
})
// Handles left open after `nuxt.close()` must not keep the parent waiting.
.finally(async () => {
await Promise.all([process.stdout, process.stderr].map(stream => new Promise(resolve => stream.write('', resolve))))
process.exit()
})
61 changes: 58 additions & 3 deletions packages/nuxt-cli/test/unit/commands/typecheck.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import process from 'node:process'

import { join } from 'pathe'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { runInstall, answers, resolveModulePath, tinyexec, writeTypes } = vi.hoisted(() => ({
const { runInstall, answers, resolveModulePath, spawn, tinyexec, writeTypes } = vi.hoisted(() => ({
runInstall: vi.fn((_options: Record<string, unknown>) => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })),
answers: { select: [] as unknown[], confirm: [] as unknown[] },
resolveModulePath: vi.fn(),
spawn: vi.fn(),
tinyexec: vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
writeTypes: vi.fn(() => Promise.resolve()),
}))

vi.mock('tinyexec', () => ({ x: tinyexec }))

vi.mock('node:child_process', async importOriginal => ({
...await importOriginal<typeof import('node:child_process')>(),
spawn,
}))

vi.mock('exsolve', async importOriginal => ({
...await importOriginal<typeof import('exsolve')>(),
resolveModulePath,
Expand Down Expand Up @@ -71,10 +78,19 @@ function installed(...names: string[]): void {
})
}

async function runTypecheck(argv: string[] = []): Promise<{ output: string, exitCode: number | undefined }> {
/** Make the type generation process exit with the given code or signal. */
function typegenExits(code: number | null, signal: NodeJS.Signals | null = null): void {
spawn.mockImplementation(() => {
const child = new EventEmitter()
setImmediate(() => child.emit('exit', code, signal))
return child
})
}

async function runTypecheck(argv: string[] = [], data?: { overrides?: Record<string, any> }): Promise<{ output: string, exitCode: number | undefined }> {
process.exitCode = undefined
const renderer = await render(async () => {
await runCommandDef(typecheck, [`--cwd=${cwd}`, ...argv])
await runCommandDef(typecheck, [`--cwd=${cwd}`, ...argv], data)
})
const exitCode = process.exitCode as number | undefined
process.exitCode = 0
Expand All @@ -87,6 +103,7 @@ beforeEach(async () => {
answers.confirm.length = 0
vi.clearAllMocks()
tinyexec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
typegenExits(0)
runInstall.mockImplementation(() => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] }))
installed('typescript', 'vue-tsc/bin/vue-tsc.js')
await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }))
Expand Down Expand Up @@ -223,3 +240,41 @@ describe('typecheck results', () => {
expect(exitCode).toBe(2)
})
})

describe('typecheck type generation', () => {
it('should generate types in a separate node process', async () => {
await runTypecheck(['--dotenv=.env.test', '--logLevel=silent', '--extends=./layer'])

expect(spawn).toHaveBeenCalledWith(process.execPath, [expect.stringMatching(/write-types\.mjs$/), expect.any(String)], expect.objectContaining({ cwd, stdio: 'inherit' }))
expect(JSON.parse(spawn.mock.calls[0]![1][1])).toEqual({
cwd,
dotenv: ['.env.test'],
logLevel: 'silent',
overrides: { extends: ['./layer'] },
})
expect(writeTypes).not.toHaveBeenCalled()
expect(tinyexec).toHaveBeenCalledTimes(1)
})

it('should fail without type-checking when generating types fails', async () => {
typegenExits(1)

await expect(runTypecheck()).rejects.toThrow('Generating types failed with exit code 1')
expect(tinyexec).not.toHaveBeenCalled()
})

it('should report the signal that terminated type generation', async () => {
typegenExits(null, 'SIGKILL')

await expect(runTypecheck()).rejects.toThrow('Generating types was terminated by SIGKILL')
expect(tinyexec).not.toHaveBeenCalled()
})

it('should generate types in this process when called with overrides', async () => {
await runTypecheck([], { overrides: { hooks: { ready: () => {} } } })

expect(spawn).not.toHaveBeenCalled()
expect(writeTypes).toHaveBeenCalledTimes(1)
expect(tinyexec).toHaveBeenCalledTimes(1)
})
})
2 changes: 1 addition & 1 deletion packages/nuxt-cli/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const packaging: PackagingContract = {
}

export default defineCliConfig({
entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts'],
entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts', 'src/write-types.ts'],
deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES },
...packaging,
})
Loading