diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 68958773b..7cf42a888 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -16,6 +16,10 @@ This guide covers the full testing lifecycle: 4. **🛠️ Fixing Problems** - Resolve compilation and runtime issues 5. **✅ Validation** - Ensure coverage and resilience +## Learnings + +- Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1). + ### When to Use This Guide **User Requests Testing:** diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9297f7df6..5a8a0ab03 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -252,6 +252,9 @@ jobs: name: Integration Tests runs-on: ${{ matrix.os }} needs: [smoke-tests] + env: + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'true' + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false matrix: @@ -325,6 +328,12 @@ jobs: - name: Compile Tests run: npm run compile-tests + - name: Set up Conda + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3 + with: + activate-environment: '' + auto-activate: false + - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -335,14 +344,6 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test - - name: Run Package Manager Network Integration Tests - if: runner.os == 'Linux' && matrix.python-version == '3.12' - uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 - env: - VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' - with: - run: npm run integration-test -- --grep "Package Manager" - integration-tests-multiroot: name: Integration Tests (Multi-Root) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 23db9b117..33528bae9 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -253,6 +253,9 @@ jobs: name: Integration Tests runs-on: ${{ matrix.os }} needs: [smoke-tests] + env: + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'true' + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false matrix: @@ -326,6 +329,12 @@ jobs: - name: Compile Tests run: npm run compile-tests + - name: Set up Conda + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3 + with: + activate-environment: '' + auto-activate: false + - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -335,11 +344,3 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test - - - name: Run Package Manager Network Integration Tests - if: runner.os == 'Linux' && matrix.python-version == '3.12' - uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 - env: - VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' - with: - run: npm run integration-test -- --grep "Package Manager" diff --git a/src/extension.ts b/src/extension.ts index f45d46aa2..e52e1695f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -269,6 +269,16 @@ export async function activate(context: ExtensionContext): Promise envManagers.packageManagers.map((manager) => manager.id), ), + commands.registerCommand( + 'python-envs.test.resolveEnvironmentWithManager', + async (managerId: string, environmentUri: Uri) => { + const manager = envManagers.getEnvironmentManager(managerId); + if (!manager) { + throw new Error(`Environment manager not found: ${managerId}`); + } + return manager.resolve(environmentUri); + }, + ), commands.registerCommand( 'python-envs.test.getDirectPackageNames', async (environment: PythonEnvironment) => { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index faec04a24..11a849538 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -2,6 +2,7 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { compare, explain as parse, rcompare } from '@renovatebot/pep440'; import { CancellationError, + CancellationToken, Disposable, Event, EventEmitter, @@ -9,7 +10,6 @@ import { MarkdownString, ProgressLocation, ThemeIcon, - window, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -22,6 +22,7 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { showErrorMessage, withProgress } from '../../common/window.apis'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { runPython, runUV, shouldUseUv } from './helpers'; import { getWorkspacePackagesToInstall } from './pipUtils'; @@ -75,45 +76,52 @@ export class PipPackageManager implements PackageManager, Disposable { install: toInstall, uninstall: toUninstall, }; - await window.withProgress( + const execute = async (token?: CancellationToken): Promise => { + try { + await managePackages(environment, manageOptions, this, token); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + () => this.fetchPackages(environment, !manageOptions.runHeadless), + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + this.log.error('Error managing packages', e); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } + throw e; + } + }; + + if (manageOptions.runHeadless) { + await execute(); + return; + } + + await withProgress( { location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true, }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - () => this.fetchPackages(environment, !manageOptions.runHeadless), - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages', e); - if (!manageOptions.runHeadless) { - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } async refresh(environment: PythonEnvironment): Promise { - await window.withProgress( + await withProgress( { location: ProgressLocation.Window, title: 'Refreshing packages', diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index f6ff2903a..cdabf6d1e 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -200,7 +200,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne try { return await runPython( environment.execInfo.run.executable, - ['-m', 'pip', 'list', '--format=json', ...(args ?? [])], + ['-m', 'pip', 'list', '--format=json', '--disable-pip-version-check', ...(args ?? [])], undefined, log, undefined, diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index bf7936a84..233605061 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -3,6 +3,7 @@ import { explain as parse, rcompare } from '@renovatebot/pep440'; import * as path from 'path'; import { CancellationError, + CancellationToken, Disposable, Event, EventEmitter, @@ -72,37 +73,44 @@ export class CondaPackageManager implements PackageManager, Disposable { install: toInstall, uninstall: toUninstall, }; + const execute = async (token?: CancellationToken): Promise => { + try { + await managePackages(environment, manageOptions, token, this.log); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + + this.log.error('Error installing packages', e); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } + throw e; + } + }; + + if (manageOptions.runHeadless) { + await execute(); + return; + } + await withProgress( { location: ProgressLocation.Notification, title: CondaStrings.condaInstallingPackages, cancellable: true, }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - - this.log.error('Error installing packages', e); - if (!manageOptions.runHeadless) { - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } @@ -178,16 +186,6 @@ export class CondaPackageManager implements PackageManager, Disposable { } } - /** - * Lists available versions for a package via `conda search --json`, newest first. - * - * Conda always supports version lookup, so operational failures (command, network, or - * malformed/unparseable output) propagate instead of being swallowed into `undefined`. - * - * @param _environment - Unused; conda resolves versions from its configured channels. - * @param packageName - The package whose versions should be listed. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). - */ async getPackageAvailableVersions( _environment: PythonEnvironment, packageName: string, @@ -197,7 +195,6 @@ export class CondaPackageManager implements PackageManager, Disposable { if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed[packageName])) { throw new Error(`Conda returned unexpected package version data for: ${packageName}`); } - const uniqueVersions = new Map(); parsed[packageName] .filter((entry: { version?: string }) => !!entry.version?.trim()) diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 06d3ec54f..42b3eca06 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1253,7 +1253,7 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, - token: CancellationToken, + token: CancellationToken | undefined, log: LogOutputChannel, ): Promise { if (options.uninstall && options.uninstall.length > 0) { diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index decb2b373..e81bc92bf 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -82,39 +82,46 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } + const execute = async (token?: CancellationToken): Promise => { + try { + await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + this.log.error('Error managing packages with Poetry', e); + if (!options.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } + throw e; + } + }; + + if (options.runHeadless) { + await execute(); + return; + } + await withProgress( { location: ProgressLocation.Notification, title: 'Managing packages with Poetry', cancellable: true, }, - async (_progress, token) => { - try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages with Poetry', e); - if (!options.runHeadless) { - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } diff --git a/src/test/common/testUtils.unit.test.ts b/src/test/common/testUtils.unit.test.ts index 526367a63..01c75e9ed 100644 --- a/src/test/common/testUtils.unit.test.ts +++ b/src/test/common/testUtils.unit.test.ts @@ -71,6 +71,37 @@ suite('Test Utilities', () => { ); assert.ok(counter >= 3); }); + + test('should preserve condition errors when retries are disabled', async () => { + const conditionError = new Error('Package refresh failed'); + + await assert.rejects( + () => waitForCondition(() => Promise.reject(conditionError), 1000, 'Should not time out', 10, false), + (error: unknown) => error === conditionError, + ); + }); + + test('should retry and preserve the last condition error at timeout', async () => { + const conditionError = new Error('Package refresh failed'); + let attempts = 0; + + await assert.rejects( + () => + waitForCondition( + () => { + attempts++; + return Promise.reject(conditionError); + }, + 50, + 'Should preserve the refresh error', + 10, + true, + true, + ), + (error: unknown) => error === conditionError, + ); + assert.ok(attempts > 1, 'The rejected condition should be retried before timing out'); + }); }); suite('retryUntilSuccess', () => { diff --git a/src/test/integration/environmentFixture.ts b/src/test/integration/environmentFixture.ts new file mode 100644 index 000000000..41ef6616c --- /dev/null +++ b/src/test/integration/environmentFixture.ts @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import type { ChildProcess } from 'child_process'; +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { spawnProcess } from '../../common/childProcess.apis'; +import { CONDA_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { PythonProjectSettings } from '../../internal.api'; +import { waitForCondition } from '../testUtils'; + +const OWNERSHIP_FILE_NAME = '.python-envs-test-owner.json'; +const COMMAND_TIMEOUT_MS = 180_000; +const DISCOVERY_TIMEOUT_MS = 60_000; +const API_REMOVAL_SETTLE_TIMEOUT_MS = 10_000; + +export interface EnvironmentFixtureProvider { + readonly environmentDirectory: string; + readonly managerId: string; + create( + api: PythonEnvironmentApi, + prefix: vscode.Uri, + projectUri: vscode.Uri, + ): Promise; + discover( + api: PythonEnvironmentApi, + prefix: vscode.Uri, + projectUri: vscode.Uri, + ): Promise; + remove(prefix: vscode.Uri): Promise; +} + +export interface EnvironmentFixtureRequest { + readonly name: string; + readonly packageManagerId: string; + readonly provider: EnvironmentFixtureProvider; +} + +export interface EnvironmentFixture { + readonly environment: PythonEnvironment; + readonly prefix: vscode.Uri; + readonly projectUri: vscode.Uri; + dispose(): Promise; +} + +interface CommandResult { + stdout: string; + stderr: string; +} + +/** + * Creates an isolated environment owned by the integration test and returns a lease that removes it. + * + * @param api The activated Python Environments extension API. + * @param workspaceFolder Workspace folder that owns the temporary test project. + * @param request Environment and package manager configuration for the fixture. + */ +export async function createEnvironmentFixture( + api: PythonEnvironmentApi, + workspaceFolder: vscode.WorkspaceFolder, + request: EnvironmentFixtureRequest, +): Promise { + const token = randomUUID(); + const fixtureName = `pyenvs-${sanitizeName(request.name).slice(0, 8)}-${process.pid}-${token.slice(0, 8)}`; + const projectUri = vscode.Uri.file(path.join(os.tmpdir(), fixtureName)); + const prefix = vscode.Uri.joinPath(projectUri, request.provider.environmentDirectory); + const markerUri = vscode.Uri.joinPath(projectUri, OWNERSHIP_FILE_NAME); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); + const previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; + let projectSettingAdded = false; + let environmentCreated = false; + let environment: PythonEnvironment | undefined; + let apiRemovalSettled = false; + let disposePromise: Promise | undefined; + let markerWritten = false; + let projectRootCreated = false; + const getApiRemoval = createSingleFlightOperation(() => { + if (!environment) { + return Promise.resolve(); + } + apiRemovalSettled = false; + return api.removeEnvironment(environment, { runHeadless: true }).finally(() => { + apiRemovalSettled = true; + }); + }); + + const cleanup = async (): Promise => { + const cleanupErrors: Error[] = []; + let environmentRemovalPending = false; + + if (markerWritten && (environmentCreated || (await pathExists(prefix)))) { + let ownershipVerified = false; + try { + await verifyOwnership(projectUri, markerUri, token, prefix); + ownershipVerified = true; + } catch (error) { + cleanupErrors.push(toError(error)); + } + if (ownershipVerified && environment) { + const apiRemovalPromise = getApiRemoval(); + try { + await withTimeout( + apiRemovalPromise, + COMMAND_TIMEOUT_MS, + `${request.name} API environment removal timed out`, + ); + } catch (error) { + cleanupErrors.push(toError(error)); + if (!apiRemovalSettled) { + try { + await withTimeout( + apiRemovalPromise, + API_REMOVAL_SETTLE_TIMEOUT_MS, + `${request.name} API environment removal did not settle after timing out`, + ); + } catch (settleError) { + if (apiRemovalSettled) { + cleanupErrors.push(toError(settleError)); + } else { + environmentRemovalPending = true; + cleanupErrors.push( + new Error( + `${request.name} direct cleanup was skipped because API removal is still running`, + ), + ); + } + } + } + } + } + if (ownershipVerified && (await pathExists(prefix))) { + cleanupErrors.push( + new Error(`${request.name} API removal left the environment on disk: ${prefix.fsPath}`), + ); + if (!environmentRemovalPending) { + try { + await request.provider.remove(prefix); + await assertPathMissing(prefix, `${request.name} environment was not removed`); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + } + if (ownershipVerified && !(await pathExists(prefix))) { + environmentCreated = false; + environment = undefined; + } + } + + try { + await api.setEnvironment(projectUri, undefined); + } catch (error) { + cleanupErrors.push(toError(error)); + } + + if (projectSettingAdded) { + try { + await config.update( + 'pythonProjects', + previousPythonProjects, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await waitForCondition( + () => + !api + .getPythonProjects() + .some((project) => pathsEqual(project.uri.fsPath, projectUri.fsPath)), + 10_000, + `Python project was not unregistered: ${projectUri.fsPath}`, + ); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + + if (!environmentRemovalPending && projectRootCreated && (await pathExists(projectUri))) { + try { + if (markerWritten) { + await verifyOwnership(projectUri, markerUri, token, prefix); + } else if (environmentCreated) { + throw new Error(`Refusing to remove an unmarked fixture after environment creation: ${projectUri.fsPath}`); + } + await vscode.workspace.fs.delete(projectUri, { recursive: true, useTrash: false }); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + + if (cleanupErrors.length > 0) { + throw new Error(cleanupErrors.map((error) => error.message).join('\n')); + } + }; + + const dispose = (): Promise => { + if (!disposePromise) { + disposePromise = cleanup().catch((error) => { + disposePromise = undefined; + throw error; + }); + } + return disposePromise; + }; + + try { + await assertPathMissing(projectUri, `Fixture directory already exists: ${projectUri.fsPath}`); + await vscode.workspace.fs.createDirectory(projectUri); + projectRootCreated = true; + await vscode.workspace.fs.writeFile( + markerUri, + Buffer.from(JSON.stringify({ managerId: request.provider.managerId, token }), 'utf8'), + ); + markerWritten = true; + + const pythonProjects = config.get('pythonProjects', []); + const projectSetting: PythonProjectSettings = { + path: projectUri.fsPath, + envManager: request.provider.managerId, + packageManager: request.packageManagerId, + workspace: workspaceFolder.name, + }; + await config.update( + 'pythonProjects', + [...pythonProjects, projectSetting], + vscode.ConfigurationTarget.WorkspaceFolder, + ); + projectSettingAdded = true; + + await waitForCondition( + () => api.getPythonProjects().some((project) => pathsEqual(project.uri.fsPath, projectUri.fsPath)), + 10_000, + `Python project was not registered: ${projectUri.fsPath}`, + ); + + environment = await request.provider.create(api, prefix, projectUri); + environmentCreated = true; + + const candidate = + environment ?? + (await withTimeout( + request.provider.discover(api, prefix, projectUri), + DISCOVERY_TIMEOUT_MS, + `${request.name} environment discovery timed out`, + )); + if ( + candidate?.envId.managerId !== request.provider.managerId || + !(await canonicalPathsEqual(candidate.sysPrefix, prefix.fsPath)) + ) { + throw new Error( + `${request.name} environment was not discovered: ${prefix.fsPath}. Resolved environment: ${ + candidate ? `${candidate.envId.managerId} (${candidate.sysPrefix})` : 'none' + }`, + ); + } + environment = candidate; + + return { + environment, + prefix, + projectUri, + dispose, + }; + } catch (error) { + try { + await dispose(); + } catch (cleanupError) { + throw new Error( + `${request.name} fixture setup failed: ${toError(error).message}\nCleanup failed: ${toError(cleanupError).message}`, + ); + } + throw error; + } +} + +/** + * Creates a provider for standard-library virtual environments. + */ +export function createVenvFixtureProvider(): EnvironmentFixtureProvider { + return { + environmentDirectory: '.venv', + managerId: VENV_MANAGER_ID, + create: async (_api, prefix) => { + await runFixtureCommand('python', ['-m', 'venv', prefix.fsPath]); + return undefined; + }, + discover: async (_api, prefix) => + resolveEnvironmentWithManager( + VENV_MANAGER_ID, + vscode.Uri.joinPath( + prefix, + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ), + ), + remove: async (prefix) => { + if (await pathExists(prefix)) { + await vscode.workspace.fs.delete(prefix, { recursive: true, useTrash: false }); + } + }, + }; +} + +/** + * Creates a provider for Conda environments. + */ +export function createCondaFixtureProvider(): EnvironmentFixtureProvider { + return { + environmentDirectory: '.conda', + managerId: CONDA_MANAGER_ID, + create: async (_api, prefix) => { + const version = await runFixtureCommand('python', [ + '-c', + 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")', + ]); + await runFixtureCommand(await getCondaExecutable(), [ + 'create', + '--yes', + '--prefix', + prefix.fsPath, + `python=${version.stdout.trim()}`, + ]); + return undefined; + }, + discover: async (_api, prefix) => resolveEnvironmentWithManager(CONDA_MANAGER_ID, prefix), + remove: async (prefix) => { + if (await pathExists(prefix)) { + await runFixtureCommand(await getCondaExecutable(), [ + 'env', + 'remove', + '--yes', + '--prefix', + prefix.fsPath, + ]); + } + }, + }; +} + +async function runFixtureCommand(command: string, args: string[], cwd?: vscode.Uri): Promise { + return new Promise((resolve, reject) => { + const child = spawnProcess(command, args, { + cwd: cwd?.fsPath, + detached: process.platform !== 'win32', + stdio: 'pipe', + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let forceTimer: NodeJS.Timeout | undefined; + const finish = (error?: Error, result?: CommandResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (forceTimer) { + clearTimeout(forceTimer); + } + if (error) { + reject(error); + } else { + resolve(result ?? { stdout, stderr }); + } + }; + const timer = setTimeout(() => { + timedOut = true; + const timeoutError = new Error( + `${command} ${args.join(' ')} timed out after ${COMMAND_TIMEOUT_MS}ms`, + ); + void terminateProcessTree(child).catch((error) => { + finish(new Error(`${timeoutError.message}\nFailed to terminate process tree: ${toError(error).message}`)); + }); + forceTimer = setTimeout(() => finish(timeoutError), 10_000); + }, COMMAND_TIMEOUT_MS); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (data: string) => { + stdout += data; + }); + child.stderr.on('data', (data: string) => { + stderr += data; + }); + child.on('error', (error) => { + finish(timedOut ? new Error(`${command} timed out and failed to terminate: ${error.message}`) : error); + }); + child.on('close', (code) => { + if (timedOut) { + finish(new Error(`${command} ${args.join(' ')} timed out after ${COMMAND_TIMEOUT_MS}ms`)); + return; + } + if (code === 0) { + finish(undefined, { stdout, stderr }); + return; + } + finish( + new Error( + `${command} ${args.join(' ')} exited with code ${code}\n${stderr.trim() || stdout.trim()}`, + ), + ); + }); + }); +} + +async function terminateProcessTree(child: ChildProcess): Promise { + if (!child.pid) { + child.kill('SIGKILL'); + return; + } + if (process.platform !== 'win32') { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + return; + } + + await new Promise((resolve, reject) => { + const killer = spawnProcess('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + killer.on('error', reject); + killer.on('close', (code) => { + if (code === 0 || child.exitCode !== null) { + resolve(); + } else { + reject(new Error(`taskkill exited with code ${code}`)); + } + }); + }); +} + +async function getCondaExecutable(): Promise { + const condaRoot = process.env.CONDA; + if (condaRoot) { + const executable = + process.platform === 'win32' + ? path.join(condaRoot, 'Scripts', 'conda.exe') + : path.join(condaRoot, 'bin', 'conda'); + try { + await fs.access(executable); + return executable; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + throw error; + } + } + } + return process.platform === 'win32' ? 'conda.exe' : 'conda'; +} + +async function resolveEnvironmentWithManager( + managerId: string, + environmentUri: vscode.Uri, +): Promise { + return vscode.commands.executeCommand( + 'python-envs.test.resolveEnvironmentWithManager', + managerId, + environmentUri, + ); +} + +async function verifyOwnership( + projectUri: vscode.Uri, + markerUri: vscode.Uri, + token: string, + prefix: vscode.Uri, +): Promise { + if (!isPathWithin(projectUri.fsPath, prefix.fsPath)) { + throw new Error(`Refusing to remove environment outside fixture root: ${prefix.fsPath}`); + } + const marker = JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(markerUri)).toString('utf8')) as { + token?: string; + }; + if (marker.token !== token) { + throw new Error(`Refusing to remove fixture without matching ownership marker: ${projectUri.fsPath}`); + } +} + +function isPathWithin(parent: string, candidate: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +function pathsEqual(first: string, second: string): boolean { + const firstPath = path.resolve(vscode.Uri.file(first).fsPath); + const secondPath = path.resolve(vscode.Uri.file(second).fsPath); + return normalizePath(firstPath) === normalizePath(secondPath); +} + +async function canonicalPathsEqual(first: string, second: string): Promise { + const [firstPath, secondPath] = await Promise.all([canonicalPath(first), canonicalPath(second)]); + return normalizePath(firstPath) === normalizePath(secondPath); +} + +async function canonicalPath(value: string): Promise { + return await fs.realpath(path.resolve(vscode.Uri.file(value).fsPath)); +} + +async function pathExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch (error) { + if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') { + return false; + } + throw error; + } +} + +async function assertPathMissing(uri: vscode.Uri, message: string): Promise { + if (await pathExists(uri)) { + throw new Error(message); + } +} + +async function withTimeout(operation: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${message} after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +/** + * Returns a function that starts an asynchronous operation at most once and shares its promise. + */ +export function createSingleFlightOperation(operation: () => Promise): () => Promise { + let promise: Promise | undefined; + return () => { + promise ??= operation(); + return promise; + }; +} + +function sanitizeName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9-]/g, '-'); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/test/integration/environmentFixture.unit.test.ts b/src/test/integration/environmentFixture.unit.test.ts new file mode 100644 index 000000000..3b8f1859e --- /dev/null +++ b/src/test/integration/environmentFixture.unit.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { createSingleFlightOperation } from './environmentFixture'; + +suite('Environment fixture helpers', () => { + test('shares a delayed operation across retries', async () => { + let calls = 0; + let resolveOperation: (() => void) | undefined; + const getOperation = createSingleFlightOperation( + () => + new Promise((resolve) => { + calls++; + resolveOperation = resolve; + }), + ); + + const first = getOperation(); + const retry = getOperation(); + + assert.strictEqual(retry, first); + assert.strictEqual(calls, 1); + assert.ok(resolveOperation); + resolveOperation(); + await Promise.all([first, retry]); + assert.strictEqual(getOperation(), first); + assert.strictEqual(calls, 1); + }); +}); diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 22484bd02..7ec7d5058 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -2,57 +2,49 @@ import * as vscode from 'vscode'; import { compare } from '@renovatebot/pep440'; import assert from 'assert'; -import * as path from 'path'; import { Package, PythonEnvironment, PythonEnvironmentApi, - PythonProject, isPackageVersionLookupNotSupportedError, } from '../../api'; -import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; -import { PythonProjectSettings } from '../../internal.api'; -import { getConda } from '../../managers/conda/condaUtils'; +import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants'; import { ENVS_EXTENSION_ID } from '../constants'; import { waitForCondition } from '../testUtils'; +import { + createCondaFixtureProvider, + createEnvironmentFixture, + createVenvFixtureProvider, + EnvironmentFixture, + EnvironmentFixtureProvider, +} from './environmentFixture'; type PackageManagerId = `${string}:${string}`; interface PackageManagerProfile { - environmentManagerId: string; name: string; + packageName: string; packageManagerId: PackageManagerId; - projectDirectory: string; - prerequisite(api: PythonEnvironmentApi): Promise; - supportsVersionLookup(packages: Package[]): boolean; + provider: EnvironmentFixtureProvider; + supportsVersionLookup(packages: Package[]): boolean | undefined; } const profiles: PackageManagerProfile[] = [ { - environmentManagerId: VENV_MANAGER_ID, name: 'Pip', + packageName: 'requests', packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, - projectDirectory: 'pip', - prerequisite: async (api) => - (await api.getEnvironments('global')).some((environment) => environment.version.startsWith('3.')), + provider: createVenvFixtureProvider(), supportsVersionLookup: (packages) => { const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; - return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; + return pipVersion === undefined ? undefined : compare(pipVersion, '21.2') >= 0; }, }, { - environmentManagerId: CONDA_MANAGER_ID, name: 'Conda', + packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, - projectDirectory: 'conda', - prerequisite: async () => { - try { - await getConda(); - return true; - } catch { - return false; - } - }, + provider: createCondaFixtureProvider(), supportsVersionLookup: () => true, }, ]; @@ -102,14 +94,13 @@ suite('Package Manager profile coverage', function () { for (const profile of profiles) { suite(`${profile.name} Package Manager`, function () { - this.timeout(300_000); + this.timeout(600_000); let api: PythonEnvironmentApi; let environment: PythonEnvironment | undefined; - let project: PythonProject | undefined; - let workspaceUri: vscode.Uri; - let previousPythonProjects: PythonProjectSettings[] | undefined; - let pythonProjectsUpdated = false; + let fixture: EnvironmentFixture | undefined; + let previousAlwaysUseUv: boolean | undefined; + let alwaysUseUvUpdated = false; suiteSetup(async function () { if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { this.skip(); @@ -127,71 +118,51 @@ for (const profile of profiles) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert.ok(workspaceFolder, 'Integration test workspace not found'); - workspaceUri = workspaceFolder.uri; - const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); - if (!(await profile.prerequisite(api))) { - this.skip(); - return; + if (profile.packageManagerId === DEFAULT_PACKAGE_MANAGER_ID) { + previousAlwaysUseUv = config.inspect('alwaysUseUv')?.globalValue; + await config.update('alwaysUseUv', false, vscode.ConfigurationTarget.Global); + alwaysUseUvUpdated = true; } - const projectUri = vscode.Uri.joinPath( - workspaceUri, - `.package-manager-test-${profile.projectDirectory}-${process.pid}`, - ); - await vscode.workspace.fs.createDirectory(projectUri); - project = { + fixture = await createEnvironmentFixture(api, workspaceFolder, { name: `${profile.name} Package Manager Test`, - uri: projectUri, - }; - previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; - const pythonProjects = config.get('pythonProjects', []); - const projectSetting: PythonProjectSettings = { - path: path.relative(workspaceUri.fsPath, projectUri.fsPath).replace(/\\/g, '/'), - envManager: profile.environmentManagerId, - packageManager: profile.packageManagerId, - workspace: workspaceFolder.name, - }; - await config.update( - 'pythonProjects', - [...pythonProjects, projectSetting], - vscode.ConfigurationTarget.WorkspaceFolder, - ); - pythonProjectsUpdated = true; - await waitForCondition( - () => - api - .getPythonProjects() - .some((registeredProject) => registeredProject.uri.toString() === projectUri.toString()), - 10_000, - `Python project was not registered: ${projectUri.fsPath}`, - ); - - await api.refreshEnvironments(projectUri); - - environment = await api.createEnvironment(projectUri, { quickCreate: true }); - assert.ok(environment, `${profile.name} failed to create an environment after prerequisites passed`); + packageManagerId: profile.packageManagerId, + provider: profile.provider, + }); + environment = fixture.environment; assert.strictEqual( environment.envId.managerId, - profile.environmentManagerId, - `Expected an environment created by ${profile.environmentManagerId}`, + profile.provider.managerId, + `Expected an environment created by ${profile.provider.managerId}`, ); }); test(`${profile.name} Package Manager should install, list, and uninstall a package`, async () => { - const packageName = 'requests'; + const packageName = profile.packageName; const baseline = await api.getPackages(environment!, { skipCache: true }); assert.ok(baseline, 'Unable to list packages before installation'); + assert.ok( + baseline.every((pkg) => pkg.pkgId.managerId === profile.packageManagerId), + `${profile.name} lifecycle used an unexpected package manager`, + ); const wasInstalled = baseline.some((pkg) => pkg.name.toLowerCase() === packageName); if (!wasInstalled) { await api.managePackages(environment!, { install: [packageName], runHeadless: true }); } - let packages = await api.getPackages(environment!, { skipCache: true }); - assert.ok(packages, 'Unable to list packages after installation'); - assert.ok( - packages.some((pkg) => pkg.name.toLowerCase() === packageName), + let packages: Package[] | undefined; + await waitForCondition( + async () => { + packages = await api.getPackages(environment!, { skipCache: true }); + return packages?.some((pkg) => pkg.name.toLowerCase() === packageName) ?? false; + }, + 30_000, 'Package not installed', + 1_000, + true, + true, ); const directPackageNames = await vscode.commands.executeCommand( @@ -204,11 +175,16 @@ for (const profile of profiles) { if (!wasInstalled) { await api.managePackages(environment!, { uninstall: [packageName], runHeadless: true }); - packages = await api.getPackages(environment!, { skipCache: true }); - assert.ok(packages, 'Unable to list packages after uninstallation'); - assert.ok( - !packages.some((pkg) => pkg.name.toLowerCase() === packageName), + await waitForCondition( + async () => { + packages = await api.getPackages(environment!, { skipCache: true }); + return packages !== undefined && !packages.some((pkg) => pkg.name.toLowerCase() === packageName); + }, + 30_000, 'Package not uninstalled', + 1_000, + true, + true, ); } }); @@ -216,71 +192,51 @@ for (const profile of profiles) { test(`${profile.name} Package Manager should list available package versions`, async function () { const packages = await api.getPackages(environment!, { skipCache: true }); assert.ok(packages, 'Unable to list packages before version lookup'); + const supportsVersionLookup = profile.supportsVersionLookup(packages); + assert.notStrictEqual( + supportsVersionLookup, + undefined, + `${profile.name} version lookup capability could not be determined`, + ); - if (!profile.supportsVersionLookup(packages)) { - // The profile declares that the active manager/tool version does not support - // version lookup, so the API must surface the typed unsupported-capability error - // rather than an operational failure. Assert that contract, then skip. - await assert.rejects( - () => api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' }), - (error: unknown) => isPackageVersionLookupNotSupportedError(error), - `${profile.name} did not report unsupported version lookup with the typed error`, - ); - this.skip(); - return; + let versions; + try { + versions = await api.getPackageAvailableVersions(environment!, profile.packageName, { + errorMode: 'throw', + }); + } catch (error) { + if (isPackageVersionLookupNotSupportedError(error)) { + assert.strictEqual( + supportsVersionLookup, + false, + `${profile.name} unexpectedly reported version lookup as unsupported`, + ); + this.skip(); + return; + } + throw error; } - // Supported profiles must resolve to a defined, non-empty result; operational failures - // propagate and fail the test instead of silently resolving to undefined. - const versions = await api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' }); - assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); + assert.strictEqual( + supportsVersionLookup, + true, + `${profile.name} returned versions despite declaring lookup unsupported`, + ); + assert.ok(versions, `${profile.name} unexpectedly returned no package versions`); assert.ok(versions.length > 0, 'No package versions available'); }); suiteTeardown(async () => { try { - if (environment) { - const environmentPath = environment.environmentPath; - await api.removeEnvironment(environment, { runHeadless: true }); - await assert.rejects( - async () => vscode.workspace.fs.stat(environmentPath), - (error: unknown) => - error instanceof vscode.FileSystemError && error.code === 'FileNotFound', - `Environment was not removed: ${environmentPath.fsPath}`, - ); + if (fixture) { + await fixture.dispose(); } } finally { - const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); - if (project) { - try { - await api.setEnvironment(project.uri, undefined); - } finally { - try { - if (pythonProjectsUpdated) { - await config.update( - 'pythonProjects', - previousPythonProjects, - vscode.ConfigurationTarget.WorkspaceFolder, - ); - await waitForCondition( - () => - !api - .getPythonProjects() - .some( - (registeredProject) => - registeredProject.uri.toString() === project!.uri.toString(), - ), - 10_000, - `Python project was not unregistered: ${project.uri.fsPath}`, - ); - } - } finally { - await vscode.workspace.fs.delete(project.uri, { - recursive: true, - useTrash: false, - }); - } - } + if (alwaysUseUvUpdated) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'Integration test workspace not found during teardown'); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); + await config.update('alwaysUseUv', previousAlwaysUseUv, vscode.ConfigurationTarget.Global); } } }); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 549bdd2de..a8920f840 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -4,7 +4,13 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import { + Package, + PythonEnvironment, + PythonEnvironmentApi, + isPackageVersionLookupNotSupportedError, +} from '../../../api'; +import * as helpers from '../../../managers/builtin/helpers'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; import * as builtinUtils from '../../../managers/builtin/utils'; import { VenvManager } from '../../../managers/builtin/venvManager'; @@ -60,4 +66,73 @@ suite('PipPackageManager', () => { assert.strictEqual(secondResult, undefined); assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); }); + + test('reports version lookup as unsupported for pip older than 21.2', async () => { + const manager = createManager(); + const environment = createEnvironment(); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'runPython').resolves('pip 20.3.4 from /path/to/pip (python 3.12)'); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests'), + isPackageVersionLookupNotSupportedError, + ); + }); + + test('propagates version lookup command failures for supported pip', async () => { + const manager = createManager(); + const environment = createEnvironment(); + const lookupError = new Error('pip index failed'); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const runPython = sinon.stub(helpers, 'runPython'); + runPython.onFirstCall().resolves('pip 25.1 from /path/to/pip (python 3.12)'); + runPython.onSecondCall().rejects(lookupError); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests'), + (error: unknown) => error === lookupError, + ); + }); + + test('normalizes discovered Python versions for pip lookup', async () => { + const manager = createManager(); + const environment = { + ...createEnvironment(), + version: '3.13.14.final.0', + }; + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const runPython = sinon.stub(helpers, 'runPython'); + runPython.onFirstCall().resolves('pip 25.1 from /path/to/pip (python 3.13)'); + runPython.onSecondCall().resolves(JSON.stringify({ versions: ['2.32.5'] })); + + await manager.getPackageAvailableVersions(environment, 'requests'); + + assert.deepStrictEqual(runPython.secondCall.args[1], [ + '-m', + 'pip', + 'index', + 'versions', + 'requests', + '--json', + '--python-version', + '3.13.14', + ]); + }); + + function createManager(): PipPackageManager { + return new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, + {} as VenvManager, + ); + } + + function createEnvironment(): PythonEnvironment { + return { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + execInfo: { run: { executable: 'python', args: [] } }, + version: '3.12.0', + } as unknown as PythonEnvironment; + } }); diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts index dff10003c..fe2349cc9 100644 --- a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -50,4 +50,19 @@ suite('Pip package refresh', () => { assert.strictEqual(result, undefined); assert.ok(showErrorMessageWithLogsStub.notCalled); }); + + test('disables the pip version check when listing packages', async () => { + const runPythonStub = helpers.runPython as sinon.SinonStub; + runPythonStub.resolves('[]'); + + await refreshPipPackages(environment, log); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], [ + '-m', + 'pip', + 'list', + '--format=json', + '--disable-pip-version-check', + ]); + }); }); diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts new file mode 100644 index 000000000..643641992 --- /dev/null +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { + PackageManager, + PythonEnvironment, + PythonEnvironmentApi, + isPackageVersionLookupNotSupportedError, +} from '../../../api'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import * as errorUtils from '../../../common/errors/utils'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { InternalPackageManager } from '../../../internal.api'; +import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; +import * as pipUtils from '../../../managers/builtin/pipUtils'; +import * as builtinUtils from '../../../managers/builtin/utils'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { PoetryManager } from '../../../managers/poetry/poetryManager'; +import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('Package manager headless conformance', () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.joinPath(Uri.file(__dirname), 'path', 'to', 'environment'), + execInfo: { run: { executable: 'python', args: [] } }, + version: '3.12.0', + } as unknown as PythonEnvironment; + + teardown(() => { + sinon.restore(); + }); + + test('does not invoke interactive package input when no packages are provided', async () => { + const pipPicker = sinon.stub(pipUtils, 'getWorkspacePackagesToInstall'); + const condaPicker = sinon.stub(condaUtils, 'getCommonCondaPackagesToInstall'); + const poetryInput = sinon.stub(windowApis, 'showInputBox'); + + for (const manager of createManagers().all) { + await manager.manage(environment, { install: [], runHeadless: true }); + } + + assert.ok(pipPicker.notCalled); + assert.ok(condaPicker.notCalled); + assert.ok(poetryInput.notCalled); + }); + + test('rejects failures without showing error notifications', async () => { + const operationError = new Error('package operation failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(builtinUtils, 'managePackages').rejects(operationError); + sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + sinon.stub(childProcessApis, 'spawnProcess').callsFake(() => { + const process = new MockChildProcess('poetry', ['add', 'requests']); + setImmediate(() => process.emit('error', operationError)); + return process as unknown as ReturnType; + }); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + for (const manager of createManagers().all) { + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === operationError, + ); + } + await flushImmediate(); + + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('suppresses Pip refresh failures without showing progress or error notifications', async () => { + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(builtinUtils, 'managePackages').resolves(); + sinon.stub(uvEnvironments, 'getUvEnvironments').resolves([]); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: sinon.stub().withArgs('alwaysUseUv').returns(false), + } as unknown as ReturnType); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake(() => { + const process = new MockChildProcess('python', ['-m', 'pip', 'list']); + setImmediate(() => { + process.emit('exit', 1, null); + process.emit('close', 1, null); + }); + return process as unknown as ReturnType; + }); + sinon.stub(PipPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers().pip; + + await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await flushImmediate(); + + assert.ok(spawnProcess.called); + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('rejects Conda refresh failures without showing progress or error notifications', async () => { + const refreshError = new Error('package refresh failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(condaUtils, 'managePackages').resolves(); + sinon.stub(condaUtils, 'runCondaExecutable').rejects(refreshError); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers().conda; + + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === refreshError, + ); + await flushImmediate(); + + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('suppresses Poetry refresh failures without showing progress or error notifications', async () => { + const refreshError = new Error('package refresh failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + sinon.stub(PoetryPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake((_command, args) => { + const process = new MockChildProcess('poetry', args); + setImmediate(() => { + if (args[0] === 'add') { + process.emit('close', 0, null); + } else { + process.emit('error', refreshError); + } + }); + return process as unknown as ReturnType; + }); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers().poetry; + + await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await flushImmediate(); + + assert.strictEqual(spawnProcess.callCount, 2); + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('reports missing version lookup implementations as unsupported', async () => { + const manager = new InternalPackageManager('test:unsupported', { + name: 'unsupported', + manage: sinon.stub().resolves(), + refresh: sinon.stub().resolves(), + getPackages: sinon.stub().resolves([]), + }); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests', { errorMode: 'throw' }), + isPackageVersionLookupNotSupportedError, + ); + }); + + test('recognizes unsupported lookup errors across module boundaries', () => { + assert.ok( + isPackageVersionLookupNotSupportedError({ + code: 'PackageVersionLookupNotSupported', + }), + ); + }); + + test('reports Poetry version lookup as unsupported', async () => { + await assert.rejects( + createManagers().poetry.getPackageAvailableVersions!(environment, 'requests'), + isPackageVersionLookupNotSupportedError, + ); + }); + + function createManagers(): { + pip: PackageManager; + conda: PackageManager; + poetry: PackageManager; + all: PackageManager[]; + } { + const api = { + createPackageItem: sinon.stub(), + getPythonProjects: sinon.stub().returns([]), + } as unknown as PythonEnvironmentApi; + const log = { + append: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + show: sinon.stub(), + } as unknown as LogOutputChannel; + const pip = new PipPackageManager(api, log, { + getProjectsByEnvironment: sinon.stub().returns([]), + } as unknown as VenvManager); + const conda = new CondaPackageManager(api, log); + const poetry = new PoetryPackageManager(api, log, {} as PoetryManager); + return { pip, conda, poetry, all: [pip, conda, poetry] }; + } + + async function flushImmediate(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } +}); diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts index ea6614daf..e23631bbc 100644 --- a/src/test/managers/conda/condaPackageManager.unit.test.ts +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -38,4 +38,21 @@ suite('CondaPackageManager', () => { assert.ok(logError.calledOnce); assert.ok(showErrorMessageWithLogs.notCalled); }); + + test('propagates package version lookup failures', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + } as PythonEnvironment; + const manager = new CondaPackageManager( + {} as PythonEnvironmentApi, + { error: sinon.stub() } as unknown as LogOutputChannel, + ); + const lookupError = new Error('conda search failed'); + sinon.stub(condaUtils, 'runCondaExecutable').rejects(lookupError); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'flask'), + (error: unknown) => error === lookupError, + ); + }); }); diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 87df07a5b..3bb62ccde 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -28,6 +28,8 @@ export function sleep(ms: number): Promise { * @param timeoutMs - Maximum time to wait (default: 10 seconds) * @param errorMessage - Error message if condition is not met * @param pollIntervalMs - How often to check condition (default: 100ms) + * @param retryOnError - Whether rejected conditions should be retried (default: true) + * @param rejectWithLastError - Whether a timeout after rejected conditions should preserve the last error * * @example * // Wait for extension to activate @@ -50,22 +52,34 @@ export async function waitForCondition( timeoutMs: number = 10_000, errorMessage: string | (() => string) = 'Condition not met within timeout', pollIntervalMs: number = 100, + retryOnError: boolean = true, + rejectWithLastError: boolean = false, ): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); + let lastError: unknown; const checkCondition = async () => { try { const result = await condition(); + lastError = undefined; if (result) { resolve(); return; } - } catch { - // Condition threw - keep waiting + } catch (error) { + if (!retryOnError) { + reject(error); + return; + } + lastError = error; } if (Date.now() - startTime >= timeoutMs) { + if (rejectWithLastError && lastError !== undefined) { + reject(lastError); + return; + } const msg = typeof errorMessage === 'function' ? errorMessage() : errorMessage; reject(new Error(`${msg} (waited ${timeoutMs}ms)`)); return;