diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index b374c38d1..68958773b 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -606,3 +606,4 @@ envConfig.inspect - **Never skip tests to hide infrastructure problems**: If tests require native binaries (like `pet`), the CI workflow must build/download them. Skipping tests when infrastructure is missing gives false confidence. Build from source (like vscode-python does) rather than skipping. Tests should fail clearly when something is wrong (2) - **No retries for masking flakiness**: Mocha `retries` should not be used to mask test flakiness. If a test is flaky, fix the root cause. Retries hide real issues and slow down CI (1) - **pet binary is required for environment manager registration**: The smoke/E2E/integration tests require the `pet` binary from `microsoft/python-environment-tools` to be built and placed in `python-env-tools/bin/`. Without it, `waitForApiReady()` will timeout because managers never register. CI must build pet from source using `cargo build --release --package pet` (2) +- **Check exact project registration with `getPythonProjects()`**: `getPythonProject(uri)` can return a containing parent project, so it cannot prove that a nested project was registered or unregistered (1) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1298ffa0b..9297f7df6 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -335,6 +335,14 @@ 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 96867be26..23db9b117 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -335,3 +335,11 @@ 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/api/CHANGELOG.md b/api/CHANGELOG.md index 082eac300..616edca22 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] + +### Added + +- Added `PackageManagementInteractionOptions` with an optional `runHeadless?: boolean` property, mixed into `PackageManagementOptions`. When `true`, package management operations run without any user prompts or interaction — steps that would normally require input, such as selecting packages to install when none are specified, are skipped instead of prompting — for automated or headless scenarios such as integration tests. +- Added `RemoveEnvironmentOptions` with an optional `runHeadless?: boolean` property to remove environments without a confirmation prompt in automated or headless scenarios. + ## [1.1.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 8363de4f9..7745eab9a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 7f68cf0b2..6b1c6e70e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "Microsoft Corporation" }, diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index c45ae1cbd..00512e001 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -329,6 +329,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. */ @@ -392,7 +403,7 @@ export interface EnvironmentManager { * @param environment - The Python environment to remove. * @returns A promise that resolves when the environment is removed. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -739,49 +750,62 @@ export interface GetPackagesOptions { } /** - * Options for package management. + * Options controlling user interaction during package management operations. */ -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -881,9 +905,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/src/api.ts b/src/api.ts index 5d63a3aef..2779d27b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -345,6 +345,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. * @@ -425,7 +436,7 @@ export interface EnvironmentManager { * Invoked to delete the given environment. Typical triggers include an explicit user * action (such as a "Delete Environment" command) and programmatic removal via the API. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -872,47 +883,63 @@ export interface GetPackagesOptions { skipCache?: boolean; } -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +/** + * Options controlling user interaction during package management operations. + */ +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -1011,9 +1038,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/src/extension.ts b/src/extension.ts index 1d2d98aee..46f89009b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -258,6 +258,21 @@ export async function activate(context: ExtensionContext): Promise + envManagers.packageManagers.map((manager) => manager.id), + ), + commands.registerCommand( + 'python-envs.test.getDirectPackageNames', + async (environment: PythonEnvironment) => { + const manager = envManagers.getPackageManager(environment); + const names = await manager?.getDirectPackageNames?.(environment); + return names ? Array.from(names) : undefined; + }, + ), + ] + : []), commands.registerCommand('python-envs.searchSettings', async () => { await openSearchSettings(); }), diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 9c494b9eb..e93ed0cdb 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -29,6 +29,7 @@ import { PythonTerminalCreateOptions, PythonTerminalExecutionOptions, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../api'; @@ -107,9 +108,7 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { this.previousProjects = current; if (added.length > 0 || removed.length > 0) { - traceInfo( - `Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`, - ); + traceInfo(`Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`); this._onDidChangePythonProjects.fire({ added, removed }); } }), @@ -197,13 +196,13 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { return result; } } - async removeEnvironment(environment: PythonEnvironment): Promise { + async removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { await waitForEnvManagerId([environment.envId.managerId]); const manager = this.envManagers.getEnvironmentManager(environment); if (!manager) { return Promise.reject(new Error('No environment manager found')); } - return manager.remove(environment); + return manager.remove(environment, options); } async refreshEnvironments(scope: RefreshEnvironmentsScope): Promise { const currentScope = checkUri(scope) as RefreshEnvironmentsScope; diff --git a/src/internal.api.ts b/src/internal.api.ts index 9b09d5cf8..6d41cb5c3 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -26,6 +26,7 @@ import { PythonProjectCreator, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from './api'; @@ -208,9 +209,9 @@ export class InternalEnvironmentManager implements EnvironmentManager { return this.manager.remove !== undefined; } - remove(scope: PythonEnvironment): Promise { + remove(scope: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { return this.manager.remove - ? this.manager.remove(scope) + ? this.manager.remove(scope, options) : Promise.reject(new RemoveEnvironmentNotSupported(`Remove Environment not supported by: ${this.id}`)); } @@ -405,6 +406,12 @@ export class InternalPackageManager implements PackageManager { : Promise.resolve(undefined); } + getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { + return this.manager.getDirectPackageNames + ? this.manager.getDirectPackageNames(environment) + : Promise.resolve(undefined); + } + formatInstallSpec(packageName: string, version: string): string { return this.manager.formatInstallSpec ? this.manager.formatInstallSpec(packageName, version) diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index bd3bbb761..836244bb7 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -55,6 +55,10 @@ export class PipPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const projects = this.venv.getProjectsByEnvironment(environment); const result = await getWorkspacePackagesToInstall(this.api, options, projects, environment, this.log); if (result) { @@ -86,18 +90,21 @@ export class PipPackageManager implements PackageManager, Disposable { (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); - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + const result = await window.showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } throw e; } }, @@ -119,25 +126,31 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, ); - this.packages.set(environment.envId.id, packages ?? []); + if (packages !== undefined) { + this.packages.set(environment.envId.id, packages); + } }, ); } async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const data = await refreshPipPackages(environment, this.log); - if (data === undefined) { - return this.packages.get(environment.envId.id); - } - - const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); - this.packages.set(environment.envId.id, packages); - return packages; + return this.fetchPackages(environment); } return this.packages.get(environment.envId.id); } + private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise { + const data = await refreshPipPackages(environment, this.log, { showErrors }); + if (data === undefined) { + return this.packages.get(environment.envId.id); + } + + const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + this.packages.set(environment.envId.id, packages); + return packages; + } + async getVersion(environment: PythonEnvironment): Promise { try { const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); @@ -186,9 +199,9 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. + // pip >= 25.1 - use `pip index versions --json` to get available versions in a machine readable format. const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { + if (pipVersion && compare(pipVersion.public, '25.1') >= 0) { const output = await runPython( python, ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], @@ -198,7 +211,17 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + if (pipVersion && compare(pipVersion.public, '21.2') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], + undefined, + this.log, + ); + return parsePipIndexVersionsText(output); + } + + // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. } catch { return undefined; } @@ -245,3 +268,17 @@ export function parsePipIndexVersionsJson(output: string): Pep440Version[] | und return undefined; } } + +/** Parses the legacy text output from `pip index versions `. */ +export function parsePipIndexVersionsText(output: string): Pep440Version[] | undefined { + const match = output.match(/^Available versions:\s*(.+)$/im); + if (!match) { + return undefined; + } + const versions = match[1] + .split(',') + .map((version) => parse(version.trim())) + .filter((version): version is Pep440Version => version !== null) + .sort((a, b) => rcompare(a.public, b.public)); + return versions.length > 0 ? versions : undefined; +} diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index dc44fe759..f6ff2903a 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -218,7 +218,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne export async function refreshPipPackages( environment: PythonEnvironment, log?: LogOutputChannel, - options?: { showProgress: boolean }, + options?: { showProgress?: boolean; showErrors?: boolean }, ): Promise { let data: string; try { @@ -238,7 +238,9 @@ export async function refreshPipPackages( return parsePipListJson(data, log); } catch (e) { log?.error('Error refreshing packages', e); - showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + if (options?.showErrors !== false) { + showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + } return undefined; } } diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 7af0f450a..6dcda4df8 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -1,14 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { - EventEmitter, - l10n, - LogOutputChannel, - MarkdownString, - ProgressLocation, - ThemeIcon, - Uri, -} from 'vscode'; +import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -24,6 +16,7 @@ import { PythonProject, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; @@ -265,11 +258,11 @@ export class VenvManager implements EnvironmentManager { /** * Removes the specified Python environment, updates internal collections, and fires change events as needed. */ - async remove(environment: PythonEnvironment): Promise { + async remove(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { try { this.skipWatcherRefresh = true; - const isRemoved = await removeVenv(environment, this.log); + const isRemoved = await removeVenv(environment, this.log, options); if (!isRemoved) { return; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c06146999..2962235e1 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -11,7 +11,13 @@ import { ThemeIcon, Uri, } from 'vscode'; -import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; +import { + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentInfo, + RemoveEnvironmentOptions, +} from '../../api'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; @@ -553,7 +559,11 @@ async function validateVenvRemovalPath(envPath: string, log: LogOutputChannel): return undefined; } -export async function removeVenv(environment: PythonEnvironment, log: LogOutputChannel): Promise { +export async function removeVenv( + environment: PythonEnvironment, + log: LogOutputChannel, + options?: RemoveEnvironmentOptions, +): Promise { const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python'; const envFsPath = path.normalize(environment.environmentPath.fsPath); @@ -568,15 +578,19 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC // Normalize path for UI display - ensure forward slashes on Windows const displayPath = normalizePath(envPath); - const confirm = await showWarningMessage( - l10n.t('Are you sure you want to remove {0}?', displayPath), - { - modal: true, - }, - { title: Common.yes }, - { title: Common.no, isCloseAffordance: true }, - ); - if (confirm?.title === Common.yes) { + const confirmed = + options?.runHeadless === true || + ( + await showWarningMessage( + l10n.t('Are you sure you want to remove {0}?', displayPath), + { + modal: true, + }, + { title: Common.yes }, + { title: Common.no, isCloseAffordance: true }, + ) + )?.title === Common.yes; + if (confirmed) { const result = await withProgress( { location: ProgressLocation.Notification, diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 3e16ae361..6c484fccd 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -9,6 +9,8 @@ import { normalizePackageName } from '../builtin/utils'; */ export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: Package }[]) => void; +type PackageFetcher = () => Promise; + /** * Computes the list of package changes between a before and after snapshot. * @param before - The previous list of packages. @@ -41,19 +43,30 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: * This function calls {@link PackageManager.getPackages} with `skipCache` to fetch * the latest snapshot. The caller should pass the previously cached packages * so changes can be computed against the pre-refresh state. + * + * @param packageManager The package manager whose packages changed. + * @param environment The environment whose packages should be refreshed. + * @param before The package snapshot from before the operation. + * @param onChanges Callback invoked when package changes are detected. + * @param fetchPackages Optional internal fetcher for operation-specific refresh behavior. */ export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, before: Package[] | undefined, onChanges: PackageChangesCallback, + fetchPackages?: PackageFetcher, ): Promise { const [after, afterDirectDependenciesNames] = await Promise.all([ - packageManager.getPackages(environment, { skipCache: true }).then((pkgs) => pkgs ?? []), + fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }), // Handle transitive dependencies (best-effort, don't break package refresh on failure) packageManager.getDirectPackageNames?.(environment).catch(() => undefined), ]); + if (after === undefined) { + return undefined; + } + // Enrich packages with transitive dependency info (best-effort, creates new objects to respect readonly) const enriched = afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0 ? after.map((pkg) => ({ diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d4fb44be3..d395d0ce6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -54,6 +54,10 @@ export class CondaPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const result = await getCommonCondaPackagesToInstall(environment, options, this.api); if (result) { toInstall = result.install; @@ -91,9 +95,12 @@ export class CondaPackageManager implements PackageManager, Disposable { } this.log.error('Error installing packages', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } + throw e; } }, ); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 9525254cb..e946f0452 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -59,6 +59,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package input prompt. + return; + } // Show package input UI if no packages are specified const installInput = await showInputBox({ prompt: 'Enter packages to install (comma separated)', @@ -99,12 +103,14 @@ export class PoetryPackageManager implements PackageManager, Disposable { throw e; } this.log.error('Error managing packages with Poetry', e); - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + 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; } }, diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 5998b6a17..7eb2a75c2 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -282,13 +282,13 @@ suite('Integration: Package Management', function () { try { if (wasInstalled) { // Uninstall first - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; await sleep(2000); } // Install package - await api.managePackages(targetEnv, { install: [testPackage] }); + await api.managePackages(targetEnv, { install: [testPackage], runHeadless: true }); packageInstalled = true; // Refresh and verify @@ -299,7 +299,7 @@ suite('Integration: Package Management', function () { assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); // Uninstall - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; // Refresh and verify @@ -312,7 +312,7 @@ suite('Integration: Package Management', function () { // Ensure cleanup even if assertions fail if (packageInstalled) { try { - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); } catch { console.log('Cleanup: failed to uninstall test package'); } diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts new file mode 100644 index 000000000..f42df8539 --- /dev/null +++ b/src/test/integration/packageManager.integration.test.ts @@ -0,0 +1,271 @@ +import * as vscode from 'vscode'; + +import { compare } from '@renovatebot/pep440'; +import assert from 'assert'; +import * as path from 'path'; +import { Package, PythonEnvironment, PythonEnvironmentApi, PythonProject } 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 { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +type PackageManagerId = `${string}:${string}`; + +interface PackageManagerProfile { + environmentManagerId: string; + name: string; + packageManagerId: PackageManagerId; + projectDirectory: string; + prerequisite(api: PythonEnvironmentApi): Promise; + supportsVersionLookup(packages: Package[]): boolean; +} + +const profiles: PackageManagerProfile[] = [ + { + environmentManagerId: VENV_MANAGER_ID, + name: 'Pip', + packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, + projectDirectory: 'pip', + prerequisite: async (api) => + (await api.getEnvironments('global')).some((environment) => environment.version.startsWith('3.')), + supportsVersionLookup: (packages) => { + const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; + return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; + }, + }, + { + environmentManagerId: CONDA_MANAGER_ID, + name: 'Conda', + packageManagerId: CONDA_MANAGER_ID, + projectDirectory: 'conda', + prerequisite: async () => { + try { + await getConda(); + return true; + } catch { + return false; + } + }, + supportsVersionLookup: () => true, + }, +]; + +const deferredPackageManagers: Readonly> = { + 'ms-python.python:poetry': 'Poetry lifecycle coverage requires a controlled Poetry installation.', +}; + +const deferredProfiles = { + pipWithUv: 'uv-backed Pip selection uses a machine-scoped setting and is unstable within one extension host.', +} as const; + +suite('Package Manager profile coverage', function () { + this.timeout(60_000); + + test('covers or explicitly defers every registered package manager', async () => { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + const api: PythonEnvironmentApi = extension.isActive ? extension.exports : await extension.activate(); + await api.getEnvironments('global'); + + const registeredIds = await vscode.commands.executeCommand( + 'python-envs.test.getPackageManagerIds', + ); + assert.ok(registeredIds, 'Registered package-manager IDs are unavailable'); + + const coveredIds = new Set(profiles.map((profile) => profile.packageManagerId)); + const uncoveredIds = registeredIds.filter( + (managerId) => + !coveredIds.has(managerId as PackageManagerId) && + deferredPackageManagers[managerId as PackageManagerId] === undefined, + ); + assert.deepStrictEqual(uncoveredIds, [], `Package managers lack lifecycle coverage: ${uncoveredIds.join(', ')}`); + + for (const profile of profiles) { + assert.ok( + registeredIds.includes(profile.packageManagerId), + `Profile references an unregistered package manager: ${profile.packageManagerId}`, + ); + } + + for (const [profileName, reason] of Object.entries(deferredProfiles)) { + assert.ok(reason.length > 0, `Deferred profile lacks a reason: ${profileName}`); + } + }); +}); + +for (const profile of profiles) { + suite(`${profile.name} Package Manager`, function () { + this.timeout(300_000); + + let api: PythonEnvironmentApi; + let environment: PythonEnvironment | undefined; + let project: PythonProject | undefined; + let workspaceUri: vscode.Uri; + let previousPythonProjects: PythonProjectSettings[] | undefined; + let pythonProjectsUpdated = false; + suiteSetup(async function () { + if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { + this.skip(); + return; + } + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate in time'); + } + api = extension.exports; + assert.ok(api, 'API not available'); + + 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); + + if (!(await profile.prerequisite(api))) { + this.skip(); + return; + } + + const projectUri = vscode.Uri.joinPath( + workspaceUri, + `.package-manager-test-${profile.projectDirectory}-${process.pid}`, + ); + await vscode.workspace.fs.createDirectory(projectUri); + project = { + 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`); + assert.strictEqual( + environment.envId.managerId, + profile.environmentManagerId, + `Expected an environment created by ${profile.environmentManagerId}`, + ); + }); + + test(`${profile.name} Package Manager should install, list, and uninstall a package`, async () => { + const packageName = 'requests'; + const baseline = await api.getPackages(environment!, { skipCache: true }); + assert.ok(baseline, 'Unable to list packages before installation'); + 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), + 'Package not installed', + ); + + const directPackageNames = await vscode.commands.executeCommand( + 'python-envs.test.getDirectPackageNames', + environment!, + ); + if (directPackageNames !== undefined) { + assert.ok(directPackageNames.includes(packageName), 'Installed package was not reported as direct'); + } + + 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), + 'Package not uninstalled', + ); + } + }); + + 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'); + if (!profile.supportsVersionLookup(packages)) { + this.skip(); + return; + } + + const versions = await api.getPackageAvailableVersions(environment!, 'requests'); + assert.ok(versions, `${profile.name} unexpectedly failed to retrieve 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}`, + ); + } + } 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, + }); + } + } + } + } + }); + }); +} diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 8a64abf1b..549bdd2de 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -40,4 +40,24 @@ suite('PipPackageManager', () => { assert.deepStrictEqual(initial, [cachedPackage]); assert.deepStrictEqual(afterFailedRefresh, [cachedPackage]); }); + + test('preserves undefined when an uncached refresh fails', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const manager = new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, + {} as VenvManager, + ); + const refreshPackages = sinon.stub(builtinUtils, 'refreshPipPackages').resolves(undefined); + + const firstResult = await manager.getPackages(environment); + const secondResult = await manager.getPackages(environment); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); + }); }); diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts new file mode 100644 index 000000000..dff10003c --- /dev/null +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -0,0 +1,53 @@ +// 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 { PythonEnvironment } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as helpers from '../../../managers/builtin/helpers'; +import { refreshPipPackages } from '../../../managers/builtin/utils'; + +suite('Pip package refresh', () => { + let environment: PythonEnvironment; + let log: LogOutputChannel; + let showErrorMessageWithLogsStub: sinon.SinonStub; + + setup(() => { + environment = { + environmentPath: Uri.file('.'), + execInfo: { + run: { + executable: 'python', + }, + }, + } as PythonEnvironment; + log = { + error: sinon.stub(), + info: sinon.stub(), + } as unknown as LogOutputChannel; + + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'runPython').rejects(new Error('pip list failed')); + showErrorMessageWithLogsStub = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('shows an error when an interactive refresh fails', async () => { + const result = await refreshPipPackages(environment, log); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); + + test('does not show an error when a headless refresh fails', async () => { + const result = await refreshPipPackages(environment, log, { showErrors: false }); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); +}); diff --git a/src/test/managers/builtin/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts index 5c06c394b..b2bd15f6b 100644 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ b/src/test/managers/builtin/pipVersions.unit.test.ts @@ -1,13 +1,16 @@ -import assert from 'assert'; import { explain } from '@renovatebot/pep440'; -import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipPackageManager'; +import assert from 'assert'; +import { parsePipIndexVersionsJson, parsePipIndexVersionsText } from '../../../managers/builtin/pipPackageManager'; suite('Pip Version Parsing', () => { suite('parsePipIndexVersionsJson', () => { test('parses valid JSON with versions array', () => { const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); + assert.deepStrictEqual( + versions, + ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v)), + ); }); test('parses output with a single version', () => { @@ -33,5 +36,29 @@ suite('Pip Version Parsing', () => { assert.strictEqual(versions, undefined); }); }); -}); + suite('parsePipIndexVersionsText', () => { + test('parses and sorts the available versions line', () => { + const output = [ + 'requests (2.32.5)', + 'Available versions: 2.31.0, 2.32.5, 2.30.0', + ' INSTALLED: 2.31.0', + ' LATEST: 2.32.5', + ].join('\n'); + const versions = parsePipIndexVersionsText(output); + assert.deepStrictEqual( + versions, + ['2.32.5', '2.31.0', '2.30.0'].map((version) => explain(version)), + ); + }); + + test('returns undefined when the available versions line is missing', () => { + assert.strictEqual(parsePipIndexVersionsText('ERROR: No matching distribution found'), undefined); + }); + + test('ignores invalid versions', () => { + const versions = parsePipIndexVersionsText('Available versions: invalid, 1.2.3'); + assert.deepStrictEqual(versions, [explain('1.2.3')]); + }); + }); +}); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 7e1e202be..12bf1c7b3 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -47,12 +47,11 @@ function createManager( const baseManager = { getEnvironments: sinon.stub().resolves(baseEnvironments), } as any as EnvironmentManager; - const manager = new VenvManager( - {} as NativePythonFinder, - api, - baseManager, - { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, - ); + const manager = new VenvManager({} as NativePythonFinder, api, baseManager, { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + } as any); (manager as any)._initialized = { completed: true, promise: Promise.resolve() }; (manager as any).collection = []; return manager; @@ -221,6 +220,17 @@ suite('VenvManager.remove - orchestration', () => { assert.strictEqual(events[0][0].environment, env); }); + test('forwards headless removal options to the removal helper', async () => { + const manager = createManager(); + const env = environment(); + removeVenvStub.resolves(true); + + await manager.remove(env, { runHeadless: true }); + + assert.strictEqual(removeVenvStub.firstCall.args[0], env); + assert.deepStrictEqual(removeVenvStub.firstCall.args[2], { runHeadless: true }); + }); + test('does not mutate state when the removal helper returns false', async () => { const manager = createManager(); const env = environment(); diff --git a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts index 068eb5dca..b1fae91bf 100644 --- a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts @@ -1,6 +1,13 @@ import * as assert from 'assert'; +import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; +import * as sinon from 'sinon'; +import * as windowApis from '../../../common/window.apis'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { removeVenv } from '../../../managers/builtin/venvUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; suite('venvUtils Path Validation', () => { suite('isDriveRoot behavior', () => { @@ -146,4 +153,28 @@ suite('venvUtils removeVenv validation integration', () => { 'Should check for pyvenv.cfg in the environment root', ); }); + + test('headless removal skips confirmation and removes the environment', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'remove-venv-')); + const envPath = path.join(tempRoot, '.venv'); + await fs.outputFile(path.join(envPath, 'pyvenv.cfg'), 'home = base'); + const showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(uvEnvironments, 'removeUvEnvironment').resolves(); + + try { + const removed = await removeVenv( + createMockPythonEnvironment({ name: '.venv', envPath }), + createMockLogOutputChannel(), + { runHeadless: true }, + ); + + assert.strictEqual(removed, true); + assert.strictEqual(showWarningMessageStub.callCount, 0); + assert.strictEqual(await fs.pathExists(envPath), false); + } finally { + sinon.restore(); + await fs.remove(tempRoot); + } + }); }); diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 1f65b3c75..8f6f77402 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -127,6 +127,35 @@ suite('packageChanges', () => { assert.strictEqual(changes[0].kind, PackageChangeKind.add); }); + test('uses an operation-specific package fetcher when provided', async () => { + const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; + const fetchPackages = sinon.stub().resolves(fetched); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify( + packageManager, + environment, + undefined, + onChanges, + fetchPackages, + ); + + assert.deepStrictEqual(result, fetched); + assert.ok(fetchPackages.calledOnce); + assert.ok(getPackagesStub.notCalled); + }); + + test('preserves undefined and does not report removals when fetching fails', async () => { + const before = [{ name: 'requests', version: '2.31.0' } as Package]; + getPackagesStub.resolves(undefined); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify(packageManager, environment, before, onChanges); + + assert.strictEqual(result, undefined); + assert.ok(onChanges.notCalled); + }); + test('does not fire callback when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(pkgs); diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts new file mode 100644 index 000000000..ea6614daf --- /dev/null +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -0,0 +1,41 @@ +// 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 { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as windowApis from '../../../common/window.apis'; +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; + +suite('CondaPackageManager', () => { + teardown(() => { + sinon.restore(); + }); + + test('headless package failures reject without showing error UI', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const logError = sinon.stub(); + const log = { + error: logError, + } as unknown as LogOutputChannel; + const manager = new CondaPackageManager({} as PythonEnvironmentApi, log); + const operationError = new Error('conda install failed'); + sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === operationError, + ); + + assert.ok(logError.calledOnce); + assert.ok(showErrorMessageWithLogs.notCalled); + }); +});