diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 3ecbdcc9..7a5c3afc 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -537,6 +537,22 @@ envConfig.inspect - ❌ Tests that don't clean up mocks properly - ❌ Overly complex test setup that's hard to understand +## 🔄 Reviewing and Improving Existing Tests + +### Quick Review Process + +1. **Read test files** - Check structure and mock setup +2. **Run tests** - Establish baseline functionality +3. **Apply improvements** - Use patterns below +4. **Verify** - Ensure tests still pass + +### Common Fixes + +- Over-complex mocks → Minimal mocks with only needed methods +- Brittle assertions → Behavior-focused with error messages +- Vague test names → Clear scenario descriptions +- Missing structure → Mock → Run → Assert pattern + ## 🧠 Agent Learnings - Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility (1) @@ -551,3 +567,7 @@ envConfig.inspect - When a targeted test run yields 0 tests, first verify the compiled JS exists under `out/test` (rootDir is `src`); absence almost always means the test file sits outside `src` or compilation hasn't run yet (1) - When unit tests fail with VS Code API errors like `TypeError: X is not a constructor` or `Cannot read properties of undefined (reading 'Y')`, check if VS Code APIs are properly mocked in `/src/test/unittests.ts` - add missing Task-related APIs (`Task`, `TaskScope`, `ShellExecution`, `TaskRevealKind`, `TaskPanelKind`) and namespace mocks (`tasks`) following the existing pattern of `mockedVSCode.X = vscodeMocks.vscMockExtHostedTypes.X` (1) - Create minimal mock objects with only required methods and use TypeScript type assertions (e.g., mockApi as PythonEnvironmentApi) to satisfy interface requirements instead of implementing all interface methods when only specific methods are needed for the test (1) +- When reviewing existing tests, focus on behavior rather than implementation details in test names and assertions - transform "should return X when Y" into "should [expected behavior] when [scenario context]" (1) +- Simplify mock setup by only mocking methods actually used in tests and use `as unknown as Type` for TypeScript compatibility (1) +- When testing async functions that use child processes, call the function first to get a promise, then use setTimeout to emit mock events, then await the promise - this ensures proper timing of mock setup versus function execution (1) +- Cannot stub internal function calls within the same module after import - stub external dependencies instead (e.g., stub `childProcessApis.spawnProcess` rather than trying to stub `helpers.isUvInstalled` when testing `helpers.shouldUseUv`) because intra-module calls use direct references, not module exports (1) diff --git a/package.json b/package.json index 46a8e66d..4c9f0396 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,12 @@ "items": { "type": "string" } + }, + "python-envs.alwaysUseUv": { + "type": "boolean", + "description": "%python-envs.alwaysUseUv.description%", + "default": true, + "scope": "machine" } } }, diff --git a/package.nls.json b/package.nls.json index 38c88ecf..33a270de 100644 --- a/package.nls.json +++ b/package.nls.json @@ -39,5 +39,6 @@ "python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal", "python-envs.uninstallPackage.title": "Uninstall Package", "python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer", - "python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal..." + "python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...", + "python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv." } diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 4f71b9b4..35d84341 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -1,20 +1,30 @@ -import * as ch from 'child_process'; import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode'; -import { createDeferred } from '../../common/utils/deferred'; -import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { spawnProcess } from '../../common/childProcess.apis'; import { EventNames } from '../../common/telemetry/constants'; +import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { createDeferred } from '../../common/utils/deferred'; +import { getConfiguration } from '../../common/workspace.apis'; +import { getUvEnvironments } from './uvEnvironments'; + +let available = createDeferred(); + +/** + * Reset the UV installation cache. + */ +export function resetUvInstallationCache(): void { + available = createDeferred(); +} -const available = createDeferred(); export async function isUvInstalled(log?: LogOutputChannel): Promise { if (available.completed) { return available.promise; } log?.info(`Running: uv --version`); - const proc = ch.spawn('uv', ['--version']); + const proc = spawnProcess('uv', ['--version']); proc.on('error', () => { available.resolve(false); }); - proc.stdout.on('data', (d) => log?.info(d.toString())); + proc.stdout?.on('data', (d) => log?.info(d.toString())); proc.on('exit', (code) => { if (code === 0) { sendTelemetryEvent(EventNames.VENV_USING_UV); @@ -24,6 +34,31 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise { return available.promise; } +/** + * Determines if uv should be used for managing a virtual environment. + * @param log - Optional log output channel for logging operations + * @param envPath - Optional environment path to check against UV environments list + * @returns True if uv should be used, false otherwise. For UV environments, returns true if uv is installed. For other environments, checks the 'python-envs.alwaysUseUv' setting and uv availability. + */ +export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Promise { + if (envPath) { + // always use uv if the given environment is stored as a uv env + const uvEnvs = await getUvEnvironments(); + if (uvEnvs.includes(envPath)) { + return await isUvInstalled(log); + } + } + + // For other environments, check the user setting + const config = getConfiguration('python-envs'); + const alwaysUseUv = config.get('alwaysUseUv', true); + + if (alwaysUseUv) { + return await isUvInstalled(log); + } + return false; +} + export async function runUV( args: string[], cwd?: string, @@ -32,7 +67,7 @@ export async function runUV( ): Promise { log?.info(`Running: uv ${args.join(' ')}`); return new Promise((resolve, reject) => { - const proc = ch.spawn('uv', args, { cwd: cwd }); + const proc = spawnProcess('uv', args, { cwd: cwd }); token?.onCancellationRequested(() => { proc.kill(); reject(new CancellationError()); @@ -67,7 +102,7 @@ export async function runPython( ): Promise { log?.info(`Running: ${python} ${args.join(' ')}`); return new Promise((resolve, reject) => { - const proc = ch.spawn(python, args, { cwd: cwd }); + const proc = spawnProcess(python, args, { cwd: cwd }); token?.onCancellationRequested(() => { proc.kill(); reject(new CancellationError()); diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index da520279..81d26ea0 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -1,5 +1,6 @@ import { CancellationError, + Disposable, Event, EventEmitter, LogOutputChannel, @@ -18,10 +19,9 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { getWorkspacePackagesToInstall } from './pipUtils'; import { managePackages, refreshPackages } from './utils'; -import { Disposable } from 'vscode-jsonrpc'; import { VenvManager } from './venvManager'; -import { getWorkspacePackagesToInstall } from './pipUtils'; function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { const changes: { kind: PackageChangeKind; pkg: Package }[] = []; diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 5a87c7b9..da2aa2ad 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -18,7 +18,7 @@ import { NativePythonFinder, } from '../common/nativePythonFinder'; import { shortVersion, sortEnvironments } from '../common/utils'; -import { isUvInstalled, runPython, runUV } from './helpers'; +import { runPython, runUV, shouldUseUv } from './helpers'; import { parsePipList, PipPackage } from './pipListUtils'; function asPackageQuickPickItem(name: string, version?: string): QuickPickItem { @@ -139,11 +139,20 @@ export async function refreshPythons( } async function refreshPipPackagesRaw(environment: PythonEnvironment, log?: LogOutputChannel): Promise { - const useUv = await isUvInstalled(); + // Use environmentPath directly for consistency with UV environment tracking + const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); if (useUv) { return await runUV(['pip', 'list', '--python', environment.execInfo.run.executable], undefined, log); } - return await runPython(environment.execInfo.run.executable, ['-m', 'pip', 'list'], undefined, log); + try { + return await runPython(environment.execInfo.run.executable, ['-m', 'pip', 'list'], undefined, log); + } catch (ex) { + log?.error('Error running pip list', ex); + log?.info( + 'Package list retrieval attempted using pip, action can be done with uv if installed and setting `alwaysUseUv` is enabled.', + ); + throw ex; + } } export async function refreshPipPackages( @@ -194,7 +203,8 @@ export async function managePackages( throw new Error('Python 2.* is not supported (deprecated)'); } - const useUv = await isUvInstalled(); + // Use environmentPath directly for consistency with UV environment tracking + const useUv = await shouldUseUv(manager.log, environment.environmentPath.fsPath); const uninstallArgs = ['pip', 'uninstall']; if (options.uninstall && options.uninstall.length > 0) { if (useUv) { diff --git a/src/managers/builtin/uvEnvironments.ts b/src/managers/builtin/uvEnvironments.ts new file mode 100644 index 00000000..531fa878 --- /dev/null +++ b/src/managers/builtin/uvEnvironments.ts @@ -0,0 +1,52 @@ +import { ENVS_EXTENSION_ID } from '../../common/constants'; +import { getWorkspacePersistentState } from '../../common/persistentState'; + +/** + * Persistent storage key for UV-managed virtual environments. + * + * This key is used to store a list of environment paths that were created or identified + * as UV-managed virtual environments. The stored paths correspond to the + * PythonEnvironmentInfo.environmentPath.fsPath values. + */ +export const UV_ENVS_KEY = `${ENVS_EXTENSION_ID}:uv:UV_ENVIRONMENTS`; + +/** + * @returns Array of environment paths (PythonEnvironmentInfo.environmentPath.fsPath values) + * that are known to be UV-managed virtual environments + */ +export async function getUvEnvironments(): Promise { + const state = await getWorkspacePersistentState(); + return (await state.get(UV_ENVS_KEY)) ?? []; +} + +/** + * @param environmentPath The environment path (should be PythonEnvironmentInfo.environmentPath.fsPath) + * to mark as UV-managed. Duplicates are automatically ignored. + */ +export async function addUvEnvironment(environmentPath: string): Promise { + const state = await getWorkspacePersistentState(); + const uvEnvs = await getUvEnvironments(); + if (!uvEnvs.includes(environmentPath)) { + uvEnvs.push(environmentPath); + await state.set(UV_ENVS_KEY, uvEnvs); + } +} + +/** + * @param environmentPath The environment path (PythonEnvironmentInfo.environmentPath.fsPath) + * to remove from UV tracking. No-op if path not found. + */ +export async function removeUvEnvironment(environmentPath: string): Promise { + const state = await getWorkspacePersistentState(); + const uvEnvs = await getUvEnvironments(); + const filtered = uvEnvs.filter((path) => path !== environmentPath); + await state.set(UV_ENVS_KEY, filtered); +} + +/** + * Clears all UV-managed environment paths from the tracking list. + */ +export async function clearUvEnvironments(): Promise { + const state = await getWorkspacePersistentState(); + await state.set(UV_ENVS_KEY, []); +} diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index b294319b..521f7433 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -25,9 +25,10 @@ import { NativePythonFinder, } from '../common/nativePythonFinder'; import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; -import { isUvInstalled, runPython, runUV } from './helpers'; +import { runPython, runUV, shouldUseUv } from './helpers'; import { getProjectInstallable, PipPackages } from './pipUtils'; import { resolveSystemPythonEnvironmentPath } from './utils'; +import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments'; import { createStepBasedVenvFlow } from './venvStepBasedFlow'; export const VENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:venv:WORKSPACE_SELECTED`; @@ -54,7 +55,7 @@ export interface CreateEnvironmentResult { } export async function clearVenvCache(): Promise { - const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY]; + const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY, UV_ENVS_KEY]; const state = await getWorkspacePersistentState(); await state.clear(keys); } @@ -131,6 +132,10 @@ async function getPythonInfo(env: NativeEnvInfo): Promise const venvName = env.name ?? getName(env.executable); const sv = shortVersion(env.version); const name = `${venvName} (${sv})`; + let description = undefined; + if (env.kind === NativePythonEnvironmentKind.venvUv) { + description = l10n.t('uv'); + } const binDir = path.dirname(env.executable); @@ -142,7 +147,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise shortDisplayName: `${sv} (${venvName})`, displayPath: env.executable, version: env.version, - description: undefined, + description: description, tooltip: env.executable, environmentPath: Uri.file(env.executable), iconPath: new ThemeIcon('python'), @@ -176,7 +181,7 @@ export async function findVirtualEnvironments( const envs = data .filter((e) => isNativeEnvInfo(e)) .map((e) => e as NativeEnvInfo) - .filter((e) => e.kind === NativePythonEnvironmentKind.venv); + .filter((e) => e.kind === NativePythonEnvironmentKind.venv || e.kind === NativePythonEnvironmentKind.venvUv); for (const e of envs) { if (!(e.prefix && e.executable && e.version)) { @@ -187,6 +192,11 @@ export async function findVirtualEnvironments( const env = api.createPythonEnvironmentItem(await getPythonInfo(e), manager); collection.push(env); log.info(`Found venv environment: ${env.name}`); + + // Track UV environments using environmentPath for consistency + if (e.kind === NativePythonEnvironmentKind.venvUv) { + await addUvEnvironment(env.environmentPath.fsPath); + } } return collection; } @@ -290,7 +300,7 @@ export async function createWithProgress( async () => { const result: CreateEnvironmentResult = {}; try { - const useUv = await isUvInstalled(log); + const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath); // env creation if (basePython.execInfo?.run.executable) { if (useUv) { @@ -316,6 +326,10 @@ export async function createWithProgress( const resolved = await nativeFinder.resolve(pythonPath); const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved), manager); + if (useUv && resolved.kind === NativePythonEnvironmentKind.venvUv) { + await addUvEnvironment(env.environmentPath.fsPath); + } + // install packages if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) { try { @@ -435,6 +449,7 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC async () => { try { await fsapi.remove(envPath); + await removeUvEnvironment(environment.environmentPath.fsPath); return true; } catch (e) { log.error(`Failed to remove virtual environment: ${e}`); @@ -459,7 +474,7 @@ export async function resolveVenvPythonEnvironmentPath( ): Promise { const resolved = await nativeFinder.resolve(fsPath); - if (resolved.kind === NativePythonEnvironmentKind.venv) { + if (resolved.kind === NativePythonEnvironmentKind.venv || resolved.kind === NativePythonEnvironmentKind.venvUv) { const envInfo = await getPythonInfo(resolved); return api.createPythonEnvironmentItem(envInfo, manager); } diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index b84c0a95..57b7e2c5 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -69,6 +69,7 @@ export enum NativePythonEnvironmentKind { linuxGlobal = 'LinuxGlobal', macXCode = 'MacXCode', venv = 'Venv', + venvUv = 'Uv', virtualEnv = 'VirtualEnv', virtualEnvWrapper = 'VirtualEnvWrapper', windowsStore = 'WindowsStore', diff --git a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts new file mode 100644 index 00000000..b9b91549 --- /dev/null +++ b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts @@ -0,0 +1,218 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import { EventNames } from '../../../common/telemetry/constants'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import { isUvInstalled, resetUvInstallationCache } from '../../../managers/builtin/helpers'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('Helpers - isUvInstalled', () => { + let mockLog: LogOutputChannel; + let spawnStub: sinon.SinonStub; + let sendTelemetryEventStub: sinon.SinonStub; + + setup(() => { + // Reset UV installation cache before each test to ensure clean state + resetUvInstallationCache(); + + // Create a mock for LogOutputChannel + mockLog = { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + append: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + show: sinon.stub(), + hide: sinon.stub(), + dispose: sinon.stub(), + clear: sinon.stub(), + replace: sinon.stub(), + appendLine: sinon.stub(), + name: 'test-log', + logLevel: 1, + onDidChangeLogLevel: sinon.stub() as LogOutputChannel['onDidChangeLogLevel'], + } as unknown as LogOutputChannel; + + // Stub childProcess.apis spawnProcess + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + + // Stub telemetry + sendTelemetryEventStub = sinon.stub(telemetrySender, 'sendTelemetryEvent'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should return true when uv --version succeeds', async () => { + // Arrange - Create mock process that simulates successful uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - Call isUvInstalled and simulate successful process + const resultPromise = isUvInstalled(mockLog); + + // Simulate successful uv --version command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert + assert.strictEqual(result, true); + assert( + sendTelemetryEventStub.calledWith(EventNames.VENV_USING_UV), + 'Should send telemetry event when UV is available', + ); + assert(spawnStub.calledWith('uv', ['--version']), 'Should spawn uv --version command'); + }); + + test('should return false when uv --version fails with non-zero exit code', async () => { + // Arrange - Create mock process that simulates failed uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - Call isUvInstalled and simulate failed process + const resultPromise = isUvInstalled(mockLog); + + // Simulate failed uv --version command + setTimeout(() => { + mockProcess.emit('exit', 1, null); + }, 10); + + const result = await resultPromise; + + // Assert + assert.strictEqual(result, false); + assert(sendTelemetryEventStub.notCalled, 'Should not send telemetry event when UV is not available'); + assert(spawnStub.calledWith('uv', ['--version']), 'Should spawn uv --version command'); + }); + + test('should return false when uv command is not found (error event)', async () => { + // Arrange - Create mock process that simulates command not found + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - Call isUvInstalled and simulate error (command not found) + const resultPromise = isUvInstalled(mockLog); + + // Simulate error event (e.g., command not found) + setTimeout(() => { + mockProcess.emit('error', new Error('spawn uv ENOENT')); + }, 10); + + const result = await resultPromise; + + // Assert + assert.strictEqual(result, false); + assert(sendTelemetryEventStub.notCalled, 'Should not send telemetry event when UV command is not found'); + assert(spawnStub.calledWith('uv', ['--version']), 'Should spawn uv --version command'); + }); + + test('should log uv --version command when logger provided', async () => { + // Arrange - Create mock process + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - Call isUvInstalled with logger + const resultPromise = isUvInstalled(mockLog); + + // Simulate successful command with output + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + await resultPromise; + + // Assert + assert( + (mockLog.info as sinon.SinonStub).calledWith('Running: uv --version'), + 'Should log the command being run', + ); + assert((mockLog.info as sinon.SinonStub).calledWith('uv 0.1.0\n'), 'Should log the command output'); + }); + + test('should work without logger', async () => { + // Arrange - Create mock process + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - Call isUvInstalled without logger + const resultPromise = isUvInstalled(); + + // Simulate successful command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert + assert.strictEqual(result, true); + assert(spawnStub.calledWith('uv', ['--version']), 'Should spawn uv --version command even without logger'); + }); + + test('should return cached result on subsequent calls', async () => { + // Arrange - Create mock process for first call + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Act - First call + const firstCallPromise = isUvInstalled(mockLog); + + // Simulate successful command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const firstResult = await firstCallPromise; + + // Act - Second call (should use cached result) + const secondResult = await isUvInstalled(mockLog); + + // Assert + assert.strictEqual(firstResult, true); + assert.strictEqual(secondResult, true); + assert(spawnStub.calledOnce, 'Should only spawn process once, second call should use cached result'); + }); + + test('should check uv installation again after cache reset', async () => { + // Arrange - First call + let mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + const firstCallPromise = isUvInstalled(mockLog); + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const firstResult = await firstCallPromise; + + // Act - Reset cache + resetUvInstallationCache(); + + // Arrange - Second call after reset + mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + const secondCallPromise = isUvInstalled(mockLog); + setTimeout(() => { + mockProcess.emit('exit', 1, null); // Simulate failure this time + }, 10); + + const secondResult = await secondCallPromise; + + // Assert + assert.strictEqual(firstResult, true); + assert.strictEqual(secondResult, false); + assert(spawnStub.calledTwice, 'Should spawn process twice after cache reset'); + }); +}); diff --git a/src/test/managers/builtin/helpers.shouldUseUv.unit.test.ts b/src/test/managers/builtin/helpers.shouldUseUv.unit.test.ts new file mode 100644 index 00000000..e666ea48 --- /dev/null +++ b/src/test/managers/builtin/helpers.shouldUseUv.unit.test.ts @@ -0,0 +1,221 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import * as persistentState from '../../../common/persistentState'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { resetUvInstallationCache, shouldUseUv } from '../../../managers/builtin/helpers'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +interface MockWorkspaceConfig { + get: sinon.SinonStub; + inspect: sinon.SinonStub; + update: sinon.SinonStub; +} + +suite('Helpers - shouldUseUv', () => { + let mockGetConfiguration: sinon.SinonStub; + let mockConfig: MockWorkspaceConfig; + let mockLog: LogOutputChannel; + let getWorkspacePersistentStateStub: sinon.SinonStub; + let mockPersistentState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let getUvEnvironmentsStub: sinon.SinonStub; + let spawnStub: sinon.SinonStub; + + setup(() => { + // Reset UV installation cache before each test to ensure clean state + resetUvInstallationCache(); + + mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); + mockConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + mockGetConfiguration.withArgs('python-envs').returns(mockConfig); + + // Mock persistent state + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + mockPersistentState = { + get: sinon.stub(), + set: sinon.stub(), + clear: sinon.stub(), + }; + getWorkspacePersistentStateStub.returns(Promise.resolve(mockPersistentState)); + + // Mock UV-related functions + getUvEnvironmentsStub = sinon.stub(uvEnvironments, 'getUvEnvironments'); + + // No default behaviors set - each test configures what it needs + // Create a more complete mock for LogOutputChannel + mockLog = { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + append: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + show: sinon.stub(), + hide: sinon.stub(), + dispose: sinon.stub(), + clear: sinon.stub(), + replace: sinon.stub(), + appendLine: sinon.stub(), + name: 'test-log', + logLevel: 1, + onDidChangeLogLevel: sinon.stub() as LogOutputChannel['onDidChangeLogLevel'], + } as unknown as LogOutputChannel; + + // Stub childProcess.apis spawnProcess + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should return true when alwaysUseUv is true and UV is installed', async () => { + // Mock - alwaysUseUv is true and UV is installed + const mockInspectResult = { + globalRemoteValue: true, + globalLocalValue: true, + globalValue: true, + }; + + mockConfig.get.withArgs('alwaysUseUv', true).returns(true); + mockConfig.get.withArgs('alwaysUseUv').returns(true); + mockConfig.inspect.withArgs('alwaysUseUv').returns(mockInspectResult); + + getUvEnvironmentsStub.resolves([]); + + // Arrange - Create mock process that simulates successful uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Run - Call function and set up mock events in parallel + const resultPromise = shouldUseUv(mockLog); + + // Simulate successful uv --version command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert - Should return true when setting is true and UV is installed + assert.strictEqual(result, true); + }); + + test('should return false when alwaysUseUv is false', async () => { + // Mock - alwaysUseUv is false + mockConfig.get.withArgs('alwaysUseUv', true).returns(false); + getUvEnvironmentsStub.resolves([]); + + // Run + const result = await shouldUseUv(mockLog); + + // Assert - Should not use UV when setting is false + assert.strictEqual(result, false); + }); + + test('should return true for UV environment path when UV is installed', async () => { + // Mock - UV environments list with test path and UV is installed + const uvEnvPath = '/path/to/uv/env'; + getUvEnvironmentsStub.resolves([uvEnvPath]); + mockConfig.get.withArgs('alwaysUseUv', true).returns(false); + + // Arrange - Create mock process that simulates successful uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Run - Call function and set up mock events in parallel + const resultPromise = shouldUseUv(mockLog, uvEnvPath); + + // Simulate successful uv --version command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert - Should return true for UV environments when UV is installed + assert.strictEqual(result, true); + }); + + test('should return false for non-UV environment when alwaysUseUv is false', async () => { + // Mock - Non-UV environment, alwaysUseUv is false + const nonUvEnvPath = '/path/to/regular/env'; + mockConfig.get.withArgs('alwaysUseUv', true).returns(false); + getUvEnvironmentsStub.resolves([]); + + // Run + const result = await shouldUseUv(mockLog, nonUvEnvPath); + + // Assert - Should not use UV for non-UV environments when setting is false + assert.strictEqual(result, false); + }); + + test('should check setting when alwaysUseUv is true for non-UV environment', async () => { + // Mock - Non-UV environment, alwaysUseUv is true, UV is installed + const nonUvEnvPath = '/path/to/regular/env'; + mockConfig.get.withArgs('alwaysUseUv', true).returns(true); + getUvEnvironmentsStub.resolves([]); + + // Arrange - Create mock process that simulates successful uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Run - Call function and set up mock events in parallel + const resultPromise = shouldUseUv(mockLog, nonUvEnvPath); + + // Simulate successful uv --version command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert - Should return true when alwaysUseUv is true and UV is installed + assert.strictEqual(result, true); + }); + + test('should use default value true when alwaysUseUv setting is not configured', async () => { + // Mock - Setting not configured, should use default of true, UV is installed + mockConfig.get.withArgs('alwaysUseUv', true).returns(true); + getUvEnvironmentsStub.resolves([]); + + // Arrange - Create mock process that simulates successful uv --version + const mockProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(mockProcess); + + // Run - Call function and set up mock events in parallel + const resultPromise = shouldUseUv(mockLog); + + // Simulate successful uv --version command + setTimeout(() => { + mockProcess.stdout?.emit('data', 'uv 0.1.0\n'); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + // Assert - Should return true with default setting when UV is installed + assert.strictEqual(result, true); + }); + + test('should respect alwaysUseUv setting when no environment path provided', async () => { + // Mock - No environment path specified, alwaysUseUv is false + mockConfig.get.withArgs('alwaysUseUv', true).returns(false); + getUvEnvironmentsStub.resolves([]); + + // Run + const result = await shouldUseUv(mockLog); + + // Assert - Should not use UV when setting is false + assert.strictEqual(result, false); + }); +}); diff --git a/src/test/managers/builtin/venvUtils.uvTracking.unit.test.ts b/src/test/managers/builtin/venvUtils.uvTracking.unit.test.ts new file mode 100644 index 00000000..04fe2665 --- /dev/null +++ b/src/test/managers/builtin/venvUtils.uvTracking.unit.test.ts @@ -0,0 +1,127 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as persistentState from '../../../common/persistentState'; +import { + UV_ENVS_KEY, + addUvEnvironment, + clearUvEnvironments, + getUvEnvironments, + removeUvEnvironment, +} from '../../../managers/builtin/uvEnvironments'; +import { clearVenvCache } from '../../../managers/builtin/venvUtils'; + +suite('venvUtils UV Environment Tracking', () => { + let mockState: { + get: sinon.SinonStub; + set: sinon.SinonStub; + clear: sinon.SinonStub; + }; + let getWorkspacePersistentStateStub: sinon.SinonStub; + + setup(() => { + // Create minimal mock state with only required methods + mockState = { + get: sinon.stub(), + set: sinon.stub(), + clear: sinon.stub(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should return empty array when no UV environments have been stored', async () => { + // Mock - No stored environments + mockState.get.withArgs(UV_ENVS_KEY).resolves(undefined); + + // Run + const result = await getUvEnvironments(); + + // Assert - Should return empty array for fresh state + assert.deepStrictEqual(result, [], 'Should return empty array when no environments stored'); + }); + + test('should return previously stored UV environments', async () => { + // Mock - Existing stored environments + const storedEnvs = ['/path/to/env1', '/path/to/env2']; + mockState.get.withArgs(UV_ENVS_KEY).resolves(storedEnvs); + + // Run + const result = await getUvEnvironments(); + + // Assert - Should return stored environments + assert.deepStrictEqual(result, storedEnvs, 'Should return all stored UV environments'); + }); + + test('should add new environment to tracking list', async () => { + // Mock - Existing environment list + const existingEnvs = ['/path/to/env1']; + const newEnvPath = '/path/to/env2'; + mockState.get.withArgs(UV_ENVS_KEY).resolves(existingEnvs); + + // Run + await addUvEnvironment(newEnvPath); + + // Assert - Should store updated list with new environment + const expectedList = ['/path/to/env1', '/path/to/env2']; + assert.ok(mockState.set.calledWith(UV_ENVS_KEY, expectedList), 'Should add new environment to existing list'); + }); + + test('should ignore duplicate environment additions', async () => { + // Mock - Environment already exists in list + const existingEnvs = ['/path/to/env1', '/path/to/env2']; + const duplicateEnvPath = '/path/to/env1'; + mockState.get.withArgs(UV_ENVS_KEY).resolves(existingEnvs); + + // Run + await addUvEnvironment(duplicateEnvPath); + + // Assert - Should not modify state for duplicates + assert.ok(mockState.set.notCalled, 'Should not update storage when adding duplicate environment'); + }); + + test('should remove specified environment from tracking list', async () => { + // Mock - List with multiple environments + const existingEnvs = ['/path/to/env1', '/path/to/env2']; + const envToRemove = '/path/to/env1'; + mockState.get.withArgs(UV_ENVS_KEY).resolves(existingEnvs); + + // Run + await removeUvEnvironment(envToRemove); + + // Assert - Should store filtered list without removed environment + const expectedList = ['/path/to/env2']; + assert.ok( + mockState.set.calledWith(UV_ENVS_KEY, expectedList), + 'Should remove specified environment from tracking list', + ); + }); + + test('should clear all tracked UV environments', async () => { + // Mock - (no setup needed for clear operation) + + // Run + await clearUvEnvironments(); + + // Assert - Should reset to empty list + assert.ok(mockState.set.calledWith(UV_ENVS_KEY, []), 'Should clear all UV environments from tracking'); + }); + + test('should include UV environments when clearing venv cache', async () => { + // Mock - (no setup needed for clear operation) + + // Run + await clearVenvCache(); + + // Assert - Should clear UV environments as part of cache clearing + assert.ok(mockState.clear.called, 'Should call clear on persistent state'); + const clearArgs = mockState.clear.getCall(0).args[0]; + assert.ok( + Array.isArray(clearArgs) && clearArgs.includes(UV_ENVS_KEY), + 'Should include UV environments key in cache clearing', + ); + }); +});