From b57c83248e9175b6d5cd60f2bf25742e7ce1ea64 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 16 Jul 2026 15:26:56 -0700 Subject: [PATCH] Add venv and conda lifecycle coverage (#1624) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f42031a-bd90-4448-bffe-b5c0ab522b6d --- src/managers/builtin/venvManager.ts | 4 +- .../venvManager.createRemove.unit.test.ts | 288 ++++++++++++++++++ .../condaEnvManager.createRemove.unit.test.ts | 207 +++++++++++++ src/test/mocks/pythonEnvironment.ts | 7 +- 4 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/test/managers/builtin/venvManager.createRemove.unit.test.ts create mode 100644 src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 866c87d05..7af0f450a 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -1,7 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { - commands, EventEmitter, l10n, LogOutputChannel, @@ -28,6 +27,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { executeCommand } from '../../common/command.api'; import { PYTHON_EXTENSION_ID } from '../../common/constants'; import { VenvManagerStrings } from '../../common/localize'; import { traceError, traceWarn } from '../../common/logging'; @@ -238,7 +238,7 @@ export class VenvManager implements EnvironmentManager { // Open the parent folder of the venv in the current window immediately after creation const envParent = environment.sysPrefix; try { - await commands.executeCommand('revealInExplorer', Uri.file(envParent)); + await executeCommand('revealInExplorer', Uri.file(envParent)); } catch (error) { showErrorMessage( l10n.t( diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts new file mode 100644 index 000000000..7e1e202be --- /dev/null +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentChangeKind, + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../../api'; +import * as commandApis from '../../../common/command.api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; +import { normalizePath } from '../../../common/utils/pathUtils'; +import * as windowApis from '../../../common/window.apis'; +import * as envCommands from '../../../features/envCommands'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +const TEST_ROOT = Uri.file(path.join(os.tmpdir(), 'vscode-python-envs-tests', 'venv-manager')).fsPath; + +function testPath(...segments: string[]): string { + return path.join(TEST_ROOT, ...segments); +} + +function venvPythonPath(venvRoot: string): string { + return path.join(venvRoot, process.platform === 'win32' ? 'Scripts' : 'bin', 'python'); +} + +function createManager( + apiOverrides?: Partial, + baseEnvironments: PythonEnvironment[] = [], +): VenvManager { + const api = { + getEnvironments: sinon.stub().resolves([]), + getPythonProject: sinon.stub().returns(undefined), + getPythonProjects: sinon.stub().returns([]), + refreshEnvironments: sinon.stub().resolves(undefined), + ...apiOverrides, + } as any as PythonEnvironmentApi; + const baseManager = { + getEnvironments: sinon.stub().resolves(baseEnvironments), + } as any as EnvironmentManager; + const manager = new VenvManager( + {} as NativePythonFinder, + api, + baseManager, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + (manager as any)._initialized = { completed: true, promise: Promise.resolve() }; + (manager as any).collection = []; + return manager; +} + +suite('VenvManager.create - orchestration', () => { + let createPythonVenvStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + let quickCreateVenvStub: sinon.SinonStub; + let showErrorStub: sinon.SinonStub; + let envDir: string; + let pythonPath: string; + let tmpRoot: string; + + setup(async () => { + createPythonVenvStub = sinon.stub(venvUtils, 'createPythonVenv'); + quickCreateVenvStub = sinon.stub(venvUtils, 'quickCreateVenv'); + sinon.stub(envCommands, 'findParentIfFile').callsFake(async (value: string) => value); + showErrorStub = sinon.stub(windowApis, 'showErrorMessage'); + executeCommandStub = sinon.stub(commandApis, 'executeCommand').resolves(); + + tmpRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'venvmgr-')); + envDir = Uri.file(path.join(tmpRoot, 'project', '.venv')).fsPath; + pythonPath = venvPythonPath(envDir); + await fse.outputFile(pythonPath, ''); + }); + + teardown(async () => { + sinon.restore(); + if (tmpRoot) { + await fse.remove(tmpRoot); + } + }); + + function createdEnvironment(): PythonEnvironment { + return createMockPythonEnvironment({ + name: '.venv', + envPath: pythonPath, + sysPrefix: envDir, + version: '3.12.0', + managerId: VENV_MANAGER_ID, + }); + } + + test('non-quick create delegates, caches the environment, and performs side effects', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }); + const created = createdEnvironment(); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv]) }); + createPythonVenvStub.resolves({ environment: created }); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + const scope = Uri.file(path.join(tmpRoot, 'project')); + + const result = await manager.create(scope, undefined); + + assert.strictEqual(result, created); + assert.deepStrictEqual(createPythonVenvStub.firstCall.args[4], [globalEnv]); + assert.strictEqual(createPythonVenvStub.firstCall.args[5].fsPath, scope.fsPath); + assert.deepStrictEqual(createPythonVenvStub.firstCall.args[6], { showQuickAndCustomOptions: true }); + assert.deepStrictEqual((manager as any).collection, [created]); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0][0].kind, EnvironmentChangeKind.add); + assert.strictEqual(await fse.readFile(path.join(envDir, '.gitignore'), 'utf8'), '*\n'); + assert.ok(executeCommandStub.calledOnceWithExactly('revealInExplorer', Uri.file(envDir))); + }); + + test('quick create uses the selected global Python and forwards additional packages', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }); + const created = createdEnvironment(); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv]) }); + (manager as any).globalEnv = globalEnv; + quickCreateVenvStub.resolves({ environment: created }); + const scope = Uri.file(path.join(tmpRoot, 'project')); + + const result = await manager.create(scope, { quickCreate: true, additionalPackages: ['pytest'] }); + + assert.strictEqual(result, created); + assert.ok(createPythonVenvStub.notCalled); + assert.strictEqual(quickCreateVenvStub.firstCall.args[4], globalEnv); + assert.strictEqual(quickCreateVenvStub.firstCall.args[5].fsPath, scope.fsPath); + assert.deepStrictEqual(quickCreateVenvStub.firstCall.args[6], ['pytest']); + }); + + test('reports creation errors without adding an environment', async () => { + const manager = createManager({ + getEnvironments: sinon.stub().resolves([ + createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }), + ]), + }); + createPythonVenvStub.resolves({ envCreationErr: 'creation failed' }); + + const result = await manager.create(Uri.file(path.join(tmpRoot, 'project')), undefined); + + assert.strictEqual(result, undefined); + assert.deepStrictEqual((manager as any).collection, []); + assert.ok(showErrorStub.calledOnce); + }); + + test('restores the watcher guard when creation throws', async () => { + const manager = createManager({ + getEnvironments: sinon.stub().resolves([ + createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }), + ]), + }); + createPythonVenvStub.rejects(new Error('creation failed')); + + await assert.rejects(manager.create(Uri.file(path.join(tmpRoot, 'project')), undefined), /creation failed/); + + assert.strictEqual((manager as any).skipWatcherRefresh, false); + }); +}); + +suite('VenvManager.remove - orchestration', () => { + let removeVenvStub: sinon.SinonStub; + + setup(() => { + removeVenvStub = sinon.stub(venvUtils, 'removeVenv'); + sinon.stub(venvUtils, 'setVenvForGlobal').resolves(); + sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + + function environment(): PythonEnvironment { + const root = testPath('workspace', 'project', '.venv'); + return createMockPythonEnvironment({ + name: '.venv', + envPath: venvPythonPath(root), + sysPrefix: root, + version: '3.12.0', + managerId: VENV_MANAGER_ID, + }); + } + + test('successful removal updates the collection and fires a remove event', async () => { + const manager = createManager(); + const env = environment(); + (manager as any).collection = [env]; + removeVenvStub.resolves(true); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + await manager.remove(env); + + assert.deepStrictEqual((manager as any).collection, []); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0][0].kind, EnvironmentChangeKind.remove); + assert.strictEqual(events[0][0].environment, env); + }); + + test('does not mutate state when the removal helper returns false', async () => { + const manager = createManager(); + const env = environment(); + (manager as any).collection = [env]; + removeVenvStub.resolves(false); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + await manager.remove(env); + + assert.deepStrictEqual((manager as any).collection, [env]); + assert.strictEqual(events.length, 0); + }); + + test('clears mapped project state and reports the effective fallback', async () => { + const projectUri = Uri.file(testPath('workspace', 'project')); + const project = { name: 'project', uri: projectUri }; + const fallback = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.13.0', + }); + const manager = createManager({ getPythonProject: sinon.stub().returns(project) }, [fallback]); + const env = environment(); + (manager as any).collection = [env]; + (manager as any).globalEnv = fallback; + (manager as any).fsPathToEnv = new Map([[normalizePath(projectUri.fsPath), env]]); + removeVenvStub.resolves(true); + const events: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironment((event) => events.push(event)); + + await manager.remove(env); + + assert.strictEqual((manager as any).fsPathToEnv.size, 0); + assert.strictEqual(events.length, 1); + assert.strictEqual(normalizePath(events[0].uri!.fsPath), normalizePath(projectUri.fsPath)); + assert.strictEqual(events[0].old, env); + assert.strictEqual(events[0].new, fallback); + }); + + test('clears the current global environment', async () => { + const manager = createManager(); + const env = environment(); + (manager as any).collection = [env]; + (manager as any).globalEnv = env; + removeVenvStub.resolves(true); + const events: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironment((event) => events.push(event)); + + await manager.remove(env); + + assert.strictEqual((manager as any).globalEnv, undefined); + assert.strictEqual(events.length, 1); + assert.deepStrictEqual(events[0], { uri: undefined, old: env, new: undefined }); + }); + + test('restores the watcher guard when removal throws', async () => { + const manager = createManager(); + removeVenvStub.rejects(new Error('removal failed')); + + await assert.rejects(manager.remove(environment()), /removal failed/); + + assert.strictEqual((manager as any).skipWatcherRefresh, false); + }); +}); diff --git a/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts b/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts new file mode 100644 index 000000000..3c3abdf25 --- /dev/null +++ b/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentChangeKind, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, +} from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { CondaEnvManager } from '../../../managers/conda/condaEnvManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment'; + +const TEST_ROOT = Uri.file(path.join(os.tmpdir(), 'vscode-python-envs-tests', 'conda-manager')).fsPath; +const DEFAULT_CONDA_PREFIX = path.join(TEST_ROOT, 'miniconda3'); + +function testPath(...segments: string[]): string { + return path.join(TEST_ROOT, ...segments); +} + +function flushSetImmediate(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function createManager(apiOverrides?: Partial): CondaEnvManager { + const api = { + getPythonProject: sinon.stub().returns(undefined), + getPythonProjects: sinon.stub().returns([]), + ...apiOverrides, + } as any as PythonEnvironmentApi; + const manager = new CondaEnvManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + (manager as any)._initialized = { completed: true, promise: Promise.resolve() }; + (manager as any).collection = []; + return manager; +} + +suite('CondaEnvManager.create - orchestration', () => { + let createCondaStub: sinon.SinonStub; + let generateNameStub: sinon.SinonStub; + let getDefaultPrefixStub: sinon.SinonStub; + let quickCreateStub: sinon.SinonStub; + let showErrorStub: sinon.SinonStub; + + setup(() => { + createCondaStub = sinon.stub(condaUtils, 'createCondaEnvironment'); + quickCreateStub = sinon.stub(condaUtils, 'quickCreateConda'); + getDefaultPrefixStub = sinon.stub(condaUtils, 'getDefaultCondaPrefix').resolves(DEFAULT_CONDA_PREFIX); + generateNameStub = sinon.stub(condaUtils, 'generateName').resolves('./.conda'); + showErrorStub = sinon.stub(windowApis, 'showErrorMessage'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('non-quick create delegates, caches the environment, and fires an add event', async () => { + const manager = createManager(); + const env = makeEnv('myenv', testPath('miniconda3', 'envs', 'myenv'), '3.12.0'); + createCondaStub.resolves(env); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + const scope = Uri.file(testPath('workspace', 'project')); + + const result = await manager.create(scope, undefined); + + assert.strictEqual(result, env); + assert.strictEqual(createCondaStub.firstCall.args[3], scope); + assert.ok(quickCreateStub.notCalled); + assert.deepStrictEqual((manager as any).collection, [env]); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0][0].kind, EnvironmentChangeKind.add); + assert.strictEqual(events[0][0].environment, env); + }); + + test('global quick create resolves a prefix and forwards additional packages', async () => { + const manager = createManager(); + const env = makeEnv('global-env', testPath('miniconda3', 'envs', 'global-env'), '3.12.0'); + quickCreateStub.resolves(env); + + const result = await manager.create('global', { + quickCreate: true, + additionalPackages: ['pytest'], + }); + + assert.strictEqual(result, env); + assert.ok(getDefaultPrefixStub.calledOnce); + assert.ok(generateNameStub.calledOnceWith(DEFAULT_CONDA_PREFIX)); + assert.strictEqual(quickCreateStub.firstCall.args[3], DEFAULT_CONDA_PREFIX); + assert.strictEqual(quickCreateStub.firstCall.args[4], './.conda'); + assert.deepStrictEqual(quickCreateStub.firstCall.args[5], ['pytest']); + }); + + test('project quick create uses the project root and writes .gitignore', async () => { + const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'condamgr-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + const envPath = path.join(projectUri.fsPath, '.conda'); + await fse.mkdirp(envPath); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getPythonProjects: sinon.stub().returns([project]), + }); + const env = makeEnv('project-env', envPath, '3.12.0'); + quickCreateStub.resolves(env); + + const result = await manager.create(projectUri, { quickCreate: true }); + + assert.strictEqual(result, env); + assert.ok(getDefaultPrefixStub.notCalled); + assert.strictEqual(quickCreateStub.firstCall.args[3], projectUri.fsPath); + assert.strictEqual(await fse.readFile(path.join(envPath, '.gitignore'), 'utf8'), '*\n'); + } finally { + await fse.remove(tempRoot); + } + }); + + test('does not mutate state when creation returns no environment', async () => { + const manager = createManager(); + createCondaStub.resolves(undefined); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + const result = await manager.create(Uri.file(testPath('workspace', 'project')), undefined); + + assert.strictEqual(result, undefined); + assert.deepStrictEqual((manager as any).collection, []); + assert.strictEqual(events.length, 0); + }); + + test('reports thrown creation errors without mutating state', async () => { + const manager = createManager(); + createCondaStub.rejects(new Error('creation failed')); + + const result = await manager.create(Uri.file(testPath('workspace', 'project')), undefined); + + assert.strictEqual(result, undefined); + assert.deepStrictEqual((manager as any).collection, []); + assert.ok(showErrorStub.calledOnce); + }); +}); + +suite('CondaEnvManager.remove - orchestration', () => { + let deleteCondaStub: sinon.SinonStub; + + setup(() => { + deleteCondaStub = sinon.stub(condaUtils, 'deleteCondaEnvironment'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('successful removal updates caches and fires collection and project events', async () => { + const projectUri = Uri.file(testPath('workspace', 'project')); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ getPythonProject: sinon.stub().returns(project) }); + const env = makeEnv('project-env', testPath('workspace', 'project', '.conda'), '3.12.0'); + (manager as any).collection = [env]; + (manager as any).fsPathToEnv = new Map([[projectUri.fsPath, env]]); + deleteCondaStub.resolves(true); + const environmentEvents: DidChangeEnvironmentsEventArgs[] = []; + const selectionEvents: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironments((event) => environmentEvents.push(event)); + manager.onDidChangeEnvironment((event) => selectionEvents.push(event)); + + await manager.remove(env); + await flushSetImmediate(); + + assert.ok(deleteCondaStub.calledOnceWithExactly(env, (manager as any).log)); + assert.deepStrictEqual((manager as any).collection, []); + assert.strictEqual((manager as any).fsPathToEnv.size, 0); + assert.strictEqual(environmentEvents.length, 1); + assert.strictEqual(environmentEvents[0][0].kind, EnvironmentChangeKind.remove); + assert.strictEqual(selectionEvents.length, 1); + assert.strictEqual(selectionEvents[0].uri, projectUri); + assert.strictEqual(selectionEvents[0].old, env); + assert.strictEqual(selectionEvents[0].new, undefined); + }); + + test('logs rejected deletion without firing a success event', async () => { + const manager = createManager(); + const env = makeEnv('project-env', testPath('workspace', 'project', '.conda'), '3.12.0'); + (manager as any).collection = [env]; + deleteCondaStub.rejects(new Error('removal failed')); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + await manager.remove(env); + await flushSetImmediate(); + + assert.strictEqual(events.length, 0); + assert.ok(((manager as any).log.error as sinon.SinonStub).called); + }); +}); diff --git a/src/test/mocks/pythonEnvironment.ts b/src/test/mocks/pythonEnvironment.ts index c4e9608cc..78562c4ed 100644 --- a/src/test/mocks/pythonEnvironment.ts +++ b/src/test/mocks/pythonEnvironment.ts @@ -11,8 +11,10 @@ import { PythonEnvironmentImpl } from '../../internal.api'; export interface MockPythonEnvironmentOptions { /** Environment name, e.g. `myenv`. Defaults to `test-env`. */ name?: string; - /** Filesystem path for `environmentPath`, `displayPath`, and `sysPrefix`. */ + /** Filesystem path for `environmentPath` and `displayPath`. */ envPath: string; + /** Filesystem path for `sysPrefix`. Defaults to `envPath`. */ + sysPrefix?: string; /** Version string. Defaults to `3.12.0`. */ version?: string; /** Manager id. Defaults to `ms-python.python:conda`. */ @@ -38,6 +40,7 @@ export function createMockPythonEnvironment(options: MockPythonEnvironmentOption const { name = 'test-env', envPath, + sysPrefix = envPath, version = '3.12.0', managerId = 'ms-python.python:conda', id = `${name}-test`, @@ -55,7 +58,7 @@ export function createMockPythonEnvironment(options: MockPythonEnvironmentOption version, description, environmentPath: Uri.file(envPath), - sysPrefix: envPath, + sysPrefix, execInfo: { run: { executable: 'python' }, ...(hasActivation && {