diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 4285f821ad..ad58061ce4 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -29,6 +29,7 @@ "hono": "4.7.10", "minimatch": "6.2.0", "openapi-fetch": "0.14.0", + "package-manager-detector": "1.8.0", "postgres": "3.4.7", "prisma-6-language-server": "npm:@prisma/language-server@6.19.0-hotfix.1", "vscode-languageclient": "7.0.0", diff --git a/packages/vscode/src/plugins/prisma-language-server/installPrismaCli.ts b/packages/vscode/src/plugins/prisma-language-server/installPrismaCli.ts new file mode 100644 index 0000000000..9b41a71acf --- /dev/null +++ b/packages/vscode/src/plugins/prisma-language-server/installPrismaCli.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto' +import { detect } from 'package-manager-detector/detect' +import { resolveCommand } from 'package-manager-detector/commands' +import { commands, tasks, Task, ShellExecution, TaskRevealKind, window, workspace, type WorkspaceFolder } from 'vscode' + +const supportedAgents = ['npm', 'pnpm', 'pnpm@6', 'yarn', 'yarn@berry', 'bun'] as const + +type SupportedAgent = (typeof supportedAgents)[number] + +function isSupportedAgent(agent: string): agent is SupportedAgent { + return supportedAgents.some((supported) => supported === agent) +} + +async function runInstallTask(folder: WorkspaceFolder, command: string, args: string[]): Promise { + const installId = randomUUID() + const task = new Task( + { type: 'prisma-cli-install', installId }, + folder, + 'Install Prisma ORM 8 CLI', + 'Prisma', + new ShellExecution(command, args, { cwd: folder.uri.fsPath }), + [], + ) + task.presentationOptions = { reveal: TaskRevealKind.Always, focus: true } + + let complete!: (exitCode: number | undefined) => void + const completion = new Promise((resolve) => { + complete = resolve + }) + // Subscribe before launching: a short-lived process may exit before executeTask resolves. + const processEnd = tasks.onDidEndTaskProcess((event) => { + if (event.execution.task.definition.installId === installId) complete(event.exitCode) + }) + const taskEnd = tasks.onDidEndTask((event) => { + // Covers cancellation or a task that never started a process. A process-end event, when + // present, precedes this event and has already settled completion with the exit code. + if (event.execution.task.definition.installId === installId) complete(undefined) + }) + try { + await tasks.executeTask(task) + return await completion + } finally { + processEnd.dispose() + taskEnd.dispose() + } +} + +export async function installPrismaCli(folder: WorkspaceFolder, isDisposed: () => boolean): Promise { + const canInstall = () => !isDisposed() && workspace.isTrusted && folder.uri.scheme === 'file' + if (!canInstall()) return + + try { + const detected = await detect({ + cwd: folder.uri.fsPath, + stopDir: folder.uri.fsPath, + strategies: ['packageManager-field', 'install-metadata', 'lockfile'], + }) + if (!canInstall()) return + + let agent = detected?.agent + if (!agent || !isSupportedAgent(agent)) { + const selected = await window.showQuickPick( + [ + { label: 'npm', agent: 'npm' as const }, + { label: 'pnpm', agent: 'pnpm' as const }, + { label: 'Yarn Classic', agent: 'yarn' as const }, + { label: 'Yarn (2 or later)', agent: 'yarn@berry' as const }, + { label: 'Bun', agent: 'bun' as const }, + ], + { placeHolder: `Choose a package manager to install prisma@latest in "${folder.name}"` }, + ) + if (!selected) return + agent = selected.agent + } + + const args = ['-D', 'prisma@latest'] + // Installation targets the open root, including monorepo roots protected by these managers. + if (agent === 'pnpm' || agent === 'pnpm@6' || agent === 'yarn') { + args.push('--ignore-workspace-root-check') + } + const command = resolveCommand(agent, 'add', args) + if (!command) throw new Error(`No add command available for ${agent}`) + + if (!canInstall()) return + const exitCode = await runInstallTask(folder, command.command, command.args) + if (exitCode !== 0) { + throw new Error(`Prisma CLI install task did not succeed (exit code: ${exitCode ?? 'unknown'})`) + } + } catch (error) { + console.error('Automatic Prisma ORM 8 CLI installation failed', error) + if (canInstall()) { + void window.showErrorMessage( + `Automatic Prisma ORM 8 CLI installation failed in workspace "${folder.name}". Install "prisma@latest" manually.`, + ) + } + return + } + + if (!canInstall()) return + try { + await commands.executeCommand('prisma.restartLanguageServer') + } catch (error) { + console.error('Prisma ORM 8 language server restart failed', error) + if (canInstall()) { + void window.showErrorMessage(`Prisma ORM 8 CLI was installed, but the language server could not restart.`) + } + } +} diff --git a/packages/vscode/src/plugins/prisma-language-server/prismaNextClients.ts b/packages/vscode/src/plugins/prisma-language-server/prismaNextClients.ts index 0e02cd8270..dbd318cea3 100644 --- a/packages/vscode/src/plugins/prisma-language-server/prismaNextClients.ts +++ b/packages/vscode/src/plugins/prisma-language-server/prismaNextClients.ts @@ -1,10 +1,12 @@ import path from 'node:path' import { stat } from 'node:fs/promises' import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'node:child_process' -import { workspace, type Disposable, type TextDocument, type WorkspaceFolder } from 'vscode' +import { window, workspace, type Disposable, type MessageItem, type TextDocument, type WorkspaceFolder } from 'vscode' import { CloseAction, ErrorAction, type LanguageClientOptions } from 'vscode-languageclient' import { LanguageClient, type ChildProcessInfo, type ServerOptions } from 'vscode-languageclient/node' +import { installPrismaCli } from './installPrismaCli' + const prismaCliRelativePath = ['node_modules', 'prisma', 'dist', 'prisma.js'] as const export type SpawnPrismaNextProcess = ( @@ -33,6 +35,7 @@ export class PrismaNextClients { private readonly clients = new Map>() private readonly failedAt = new Map() + private readonly missingCliWarnings = new Set() private disposed = false constructor(private readonly registerDisposable: (disposable: Disposable) => void) {} @@ -72,6 +75,7 @@ export class PrismaNextClients { const pending = [...this.clients.values()] this.clients.clear() this.failedAt.clear() + this.missingCliWarnings.clear() await Promise.allSettled(pending.map(async (client) => (await client)?.stop())) } @@ -84,11 +88,22 @@ export class PrismaNextClients { const entrypoint = getPrismaNextEntrypoint(workspaceFolder) let client: LanguageClient | undefined try { - if (!(await isFile(entrypoint)) || this.disposed) return undefined + const cliExists = await isFile(entrypoint) + if (this.disposed) return undefined + + const key = workspaceFolder.uri.toString() + if (!cliExists) { + if (!this.missingCliWarnings.has(key)) { + this.missingCliWarnings.add(key) + void this.showMissingCliWarning(workspaceFolder) + } + return undefined + } + this.missingCliWarnings.delete(key) client = new LanguageClient( `prisma-next:${workspaceFolder.uri.toString()}`, - `Prisma Next Language Server (${workspaceFolder.name})`, + `Prisma ORM 8 Language Server (${workspaceFolder.name})`, createPrismaNextServerOptions(workspaceFolder, entrypoint, { handleProcessError: (error) => this.handleError(workspaceFolder, error), }), @@ -114,6 +129,19 @@ export class PrismaNextClients { } } + private async showMissingCliWarning(workspaceFolder: WorkspaceFolder): Promise { + const installAction: MessageItem = { title: 'Install prisma@latest' } + const selected = await window.showWarningMessage( + `The Prisma ORM 8 CLI is required for autocomplete, formatting, and error checking in workspace "${workspaceFolder.name}".`, + { modal: true }, + installAction, + { title: 'Continue without language features', isCloseAffordance: true }, + ) + if (selected === installAction) { + await installPrismaCli(workspaceFolder, () => this.disposed) + } + } + private handleError(workspaceFolder: WorkspaceFolder, error: unknown): void { console.error(`Prisma Next Language Server failed for ${workspaceFolder.uri.toString()}`, error) } diff --git a/packages/vscode/tests/__tests__/installPrismaCli.test.mjs b/packages/vscode/tests/__tests__/installPrismaCli.test.mjs new file mode 100644 index 0000000000..c884dd1e15 --- /dev/null +++ b/packages/vscode/tests/__tests__/installPrismaCli.test.mjs @@ -0,0 +1,269 @@ +import path from 'node:path' +import os from 'node:os' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + showQuickPick: vi.fn(), + showErrorMessage: vi.fn(), + executeTask: vi.fn(), + executeCommand: vi.fn(), + runCommand: vi.fn(), + processEndListeners: new Set(), + taskEndListeners: new Set(), + workspace: { isTrusted: true }, +})) + +vi.mock('vscode', () => ({ + window: { + showQuickPick: mocks.showQuickPick, + showErrorMessage: mocks.showErrorMessage, + }, + workspace: mocks.workspace, + commands: { executeCommand: mocks.executeCommand }, + tasks: { + executeTask: mocks.executeTask, + onDidEndTaskProcess: (listener) => { + mocks.processEndListeners.add(listener) + return { dispose: () => mocks.processEndListeners.delete(listener) } + }, + onDidEndTask: (listener) => { + mocks.taskEndListeners.add(listener) + return { dispose: () => mocks.taskEndListeners.delete(listener) } + }, + }, + Task: class { + constructor(definition, scope, name, source, execution) { + Object.assign(this, { definition, scope, name, source, execution }) + } + }, + ShellExecution: class { + constructor(command, args, options) { + Object.assign(this, { command, args, options }) + mocks.runCommand([command, ...args].join(' ')) + } + }, + TaskRevealKind: { Always: 1 }, +})) + +import { installPrismaCli } from '../../src/plugins/prisma-language-server/installPrismaCli' + +let parent +let folder + +beforeEach(async () => { + vi.resetAllMocks() + mocks.workspace.isTrusted = true + parent = await mkdtemp(path.join(os.tmpdir(), 'prisma-cli-install-')) + const root = path.join(parent, 'my project') + await mkdir(root) + folder = { name: 'my-project', uri: { scheme: 'file', fsPath: root } } + mocks.processEndListeners.clear() + mocks.taskEndListeners.clear() + mocks.executeTask.mockImplementation(async (task) => { + const execution = { task } + for (const listener of mocks.processEndListeners) listener({ execution, exitCode: 0 }) + for (const listener of mocks.taskEndListeners) listener({ execution }) + return execution + }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(parent, { recursive: true, force: true }) +}) + +async function setFile(filename, content) { + const target = path.join(folder.uri.fsPath, filename) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, content) +} + +describe('Prisma CLI installation with package-manager-detector', () => { + it.each([ + ['npm@10.0.0', 'npm i -D prisma@latest'], + ['pnpm@10.0.0', 'pnpm add -D prisma@latest --ignore-workspace-root-check'], + ['pnpm@6.0.0', 'pnpm add -D prisma@latest --ignore-workspace-root-check'], + ['yarn@1.22.0', 'yarn add -D prisma@latest --ignore-workspace-root-check'], + ['yarn@4.0.0', 'yarn add -D prisma@latest'], + ['bun@1.0.0', 'bun add -D prisma@latest'], + ])('prefers packageManager %s over conflicting lockfiles', async (packageManager, command) => { + await setFile('package.json', JSON.stringify({ packageManager })) + await setFile('pnpm-lock.yaml', '') + await setFile('package-lock.json', '{}') + await installPrismaCli(folder, () => false) + + expect(mocks.runCommand).toHaveBeenCalledWith(command) + expect(mocks.executeTask).toHaveBeenCalledOnce() + const task = mocks.executeTask.mock.calls[0][0] + expect(task.scope).toBe(folder) + expect(task.execution.options.cwd).toBe(folder.uri.fsPath) + expect(task.presentationOptions).toEqual({ reveal: 1, focus: true }) + expect(mocks.showQuickPick).not.toHaveBeenCalled() + }) + + it.each([ + ['package-lock.json', 'npm i -D prisma@latest'], + ['npm-shrinkwrap.json', 'npm i -D prisma@latest'], + ['pnpm-lock.yaml', 'pnpm add -D prisma@latest --ignore-workspace-root-check'], + ['yarn.lock', 'yarn add -D prisma@latest --ignore-workspace-root-check'], + ['bun.lock', 'bun add -D prisma@latest'], + ['bun.lockb', 'bun add -D prisma@latest'], + ])('detects %s', async (filename, command) => { + await setFile(filename, '') + await installPrismaCli(folder, () => false) + expect(mocks.runCommand).toHaveBeenCalledWith(command) + }) + + it('recognizes modern Yarn installation metadata before the lockfile', async () => { + await setFile('node_modules/.yarn-state.yml', '') + await setFile('yarn.lock', '') + await installPrismaCli(folder, () => false) + expect(mocks.runCommand).toHaveBeenCalledWith('yarn add -D prisma@latest') + }) + + it('does not detect package managers outside the workspace root', async () => { + await writeFile(path.join(parent, 'package.json'), '{"packageManager":"pnpm@10.0.0"}') + mocks.showQuickPick.mockResolvedValue({ label: 'npm', agent: 'npm' }) + await installPrismaCli(folder, () => false) + expect(mocks.showQuickPick).toHaveBeenCalledOnce() + expect(mocks.runCommand).toHaveBeenCalledWith('npm i -D prisma@latest') + }) + + it.each(['deno@2.0.0', 'npm; malicious-command'])( + 'asks for a supported manager instead of executing %s', + async (packageManager) => { + await setFile('package.json', JSON.stringify({ packageManager })) + await installPrismaCli(folder, () => false) + expect(mocks.showQuickPick).toHaveBeenCalledOnce() + expect(mocks.executeTask).not.toHaveBeenCalled() + }, + ) + + it('does not install if the package manager picker is dismissed', async () => { + await installPrismaCli(folder, () => false) + expect(mocks.showQuickPick).toHaveBeenCalledOnce() + expect(mocks.executeTask).not.toHaveBeenCalled() + }) + + it('restarts after successful installation even if the process exits before executeTask resolves', async () => { + await setFile('package-lock.json', '{}') + await installPrismaCli(folder, () => false) + expect(mocks.executeCommand).toHaveBeenCalledOnce() + expect(mocks.executeCommand).toHaveBeenCalledWith('prisma.restartLanguageServer') + expect(mocks.showErrorMessage).not.toHaveBeenCalled() + expect(mocks.processEndListeners.size).toBe(0) + expect(mocks.taskEndListeners.size).toBe(0) + }) + + it.each([1, undefined])('does not restart when the install exits with %s', async (exitCode) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await setFile('package-lock.json', '{}') + mocks.executeTask.mockImplementation(async (task) => { + const execution = { task } + for (const listener of mocks.processEndListeners) listener({ execution, exitCode }) + for (const listener of mocks.taskEndListeners) listener({ execution }) + return execution + }) + await installPrismaCli(folder, () => false) + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('Install "prisma@latest" manually')) + expect(mocks.processEndListeners.size).toBe(0) + expect(mocks.taskEndListeners.size).toBe(0) + }) + + it('cleans up without restarting if the task ends without a process', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await setFile('package-lock.json', '{}') + mocks.executeTask.mockImplementation(async (task) => { + const execution = { task } + for (const listener of mocks.taskEndListeners) listener({ execution }) + return execution + }) + await installPrismaCli(folder, () => false) + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.processEndListeners.size).toBe(0) + expect(mocks.taskEndListeners.size).toBe(0) + }) + + it('ignores other tasks and waits for its own installation to finish', async () => { + await setFile('package-lock.json', '{}') + mocks.executeTask.mockImplementation(async (task) => ({ task })) + const installing = installPrismaCli(folder, () => false) + await vi.waitFor(() => expect(mocks.executeTask).toHaveBeenCalledOnce()) + const unrelated = { task: { definition: { installId: 'other' } } } + for (const listener of mocks.processEndListeners) listener({ execution: unrelated, exitCode: 0 }) + for (const listener of mocks.taskEndListeners) listener({ execution: unrelated }) + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.executeCommand).not.toHaveBeenCalled() + + const execution = { task: mocks.executeTask.mock.calls[0][0] } + for (const listener of mocks.processEndListeners) listener({ execution, exitCode: 0 }) + for (const listener of mocks.taskEndListeners) listener({ execution }) + await installing + expect(mocks.executeCommand).toHaveBeenCalledOnce() + expect(mocks.executeCommand).toHaveBeenCalledWith('prisma.restartLanguageServer') + }) + + it.each(['trust', 'disposal'])('rechecks %s after successful installation', async (change) => { + await setFile('package-lock.json', '{}') + let disposed = false + mocks.executeTask.mockImplementation(async (task) => { + if (change === 'trust') mocks.workspace.isTrusted = false + else disposed = true + const execution = { task } + for (const listener of mocks.processEndListeners) listener({ execution, exitCode: 0 }) + return execution + }) + await installPrismaCli(folder, () => disposed) + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.processEndListeners.size).toBe(0) + expect(mocks.taskEndListeners.size).toBe(0) + }) + + it('reports restart failure without asking the user to retry', async () => { + await setFile('package-lock.json', '{}') + mocks.executeCommand.mockRejectedValue(new Error('Restart failed')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await installPrismaCli(folder, () => false) + expect(mocks.showErrorMessage).toHaveBeenCalledOnce() + const message = mocks.showErrorMessage.mock.calls[0][0] + expect(message).toContain('CLI was installed') + expect(message).not.toContain('Prisma: Restart Language Server') + expect(message).not.toContain('Install "prisma@latest" manually') + }) + + it('reports task launch failures and cleans up listeners', async () => { + await setFile('package-lock.json', '{}') + mocks.executeTask.mockRejectedValue(new Error('Task unavailable')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await installPrismaCli(folder, () => false) + expect(mocks.showErrorMessage).toHaveBeenCalledOnce() + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.processEndListeners.size).toBe(0) + expect(mocks.taskEndListeners.size).toBe(0) + }) + + it('does not install in an untrusted workspace', async () => { + mocks.workspace.isTrusted = false + await installPrismaCli(folder, () => false) + expect(mocks.showQuickPick).not.toHaveBeenCalled() + expect(mocks.executeTask).not.toHaveBeenCalled() + }) + + it('does not install after disposal', async () => { + await installPrismaCli(folder, () => true) + expect(mocks.executeTask).not.toHaveBeenCalled() + }) + + it.each(['trust', 'disposal'])('rechecks %s after the package manager picker', async (change) => { + let disposed = false + mocks.showQuickPick.mockImplementation(async () => { + if (change === 'trust') mocks.workspace.isTrusted = false + else disposed = true + return { label: 'npm', agent: 'npm' } + }) + await installPrismaCli(folder, () => disposed) + expect(mocks.executeTask).not.toHaveBeenCalled() + }) +}) diff --git a/packages/vscode/tests/__tests__/prismaNextClients.test.mjs b/packages/vscode/tests/__tests__/prismaNextClients.test.mjs new file mode 100644 index 0000000000..b2134a28e5 --- /dev/null +++ b/packages/vscode/tests/__tests__/prismaNextClients.test.mjs @@ -0,0 +1,139 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + stat: vi.fn(), + installPrismaCli: vi.fn(), + showWarningMessage: vi.fn(), + getWorkspaceFolder: vi.fn(), + start: vi.fn(), + onReady: vi.fn(), + stop: vi.fn(), +})) + +vi.mock('../../src/plugins/prisma-language-server/installPrismaCli', () => ({ + installPrismaCli: mocks.installPrismaCli, +})) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat })) +vi.mock('vscode', () => ({ + window: { showWarningMessage: mocks.showWarningMessage }, + workspace: { isTrusted: true, getWorkspaceFolder: mocks.getWorkspaceFolder }, +})) +vi.mock('vscode-languageclient', () => ({ CloseAction: { DoNotRestart: 1 }, ErrorAction: { Shutdown: 1 } })) +vi.mock('vscode-languageclient/node', () => ({ + LanguageClient: class { + start = mocks.start + onReady = mocks.onReady + stop = mocks.stop + }, +})) + +import { PrismaNextClients } from '../../src/plugins/prisma-language-server/prismaNextClients' + +const folder = { + name: 'my-project', + uri: { scheme: 'file', fsPath: '/my-project', toString: () => 'file:///my-project' }, +} +const document = { uri: { scheme: 'file' } } + +// Flush the asynchronous filesystem check and client startup without real timers. +async function ensureClient(clients) { + clients.ensureClientFor(document) + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalled()) + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('missing Prisma Next CLI warning', () => { + let clients + + beforeEach(() => { + vi.resetAllMocks() + mocks.getWorkspaceFolder.mockReturnValue(folder) + mocks.stat.mockRejectedValue(Object.assign(new Error('Missing CLI'), { code: 'ENOENT' })) + clients = new PrismaNextClients(vi.fn()) + }) + + afterEach(async () => { + await clients.dispose() + vi.restoreAllMocks() + }) + + it('shows installation guidance when the CLI is missing', async () => { + await ensureClient(clients) + + expect(mocks.showWarningMessage).toHaveBeenCalledOnce() + const [message] = mocks.showWarningMessage.mock.calls[0] + expect(message).toContain('my-project') + expect(message).toContain('Prisma ORM 8 CLI is required for autocomplete, formatting, and error checking') + expect(mocks.showWarningMessage.mock.calls[0][1]).toEqual({ modal: true }) + expect(mocks.showWarningMessage.mock.calls[0][3]).toEqual({ + title: 'Continue without language features', + isCloseAffordance: true, + }) + expect(message).not.toContain('Next') + expect(message).not.toContain('manually') + expect(message).not.toContain('Prisma: Restart Language Server') + expect(mocks.start).not.toHaveBeenCalled() + }) + + it('offers installation and invokes the installer when selected', async () => { + mocks.showWarningMessage.mockImplementation(async (_message, _options, installAction) => installAction) + await ensureClient(clients) + + expect(mocks.showWarningMessage.mock.calls[0][2]).toEqual({ title: 'Install prisma@latest' }) + expect(mocks.installPrismaCli).toHaveBeenCalledWith(folder, expect.any(Function)) + const isDisposed = mocks.installPrismaCli.mock.calls[0][1] + expect(isDisposed()).toBe(false) + await clients.dispose() + expect(isDisposed()).toBe(true) + }) + + it('does not install when continuing without language features', async () => { + mocks.showWarningMessage.mockImplementation( + async (_message, _options, _installAction, continueAction) => continueAction, + ) + await ensureClient(clients) + expect(mocks.installPrismaCli).not.toHaveBeenCalled() + }) + + it('does not install when the warning is dismissed', async () => { + await ensureClient(clients) + expect(mocks.installPrismaCli).not.toHaveBeenCalled() + }) + + it('does not repeat the warning on automatic retries but permits an explicit restart', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(0) + await ensureClient(clients) + now.mockReturnValue(30_000) + await ensureClient(clients) + + expect(mocks.stat).toHaveBeenCalledTimes(2) + expect(mocks.showWarningMessage).toHaveBeenCalledOnce() + + await clients.stopAll() + await ensureClient(clients) + expect(mocks.showWarningMessage).toHaveBeenCalledTimes(2) + }) + + it('starts without a warning when the CLI exists', async () => { + mocks.stat.mockResolvedValue({ isFile: () => true }) + await ensureClient(clients) + + expect(mocks.start).toHaveBeenCalledOnce() + expect(mocks.showWarningMessage).not.toHaveBeenCalled() + }) + + it('does not notify after disposal while the filesystem check is pending', async () => { + let finishStat + mocks.stat.mockReturnValue( + new Promise((resolve) => { + finishStat = resolve + }), + ) + clients.ensureClientFor(document) + const disposed = clients.dispose() + finishStat({ isFile: () => false }) + await disposed + + expect(mocks.showWarningMessage).not.toHaveBeenCalled() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eb6cd6252..4544d89b1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: openapi-fetch: specifier: 0.14.0 version: 0.14.0 + package-manager-detector: + specifier: 1.8.0 + version: 1.8.0 postgres: specifier: 3.4.7 version: 3.4.7