From 0fa9ad661e7d8fac3299a2e04bab110a4afc1d26 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 7 Aug 2026 16:16:58 -0700 Subject: [PATCH 1/6] test: cover registered package manager lifecycles (Fixes #1701) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b2fe9b5-38ea-442f-b07a-b6c71134d480 --- .vscode-test.mjs | 2 +- src/extension.ts | 11 + src/internal.api.ts | 5 + .../packageManagement.integration.test.ts | 813 ++++++++++-------- .../integration/packageManagerFixtures.ts | 90 ++ 5 files changed, 578 insertions(+), 343 deletions(-) create mode 100644 src/test/integration/packageManagerFixtures.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 23d18eb94..c6953e567 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -15,7 +15,7 @@ const userDir = path.join(userDataDir, 'User'); fs.mkdirSync(userDir, { recursive: true }); fs.writeFileSync( path.join(userDir, 'settings.json'), - JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', + JSON.stringify({ 'python.useEnvironmentsExtension': true, 'python-envs.alwaysUseUv': false }) + '\n', ); export default defineConfig([ diff --git a/src/extension.ts b/src/extension.ts index c4c2e2a9c..0b8049b0a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -732,6 +732,17 @@ export async function activate(context: ExtensionContext): Promise + envManagers.packageManagers.map((manager) => ({ + id: manager.id, + manager: manager.registeredManager, + })), + }); + } + return api; } diff --git a/src/internal.api.ts b/src/internal.api.ts index 04a198ac4..cae22cf01 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -324,6 +324,11 @@ export class InternalPackageManager implements PackageManager { private readonly manager: PackageManager, ) {} + /** The live registered implementation. Only exposed to the integration-test bridge. */ + public get registeredManager(): PackageManager { + return this.manager; + } + public get name(): string { return this.manager.name; } diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 5998b6a17..08ba44804 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -1,396 +1,525 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -/** - * Integration Test: Package Management - * - * PURPOSE: - * Verify that package management works correctly for different - * environment types and managers. - * - * WHAT THIS TESTS: - * 1. getPackages returns packages for environments - * 2. Package installation via API - * 3. Package uninstallation via API - * 4. Refresh updates package list - * 5. Events fire when packages change - * - * NOTE: Some tests may install/uninstall actual packages. - * These should use safe test packages that don't have side effects. - */ - import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import * as vscode from 'vscode'; -import { DidChangePackagesEventArgs, PythonEnvironmentApi } from '../../api'; +import { Package, PackageManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; import { ENVS_EXTENSION_ID } from '../constants'; -import { sleep, TestEventHandler, waitForCondition } from '../testUtils'; +import { waitForCondition } from '../testUtils'; +import { + ActivePackageManagerFixture, + CapabilityExpectation, + packageManagerFixtures, + PackageManagerProfile, +} from './packageManagerFixtures'; -suite('Integration: Package Management', function () { - this.timeout(120_000); // Package operations can be slow +const EXPECTED_REGISTERED_MANAGER_IDS = packageManagerFixtures.map((fixture) => fixture.id); - let api: PythonEnvironmentApi; +type ActivePackageManagerProfile = Extract; - suiteSetup(async function () { - this.timeout(30_000); +interface RegisteredPackageManager { + readonly id: string; + readonly manager: PackageManager; +} - const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); - assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); +interface IntegrationTestApi extends PythonEnvironmentApi { + getRegisteredPackageManagersForTests(): readonly RegisteredPackageManager[]; +} - if (!extension.isActive) { - await extension.activate(); - await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); - } +interface SettingSnapshot { + readonly key: string; + readonly value: T | undefined; + readonly target: vscode.ConfigurationTarget; +} - api = extension.exports as PythonEnvironmentApi; - assert.ok(api, 'API not available'); - }); +class PrerequisiteUnavailable extends Error {} - /** - * Test: Package management APIs are available - * - * The API should have all package management methods. - */ - test('Package management APIs are available', async function () { - assert.ok(typeof api.getPackages === 'function', 'getPackages should be a function'); - assert.ok(typeof api.refreshPackages === 'function', 'refreshPackages should be a function'); - assert.ok(typeof api.managePackages === 'function', 'managePackages should be a function'); - assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); - }); +suite('Integration: Package manager lifecycles', function () { + this.timeout(900_000); - /** - * Test: getPackages returns array for environment - * - * For a valid environment, getPackages should return a list of packages. - */ - test('getPackages returns packages for environment', async function () { - const environments = await api.getEnvironments('all'); + let api: IntegrationTestApi; - if (environments.length === 0) { - this.skip(); - return; - } - - // Try to find an environment that likely has packages (not system Python) - let targetEnv = environments[0]; - for (const env of environments) { - // Prefer environments that are likely virtual envs with packages - if (env.displayName.includes('venv') || env.displayName.includes('.venv')) { - targetEnv = env; - break; - } - } - - const packages = await api.getPackages(targetEnv); - - // May be undefined if package manager not available - if (packages === undefined) { - this.skip(); - return; - } - - assert.ok(Array.isArray(packages), 'getPackages should return array'); - console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); - }); - - /** - * Test: Packages have valid structure - * - * Each package should have required properties. - */ - test('Packages have valid structure', async function () { - const environments = await api.getEnvironments('all'); + suiteSetup(async function () { + this.timeout(120_000); - if (environments.length === 0) { - this.skip(); - return; + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Bootstrap: extension ${ENVS_EXTENSION_ID} was not found`); + if (!extension.isActive) { + await extension.activate(); } - const packages = await api.getPackages(environments[0]); + api = extension.exports as IntegrationTestApi; + assert.ok(api, 'Bootstrap: extension API was not exported'); + assert.strictEqual( + typeof api.getRegisteredPackageManagersForTests, + 'function', + 'Bootstrap: integration-test package-manager bridge was not exposed', + ); - if (!packages || packages.length === 0) { - this.skip(); - return; - } + const folders = vscode.workspace.workspaceFolders; + assert.ok(folders && folders.length > 0, 'Bootstrap: integration tests require a workspace folder'); - for (const pkg of packages) { - assert.ok(pkg.pkgId, 'Package must have pkgId'); - assert.ok(pkg.pkgId.id, 'pkgId must have id'); - assert.ok(pkg.pkgId.managerId, 'pkgId must have managerId'); - assert.ok(pkg.pkgId.environmentId, 'pkgId must have environmentId'); - assert.ok(typeof pkg.name === 'string', 'Package must have name'); - assert.ok(pkg.name.length > 0, 'Package name should not be empty'); - assert.ok(typeof pkg.displayName === 'string', 'Package must have displayName'); - } + await waitForCondition( + () => EXPECTED_REGISTERED_MANAGER_IDS.every((id) => registeredManagers(api).has(id)), + 90_000, + () => + `Bootstrap: package managers did not finish registering; found ${[ + ...registeredManagers(api).keys(), + ].join(', ')}`, + ); }); - /** - * Test: refreshPackages updates package list - * - * After refreshing, the package list should be consistent. - * Multiple calls should return the same packages (idempotent). - */ - test('refreshPackages updates list', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; - } + test('fixture list exactly covers every live registered package manager', () => { + const registeredIds = [...registeredManagers(api).keys()].sort(); + const fixtureIds = packageManagerFixtures.map((fixture) => fixture.id).sort(); - const env = environments[0]; - - // Get initial packages - const initial = await api.getPackages(env); - - if (initial === undefined) { - this.skip(); - return; - } - - const initialCount = initial.length; - - // Refresh - await api.refreshPackages(env); - - // Get updated packages - const after = await api.getPackages(env); - - assert.ok(Array.isArray(after), 'Should return array after refresh'); - - // Package counts should be identical (no external changes during test) assert.strictEqual( - after.length, - initialCount, - `Package count should be stable after refresh: expected ${initialCount}, got ${after.length}`, + new Set(fixtureIds).size, + fixtureIds.length, + 'Registry completeness: fixture IDs must be unique', ); - }); - - /** - * Test: getPackages returns non-empty array for environments with packages - * - * For virtual environments, at minimum pip should typically be present. - */ - test('getPackages returns packages for virtual environment', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; - } - - // Find a virtual environment (more likely to have pip) - const targetEnv = environments.find( - (env) => - env.displayName.includes('venv') || - env.displayName.includes('.venv') || - env.envId.managerId.includes('venv'), + assert.deepStrictEqual( + fixtureIds, + registeredIds, + 'Registry completeness: every live manager needs one active fixture or explicit deferral, and every fixture must be registered', ); + }); - if (!targetEnv) { - console.log('No virtual environment found, skipping'); - this.skip(); - return; + for (const fixture of packageManagerFixtures) { + if (fixture.status === 'deferred') { + test(`${fixture.id} is explicitly deferred: ${fixture.reason}`, () => { + assert.ok(registeredManagers(api).has(fixture.id), `Deferred fixture: ${fixture.id} is not registered`); + }); + continue; } - const packages = await api.getPackages(targetEnv); + for (const profile of fixture.profiles) { + if (profile.status === 'deferred') { + test(`${fixture.id} (${profile.name}) is explicitly deferred: ${profile.reason}`, () => { + assert.ok(profile.reason, `Deferred profile: ${profile.name} requires a reason`); + }); + continue; + } - if (packages === undefined) { - console.log('Package manager not available for:', targetEnv.displayName); - this.skip(); - return; + test(`${fixture.id} (${profile.name}) install/list/uninstall lifecycle`, async function () { + try { + await runLifecycle(api, fixture, profile); + } catch (error) { + if (error instanceof PrerequisiteUnavailable) { + this.skip(); + return; + } + throw error; + } + }); } + } +}); - // Virtual environments should have at least pip installed - const pipInstalled = packages.some((p) => p.name.toLowerCase() === 'pip'); - assert.ok(pipInstalled, `Virtual environment ${targetEnv.displayName} should have pip installed`); - - console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); - }); - - /** - * Test: Different environments can have different packages - * - * Package lists should be environment-specific. - */ - test('Package lists are environment-specific', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length < 2) { - this.skip(); - return; +function registeredManagers(api: IntegrationTestApi): Map { + return new Map(api.getRegisteredPackageManagersForTests().map(({ id, manager }) => [id, manager])); +} + +async function runLifecycle( + api: IntegrationTestApi, + fixture: ActivePackageManagerFixture, + profile: ActivePackageManagerProfile, +): Promise { + const manager = registeredManagers(api).get(fixture.id); + assert.ok(manager, `Bootstrap (${profile.name}): live manager ${fixture.id} was not found`); + + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'python-envs-pm-')); + const projectUri = vscode.Uri.file(projectRoot); + const project: PythonProject = { name: path.basename(projectRoot), uri: projectUri }; + const config = vscode.workspace.getConfiguration('python-envs', projectUri); + const settings: SettingSnapshot[] = []; + let environment: PythonEnvironment | undefined; + let projectAdded = false; + + let scenarioError: unknown; + try { + await setWorkspaceSetting(config, settings, 'defaultEnvManager', fixture.environmentManagerId); + await setWorkspaceSetting(config, settings, 'defaultPackageManager', fixture.id); + settings.push(snapshotWorkspaceSetting(config, 'pythonProjects')); + + await api.addPythonProject(project); + projectAdded = true; + + const globalPythons = await api.getEnvironments('global'); + if (!globalPythons.some((candidate) => candidate.version.startsWith('3.'))) { + throw new PrerequisiteUnavailable(`Bootstrap (${profile.name}): no global Python 3 installation is available`); } - const env1 = environments[0]; - const env2 = environments[1]; - - const packages1 = await api.getPackages(env1); - const packages2 = await api.getPackages(env2); - - // Both should return valid results (or undefined for same reason) - if (packages1 === undefined || packages2 === undefined) { - this.skip(); - return; + environment = await api.createEnvironment(projectUri, { quickCreate: true }); + if (!environment && fixture.environmentManagerId === 'ms-python.python:venv') { + // Main can finish creating the Venv on disk before returning its item; recover it through public discovery. + await api.refreshEnvironments(projectUri); + environment = await findEnvironmentInside(projectRoot, await api.getEnvironments(projectUri)); } - - assert.ok(Array.isArray(packages1), 'Env1 packages should be array'); - assert.ok(Array.isArray(packages2), 'Env2 packages should be array'); - - console.log(`Env1 (${env1.displayName}): ${packages1.length} packages`); - console.log(`Env2 (${env2.displayName}): ${packages2.length} packages`); - }); - - /** - * Test: Package install and uninstall flow - * - * This test installs and uninstalls a small test package. - * Uses 'cowsay' as it's small and has no dependencies. - */ - test('Package install and uninstall works', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; + if (!environment && fixture.environmentManagerId === 'ms-python.python:conda') { + const condaVersion = await manager.getVersion?.(globalPythons[0]); + if (!condaVersion) { + throw new PrerequisiteUnavailable(`Bootstrap (${profile.name}): Conda is not available`); + } } - - // Find a virtual environment we can safely modify - const targetEnv = environments.find( - (env) => - (env.displayName.includes('venv') || env.displayName.includes('.venv')) && - env.envId.managerId.includes('venv'), + assert.ok( + environment, + `Bootstrap (${profile.name}): ${fixture.environmentManagerId} did not create a disposable environment`, ); - - if (!targetEnv) { - console.log('No modifiable virtual environment found'); - this.skip(); - return; + assert.strictEqual( + environment.envId.managerId, + fixture.environmentManagerId, + `Bootstrap (${profile.name}): environment was created by the wrong manager`, + ); + await assertOwnedEnvironment(projectRoot, environment, profile.name); + if (/(?:alpha|beta|rc|dev)|\d[ab]\d/i.test(environment.version)) { + throw new PrerequisiteUnavailable( + `Bootstrap (${profile.name}): quick create selected pre-release Python ${environment.version}`, + ); } - const testPackage = 'cowsay'; - - // Check if already installed - const initialPackages = await api.getPackages(targetEnv); - if (!initialPackages) { - console.log('Package manager not available for this environment'); - this.skip(); - return; + if (profile.alwaysUseUv !== undefined) { + assert.strictEqual( + config.get('alwaysUseUv'), + profile.alwaysUseUv, + `Bootstrap (${profile.name}): test runner did not configure the expected Pip execution path`, + ); } - const wasInstalled = initialPackages.some((p) => p.name.toLowerCase() === testPackage); - let packageInstalled = wasInstalled; - - try { - if (wasInstalled) { - // Uninstall first - await api.managePackages(targetEnv, { uninstall: [testPackage] }); - packageInstalled = false; - await sleep(2000); + await exerciseManager(manager, fixture, profile, environment); + } catch (error) { + scenarioError = error; + } finally { + const cleanupErrors: unknown[] = []; + if (environment) { + try { + await cleanupEnvironment(api, fixture, profile, projectRoot, environment); + } catch (error) { + cleanupErrors.push(error); } - - // Install package - await api.managePackages(targetEnv, { install: [testPackage] }); - packageInstalled = true; - - // Refresh and verify - await api.refreshPackages(targetEnv); - const afterInstall = await api.getPackages(targetEnv); - - const isNowInstalled = afterInstall?.some((p) => p.name.toLowerCase() === testPackage); - assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); - - // Uninstall - await api.managePackages(targetEnv, { uninstall: [testPackage] }); - packageInstalled = false; - - // Refresh and verify - await api.refreshPackages(targetEnv); - const afterUninstall = await api.getPackages(targetEnv); - - const isStillInstalled = afterUninstall?.some((p) => p.name.toLowerCase() === testPackage); - assert.ok(!isStillInstalled, `${testPackage} should be uninstalled after managePackages uninstall`); - } finally { - // Ensure cleanup even if assertions fail - if (packageInstalled) { - try { - await api.managePackages(targetEnv, { uninstall: [testPackage] }); - } catch { - console.log('Cleanup: failed to uninstall test package'); - } + } + if (projectAdded) { + try { + await api.setEnvironment(projectUri, undefined); + } catch (error) { + cleanupErrors.push(error); + } + try { + api.removePythonProject(project); + } catch (error) { + cleanupErrors.push(error); } } - }); + for (const setting of settings.reverse()) { + try { + await config.update(setting.key, setting.value, setting.target); + } catch (error) { + cleanupErrors.push(error); + } + } + try { + await removeDirectoryWithRetries(projectRoot); + } catch (error) { + cleanupErrors.push(error); + } - /** - * Test: onDidChangePackages event fires - * - * When packages change, the event should fire. - */ - test('onDidChangePackages event is available', async function () { - assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); - - // Verify it's subscribable - const handler = new TestEventHandler( - api.onDidChangePackages, - 'onDidChangePackages', + if (scenarioError) { + if (scenarioError instanceof Error && cleanupErrors.length > 0) { + scenarioError.message += `; cleanup also failed: ${cleanupErrors.map(String).join('; ')}`; + } + throw scenarioError; + } + if (cleanupErrors.length > 0) { + throw new Error( + `Cleanup (${profile.name}): one or more cleanup operations failed: ${cleanupErrors.map(String).join('; ')}`, + ); + } + } +} + +async function exerciseManager( + manager: PackageManager, + fixture: ActivePackageManagerFixture, + profile: ActivePackageManagerProfile, + environment: PythonEnvironment, +): Promise { + await assertVersionCapability(manager, fixture.capabilities.version, environment, profile.name); + let installSpec = fixture.packageName; + let pinnedVersion: string | undefined; + + if (profile.availableVersions === 'required') { + const getVersions = manager.getPackageAvailableVersions; + assert.ok(getVersions, `Available versions (${profile.name}): capability is declared required but missing`); + const versions = await getVersions.call(manager, environment, fixture.packageName); + assert.ok(versions && versions.length > 0, `Available versions (${profile.name}): no versions were returned`); + pinnedVersion = versions[0].public; + installSpec = formatInstallSpec(manager, fixture, pinnedVersion, profile.name); + } else { + assertCapabilityDeclaration(profile.availableVersions, `Available versions (${profile.name})`); + } + + assertFormatCapability(manager, fixture, profile.name); + + const baseline = await manager.getPackages(environment, { skipCache: true }); + assert.ok(Array.isArray(baseline), `Baseline list (${profile.name}): manager returned undefined`); + const baselineNames = new Set(baseline.map((pkg) => normalizeName(pkg.name))); + assert.ok(baselineNames instanceof Set, `Baseline list (${profile.name}): baseline was not recorded`); + + let installed = false; + try { + await manager.manage(environment, { install: [installSpec] }); + installed = true; + + const refreshed = await manager.refresh(environment); + assert.ok(Array.isArray(refreshed), `Post-install refresh (${profile.name}): manager returned undefined`); + const afterInstall = await manager.getPackages(environment, { skipCache: true }); + assert.ok(Array.isArray(afterInstall), `Post-install list (${profile.name}): manager returned undefined`); + const installedPackage = findPackage(afterInstall, fixture.packageName); + assert.ok( + installedPackage, + `Post-install list (${profile.name}): ${installSpec} was not installed; found ${afterInstall + .map((pkg) => `${pkg.name}==${pkg.version ?? 'unknown'}`) + .join(', ')}`, ); - - // Just verify we can subscribe without error - handler.dispose(); - }); - - /** - * Test: createPackageItem creates valid package - * - * The createPackageItem API should create properly structured packages. - */ - test('createPackageItem creates valid structure', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; + assert.strictEqual( + installedPackage.pkgId.managerId, + fixture.id, + `Post-install list (${profile.name}): package was attributed to the wrong manager`, + ); + if (pinnedVersion) { + assert.strictEqual( + installedPackage.version, + pinnedVersion, + `Post-install list (${profile.name}): installed version does not match the selected version`, + ); } - // This test verifies the API exists and is callable - // Full testing requires a registered package manager - assert.ok(typeof api.createPackageItem === 'function', 'createPackageItem should be a function'); - }); + await assertDirectPackage(manager, fixture, profile.name, environment, true); - /** - * Test: getPackages returns array or undefined, never throws - * - * For any environment, getPackages should return either a valid - * array of packages or undefined (if no package manager), never throw. - */ - test('getPackages returns array or undefined for all environments', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; - } + await manager.manage(environment, { uninstall: [fixture.packageName] }); + installed = false; - let arrayCount = 0; - let undefinedCount = 0; - - // Verify each environment returns valid result - for (const env of environments) { - const packages = await api.getPackages(env); - if (packages !== undefined) { - assert.ok(Array.isArray(packages), `getPackages should return array for ${env.displayName}`); - arrayCount++; - } else { - undefinedCount++; + const afterUninstallRefresh = await manager.refresh(environment); + assert.ok( + Array.isArray(afterUninstallRefresh), + `Post-uninstall refresh (${profile.name}): manager returned undefined`, + ); + const afterUninstall = await manager.getPackages(environment, { skipCache: true }); + assert.ok(Array.isArray(afterUninstall), `Post-uninstall list (${profile.name}): manager returned undefined`); + assert.ok( + !findPackage(afterUninstall, fixture.packageName), + `Post-uninstall list (${profile.name}): ${fixture.packageName} is still installed`, + ); + await assertDirectPackage(manager, fixture, profile.name, environment, false); + } catch (error) { + let cleanupError: unknown; + if (installed) { + try { + await manager.manage(environment, { uninstall: [fixture.packageName] }); + await manager.getPackages(environment, { skipCache: true }); + } catch (caught) { + cleanupError = caught; } } - - // Log results for visibility - console.log(`getPackages results: ${arrayCount} returned arrays, ${undefinedCount} returned undefined`); - - // At least some should return arrays (unless all envs lack package managers) + if (error instanceof Error && cleanupError) { + error.message += `; package cleanup also failed: ${String(cleanupError)}`; + } + throw error; + } +} + +async function assertVersionCapability( + manager: PackageManager, + expectation: CapabilityExpectation, + environment: PythonEnvironment, + profileName: string, +): Promise { + if (expectation === 'required') { + const getVersion = manager.getVersion; + assert.ok(getVersion, `Manager version (${profileName}): capability is declared required but missing`); + const version = await getVersion.call(manager, environment); + assert.ok(version, `Manager version (${profileName}): required capability returned undefined`); + return; + } + assertCapabilityDeclaration(expectation, `Manager version (${profileName})`); +} + +function assertFormatCapability( + manager: PackageManager, + fixture: ActivePackageManagerFixture, + profileName: string, +): void { + const expectation = fixture.capabilities.formatInstallSpec; + if (expectation === 'required') { assert.ok( - arrayCount > 0 || undefinedCount === environments.length, - 'At least one environment should have a package manager, or all should return undefined consistently', + manager.formatInstallSpec, + `Install spec (${profileName}): capability is declared required but missing`, ); - }); -}); + assert.strictEqual( + manager.formatInstallSpec(fixture.packageName, '1.2.3'), + `${fixture.packageName}=1.2.3`, + `Install spec (${profileName}): manager returned the wrong syntax`, + ); + return; + } + if (expectation === 'unsupported') { + assert.strictEqual( + manager.formatInstallSpec, + undefined, + `Install spec (${profileName}): fixture says unsupported but the manager now implements it`, + ); + return; + } + assertCapabilityDeclaration(expectation, `Install spec (${profileName})`); +} + +function formatInstallSpec( + manager: PackageManager, + fixture: ActivePackageManagerFixture, + version: string, + profileName: string, +): string { + if (fixture.capabilities.formatInstallSpec === 'required') { + assert.ok(manager.formatInstallSpec, `Install spec (${profileName}): required formatter is missing`); + return manager.formatInstallSpec(fixture.packageName, version); + } + return `${fixture.packageName}==${version}`; +} + +async function assertDirectPackage( + manager: PackageManager, + fixture: ActivePackageManagerFixture, + profileName: string, + environment: PythonEnvironment, + expectedPresent: boolean, +): Promise { + const expectation = fixture.capabilities.directPackageNames; + if (expectation === 'required') { + const getDirectNames = manager.getDirectPackageNames; + assert.ok(getDirectNames, `Direct packages (${profileName}): capability is declared required but missing`); + const names = await getDirectNames.call(manager, environment); + assert.ok(names, `Direct packages (${profileName}): required capability returned undefined`); + assert.strictEqual( + [...names].map(normalizeName).includes(normalizeName(fixture.packageName)), + expectedPresent, + `Direct packages (${profileName}): ${fixture.packageName} presence was incorrect after ${ + expectedPresent ? 'install' : 'uninstall' + }`, + ); + return; + } + if (expectation === 'unsupported') { + assert.strictEqual( + manager.getDirectPackageNames, + undefined, + `Direct packages (${profileName}): fixture says unsupported but the manager now implements it`, + ); + return; + } + assertCapabilityDeclaration(expectation, `Direct packages (${profileName})`); +} + +function assertCapabilityDeclaration(expectation: CapabilityExpectation, phase: string): void { + assert.notStrictEqual(expectation, 'required', `${phase}: required capability was not exercised`); + if (typeof expectation === 'object') { + assert.ok(expectation.deferred.length > 0, `${phase}: deferred capability requires a reason`); + } +} + +function findPackage(packages: readonly Package[], name: string): Package | undefined { + const normalized = normalizeName(name); + return packages.find((pkg) => normalizeName(pkg.name) === normalized); +} + +async function findEnvironmentInside( + projectRoot: string, + environments: PythonEnvironment[], +): Promise { + const resolvedProjectRoot = await fs.realpath(projectRoot); + for (const environment of environments) { + const resolvedPrefix = await fs.realpath(environment.sysPrefix); + const relative = path.relative(resolvedProjectRoot, resolvedPrefix); + if (relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { + return environment; + } + } + return undefined; +} + +function normalizeName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-'); +} + +function snapshotWorkspaceSetting( + config: vscode.WorkspaceConfiguration, + key: string, +): SettingSnapshot { + const inspection = config.inspect(key); + assert.ok(inspection, `Settings setup: ${key} is not registered`); + return { key, value: inspection.workspaceValue, target: vscode.ConfigurationTarget.Workspace }; +} + +async function setWorkspaceSetting( + config: vscode.WorkspaceConfiguration, + settings: SettingSnapshot[], + key: string, + value: T, +): Promise { + const snapshot = snapshotWorkspaceSetting(config, key); + settings.push(snapshot); + await config.update(key, value, snapshot.target); + assert.deepStrictEqual(config.inspect(key)?.workspaceValue, value, `Settings setup: ${key} was not applied`); +} + +async function assertOwnedEnvironment( + projectRoot: string, + environment: PythonEnvironment, + profileName: string, +): Promise { + const resolvedProjectRoot = await fs.realpath(projectRoot); + const resolvedEnvironmentRoot = await fs.realpath(environment.sysPrefix); + const relative = path.relative(resolvedProjectRoot, resolvedEnvironmentRoot); + assert.ok( + relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), + `Bootstrap (${profileName}): environment root is not inside the disposable project`, + ); +} + +async function removeDirectoryWithRetries(directory: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + await fs.rm(directory, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + throw lastError; +} + +async function cleanupEnvironment( + api: IntegrationTestApi, + fixture: ActivePackageManagerFixture, + profile: ActivePackageManagerProfile, + projectRoot: string, + environment: PythonEnvironment, +): Promise { + await assertOwnedEnvironment(projectRoot, environment, profile.name); + if (fixture.environmentManagerId === 'ms-python.python:conda') { + await api.removeEnvironment(environment); + return; + } + + const environmentRoot = environment.sysPrefix; + assert.ok( + path.basename(environmentRoot).startsWith('.venv'), + `Cleanup (${profile.name}): refusing to delete unexpected Venv root ${environmentRoot}`, + ); + await removeDirectoryWithRetries(environmentRoot); + await api.refreshEnvironments(projectRoot ? vscode.Uri.file(projectRoot) : undefined); +} diff --git a/src/test/integration/packageManagerFixtures.ts b/src/test/integration/packageManagerFixtures.ts new file mode 100644 index 000000000..f5321f8fb --- /dev/null +++ b/src/test/integration/packageManagerFixtures.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export type CapabilityExpectation = 'required' | 'unsupported' | { deferred: string }; + +export type PackageManagerProfile = + | { + readonly status: 'active'; + readonly name: string; + readonly alwaysUseUv?: boolean; + readonly availableVersions: CapabilityExpectation; + } + | { + readonly status: 'deferred'; + readonly name: string; + readonly reason: string; + }; + +export interface ActivePackageManagerFixture { + readonly status: 'active'; + readonly id: string; + readonly environmentManagerId: string; + readonly packageName: string; + readonly capabilities: { + readonly version: CapabilityExpectation; + readonly directPackageNames: CapabilityExpectation; + readonly formatInstallSpec: CapabilityExpectation; + }; + readonly profiles: readonly PackageManagerProfile[]; +} + +export interface DeferredPackageManagerFixture { + readonly status: 'deferred'; + readonly id: string; + readonly reason: string; +} + +export type PackageManagerFixture = ActivePackageManagerFixture | DeferredPackageManagerFixture; + +export const packageManagerFixtures: readonly PackageManagerFixture[] = [ + { + status: 'active', + id: 'ms-python.python:pip', + environmentManagerId: 'ms-python.python:venv', + packageName: 'flask', + capabilities: { + version: 'required', + directPackageNames: 'required', + formatInstallSpec: 'unsupported', + }, + profiles: [ + { + status: 'active', + name: 'pip', + alwaysUseUv: false, + availableVersions: 'required', + }, + { + status: 'deferred', + name: 'uv-backed Pip', + reason: + 'A reliable profile would require changing the machine-scoped alwaysUseUv setting during one extension-host run, and available-version lookup uses `uv tool run pip`, which adds network tool seeding. The normal pip path is pinned in the test runner instead.', + }, + ], + }, + { + status: 'active', + id: 'ms-python.python:conda', + environmentManagerId: 'ms-python.python:conda', + packageName: 'flask', + capabilities: { + version: 'required', + directPackageNames: 'unsupported', + formatInstallSpec: 'required', + }, + profiles: [ + { + status: 'active', + name: 'conda', + availableVersions: 'required', + }, + ], + }, + { + status: 'deferred', + id: 'ms-python.python:poetry', + reason: + 'Poetry package operations require a Poetry-owned project and lockfile lifecycle; that project bootstrap is deferred to dedicated coverage.', + }, +]; From 596583f99a9f9163ab8d9becd05dde078158026e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 7 Aug 2026 16:58:38 -0700 Subject: [PATCH 2/6] fix: address review feedback (PR #1704) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b2fe9b5-38ea-442f-b07a-b6c71134d480 --- .vscode-test.mjs | 13 ++++++++++++ .../packageManagement.integration.test.ts | 21 +++++++++++++++---- .../test-workspace/project-a/.gitignore | 1 + 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/test/integration/test-workspace/project-a/.gitignore diff --git a/.vscode-test.mjs b/.vscode-test.mjs index c6953e567..c5f0cac66 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -18,6 +18,19 @@ fs.writeFileSync( JSON.stringify({ 'python.useEnvironmentsExtension': true, 'python-envs.alwaysUseUv': false }) + '\n', ); +// A terminated integration host cannot run fixture cleanup. Reset only the known +// test-owned project prefix and fixture settings before opening the workspace again. +const integrationWorkspace = path.resolve('src/test/integration/test-workspace/project-a'); +for (const entry of fs.readdirSync(integrationWorkspace, { withFileTypes: true })) { + if (entry.isDirectory() && /^\.pm-[A-Za-z0-9]{6}$/.test(entry.name)) { + fs.rmSync(path.join(integrationWorkspace, entry.name), { recursive: true, force: true }); + } +} +fs.writeFileSync( + path.join(integrationWorkspace, '.vscode', 'settings.json'), + JSON.stringify({ 'python-envs.defaultEnvManager': 'ms-python.python:system' }, undefined, 4) + os.EOL, +); + export default defineConfig([ { label: 'smokeTests', diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 08ba44804..bc18a413d 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -3,7 +3,6 @@ import * as assert from 'assert'; import * as fs from 'fs/promises'; -import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { Package, PackageManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; @@ -41,6 +40,7 @@ suite('Integration: Package manager lifecycles', function () { this.timeout(900_000); let api: IntegrationTestApi; + let workspaceFolder: vscode.WorkspaceFolder; suiteSetup(async function () { this.timeout(120_000); @@ -61,6 +61,7 @@ suite('Integration: Package manager lifecycles', function () { const folders = vscode.workspace.workspaceFolders; assert.ok(folders && folders.length > 0, 'Bootstrap: integration tests require a workspace folder'); + workspaceFolder = folders[0]; await waitForCondition( () => EXPECTED_REGISTERED_MANAGER_IDS.every((id) => registeredManagers(api).has(id)), @@ -106,7 +107,7 @@ suite('Integration: Package manager lifecycles', function () { test(`${fixture.id} (${profile.name}) install/list/uninstall lifecycle`, async function () { try { - await runLifecycle(api, fixture, profile); + await runLifecycle(api, workspaceFolder, fixture, profile); } catch (error) { if (error instanceof PrerequisiteUnavailable) { this.skip(); @@ -125,13 +126,14 @@ function registeredManagers(api: IntegrationTestApi): Map { const manager = registeredManagers(api).get(fixture.id); assert.ok(manager, `Bootstrap (${profile.name}): live manager ${fixture.id} was not found`); - const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'python-envs-pm-')); + const projectRoot = await fs.mkdtemp(path.join(workspaceFolder.uri.fsPath, '.pm-')); const projectUri = vscode.Uri.file(projectRoot); const project: PythonProject = { name: path.basename(projectRoot), uri: projectUri }; const config = vscode.workspace.getConfiguration('python-envs', projectUri); @@ -266,7 +268,10 @@ async function exerciseManager( const baseline = await manager.getPackages(environment, { skipCache: true }); assert.ok(Array.isArray(baseline), `Baseline list (${profile.name}): manager returned undefined`); const baselineNames = new Set(baseline.map((pkg) => normalizeName(pkg.name))); - assert.ok(baselineNames instanceof Set, `Baseline list (${profile.name}): baseline was not recorded`); + assert.ok( + !baselineNames.has(normalizeName(fixture.packageName)), + `Baseline list (${profile.name}): ${fixture.packageName} was already installed in the disposable environment`, + ); let installed = false; try { @@ -344,6 +349,14 @@ async function assertVersionCapability( assert.ok(version, `Manager version (${profileName}): required capability returned undefined`); return; } + if (expectation === 'unsupported') { + assert.strictEqual( + manager.getVersion, + undefined, + `Manager version (${profileName}): capability is declared unsupported but implemented`, + ); + return; + } assertCapabilityDeclaration(expectation, `Manager version (${profileName})`); } diff --git a/src/test/integration/test-workspace/project-a/.gitignore b/src/test/integration/test-workspace/project-a/.gitignore new file mode 100644 index 000000000..8bd99a19b --- /dev/null +++ b/src/test/integration/test-workspace/project-a/.gitignore @@ -0,0 +1 @@ +.pm-* From 537f5fc92c98fb54c3da34d3be05052e12c2aff9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Sat, 8 Aug 2026 00:34:47 -0700 Subject: [PATCH 3/6] test: add package manager integration lifecycle --- .../packageManager.integration.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/test/integration/packageManager.integration.test.ts diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts new file mode 100644 index 000000000..af5b4b3a3 --- /dev/null +++ b/src/test/integration/packageManager.integration.test.ts @@ -0,0 +1,114 @@ +import * as vscode from 'vscode'; + +import assert from 'assert'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { CONDA_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +const profiles = [ + { + environmentManagerId: VENV_MANAGER_ID, + environmentDirectory: '.venv', + name: 'Pip', + }, + { + environmentManagerId: CONDA_MANAGER_ID, + environmentDirectory: '.conda', + name: 'Conda', + }, +]; + +async function deleteEnvironmentDirectory(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); + } catch (error) { + if (!(error instanceof vscode.FileSystemError) || error.code !== 'FileNotFound') { + throw error; + } + } +} + +for (const profile of profiles) { + suite(`${profile.name} Package Manager`, function () { + this.timeout(300_000); + + let api: PythonEnvironmentApi; + let environment: PythonEnvironment | undefined; + let workspaceUri: vscode.Uri; + let previousDefaultEnvManager: string | undefined; + let defaultEnvManagerUpdated = false; + let previousAlwaysUseUv: boolean | undefined; + let alwaysUseUvUpdated = false; + suiteSetup(async function () { + 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); + previousDefaultEnvManager = config.inspect('defaultEnvManager')?.workspaceValue; + await config.update( + 'defaultEnvManager', + profile.environmentManagerId, + vscode.ConfigurationTarget.Workspace, + ); + defaultEnvManagerUpdated = true; + + if (profile.environmentManagerId === VENV_MANAGER_ID) { + previousAlwaysUseUv = config.inspect('alwaysUseUv')?.globalValue; + await config.update('alwaysUseUv', false, vscode.ConfigurationTarget.Global); + alwaysUseUvUpdated = true; + } + + const environmentDirectory = vscode.Uri.joinPath(workspaceUri, profile.environmentDirectory); + await deleteEnvironmentDirectory(environmentDirectory); + await api.refreshEnvironments(workspaceUri); + + environment = await api.createEnvironment(workspaceUri, { quickCreate: true }); + if (!environment) { + this.skip(); + return; + } + 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 () => { + await api.managePackages(environment!, { install: ['requests'] }); + let packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages?.some((pkg) => pkg.name === 'requests'), 'Package not installed'); + + await api.managePackages(environment!, { uninstall: ['requests'] }); + packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(!packages?.some((pkg) => pkg.name === 'requests'), 'Package not uninstalled'); + }); + + suiteTeardown(async () => { + try { + await deleteEnvironmentDirectory(vscode.Uri.joinPath(workspaceUri, profile.environmentDirectory)); + } finally { + if (alwaysUseUvUpdated) { + await vscode.workspace + .getConfiguration('python-envs') + .update('alwaysUseUv', previousAlwaysUseUv, vscode.ConfigurationTarget.Global); + } + if (defaultEnvManagerUpdated) { + await vscode.workspace + .getConfiguration('python-envs', workspaceUri) + .update('defaultEnvManager', previousDefaultEnvManager, vscode.ConfigurationTarget.Workspace); + } + } + }); + }); +} From 98396c9cdd053bb6b8c7b32bd77451b7c69d0b37 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Sat, 8 Aug 2026 00:36:36 -0700 Subject: [PATCH 4/6] Revert "fix: address review feedback (PR #1704)" This reverts commit 596583f99a9f9163ab8d9becd05dde078158026e. --- .vscode-test.mjs | 13 ------------ .../packageManagement.integration.test.ts | 21 ++++--------------- .../test-workspace/project-a/.gitignore | 1 - 3 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 src/test/integration/test-workspace/project-a/.gitignore diff --git a/.vscode-test.mjs b/.vscode-test.mjs index c5f0cac66..c6953e567 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -18,19 +18,6 @@ fs.writeFileSync( JSON.stringify({ 'python.useEnvironmentsExtension': true, 'python-envs.alwaysUseUv': false }) + '\n', ); -// A terminated integration host cannot run fixture cleanup. Reset only the known -// test-owned project prefix and fixture settings before opening the workspace again. -const integrationWorkspace = path.resolve('src/test/integration/test-workspace/project-a'); -for (const entry of fs.readdirSync(integrationWorkspace, { withFileTypes: true })) { - if (entry.isDirectory() && /^\.pm-[A-Za-z0-9]{6}$/.test(entry.name)) { - fs.rmSync(path.join(integrationWorkspace, entry.name), { recursive: true, force: true }); - } -} -fs.writeFileSync( - path.join(integrationWorkspace, '.vscode', 'settings.json'), - JSON.stringify({ 'python-envs.defaultEnvManager': 'ms-python.python:system' }, undefined, 4) + os.EOL, -); - export default defineConfig([ { label: 'smokeTests', diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index bc18a413d..08ba44804 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import * as fs from 'fs/promises'; +import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { Package, PackageManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; @@ -40,7 +41,6 @@ suite('Integration: Package manager lifecycles', function () { this.timeout(900_000); let api: IntegrationTestApi; - let workspaceFolder: vscode.WorkspaceFolder; suiteSetup(async function () { this.timeout(120_000); @@ -61,7 +61,6 @@ suite('Integration: Package manager lifecycles', function () { const folders = vscode.workspace.workspaceFolders; assert.ok(folders && folders.length > 0, 'Bootstrap: integration tests require a workspace folder'); - workspaceFolder = folders[0]; await waitForCondition( () => EXPECTED_REGISTERED_MANAGER_IDS.every((id) => registeredManagers(api).has(id)), @@ -107,7 +106,7 @@ suite('Integration: Package manager lifecycles', function () { test(`${fixture.id} (${profile.name}) install/list/uninstall lifecycle`, async function () { try { - await runLifecycle(api, workspaceFolder, fixture, profile); + await runLifecycle(api, fixture, profile); } catch (error) { if (error instanceof PrerequisiteUnavailable) { this.skip(); @@ -126,14 +125,13 @@ function registeredManagers(api: IntegrationTestApi): Map { const manager = registeredManagers(api).get(fixture.id); assert.ok(manager, `Bootstrap (${profile.name}): live manager ${fixture.id} was not found`); - const projectRoot = await fs.mkdtemp(path.join(workspaceFolder.uri.fsPath, '.pm-')); + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'python-envs-pm-')); const projectUri = vscode.Uri.file(projectRoot); const project: PythonProject = { name: path.basename(projectRoot), uri: projectUri }; const config = vscode.workspace.getConfiguration('python-envs', projectUri); @@ -268,10 +266,7 @@ async function exerciseManager( const baseline = await manager.getPackages(environment, { skipCache: true }); assert.ok(Array.isArray(baseline), `Baseline list (${profile.name}): manager returned undefined`); const baselineNames = new Set(baseline.map((pkg) => normalizeName(pkg.name))); - assert.ok( - !baselineNames.has(normalizeName(fixture.packageName)), - `Baseline list (${profile.name}): ${fixture.packageName} was already installed in the disposable environment`, - ); + assert.ok(baselineNames instanceof Set, `Baseline list (${profile.name}): baseline was not recorded`); let installed = false; try { @@ -349,14 +344,6 @@ async function assertVersionCapability( assert.ok(version, `Manager version (${profileName}): required capability returned undefined`); return; } - if (expectation === 'unsupported') { - assert.strictEqual( - manager.getVersion, - undefined, - `Manager version (${profileName}): capability is declared unsupported but implemented`, - ); - return; - } assertCapabilityDeclaration(expectation, `Manager version (${profileName})`); } diff --git a/src/test/integration/test-workspace/project-a/.gitignore b/src/test/integration/test-workspace/project-a/.gitignore deleted file mode 100644 index 8bd99a19b..000000000 --- a/src/test/integration/test-workspace/project-a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.pm-* From 4d32f1dda6b7536b48d84b26faeffb74a8bf8b53 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Sat, 8 Aug 2026 00:36:37 -0700 Subject: [PATCH 5/6] Revert "test: cover registered package manager lifecycles (Fixes #1701)" This reverts commit 0fa9ad661e7d8fac3299a2e04bab110a4afc1d26. --- .vscode-test.mjs | 2 +- src/extension.ts | 11 - src/internal.api.ts | 5 - .../packageManagement.integration.test.ts | 813 ++++++++---------- .../integration/packageManagerFixtures.ts | 90 -- 5 files changed, 343 insertions(+), 578 deletions(-) delete mode 100644 src/test/integration/packageManagerFixtures.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index c6953e567..23d18eb94 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -15,7 +15,7 @@ const userDir = path.join(userDataDir, 'User'); fs.mkdirSync(userDir, { recursive: true }); fs.writeFileSync( path.join(userDir, 'settings.json'), - JSON.stringify({ 'python.useEnvironmentsExtension': true, 'python-envs.alwaysUseUv': false }) + '\n', + JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', ); export default defineConfig([ diff --git a/src/extension.ts b/src/extension.ts index 0b8049b0a..c4c2e2a9c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -732,17 +732,6 @@ export async function activate(context: ExtensionContext): Promise - envManagers.packageManagers.map((manager) => ({ - id: manager.id, - manager: manager.registeredManager, - })), - }); - } - return api; } diff --git a/src/internal.api.ts b/src/internal.api.ts index cae22cf01..04a198ac4 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -324,11 +324,6 @@ export class InternalPackageManager implements PackageManager { private readonly manager: PackageManager, ) {} - /** The live registered implementation. Only exposed to the integration-test bridge. */ - public get registeredManager(): PackageManager { - return this.manager; - } - public get name(): string { return this.manager.name; } diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 08ba44804..5998b6a17 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -1,525 +1,396 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +/** + * Integration Test: Package Management + * + * PURPOSE: + * Verify that package management works correctly for different + * environment types and managers. + * + * WHAT THIS TESTS: + * 1. getPackages returns packages for environments + * 2. Package installation via API + * 3. Package uninstallation via API + * 4. Refresh updates package list + * 5. Events fire when packages change + * + * NOTE: Some tests may install/uninstall actual packages. + * These should use safe test packages that don't have side effects. + */ + import * as assert from 'assert'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import * as path from 'path'; import * as vscode from 'vscode'; -import { Package, PackageManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; +import { DidChangePackagesEventArgs, PythonEnvironmentApi } from '../../api'; import { ENVS_EXTENSION_ID } from '../constants'; -import { waitForCondition } from '../testUtils'; -import { - ActivePackageManagerFixture, - CapabilityExpectation, - packageManagerFixtures, - PackageManagerProfile, -} from './packageManagerFixtures'; +import { sleep, TestEventHandler, waitForCondition } from '../testUtils'; -const EXPECTED_REGISTERED_MANAGER_IDS = packageManagerFixtures.map((fixture) => fixture.id); +suite('Integration: Package Management', function () { + this.timeout(120_000); // Package operations can be slow -type ActivePackageManagerProfile = Extract; + let api: PythonEnvironmentApi; -interface RegisteredPackageManager { - readonly id: string; - readonly manager: PackageManager; -} + suiteSetup(async function () { + this.timeout(30_000); -interface IntegrationTestApi extends PythonEnvironmentApi { - getRegisteredPackageManagersForTests(): readonly RegisteredPackageManager[]; -} + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); -interface SettingSnapshot { - readonly key: string; - readonly value: T | undefined; - readonly target: vscode.ConfigurationTarget; -} + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } -class PrerequisiteUnavailable extends Error {} + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + }); -suite('Integration: Package manager lifecycles', function () { - this.timeout(900_000); + /** + * Test: Package management APIs are available + * + * The API should have all package management methods. + */ + test('Package management APIs are available', async function () { + assert.ok(typeof api.getPackages === 'function', 'getPackages should be a function'); + assert.ok(typeof api.refreshPackages === 'function', 'refreshPackages should be a function'); + assert.ok(typeof api.managePackages === 'function', 'managePackages should be a function'); + assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); + }); - let api: IntegrationTestApi; + /** + * Test: getPackages returns array for environment + * + * For a valid environment, getPackages should return a list of packages. + */ + test('getPackages returns packages for environment', async function () { + const environments = await api.getEnvironments('all'); - suiteSetup(async function () { - this.timeout(120_000); + if (environments.length === 0) { + this.skip(); + return; + } - const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); - assert.ok(extension, `Bootstrap: extension ${ENVS_EXTENSION_ID} was not found`); - if (!extension.isActive) { - await extension.activate(); + // Try to find an environment that likely has packages (not system Python) + let targetEnv = environments[0]; + for (const env of environments) { + // Prefer environments that are likely virtual envs with packages + if (env.displayName.includes('venv') || env.displayName.includes('.venv')) { + targetEnv = env; + break; + } } - api = extension.exports as IntegrationTestApi; - assert.ok(api, 'Bootstrap: extension API was not exported'); - assert.strictEqual( - typeof api.getRegisteredPackageManagersForTests, - 'function', - 'Bootstrap: integration-test package-manager bridge was not exposed', - ); + const packages = await api.getPackages(targetEnv); - const folders = vscode.workspace.workspaceFolders; - assert.ok(folders && folders.length > 0, 'Bootstrap: integration tests require a workspace folder'); + // May be undefined if package manager not available + if (packages === undefined) { + this.skip(); + return; + } - await waitForCondition( - () => EXPECTED_REGISTERED_MANAGER_IDS.every((id) => registeredManagers(api).has(id)), - 90_000, - () => - `Bootstrap: package managers did not finish registering; found ${[ - ...registeredManagers(api).keys(), - ].join(', ')}`, - ); + assert.ok(Array.isArray(packages), 'getPackages should return array'); + console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); }); - test('fixture list exactly covers every live registered package manager', () => { - const registeredIds = [...registeredManagers(api).keys()].sort(); - const fixtureIds = packageManagerFixtures.map((fixture) => fixture.id).sort(); - - assert.strictEqual( - new Set(fixtureIds).size, - fixtureIds.length, - 'Registry completeness: fixture IDs must be unique', - ); - assert.deepStrictEqual( - fixtureIds, - registeredIds, - 'Registry completeness: every live manager needs one active fixture or explicit deferral, and every fixture must be registered', - ); - }); + /** + * Test: Packages have valid structure + * + * Each package should have required properties. + */ + test('Packages have valid structure', async function () { + const environments = await api.getEnvironments('all'); - for (const fixture of packageManagerFixtures) { - if (fixture.status === 'deferred') { - test(`${fixture.id} is explicitly deferred: ${fixture.reason}`, () => { - assert.ok(registeredManagers(api).has(fixture.id), `Deferred fixture: ${fixture.id} is not registered`); - }); - continue; + if (environments.length === 0) { + this.skip(); + return; } - for (const profile of fixture.profiles) { - if (profile.status === 'deferred') { - test(`${fixture.id} (${profile.name}) is explicitly deferred: ${profile.reason}`, () => { - assert.ok(profile.reason, `Deferred profile: ${profile.name} requires a reason`); - }); - continue; - } + const packages = await api.getPackages(environments[0]); - test(`${fixture.id} (${profile.name}) install/list/uninstall lifecycle`, async function () { - try { - await runLifecycle(api, fixture, profile); - } catch (error) { - if (error instanceof PrerequisiteUnavailable) { - this.skip(); - return; - } - throw error; - } - }); + if (!packages || packages.length === 0) { + this.skip(); + return; } - } -}); -function registeredManagers(api: IntegrationTestApi): Map { - return new Map(api.getRegisteredPackageManagersForTests().map(({ id, manager }) => [id, manager])); -} - -async function runLifecycle( - api: IntegrationTestApi, - fixture: ActivePackageManagerFixture, - profile: ActivePackageManagerProfile, -): Promise { - const manager = registeredManagers(api).get(fixture.id); - assert.ok(manager, `Bootstrap (${profile.name}): live manager ${fixture.id} was not found`); - - const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'python-envs-pm-')); - const projectUri = vscode.Uri.file(projectRoot); - const project: PythonProject = { name: path.basename(projectRoot), uri: projectUri }; - const config = vscode.workspace.getConfiguration('python-envs', projectUri); - const settings: SettingSnapshot[] = []; - let environment: PythonEnvironment | undefined; - let projectAdded = false; - - let scenarioError: unknown; - try { - await setWorkspaceSetting(config, settings, 'defaultEnvManager', fixture.environmentManagerId); - await setWorkspaceSetting(config, settings, 'defaultPackageManager', fixture.id); - settings.push(snapshotWorkspaceSetting(config, 'pythonProjects')); - - await api.addPythonProject(project); - projectAdded = true; - - const globalPythons = await api.getEnvironments('global'); - if (!globalPythons.some((candidate) => candidate.version.startsWith('3.'))) { - throw new PrerequisiteUnavailable(`Bootstrap (${profile.name}): no global Python 3 installation is available`); + for (const pkg of packages) { + assert.ok(pkg.pkgId, 'Package must have pkgId'); + assert.ok(pkg.pkgId.id, 'pkgId must have id'); + assert.ok(pkg.pkgId.managerId, 'pkgId must have managerId'); + assert.ok(pkg.pkgId.environmentId, 'pkgId must have environmentId'); + assert.ok(typeof pkg.name === 'string', 'Package must have name'); + assert.ok(pkg.name.length > 0, 'Package name should not be empty'); + assert.ok(typeof pkg.displayName === 'string', 'Package must have displayName'); } + }); - environment = await api.createEnvironment(projectUri, { quickCreate: true }); - if (!environment && fixture.environmentManagerId === 'ms-python.python:venv') { - // Main can finish creating the Venv on disk before returning its item; recover it through public discovery. - await api.refreshEnvironments(projectUri); - environment = await findEnvironmentInside(projectRoot, await api.getEnvironments(projectUri)); + /** + * Test: refreshPackages updates package list + * + * After refreshing, the package list should be consistent. + * Multiple calls should return the same packages (idempotent). + */ + test('refreshPackages updates list', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; } - if (!environment && fixture.environmentManagerId === 'ms-python.python:conda') { - const condaVersion = await manager.getVersion?.(globalPythons[0]); - if (!condaVersion) { - throw new PrerequisiteUnavailable(`Bootstrap (${profile.name}): Conda is not available`); - } + + const env = environments[0]; + + // Get initial packages + const initial = await api.getPackages(env); + + if (initial === undefined) { + this.skip(); + return; } - assert.ok( - environment, - `Bootstrap (${profile.name}): ${fixture.environmentManagerId} did not create a disposable environment`, - ); + + const initialCount = initial.length; + + // Refresh + await api.refreshPackages(env); + + // Get updated packages + const after = await api.getPackages(env); + + assert.ok(Array.isArray(after), 'Should return array after refresh'); + + // Package counts should be identical (no external changes during test) assert.strictEqual( - environment.envId.managerId, - fixture.environmentManagerId, - `Bootstrap (${profile.name}): environment was created by the wrong manager`, + after.length, + initialCount, + `Package count should be stable after refresh: expected ${initialCount}, got ${after.length}`, ); - await assertOwnedEnvironment(projectRoot, environment, profile.name); - if (/(?:alpha|beta|rc|dev)|\d[ab]\d/i.test(environment.version)) { - throw new PrerequisiteUnavailable( - `Bootstrap (${profile.name}): quick create selected pre-release Python ${environment.version}`, - ); - } + }); - if (profile.alwaysUseUv !== undefined) { - assert.strictEqual( - config.get('alwaysUseUv'), - profile.alwaysUseUv, - `Bootstrap (${profile.name}): test runner did not configure the expected Pip execution path`, - ); - } + /** + * Test: getPackages returns non-empty array for environments with packages + * + * For virtual environments, at minimum pip should typically be present. + */ + test('getPackages returns packages for virtual environment', async function () { + const environments = await api.getEnvironments('all'); - await exerciseManager(manager, fixture, profile, environment); - } catch (error) { - scenarioError = error; - } finally { - const cleanupErrors: unknown[] = []; - if (environment) { - try { - await cleanupEnvironment(api, fixture, profile, projectRoot, environment); - } catch (error) { - cleanupErrors.push(error); - } + if (environments.length === 0) { + this.skip(); + return; } - if (projectAdded) { - try { - await api.setEnvironment(projectUri, undefined); - } catch (error) { - cleanupErrors.push(error); - } - try { - api.removePythonProject(project); - } catch (error) { - cleanupErrors.push(error); - } + + // Find a virtual environment (more likely to have pip) + const targetEnv = environments.find( + (env) => + env.displayName.includes('venv') || + env.displayName.includes('.venv') || + env.envId.managerId.includes('venv'), + ); + + if (!targetEnv) { + console.log('No virtual environment found, skipping'); + this.skip(); + return; } - for (const setting of settings.reverse()) { - try { - await config.update(setting.key, setting.value, setting.target); - } catch (error) { - cleanupErrors.push(error); - } + + const packages = await api.getPackages(targetEnv); + + if (packages === undefined) { + console.log('Package manager not available for:', targetEnv.displayName); + this.skip(); + return; } - try { - await removeDirectoryWithRetries(projectRoot); - } catch (error) { - cleanupErrors.push(error); + + // Virtual environments should have at least pip installed + const pipInstalled = packages.some((p) => p.name.toLowerCase() === 'pip'); + assert.ok(pipInstalled, `Virtual environment ${targetEnv.displayName} should have pip installed`); + + console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); + }); + + /** + * Test: Different environments can have different packages + * + * Package lists should be environment-specific. + */ + test('Package lists are environment-specific', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length < 2) { + this.skip(); + return; } - if (scenarioError) { - if (scenarioError instanceof Error && cleanupErrors.length > 0) { - scenarioError.message += `; cleanup also failed: ${cleanupErrors.map(String).join('; ')}`; - } - throw scenarioError; + const env1 = environments[0]; + const env2 = environments[1]; + + const packages1 = await api.getPackages(env1); + const packages2 = await api.getPackages(env2); + + // Both should return valid results (or undefined for same reason) + if (packages1 === undefined || packages2 === undefined) { + this.skip(); + return; } - if (cleanupErrors.length > 0) { - throw new Error( - `Cleanup (${profile.name}): one or more cleanup operations failed: ${cleanupErrors.map(String).join('; ')}`, - ); + + assert.ok(Array.isArray(packages1), 'Env1 packages should be array'); + assert.ok(Array.isArray(packages2), 'Env2 packages should be array'); + + console.log(`Env1 (${env1.displayName}): ${packages1.length} packages`); + console.log(`Env2 (${env2.displayName}): ${packages2.length} packages`); + }); + + /** + * Test: Package install and uninstall flow + * + * This test installs and uninstalls a small test package. + * Uses 'cowsay' as it's small and has no dependencies. + */ + test('Package install and uninstall works', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; } - } -} - -async function exerciseManager( - manager: PackageManager, - fixture: ActivePackageManagerFixture, - profile: ActivePackageManagerProfile, - environment: PythonEnvironment, -): Promise { - await assertVersionCapability(manager, fixture.capabilities.version, environment, profile.name); - let installSpec = fixture.packageName; - let pinnedVersion: string | undefined; - - if (profile.availableVersions === 'required') { - const getVersions = manager.getPackageAvailableVersions; - assert.ok(getVersions, `Available versions (${profile.name}): capability is declared required but missing`); - const versions = await getVersions.call(manager, environment, fixture.packageName); - assert.ok(versions && versions.length > 0, `Available versions (${profile.name}): no versions were returned`); - pinnedVersion = versions[0].public; - installSpec = formatInstallSpec(manager, fixture, pinnedVersion, profile.name); - } else { - assertCapabilityDeclaration(profile.availableVersions, `Available versions (${profile.name})`); - } - - assertFormatCapability(manager, fixture, profile.name); - - const baseline = await manager.getPackages(environment, { skipCache: true }); - assert.ok(Array.isArray(baseline), `Baseline list (${profile.name}): manager returned undefined`); - const baselineNames = new Set(baseline.map((pkg) => normalizeName(pkg.name))); - assert.ok(baselineNames instanceof Set, `Baseline list (${profile.name}): baseline was not recorded`); - - let installed = false; - try { - await manager.manage(environment, { install: [installSpec] }); - installed = true; - - const refreshed = await manager.refresh(environment); - assert.ok(Array.isArray(refreshed), `Post-install refresh (${profile.name}): manager returned undefined`); - const afterInstall = await manager.getPackages(environment, { skipCache: true }); - assert.ok(Array.isArray(afterInstall), `Post-install list (${profile.name}): manager returned undefined`); - const installedPackage = findPackage(afterInstall, fixture.packageName); - assert.ok( - installedPackage, - `Post-install list (${profile.name}): ${installSpec} was not installed; found ${afterInstall - .map((pkg) => `${pkg.name}==${pkg.version ?? 'unknown'}`) - .join(', ')}`, - ); - assert.strictEqual( - installedPackage.pkgId.managerId, - fixture.id, - `Post-install list (${profile.name}): package was attributed to the wrong manager`, + + // Find a virtual environment we can safely modify + const targetEnv = environments.find( + (env) => + (env.displayName.includes('venv') || env.displayName.includes('.venv')) && + env.envId.managerId.includes('venv'), ); - if (pinnedVersion) { - assert.strictEqual( - installedPackage.version, - pinnedVersion, - `Post-install list (${profile.name}): installed version does not match the selected version`, - ); + + if (!targetEnv) { + console.log('No modifiable virtual environment found'); + this.skip(); + return; } - await assertDirectPackage(manager, fixture, profile.name, environment, true); + const testPackage = 'cowsay'; - await manager.manage(environment, { uninstall: [fixture.packageName] }); - installed = false; + // Check if already installed + const initialPackages = await api.getPackages(targetEnv); + if (!initialPackages) { + console.log('Package manager not available for this environment'); + this.skip(); + return; + } - const afterUninstallRefresh = await manager.refresh(environment); - assert.ok( - Array.isArray(afterUninstallRefresh), - `Post-uninstall refresh (${profile.name}): manager returned undefined`, - ); - const afterUninstall = await manager.getPackages(environment, { skipCache: true }); - assert.ok(Array.isArray(afterUninstall), `Post-uninstall list (${profile.name}): manager returned undefined`); - assert.ok( - !findPackage(afterUninstall, fixture.packageName), - `Post-uninstall list (${profile.name}): ${fixture.packageName} is still installed`, - ); - await assertDirectPackage(manager, fixture, profile.name, environment, false); - } catch (error) { - let cleanupError: unknown; - if (installed) { - try { - await manager.manage(environment, { uninstall: [fixture.packageName] }); - await manager.getPackages(environment, { skipCache: true }); - } catch (caught) { - cleanupError = caught; + const wasInstalled = initialPackages.some((p) => p.name.toLowerCase() === testPackage); + let packageInstalled = wasInstalled; + + try { + if (wasInstalled) { + // Uninstall first + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + packageInstalled = false; + await sleep(2000); + } + + // Install package + await api.managePackages(targetEnv, { install: [testPackage] }); + packageInstalled = true; + + // Refresh and verify + await api.refreshPackages(targetEnv); + const afterInstall = await api.getPackages(targetEnv); + + const isNowInstalled = afterInstall?.some((p) => p.name.toLowerCase() === testPackage); + assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); + + // Uninstall + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + packageInstalled = false; + + // Refresh and verify + await api.refreshPackages(targetEnv); + const afterUninstall = await api.getPackages(targetEnv); + + const isStillInstalled = afterUninstall?.some((p) => p.name.toLowerCase() === testPackage); + assert.ok(!isStillInstalled, `${testPackage} should be uninstalled after managePackages uninstall`); + } finally { + // Ensure cleanup even if assertions fail + if (packageInstalled) { + try { + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + } catch { + console.log('Cleanup: failed to uninstall test package'); + } } } - if (error instanceof Error && cleanupError) { - error.message += `; package cleanup also failed: ${String(cleanupError)}`; - } - throw error; - } -} - -async function assertVersionCapability( - manager: PackageManager, - expectation: CapabilityExpectation, - environment: PythonEnvironment, - profileName: string, -): Promise { - if (expectation === 'required') { - const getVersion = manager.getVersion; - assert.ok(getVersion, `Manager version (${profileName}): capability is declared required but missing`); - const version = await getVersion.call(manager, environment); - assert.ok(version, `Manager version (${profileName}): required capability returned undefined`); - return; - } - assertCapabilityDeclaration(expectation, `Manager version (${profileName})`); -} - -function assertFormatCapability( - manager: PackageManager, - fixture: ActivePackageManagerFixture, - profileName: string, -): void { - const expectation = fixture.capabilities.formatInstallSpec; - if (expectation === 'required') { - assert.ok( - manager.formatInstallSpec, - `Install spec (${profileName}): capability is declared required but missing`, - ); - assert.strictEqual( - manager.formatInstallSpec(fixture.packageName, '1.2.3'), - `${fixture.packageName}=1.2.3`, - `Install spec (${profileName}): manager returned the wrong syntax`, - ); - return; - } - if (expectation === 'unsupported') { - assert.strictEqual( - manager.formatInstallSpec, - undefined, - `Install spec (${profileName}): fixture says unsupported but the manager now implements it`, - ); - return; - } - assertCapabilityDeclaration(expectation, `Install spec (${profileName})`); -} - -function formatInstallSpec( - manager: PackageManager, - fixture: ActivePackageManagerFixture, - version: string, - profileName: string, -): string { - if (fixture.capabilities.formatInstallSpec === 'required') { - assert.ok(manager.formatInstallSpec, `Install spec (${profileName}): required formatter is missing`); - return manager.formatInstallSpec(fixture.packageName, version); - } - return `${fixture.packageName}==${version}`; -} - -async function assertDirectPackage( - manager: PackageManager, - fixture: ActivePackageManagerFixture, - profileName: string, - environment: PythonEnvironment, - expectedPresent: boolean, -): Promise { - const expectation = fixture.capabilities.directPackageNames; - if (expectation === 'required') { - const getDirectNames = manager.getDirectPackageNames; - assert.ok(getDirectNames, `Direct packages (${profileName}): capability is declared required but missing`); - const names = await getDirectNames.call(manager, environment); - assert.ok(names, `Direct packages (${profileName}): required capability returned undefined`); - assert.strictEqual( - [...names].map(normalizeName).includes(normalizeName(fixture.packageName)), - expectedPresent, - `Direct packages (${profileName}): ${fixture.packageName} presence was incorrect after ${ - expectedPresent ? 'install' : 'uninstall' - }`, - ); - return; - } - if (expectation === 'unsupported') { - assert.strictEqual( - manager.getDirectPackageNames, - undefined, - `Direct packages (${profileName}): fixture says unsupported but the manager now implements it`, + }); + + /** + * Test: onDidChangePackages event fires + * + * When packages change, the event should fire. + */ + test('onDidChangePackages event is available', async function () { + assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); + + // Verify it's subscribable + const handler = new TestEventHandler( + api.onDidChangePackages, + 'onDidChangePackages', ); - return; - } - assertCapabilityDeclaration(expectation, `Direct packages (${profileName})`); -} - -function assertCapabilityDeclaration(expectation: CapabilityExpectation, phase: string): void { - assert.notStrictEqual(expectation, 'required', `${phase}: required capability was not exercised`); - if (typeof expectation === 'object') { - assert.ok(expectation.deferred.length > 0, `${phase}: deferred capability requires a reason`); - } -} - -function findPackage(packages: readonly Package[], name: string): Package | undefined { - const normalized = normalizeName(name); - return packages.find((pkg) => normalizeName(pkg.name) === normalized); -} - -async function findEnvironmentInside( - projectRoot: string, - environments: PythonEnvironment[], -): Promise { - const resolvedProjectRoot = await fs.realpath(projectRoot); - for (const environment of environments) { - const resolvedPrefix = await fs.realpath(environment.sysPrefix); - const relative = path.relative(resolvedProjectRoot, resolvedPrefix); - if (relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { - return environment; + + // Just verify we can subscribe without error + handler.dispose(); + }); + + /** + * Test: createPackageItem creates valid package + * + * The createPackageItem API should create properly structured packages. + */ + test('createPackageItem creates valid structure', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; } - } - return undefined; -} - -function normalizeName(name: string): string { - return name.toLowerCase().replace(/[-_.]+/g, '-'); -} - -function snapshotWorkspaceSetting( - config: vscode.WorkspaceConfiguration, - key: string, -): SettingSnapshot { - const inspection = config.inspect(key); - assert.ok(inspection, `Settings setup: ${key} is not registered`); - return { key, value: inspection.workspaceValue, target: vscode.ConfigurationTarget.Workspace }; -} - -async function setWorkspaceSetting( - config: vscode.WorkspaceConfiguration, - settings: SettingSnapshot[], - key: string, - value: T, -): Promise { - const snapshot = snapshotWorkspaceSetting(config, key); - settings.push(snapshot); - await config.update(key, value, snapshot.target); - assert.deepStrictEqual(config.inspect(key)?.workspaceValue, value, `Settings setup: ${key} was not applied`); -} - -async function assertOwnedEnvironment( - projectRoot: string, - environment: PythonEnvironment, - profileName: string, -): Promise { - const resolvedProjectRoot = await fs.realpath(projectRoot); - const resolvedEnvironmentRoot = await fs.realpath(environment.sysPrefix); - const relative = path.relative(resolvedProjectRoot, resolvedEnvironmentRoot); - assert.ok( - relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), - `Bootstrap (${profileName}): environment root is not inside the disposable project`, - ); -} - -async function removeDirectoryWithRetries(directory: string): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(directory, { recursive: true, force: true }); + + // This test verifies the API exists and is callable + // Full testing requires a registered package manager + assert.ok(typeof api.createPackageItem === 'function', 'createPackageItem should be a function'); + }); + + /** + * Test: getPackages returns array or undefined, never throws + * + * For any environment, getPackages should return either a valid + * array of packages or undefined (if no package manager), never throw. + */ + test('getPackages returns array or undefined for all environments', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); return; - } catch (error) { - lastError = error; - await new Promise((resolve) => setTimeout(resolve, 500)); } - } - throw lastError; -} - -async function cleanupEnvironment( - api: IntegrationTestApi, - fixture: ActivePackageManagerFixture, - profile: ActivePackageManagerProfile, - projectRoot: string, - environment: PythonEnvironment, -): Promise { - await assertOwnedEnvironment(projectRoot, environment, profile.name); - if (fixture.environmentManagerId === 'ms-python.python:conda') { - await api.removeEnvironment(environment); - return; - } - - const environmentRoot = environment.sysPrefix; - assert.ok( - path.basename(environmentRoot).startsWith('.venv'), - `Cleanup (${profile.name}): refusing to delete unexpected Venv root ${environmentRoot}`, - ); - await removeDirectoryWithRetries(environmentRoot); - await api.refreshEnvironments(projectRoot ? vscode.Uri.file(projectRoot) : undefined); -} + + let arrayCount = 0; + let undefinedCount = 0; + + // Verify each environment returns valid result + for (const env of environments) { + const packages = await api.getPackages(env); + if (packages !== undefined) { + assert.ok(Array.isArray(packages), `getPackages should return array for ${env.displayName}`); + arrayCount++; + } else { + undefinedCount++; + } + } + + // Log results for visibility + console.log(`getPackages results: ${arrayCount} returned arrays, ${undefinedCount} returned undefined`); + + // At least some should return arrays (unless all envs lack package managers) + assert.ok( + arrayCount > 0 || undefinedCount === environments.length, + 'At least one environment should have a package manager, or all should return undefined consistently', + ); + }); +}); diff --git a/src/test/integration/packageManagerFixtures.ts b/src/test/integration/packageManagerFixtures.ts deleted file mode 100644 index f5321f8fb..000000000 --- a/src/test/integration/packageManagerFixtures.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -export type CapabilityExpectation = 'required' | 'unsupported' | { deferred: string }; - -export type PackageManagerProfile = - | { - readonly status: 'active'; - readonly name: string; - readonly alwaysUseUv?: boolean; - readonly availableVersions: CapabilityExpectation; - } - | { - readonly status: 'deferred'; - readonly name: string; - readonly reason: string; - }; - -export interface ActivePackageManagerFixture { - readonly status: 'active'; - readonly id: string; - readonly environmentManagerId: string; - readonly packageName: string; - readonly capabilities: { - readonly version: CapabilityExpectation; - readonly directPackageNames: CapabilityExpectation; - readonly formatInstallSpec: CapabilityExpectation; - }; - readonly profiles: readonly PackageManagerProfile[]; -} - -export interface DeferredPackageManagerFixture { - readonly status: 'deferred'; - readonly id: string; - readonly reason: string; -} - -export type PackageManagerFixture = ActivePackageManagerFixture | DeferredPackageManagerFixture; - -export const packageManagerFixtures: readonly PackageManagerFixture[] = [ - { - status: 'active', - id: 'ms-python.python:pip', - environmentManagerId: 'ms-python.python:venv', - packageName: 'flask', - capabilities: { - version: 'required', - directPackageNames: 'required', - formatInstallSpec: 'unsupported', - }, - profiles: [ - { - status: 'active', - name: 'pip', - alwaysUseUv: false, - availableVersions: 'required', - }, - { - status: 'deferred', - name: 'uv-backed Pip', - reason: - 'A reliable profile would require changing the machine-scoped alwaysUseUv setting during one extension-host run, and available-version lookup uses `uv tool run pip`, which adds network tool seeding. The normal pip path is pinned in the test runner instead.', - }, - ], - }, - { - status: 'active', - id: 'ms-python.python:conda', - environmentManagerId: 'ms-python.python:conda', - packageName: 'flask', - capabilities: { - version: 'required', - directPackageNames: 'unsupported', - formatInstallSpec: 'required', - }, - profiles: [ - { - status: 'active', - name: 'conda', - availableVersions: 'required', - }, - ], - }, - { - status: 'deferred', - id: 'ms-python.python:poetry', - reason: - 'Poetry package operations require a Poetry-owned project and lockfile lifecycle; that project bootstrap is deferred to dedicated coverage.', - }, -]; From bf42d8ae0b2520bcf25084da74dc1a09ae1c3a47 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Sat, 8 Aug 2026 00:47:31 -0700 Subject: [PATCH 6/6] feat: expose registered package manager --- api/CHANGELOG.md | 1 + src/api.ts | 8 ++++++++ src/features/pythonApi.ts | 4 ++++ .../integration/packageManager.integration.test.ts | 10 ++++++++++ 4 files changed, 23 insertions(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index aedaa39ac..01b3e8872 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -8,3 +8,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.37.0] - Aligned the API package version with the Python Environments extension version. +- Added `getPackageManager` to retrieve the registered package manager for an environment. diff --git a/src/api.ts b/src/api.ts index 17e186694..a4cc7a00a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1091,6 +1091,14 @@ export interface PythonPackageManagerRegistrationApi { } export interface PythonPackageGetterApi { + /** + * Get the registered package manager associated with a Python Environment. + * + * @param environment The Python Environment whose package manager is required. + * @returns The registered package manager, or undefined if no package manager is available. + */ + getPackageManager(environment: PythonEnvironment): Promise; + /** * Refresh the list of packages in a Python Environment. * diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 7156cc5fa..39f1ca080 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -303,6 +303,10 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } return manager.manage(context, options); } + async getPackageManager(context: PythonEnvironment): Promise { + await waitForEnvManagerId([context.envId.managerId]); + return this.envManagers.getPackageManager(context); + } async refreshPackages(context: PythonEnvironment): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index af5b4b3a3..966196b2e 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -94,6 +94,16 @@ for (const profile of profiles) { assert.ok(!packages?.some((pkg) => pkg.name === 'requests'), 'Package not uninstalled'); }); + test(`${profile.name} Package Manager should list available package versions`, async () => { + const packageManager = await api.getPackageManager(environment!); + assert.ok(packageManager, 'Package manager not available'); + assert.ok(packageManager.getPackageAvailableVersions, 'Available versions method not available'); + + const versions = await packageManager.getPackageAvailableVersions(environment!, 'requests'); + assert.ok(versions, 'Package versions not available'); + assert.ok(versions.length > 0, 'No package versions available'); + }); + suiteTeardown(async () => { try { await deleteEnvironmentDirectory(vscode.Uri.joinPath(workspaceUri, profile.environmentDirectory));