From f763ea4657de13c6f87a059c0140fb07f8fb7f1b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 17 Aug 2026 16:38:06 -0700 Subject: [PATCH 01/20] test: enforce headless package conformance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3fd1a810-6840-4ac9-ac33-c8a9fda4bfc4 --- src/managers/builtin/pipPackageManager.ts | 8 +- ...ageManagerHeadlessConformance.unit.test.ts | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 836244bb7..137cc5dbd 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -9,7 +9,6 @@ import { MarkdownString, ProgressLocation, ThemeIcon, - window, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -21,6 +20,7 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { showErrorMessage, withProgress } from '../../common/window.apis'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { runPython, runUV, shouldUseUv } from './helpers'; import { getWorkspacePackagesToInstall } from './pipUtils'; @@ -74,7 +74,7 @@ export class PipPackageManager implements PackageManager, Disposable { install: toInstall, uninstall: toUninstall, }; - await window.withProgress( + await withProgress( { location: ProgressLocation.Notification, title: 'Installing packages', @@ -99,7 +99,7 @@ export class PipPackageManager implements PackageManager, Disposable { this.log.error('Error managing packages', e); if (!manageOptions.runHeadless) { setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); + const result = await showErrorMessage('Error managing packages', 'View Output'); if (result === 'View Output') { this.log.show(); } @@ -112,7 +112,7 @@ export class PipPackageManager implements PackageManager, Disposable { } async refresh(environment: PythonEnvironment): Promise { - await window.withProgress( + await withProgress( { location: ProgressLocation.Window, title: 'Refreshing packages', diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts new file mode 100644 index 000000000..50ece740d --- /dev/null +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PackageManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as windowApis from '../../../common/window.apis'; +import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; +import * as pipUtils from '../../../managers/builtin/pipUtils'; +import * as builtinUtils from '../../../managers/builtin/utils'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { PoetryManager } from '../../../managers/poetry/poetryManager'; +import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; + +suite('Package manager headless conformance', () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + + teardown(() => { + sinon.restore(); + }); + + function createManagers(): PackageManager[] { + const api = {} as PythonEnvironmentApi; + const log = { + error: sinon.stub(), + info: sinon.stub(), + show: sinon.stub(), + } as unknown as LogOutputChannel; + return [ + new PipPackageManager(api, log, { getProjectsByEnvironment: sinon.stub().returns([]) } as unknown as VenvManager), + new CondaPackageManager(api, log), + new PoetryPackageManager(api, log, {} as PoetryManager), + ]; + } + + test('does not invoke interactive package input when no packages are provided', async () => { + const pipPicker = sinon.stub(pipUtils, 'getWorkspacePackagesToInstall'); + const condaPicker = sinon.stub(condaUtils, 'getCommonCondaPackagesToInstall'); + const poetryInput = sinon.stub(windowApis, 'showInputBox'); + + for (const manager of createManagers()) { + await manager.manage(environment, { install: [], runHeadless: true }); + } + + assert.ok(pipPicker.notCalled); + assert.ok(condaPicker.notCalled); + assert.ok(poetryInput.notCalled); + }); + + test('rejects failures without showing error notifications', async () => { + const operationError = new Error('package operation failed'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(builtinUtils, 'managePackages').rejects(operationError); + sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(poetryUtils, 'getPoetry').resolves(undefined); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + for (const manager of createManagers()) { + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + ); + } + + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); +}); From cb05041fd81dff30f34d9710c98660586f8e469e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 17 Aug 2026 17:03:31 -0700 Subject: [PATCH 02/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../common/packageManagerHeadlessConformance.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index 50ece740d..30b0a3175 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -20,7 +20,7 @@ import * as poetryUtils from '../../../managers/poetry/poetryUtils'; suite('Package manager headless conformance', () => { const environment = { envId: { id: 'test-environment', managerId: 'test-manager' }, - environmentPath: Uri.file('/path/to/environment'), + environmentPath: Uri.joinPath(Uri.file(__dirname), 'path', 'to', 'environment'), } as PythonEnvironment; teardown(() => { From df6d1a28029f6aa79d3e8fc771074d9c6747ce96 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 16:23:29 -0700 Subject: [PATCH 03/20] test: disable unstable package network CI (#1717) Copilot-Session: 3fd1a810-6840-4ac9-ac33-c8a9fda4bfc4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-check.yml | 8 -------- .github/workflows/push-check.yml | 8 -------- 2 files changed, 16 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9297f7df6..1298ffa0b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -335,14 +335,6 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test - - name: Run Package Manager Network Integration Tests - if: runner.os == 'Linux' && matrix.python-version == '3.12' - uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 - env: - VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' - with: - run: npm run integration-test -- --grep "Package Manager" - integration-tests-multiroot: name: Integration Tests (Multi-Root) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 23db9b117..96867be26 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -335,11 +335,3 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test - - - name: Run Package Manager Network Integration Tests - if: runner.os == 'Linux' && matrix.python-version == '3.12' - uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 - env: - VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' - with: - run: npm run integration-test -- --grep "Package Manager" From efe2f9c9c20c39679b1da2efa5f16e32ef60adfc Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 17:20:05 -0700 Subject: [PATCH 04/20] Enforce headless package manager behavior Bypass progress UI for headless Pip, Conda, and Poetry operations while preserving refresh and error propagation. Cover post-operation refresh failures and keep live network lifecycle tests available through a manual workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../package-manager-network-check.yml | 82 +++++++++++++++++++ src/managers/builtin/pipPackageManager.ts | 64 ++++++++------- src/managers/conda/condaPackageManager.ts | 58 +++++++------ src/managers/conda/condaUtils.ts | 2 +- src/managers/poetry/poetryPackageManager.ts | 61 ++++++++------ ...ageManagerHeadlessConformance.unit.test.ts | 36 +++++++- 6 files changed, 221 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/package-manager-network-check.yml diff --git a/.github/workflows/package-manager-network-check.yml b/.github/workflows/package-manager-network-check.yml new file mode 100644 index 000000000..5e94bba63 --- /dev/null +++ b/.github/workflows/package-manager-network-check.yml @@ -0,0 +1,82 @@ +name: Package Manager Network Check + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + NODE_VERSION: '22.21.1' + +jobs: + package-manager-network-tests: + name: Package Manager Network Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Checkout Python Environment Tools + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: 'microsoft/python-environment-tools' + path: 'python-env-tools-src' + sparse-checkout: | + crates + Cargo.toml + Cargo.lock + sparse-checkout-cone-mode: false + + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + python-env-tools-src/target + key: ${{ runner.os }}-cargo-pet-${{ hashFiles('python-env-tools-src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-pet- + + - name: Build Python Environment Tools + run: cargo build --release --package pet + working-directory: python-env-tools-src + + - name: Copy pet binary + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet python-env-tools/bin/ + chmod +x python-env-tools/bin/pet + + - name: Install Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install Dependencies + run: npm ci + + - name: Compile Extension + run: npm run compile + + - name: Compile Tests + run: npm run compile-tests + + - name: Run Package Manager Network Integration Tests + uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' + with: + run: npm run integration-test -- --grep "Package Manager" diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 137cc5dbd..c8bedc24c 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -2,6 +2,7 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { compare, explain as parse, rcompare } from '@renovatebot/pep440'; import { CancellationError, + CancellationToken, Disposable, Event, EventEmitter, @@ -74,40 +75,47 @@ export class PipPackageManager implements PackageManager, Disposable { install: toInstall, uninstall: toUninstall, }; + const execute = async (token?: CancellationToken): Promise => { + try { + await managePackages(environment, manageOptions, this, token); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + () => this.fetchPackages(environment, !manageOptions.runHeadless), + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + this.log.error('Error managing packages', e); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } + throw e; + } + }; + + if (manageOptions.runHeadless) { + await execute(); + return; + } + await withProgress( { location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true, }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - () => this.fetchPackages(environment, !manageOptions.runHeadless), - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages', e); - if (!manageOptions.runHeadless) { - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d395d0ce6..8f33c09a6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -3,6 +3,7 @@ import { explain as parse, rcompare } from '@renovatebot/pep440'; import * as path from 'path'; import { CancellationError, + CancellationToken, Disposable, Event, EventEmitter, @@ -72,37 +73,44 @@ export class CondaPackageManager implements PackageManager, Disposable { install: toInstall, uninstall: toUninstall, }; + const execute = async (token?: CancellationToken): Promise => { + try { + await managePackages(environment, manageOptions, token, this.log); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + + this.log.error('Error installing packages', e); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } + throw e; + } + }; + + if (manageOptions.runHeadless) { + await execute(); + return; + } + await withProgress( { location: ProgressLocation.Notification, title: CondaStrings.condaInstallingPackages, cancellable: true, }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - - this.log.error('Error installing packages', e); - if (!manageOptions.runHeadless) { - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 06d3ec54f..42b3eca06 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1253,7 +1253,7 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, - token: CancellationToken, + token: CancellationToken | undefined, log: LogOutputChannel, ): Promise { if (options.uninstall && options.uninstall.length > 0) { diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index e946f0452..09696885b 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -81,39 +81,46 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } + const execute = async (token?: CancellationToken): Promise => { + try { + await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + this.log.error('Error managing packages with Poetry', e); + if (!options.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } + throw e; + } + }; + + if (options.runHeadless) { + await execute(); + return; + } + await withProgress( { location: ProgressLocation.Notification, title: 'Managing packages with Poetry', cancellable: true, }, - async (_progress, token) => { - try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages with Poetry', e); - if (!options.runHeadless) { - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - } - throw e; - } - }, + async (_progress, token) => execute(token), ); } diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index 30b0a3175..fdcb178a4 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -57,7 +57,7 @@ suite('Package manager headless conformance', () => { test('rejects failures without showing error notifications', async () => { const operationError = new Error('package operation failed'); - sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + const withProgress = sinon.stub(windowApis, 'withProgress'); sinon.stub(builtinUtils, 'managePackages').rejects(operationError); sinon.stub(condaUtils, 'managePackages').rejects(operationError); sinon.stub(poetryUtils, 'getPoetry').resolves(undefined); @@ -70,6 +70,40 @@ suite('Package manager headless conformance', () => { ); } + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('rejects refresh failures without showing progress or error notifications', async () => { + const refreshError = new Error('package refresh failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(builtinUtils, 'managePackages').resolves(); + sinon.stub(condaUtils, 'managePackages').resolves(); + sinon + .stub( + PoetryPackageManager.prototype as unknown as { + runPoetryManage: () => Promise; + }, + 'runPoetryManage', + ) + .resolves(); + sinon.stub(builtinUtils, 'refreshPipPackages').rejects(refreshError); + sinon.stub(CondaPackageManager.prototype, 'getPackages').rejects(refreshError); + sinon.stub(PoetryPackageManager.prototype, 'getPackages').rejects(refreshError); + sinon.stub(PipPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + sinon.stub(PoetryPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + for (const manager of createManagers()) { + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === refreshError, + ); + } + + assert.ok(withProgress.notCalled); assert.ok(showErrorMessage.notCalled); assert.ok(showErrorMessageWithLogs.notCalled); }); From cd739a59f38bda05afef74c1becf48e0feebaae9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 18:38:00 -0700 Subject: [PATCH 05/20] Run package network tests in integration matrix Enable the guarded package-manager network suites in the existing integration step for every OS and Python version, and remove the separate manual workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../package-manager-network-check.yml | 82 ------------------- .github/workflows/pr-check.yml | 2 + .github/workflows/push-check.yml | 2 + 3 files changed, 4 insertions(+), 82 deletions(-) delete mode 100644 .github/workflows/package-manager-network-check.yml diff --git a/.github/workflows/package-manager-network-check.yml b/.github/workflows/package-manager-network-check.yml deleted file mode 100644 index 5e94bba63..000000000 --- a/.github/workflows/package-manager-network-check.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Package Manager Network Check - -on: - workflow_dispatch: - -permissions: - contents: read - -env: - NODE_VERSION: '22.21.1' - -jobs: - package-manager-network-tests: - name: Package Manager Network Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Checkout Python Environment Tools - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - repository: 'microsoft/python-environment-tools' - path: 'python-env-tools-src' - sparse-checkout: | - crates - Cargo.toml - Cargo.lock - sparse-checkout-cone-mode: false - - - name: Install Rust Toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust build - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - python-env-tools-src/target - key: ${{ runner.os }}-cargo-pet-${{ hashFiles('python-env-tools-src/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-pet- - - - name: Build Python Environment Tools - run: cargo build --release --package pet - working-directory: python-env-tools-src - - - name: Copy pet binary - run: | - mkdir -p python-env-tools/bin - cp python-env-tools-src/target/release/pet python-env-tools/bin/ - chmod +x python-env-tools/bin/pet - - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Install Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.12' - - - name: Install Dependencies - run: npm ci - - - name: Compile Extension - run: npm run compile - - - name: Compile Tests - run: npm run compile-tests - - - name: Run Package Manager Network Integration Tests - 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/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1298ffa0b..4d8d81ae8 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -252,6 +252,8 @@ jobs: name: Integration Tests runs-on: ${{ matrix.os }} needs: [smoke-tests] + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false matrix: diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 96867be26..f4c1bb6f6 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -253,6 +253,8 @@ jobs: name: Integration Tests runs-on: ${{ matrix.os }} needs: [smoke-tests] + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false matrix: From 13cd44d4d562fae9a2d086bf69d17fe40a029e4b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 19:02:50 -0700 Subject: [PATCH 06/20] Stabilize package network integration setup Auto-accept Conda channel terms in CI so non-interactive environment creation can proceed. Force the Pip lifecycle profile to use Pip rather than UV and restore the previous workspace-folder setting during teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .github/workflows/pr-check.yml | 1 + .github/workflows/push-check.yml | 1 + .../packageManager.integration.test.ts | 61 ++++++++++++------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4d8d81ae8..e62b1fb1e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -253,6 +253,7 @@ jobs: runs-on: ${{ matrix.os }} needs: [smoke-tests] env: + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'yes' VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index f4c1bb6f6..61c663811 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -254,6 +254,7 @@ jobs: runs-on: ${{ matrix.os }} needs: [smoke-tests] env: + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'yes' VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index f42df8539..9b5aa4bf0 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -102,7 +102,9 @@ for (const profile of profiles) { let environment: PythonEnvironment | undefined; let project: PythonProject | undefined; let workspaceUri: vscode.Uri; + let previousAlwaysUseUv: boolean | undefined; let previousPythonProjects: PythonProjectSettings[] | undefined; + let alwaysUseUvUpdated = false; let pythonProjectsUpdated = false; suiteSetup(async function () { if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { @@ -124,6 +126,12 @@ for (const profile of profiles) { workspaceUri = workspaceFolder.uri; const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + if (profile.packageManagerId === DEFAULT_PACKAGE_MANAGER_ID) { + previousAlwaysUseUv = config.inspect('alwaysUseUv')?.workspaceFolderValue; + await config.update('alwaysUseUv', false, vscode.ConfigurationTarget.WorkspaceFolder); + alwaysUseUvUpdated = true; + } + if (!(await profile.prerequisite(api))) { this.skip(); return; @@ -234,30 +242,39 @@ for (const profile of profiles) { } } finally { const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); - if (project) { - try { + try { + if (project) { await api.setEnvironment(project.uri, undefined); + } + 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 { + try { + if (alwaysUseUvUpdated) { + await config.update( + 'alwaysUseUv', + previousAlwaysUseUv, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + } } 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 { + if (project) { await vscode.workspace.fs.delete(project.uri, { recursive: true, useTrash: false, From 6f93cf07cc860715408b569232e03ae2e6ff3dee Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 19:21:39 -0700 Subject: [PATCH 07/20] Use existing Conda environment in network tests Update the machine-scoped UV setting at global scope, reuse the hosted runner's disposable Conda environment instead of waiting for environment creation, and use a Conda-specific package to exercise lifecycle operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .github/workflows/pr-check.yml | 2 +- .github/workflows/push-check.yml | 2 +- .../packageManager.integration.test.ts | 28 +++++++++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e62b1fb1e..3c83e81a2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -253,7 +253,7 @@ jobs: runs-on: ${{ matrix.os }} needs: [smoke-tests] env: - CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'yes' + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'true' VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 61c663811..47e625c2a 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -254,7 +254,7 @@ jobs: runs-on: ${{ matrix.os }} needs: [smoke-tests] env: - CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'yes' + CONDA_PLUGINS_AUTO_ACCEPT_TOS: 'true' VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' strategy: fail-fast: false diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 9b5aa4bf0..7d723692c 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -15,9 +15,11 @@ type PackageManagerId = `${string}:${string}`; interface PackageManagerProfile { environmentManagerId: string; name: string; + packageName: string; packageManagerId: PackageManagerId; projectDirectory: string; prerequisite(api: PythonEnvironmentApi): Promise; + reuseExistingEnvironment?: boolean; supportsVersionLookup(packages: Package[]): boolean; } @@ -25,6 +27,7 @@ const profiles: PackageManagerProfile[] = [ { environmentManagerId: VENV_MANAGER_ID, name: 'Pip', + packageName: 'requests', packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, projectDirectory: 'pip', prerequisite: async (api) => @@ -37,6 +40,7 @@ const profiles: PackageManagerProfile[] = [ { environmentManagerId: CONDA_MANAGER_ID, name: 'Conda', + packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, projectDirectory: 'conda', prerequisite: async () => { @@ -47,6 +51,7 @@ const profiles: PackageManagerProfile[] = [ return false; } }, + reuseExistingEnvironment: true, supportsVersionLookup: () => true, }, ]; @@ -105,6 +110,7 @@ for (const profile of profiles) { let previousAlwaysUseUv: boolean | undefined; let previousPythonProjects: PythonProjectSettings[] | undefined; let alwaysUseUvUpdated = false; + let createdEnvironment = false; let pythonProjectsUpdated = false; suiteSetup(async function () { if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { @@ -127,8 +133,8 @@ for (const profile of profiles) { const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); if (profile.packageManagerId === DEFAULT_PACKAGE_MANAGER_ID) { - previousAlwaysUseUv = config.inspect('alwaysUseUv')?.workspaceFolderValue; - await config.update('alwaysUseUv', false, vscode.ConfigurationTarget.WorkspaceFolder); + previousAlwaysUseUv = config.inspect('alwaysUseUv')?.globalValue; + await config.update('alwaysUseUv', false, vscode.ConfigurationTarget.Global); alwaysUseUvUpdated = true; } @@ -137,6 +143,15 @@ for (const profile of profiles) { return; } + if (profile.reuseExistingEnvironment) { + await api.refreshEnvironments(undefined); + environment = (await api.getEnvironments('global')).find( + (candidate) => candidate.envId.managerId === profile.environmentManagerId, + ); + assert.ok(environment, `No existing ${profile.name} environment is available`); + return; + } + const projectUri = vscode.Uri.joinPath( workspaceUri, `.package-manager-test-${profile.projectDirectory}-${process.pid}`, @@ -172,6 +187,7 @@ for (const profile of profiles) { await api.refreshEnvironments(projectUri); environment = await api.createEnvironment(projectUri, { quickCreate: true }); + createdEnvironment = environment !== undefined; assert.ok(environment, `${profile.name} failed to create an environment after prerequisites passed`); assert.strictEqual( environment.envId.managerId, @@ -181,7 +197,7 @@ for (const profile of profiles) { }); test(`${profile.name} Package Manager should install, list, and uninstall a package`, async () => { - const packageName = 'requests'; + const packageName = profile.packageName; const baseline = await api.getPackages(environment!, { skipCache: true }); assert.ok(baseline, 'Unable to list packages before installation'); const wasInstalled = baseline.some((pkg) => pkg.name.toLowerCase() === packageName); @@ -223,14 +239,14 @@ for (const profile of profiles) { return; } - const versions = await api.getPackageAvailableVersions(environment!, 'requests'); + const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); 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) { + if (environment && createdEnvironment) { const environmentPath = environment.environmentPath; await api.removeEnvironment(environment, { runHeadless: true }); await assert.rejects( @@ -270,7 +286,7 @@ for (const profile of profiles) { await config.update( 'alwaysUseUv', previousAlwaysUseUv, - vscode.ConfigurationTarget.WorkspaceFolder, + vscode.ConfigurationTarget.Global, ); } } finally { From a435996fb7188f658f553c1cd199daad28f625ab Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 19:39:56 -0700 Subject: [PATCH 08/20] Wait for package network results Avoid the redundant global environment refresh that can block Conda setup, and poll boundedly for package installation, removal, and registry version results across hosted runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../packageManager.integration.test.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 7d723692c..5960dc6f3 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -144,7 +144,6 @@ for (const profile of profiles) { } if (profile.reuseExistingEnvironment) { - await api.refreshEnvironments(undefined); environment = (await api.getEnvironments('global')).find( (candidate) => candidate.envId.managerId === profile.environmentManagerId, ); @@ -205,11 +204,15 @@ for (const profile of profiles) { if (!wasInstalled) { await api.managePackages(environment!, { install: [packageName], runHeadless: true }); } - let packages = await api.getPackages(environment!, { skipCache: true }); - assert.ok(packages, 'Unable to list packages after installation'); - assert.ok( - packages.some((pkg) => pkg.name.toLowerCase() === packageName), + let packages: Package[] | undefined; + await waitForCondition( + async () => { + packages = await api.getPackages(environment!, { skipCache: true }); + return packages?.some((pkg) => pkg.name.toLowerCase() === packageName) ?? false; + }, + 30_000, 'Package not installed', + 1_000, ); const directPackageNames = await vscode.commands.executeCommand( @@ -222,11 +225,14 @@ for (const profile of profiles) { if (!wasInstalled) { await api.managePackages(environment!, { uninstall: [packageName], runHeadless: true }); - packages = await api.getPackages(environment!, { skipCache: true }); - assert.ok(packages, 'Unable to list packages after uninstallation'); - assert.ok( - !packages.some((pkg) => pkg.name.toLowerCase() === packageName), + await waitForCondition( + async () => { + packages = await api.getPackages(environment!, { skipCache: true }); + return packages !== undefined && !packages.some((pkg) => pkg.name.toLowerCase() === packageName); + }, + 30_000, 'Package not uninstalled', + 1_000, ); } }); @@ -239,9 +245,15 @@ for (const profile of profiles) { return; } - const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); - assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); - assert.ok(versions.length > 0, 'No package versions available'); + await waitForCondition( + async () => { + const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); + return versions !== undefined && versions.length > 0; + }, + 30_000, + `${profile.name} unexpectedly failed to retrieve package versions`, + 2_000, + ); }); suiteTeardown(async () => { From a9de0e7659e3c6b1a53919bf72d4ec7cb3d03778 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 20:04:52 -0700 Subject: [PATCH 09/20] Skip ambiguous package version results Document that undefined currently represents both unsupported version lookup and command/network failure, and skip that ambiguous outcome while retaining coverage for empty successful responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../packageManager.integration.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 5960dc6f3..da8e92d04 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -245,15 +245,14 @@ for (const profile of profiles) { return; } - await waitForCondition( - async () => { - const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); - return versions !== undefined && versions.length > 0; - }, - 30_000, - `${profile.name} unexpectedly failed to retrieve package versions`, - 2_000, - ); + const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); + // The API currently returns undefined for both unsupported lookups and command/network failures. + // Skip until those outcomes can be distinguished by the API contract. + if (versions === undefined) { + this.skip(); + return; + } + assert.ok(versions.length > 0, 'No package versions available'); }); suiteTeardown(async () => { From 8fe8255945fb42e77d3c7baa664b9ed8c66bacc1 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 18 Aug 2026 20:37:46 -0700 Subject: [PATCH 10/20] Avoid blocking Conda prerequisite lookup Use the already-discovered global Conda environment as the lifecycle-test prerequisite and skip immediately when none is available, avoiding the locator call that hangs in hosted extension tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../packageManager.integration.test.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index da8e92d04..8f8612c32 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -6,7 +6,6 @@ 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'; @@ -18,7 +17,7 @@ interface PackageManagerProfile { packageName: string; packageManagerId: PackageManagerId; projectDirectory: string; - prerequisite(api: PythonEnvironmentApi): Promise; + prerequisite?(api: PythonEnvironmentApi): Promise; reuseExistingEnvironment?: boolean; supportsVersionLookup(packages: Package[]): boolean; } @@ -43,14 +42,6 @@ const profiles: PackageManagerProfile[] = [ packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, projectDirectory: 'conda', - prerequisite: async () => { - try { - await getConda(); - return true; - } catch { - return false; - } - }, reuseExistingEnvironment: true, supportsVersionLookup: () => true, }, @@ -138,7 +129,7 @@ for (const profile of profiles) { alwaysUseUvUpdated = true; } - if (!(await profile.prerequisite(api))) { + if (profile.prerequisite && !(await profile.prerequisite(api))) { this.skip(); return; } @@ -147,7 +138,10 @@ for (const profile of profiles) { environment = (await api.getEnvironments('global')).find( (candidate) => candidate.envId.managerId === profile.environmentManagerId, ); - assert.ok(environment, `No existing ${profile.name} environment is available`); + if (!environment) { + this.skip(); + return; + } return; } From 0462436a368b46f3e4d73c5045250e827fbf5012 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 11:31:44 -0700 Subject: [PATCH 11/20] test: address package manager review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .github/workflows/pr-check.yml | 27 ++++ .github/workflows/push-check.yml | 27 ++++ .../packageManager.integration.test.ts | 51 +++++-- ...ageManagerHeadlessConformance.unit.test.ts | 138 +++++++++++++----- 4 files changed, 192 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 3c83e81a2..486785d46 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -328,6 +328,22 @@ jobs: - name: Compile Tests run: npm run compile-tests + - name: Set up Conda + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3 + with: + activate-environment: '' + auto-activate: false + + - name: Create isolated Conda test environment + shell: pwsh + run: | + $condaEnvironment = Join-Path $env:RUNNER_TEMP 'python-envs-package-manager-conda' + "VSC_PYTHON_PACKAGE_CONDA_ENV=$condaEnvironment" >> $env:GITHUB_ENV + conda create --yes --clone base --prefix $condaEnvironment + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -338,6 +354,17 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test + - name: Remove isolated Conda test environment + if: always() + shell: pwsh + run: | + if ($env:VSC_PYTHON_PACKAGE_CONDA_ENV -and (Test-Path -LiteralPath $env:VSC_PYTHON_PACKAGE_CONDA_ENV)) { + conda env remove --yes --prefix $env:VSC_PYTHON_PACKAGE_CONDA_ENV + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } + 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 47e625c2a..70edb2cc7 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -329,6 +329,22 @@ jobs: - name: Compile Tests run: npm run compile-tests + - name: Set up Conda + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3 + with: + activate-environment: '' + auto-activate: false + + - name: Create isolated Conda test environment + shell: pwsh + run: | + $condaEnvironment = Join-Path $env:RUNNER_TEMP 'python-envs-package-manager-conda' + "VSC_PYTHON_PACKAGE_CONDA_ENV=$condaEnvironment" >> $env:GITHUB_ENV + conda create --yes --clone base --prefix $condaEnvironment + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -338,3 +354,14 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test + + - name: Remove isolated Conda test environment + if: always() + shell: pwsh + run: | + if ($env:VSC_PYTHON_PACKAGE_CONDA_ENV -and (Test-Path -LiteralPath $env:VSC_PYTHON_PACKAGE_CONDA_ENV)) { + conda env remove --yes --prefix $env:VSC_PYTHON_PACKAGE_CONDA_ENV + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 8f8612c32..21875c071 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -5,6 +5,7 @@ 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 { normalizePath } from '../../common/utils/pathUtils'; import { PythonProjectSettings } from '../../internal.api'; import { ENVS_EXTENSION_ID } from '../constants'; import { waitForCondition } from '../testUtils'; @@ -18,7 +19,7 @@ interface PackageManagerProfile { packageManagerId: PackageManagerId; projectDirectory: string; prerequisite?(api: PythonEnvironmentApi): Promise; - reuseExistingEnvironment?: boolean; + existingEnvironmentPathVariable?: string; supportsVersionLookup(packages: Package[]): boolean; } @@ -42,7 +43,7 @@ const profiles: PackageManagerProfile[] = [ packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, projectDirectory: 'conda', - reuseExistingEnvironment: true, + existingEnvironmentPathVariable: 'VSC_PYTHON_PACKAGE_CONDA_ENV', supportsVersionLookup: () => true, }, ]; @@ -55,6 +56,12 @@ const deferredProfiles = { pipWithUv: 'uv-backed Pip selection uses a machine-scoped setting and is unstable within one extension host.', } as const; +function pathsEqual(first: string, second: string): boolean { + const firstPath = path.resolve(vscode.Uri.file(first).fsPath); + const secondPath = path.resolve(vscode.Uri.file(second).fsPath); + return normalizePath(firstPath) === normalizePath(secondPath); +} + suite('Package Manager profile coverage', function () { this.timeout(60_000); @@ -134,14 +141,27 @@ for (const profile of profiles) { return; } - if (profile.reuseExistingEnvironment) { - environment = (await api.getEnvironments('global')).find( - (candidate) => candidate.envId.managerId === profile.environmentManagerId, + if (profile.existingEnvironmentPathVariable) { + const environmentPath = process.env[profile.existingEnvironmentPathVariable]; + assert.ok( + environmentPath, + `Missing environment variable: ${profile.existingEnvironmentPathVariable}`, + ); + + await api.refreshEnvironments(undefined); + await waitForCondition( + async () => { + environment = (await api.getEnvironments('all')).find( + (candidate) => + candidate.envId.managerId === profile.environmentManagerId && + pathsEqual(candidate.environmentPath.fsPath, environmentPath), + ); + return environment !== undefined; + }, + 30_000, + `${profile.name} test environment was not discovered: ${environmentPath}`, + 1_000, ); - if (!environment) { - this.skip(); - return; - } return; } @@ -193,6 +213,10 @@ for (const profile of profiles) { const packageName = profile.packageName; const baseline = await api.getPackages(environment!, { skipCache: true }); assert.ok(baseline, 'Unable to list packages before installation'); + assert.ok( + baseline.every((pkg) => pkg.pkgId.managerId === profile.packageManagerId), + `${profile.name} lifecycle used an unexpected package manager`, + ); const wasInstalled = baseline.some((pkg) => pkg.name.toLowerCase() === packageName); if (!wasInstalled) { @@ -240,12 +264,9 @@ for (const profile of profiles) { } const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); - // The API currently returns undefined for both unsupported lookups and command/network failures. - // Skip until those outcomes can be distinguished by the API contract. - if (versions === undefined) { - this.skip(); - return; - } + // The API does not yet distinguish an unsupported lookup from a command or network failure. + // Supported profiles must remain strict until that result contract can be made explicit. + assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); assert.ok(versions.length > 0, 'No package versions available'); }); diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index fdcb178a4..375e4dd58 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -5,42 +5,34 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; import { PackageManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as childProcessApis from '../../../common/childProcess.apis'; import * as errorUtils from '../../../common/errors/utils'; import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; import * as pipUtils from '../../../managers/builtin/pipUtils'; import * as builtinUtils from '../../../managers/builtin/utils'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; import { VenvManager } from '../../../managers/builtin/venvManager'; import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; import * as condaUtils from '../../../managers/conda/condaUtils'; import { PoetryManager } from '../../../managers/poetry/poetryManager'; import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; suite('Package manager headless conformance', () => { const environment = { envId: { id: 'test-environment', managerId: 'test-manager' }, environmentPath: Uri.joinPath(Uri.file(__dirname), 'path', 'to', 'environment'), - } as PythonEnvironment; + execInfo: { run: { executable: 'python', args: [] } }, + version: '3.12.0', + } as unknown as PythonEnvironment; teardown(() => { sinon.restore(); }); - function createManagers(): PackageManager[] { - const api = {} as PythonEnvironmentApi; - const log = { - error: sinon.stub(), - info: sinon.stub(), - show: sinon.stub(), - } as unknown as LogOutputChannel; - return [ - new PipPackageManager(api, log, { getProjectsByEnvironment: sinon.stub().returns([]) } as unknown as VenvManager), - new CondaPackageManager(api, log), - new PoetryPackageManager(api, log, {} as PoetryManager), - ]; - } - test('does not invoke interactive package input when no packages are provided', async () => { const pipPicker = sinon.stub(pipUtils, 'getWorkspacePackagesToInstall'); const condaPicker = sinon.stub(condaUtils, 'getCommonCondaPackagesToInstall'); @@ -60,51 +52,125 @@ suite('Package manager headless conformance', () => { const withProgress = sinon.stub(windowApis, 'withProgress'); sinon.stub(builtinUtils, 'managePackages').rejects(operationError); sinon.stub(condaUtils, 'managePackages').rejects(operationError); - sinon.stub(poetryUtils, 'getPoetry').resolves(undefined); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + sinon.stub(childProcessApis, 'spawnProcess').callsFake(() => { + const process = new MockChildProcess('poetry', ['add', 'requests']); + setImmediate(() => process.emit('error', operationError)); + return process as unknown as ReturnType; + }); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); for (const manager of createManagers()) { await assert.rejects( manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === operationError, ); } + await flushImmediate(); assert.ok(withProgress.notCalled); assert.ok(showErrorMessage.notCalled); assert.ok(showErrorMessageWithLogs.notCalled); }); - test('rejects refresh failures without showing progress or error notifications', async () => { - const refreshError = new Error('package refresh failed'); + test('suppresses Pip refresh failures without showing progress or error notifications', async () => { const withProgress = sinon.stub(windowApis, 'withProgress'); sinon.stub(builtinUtils, 'managePackages').resolves(); - sinon.stub(condaUtils, 'managePackages').resolves(); - sinon - .stub( - PoetryPackageManager.prototype as unknown as { - runPoetryManage: () => Promise; - }, - 'runPoetryManage', - ) - .resolves(); - sinon.stub(builtinUtils, 'refreshPipPackages').rejects(refreshError); - sinon.stub(CondaPackageManager.prototype, 'getPackages').rejects(refreshError); - sinon.stub(PoetryPackageManager.prototype, 'getPackages').rejects(refreshError); + sinon.stub(uvEnvironments, 'getUvEnvironments').resolves([]); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: sinon.stub().withArgs('alwaysUseUv').returns(false), + } as unknown as ReturnType); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake(() => { + const process = new MockChildProcess('python', ['-m', 'pip', 'list']); + setImmediate(() => { + process.emit('exit', 1, null); + process.emit('close', 1, null); + }); + return process as unknown as ReturnType; + }); sinon.stub(PipPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers()[0]; + + await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await flushImmediate(); + + assert.ok(spawnProcess.called); + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('rejects Conda refresh failures without showing progress or error notifications', async () => { + const refreshError = new Error('package refresh failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(condaUtils, 'managePackages').resolves(); + sinon.stub(condaUtils, 'runCondaExecutable').rejects(refreshError); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers()[1]; + + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === refreshError, + ); + await flushImmediate(); + + assert.ok(withProgress.notCalled); + assert.ok(showErrorMessage.notCalled); + assert.ok(showErrorMessageWithLogs.notCalled); + }); + + test('suppresses Poetry refresh failures without showing progress or error notifications', async () => { + const refreshError = new Error('package refresh failed'); + const withProgress = sinon.stub(windowApis, 'withProgress'); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); sinon.stub(PoetryPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake((_command, args) => { + const process = new MockChildProcess('poetry', args); + setImmediate(() => { + if (args[0] === 'add') { + process.emit('close', 0, null); + } else { + process.emit('error', refreshError); + } + }); + return process as unknown as ReturnType; + }); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + const manager = createManagers()[2]; - for (const manager of createManagers()) { - await assert.rejects( - manager.manage(environment, { install: ['requests'], runHeadless: true }), - (error: unknown) => error === refreshError, - ); - } + await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await flushImmediate(); + assert.strictEqual(spawnProcess.callCount, 2); assert.ok(withProgress.notCalled); assert.ok(showErrorMessage.notCalled); assert.ok(showErrorMessageWithLogs.notCalled); }); + + function createManagers(): PackageManager[] { + const api = { + createPackageItem: sinon.stub(), + getPythonProjects: sinon.stub().returns([]), + } as unknown as PythonEnvironmentApi; + const log = { + append: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + show: sinon.stub(), + } as unknown as LogOutputChannel; + return [ + new PipPackageManager(api, log, { getProjectsByEnvironment: sinon.stub().returns([]) } as unknown as VenvManager), + new CondaPackageManager(api, log), + new PoetryPackageManager(api, log, {} as PoetryManager), + ]; + } + + async function flushImmediate(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } }); From 148891f0549292662784ac766849173405ec36f9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 12:27:19 -0700 Subject: [PATCH 12/20] test: add ephemeral environment fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .github/workflows/pr-check.yml | 21 - .github/workflows/push-check.yml | 21 - src/extension.ts | 10 + src/test/integration/environmentFixture.ts | 506 ++++++++++++++++++ .../packageManager.integration.test.ts | 175 +----- 5 files changed, 545 insertions(+), 188 deletions(-) create mode 100644 src/test/integration/environmentFixture.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 486785d46..5a8a0ab03 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -334,16 +334,6 @@ jobs: activate-environment: '' auto-activate: false - - name: Create isolated Conda test environment - shell: pwsh - run: | - $condaEnvironment = Join-Path $env:RUNNER_TEMP 'python-envs-package-manager-conda' - "VSC_PYTHON_PACKAGE_CONDA_ENV=$condaEnvironment" >> $env:GITHUB_ENV - conda create --yes --clone base --prefix $condaEnvironment - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -354,17 +344,6 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test - - name: Remove isolated Conda test environment - if: always() - shell: pwsh - run: | - if ($env:VSC_PYTHON_PACKAGE_CONDA_ENV -and (Test-Path -LiteralPath $env:VSC_PYTHON_PACKAGE_CONDA_ENV)) { - conda env remove --yes --prefix $env:VSC_PYTHON_PACKAGE_CONDA_ENV - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - } - 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 70edb2cc7..33528bae9 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -335,16 +335,6 @@ jobs: activate-environment: '' auto-activate: false - - name: Create isolated Conda test environment - shell: pwsh - run: | - $condaEnvironment = Join-Path $env:RUNNER_TEMP 'python-envs-package-manager-conda' - "VSC_PYTHON_PACKAGE_CONDA_ENV=$condaEnvironment" >> $env:GITHUB_ENV - conda create --yes --clone base --prefix $condaEnvironment - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 @@ -354,14 +344,3 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test - - - name: Remove isolated Conda test environment - if: always() - shell: pwsh - run: | - if ($env:VSC_PYTHON_PACKAGE_CONDA_ENV -and (Test-Path -LiteralPath $env:VSC_PYTHON_PACKAGE_CONDA_ENV)) { - conda env remove --yes --prefix $env:VSC_PYTHON_PACKAGE_CONDA_ENV - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - } diff --git a/src/extension.ts b/src/extension.ts index 46f89009b..bac812aee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -263,6 +263,16 @@ export async function activate(context: ExtensionContext): Promise envManagers.packageManagers.map((manager) => manager.id), ), + commands.registerCommand( + 'python-envs.test.resolveEnvironmentWithManager', + async (managerId: string, environmentUri: Uri) => { + const manager = envManagers.getEnvironmentManager(managerId); + if (!manager) { + throw new Error(`Environment manager not found: ${managerId}`); + } + return manager.resolve(environmentUri); + }, + ), commands.registerCommand( 'python-envs.test.getDirectPackageNames', async (environment: PythonEnvironment) => { diff --git a/src/test/integration/environmentFixture.ts b/src/test/integration/environmentFixture.ts new file mode 100644 index 000000000..03e968526 --- /dev/null +++ b/src/test/integration/environmentFixture.ts @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import type { ChildProcess } from 'child_process'; +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { spawnProcess } from '../../common/childProcess.apis'; +import { CONDA_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { PythonProjectSettings } from '../../internal.api'; +import { waitForCondition } from '../testUtils'; + +const OWNERSHIP_FILE_NAME = '.python-envs-test-owner.json'; +const COMMAND_TIMEOUT_MS = 180_000; +const DISCOVERY_TIMEOUT_MS = 60_000; + +export interface EnvironmentFixtureProvider { + readonly environmentDirectory: string; + readonly managerId: string; + create( + api: PythonEnvironmentApi, + prefix: vscode.Uri, + projectUri: vscode.Uri, + ): Promise; + discover( + api: PythonEnvironmentApi, + prefix: vscode.Uri, + projectUri: vscode.Uri, + ): Promise; + remove(prefix: vscode.Uri): Promise; +} + +export interface EnvironmentFixtureRequest { + readonly name: string; + readonly packageManagerId: string; + readonly provider: EnvironmentFixtureProvider; +} + +export interface EnvironmentFixture { + readonly environment: PythonEnvironment; + readonly prefix: vscode.Uri; + readonly projectUri: vscode.Uri; + dispose(): Promise; +} + +interface CommandResult { + stdout: string; + stderr: string; +} + +/** + * Creates an isolated environment owned by the integration test and returns a lease that removes it. + * + * @param api The activated Python Environments extension API. + * @param workspaceFolder Workspace folder that owns the temporary test project. + * @param request Environment and package manager configuration for the fixture. + */ +export async function createEnvironmentFixture( + api: PythonEnvironmentApi, + workspaceFolder: vscode.WorkspaceFolder, + request: EnvironmentFixtureRequest, +): Promise { + const token = randomUUID(); + const fixtureName = `pyenvs-${sanitizeName(request.name).slice(0, 8)}-${process.pid}-${token.slice(0, 8)}`; + const projectUri = vscode.Uri.file(path.join(os.tmpdir(), fixtureName)); + const prefix = vscode.Uri.joinPath(projectUri, request.provider.environmentDirectory); + const markerUri = vscode.Uri.joinPath(projectUri, OWNERSHIP_FILE_NAME); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); + const previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; + let projectSettingAdded = false; + let environmentCreated = false; + let environment: PythonEnvironment | undefined; + let disposed = false; + let markerWritten = false; + let projectRootCreated = false; + + const dispose = async (): Promise => { + if (disposed) { + return; + } + disposed = true; + const cleanupErrors: Error[] = []; + + if (markerWritten && (environmentCreated || (await pathExists(prefix)))) { + let ownershipVerified = false; + try { + await verifyOwnership(projectUri, markerUri, token, prefix); + ownershipVerified = true; + } catch (error) { + cleanupErrors.push(toError(error)); + } + if (ownershipVerified && environment) { + try { + await withTimeout( + api.removeEnvironment(environment, { runHeadless: true }), + COMMAND_TIMEOUT_MS, + `${request.name} API environment removal timed out`, + ); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + if (ownershipVerified && (await pathExists(prefix))) { + cleanupErrors.push( + new Error(`${request.name} API removal left the environment on disk: ${prefix.fsPath}`), + ); + try { + await request.provider.remove(prefix); + await assertPathMissing(prefix, `${request.name} environment was not removed`); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + } + + try { + await api.setEnvironment(projectUri, undefined); + } catch (error) { + cleanupErrors.push(toError(error)); + } + + if (projectSettingAdded) { + try { + await config.update( + 'pythonProjects', + previousPythonProjects, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await waitForCondition( + () => + !api + .getPythonProjects() + .some((project) => pathsEqual(project.uri.fsPath, projectUri.fsPath)), + 10_000, + `Python project was not unregistered: ${projectUri.fsPath}`, + ); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + + if (projectRootCreated && (await pathExists(projectUri))) { + try { + if (markerWritten) { + await verifyOwnership(projectUri, markerUri, token, prefix); + } else if (environmentCreated) { + throw new Error(`Refusing to remove an unmarked fixture after environment creation: ${projectUri.fsPath}`); + } + await vscode.workspace.fs.delete(projectUri, { recursive: true, useTrash: false }); + } catch (error) { + cleanupErrors.push(toError(error)); + } + } + + if (cleanupErrors.length > 0) { + throw new Error(cleanupErrors.map((error) => error.message).join('\n')); + } + }; + + try { + await assertPathMissing(projectUri, `Fixture directory already exists: ${projectUri.fsPath}`); + await vscode.workspace.fs.createDirectory(projectUri); + projectRootCreated = true; + await vscode.workspace.fs.writeFile( + markerUri, + Buffer.from(JSON.stringify({ managerId: request.provider.managerId, token }), 'utf8'), + ); + markerWritten = true; + + const pythonProjects = config.get('pythonProjects', []); + const projectSetting: PythonProjectSettings = { + path: projectUri.fsPath, + envManager: request.provider.managerId, + packageManager: request.packageManagerId, + workspace: workspaceFolder.name, + }; + await config.update( + 'pythonProjects', + [...pythonProjects, projectSetting], + vscode.ConfigurationTarget.WorkspaceFolder, + ); + projectSettingAdded = true; + + await waitForCondition( + () => api.getPythonProjects().some((project) => pathsEqual(project.uri.fsPath, projectUri.fsPath)), + 10_000, + `Python project was not registered: ${projectUri.fsPath}`, + ); + + environment = await request.provider.create(api, prefix, projectUri); + environmentCreated = true; + + const candidate = + environment ?? + (await withTimeout( + request.provider.discover(api, prefix, projectUri), + DISCOVERY_TIMEOUT_MS, + `${request.name} environment discovery timed out`, + )); + if ( + candidate?.envId.managerId !== request.provider.managerId || + !(await canonicalPathsEqual(candidate.sysPrefix, prefix.fsPath)) + ) { + throw new Error( + `${request.name} environment was not discovered: ${prefix.fsPath}. Resolved environment: ${ + candidate ? `${candidate.envId.managerId} (${candidate.sysPrefix})` : 'none' + }`, + ); + } + environment = candidate; + + return { + environment, + prefix, + projectUri, + dispose, + }; + } catch (error) { + try { + await dispose(); + } catch (cleanupError) { + throw new Error( + `${request.name} fixture setup failed: ${toError(error).message}\nCleanup failed: ${toError(cleanupError).message}`, + ); + } + throw error; + } +} + +/** + * Creates a provider for standard-library virtual environments. + */ +export function createVenvFixtureProvider(): EnvironmentFixtureProvider { + return { + environmentDirectory: '.venv', + managerId: VENV_MANAGER_ID, + create: async (_api, prefix) => { + await runFixtureCommand('python', ['-m', 'venv', prefix.fsPath]); + return undefined; + }, + discover: async (_api, prefix) => + resolveEnvironmentWithManager( + VENV_MANAGER_ID, + vscode.Uri.joinPath( + prefix, + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ), + ), + remove: async (prefix) => { + if (await pathExists(prefix)) { + await vscode.workspace.fs.delete(prefix, { recursive: true, useTrash: false }); + } + }, + }; +} + +/** + * Creates a provider for Conda environments. + */ +export function createCondaFixtureProvider(): EnvironmentFixtureProvider { + return { + environmentDirectory: '.conda', + managerId: CONDA_MANAGER_ID, + create: async (_api, prefix) => { + const version = await runFixtureCommand('python', [ + '-c', + 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")', + ]); + await runFixtureCommand(await getCondaExecutable(), [ + 'create', + '--yes', + '--prefix', + prefix.fsPath, + `python=${version.stdout.trim()}`, + ]); + return undefined; + }, + discover: async (_api, prefix) => resolveEnvironmentWithManager(CONDA_MANAGER_ID, prefix), + remove: async (prefix) => { + if (await pathExists(prefix)) { + await runFixtureCommand(await getCondaExecutable(), [ + 'env', + 'remove', + '--yes', + '--prefix', + prefix.fsPath, + ]); + } + }, + }; +} + +async function runFixtureCommand(command: string, args: string[], cwd?: vscode.Uri): Promise { + return new Promise((resolve, reject) => { + const child = spawnProcess(command, args, { + cwd: cwd?.fsPath, + detached: process.platform !== 'win32', + stdio: 'pipe', + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let forceTimer: NodeJS.Timeout | undefined; + const finish = (error?: Error, result?: CommandResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (forceTimer) { + clearTimeout(forceTimer); + } + if (error) { + reject(error); + } else { + resolve(result ?? { stdout, stderr }); + } + }; + const timer = setTimeout(() => { + timedOut = true; + const timeoutError = new Error( + `${command} ${args.join(' ')} timed out after ${COMMAND_TIMEOUT_MS}ms`, + ); + void terminateProcessTree(child).catch((error) => { + finish(new Error(`${timeoutError.message}\nFailed to terminate process tree: ${toError(error).message}`)); + }); + forceTimer = setTimeout(() => finish(timeoutError), 10_000); + }, COMMAND_TIMEOUT_MS); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (data: string) => { + stdout += data; + }); + child.stderr.on('data', (data: string) => { + stderr += data; + }); + child.on('error', (error) => { + finish(timedOut ? new Error(`${command} timed out and failed to terminate: ${error.message}`) : error); + }); + child.on('close', (code) => { + if (timedOut) { + finish(new Error(`${command} ${args.join(' ')} timed out after ${COMMAND_TIMEOUT_MS}ms`)); + return; + } + if (code === 0) { + finish(undefined, { stdout, stderr }); + return; + } + finish( + new Error( + `${command} ${args.join(' ')} exited with code ${code}\n${stderr.trim() || stdout.trim()}`, + ), + ); + }); + }); +} + +async function terminateProcessTree(child: ChildProcess): Promise { + if (!child.pid) { + child.kill('SIGKILL'); + return; + } + if (process.platform !== 'win32') { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + return; + } + + await new Promise((resolve, reject) => { + const killer = spawnProcess('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + killer.on('error', reject); + killer.on('close', (code) => { + if (code === 0 || child.exitCode !== null) { + resolve(); + } else { + reject(new Error(`taskkill exited with code ${code}`)); + } + }); + }); +} + +async function getCondaExecutable(): Promise { + const condaRoot = process.env.CONDA; + if (condaRoot) { + const executable = + process.platform === 'win32' + ? path.join(condaRoot, 'Scripts', 'conda.exe') + : path.join(condaRoot, 'bin', 'conda'); + try { + await fs.access(executable); + return executable; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + throw error; + } + } + } + return process.platform === 'win32' ? 'conda.exe' : 'conda'; +} + +async function resolveEnvironmentWithManager( + managerId: string, + environmentUri: vscode.Uri, +): Promise { + return vscode.commands.executeCommand( + 'python-envs.test.resolveEnvironmentWithManager', + managerId, + environmentUri, + ); +} + +async function verifyOwnership( + projectUri: vscode.Uri, + markerUri: vscode.Uri, + token: string, + prefix: vscode.Uri, +): Promise { + if (!isPathWithin(projectUri.fsPath, prefix.fsPath)) { + throw new Error(`Refusing to remove environment outside fixture root: ${prefix.fsPath}`); + } + const marker = JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(markerUri)).toString('utf8')) as { + token?: string; + }; + if (marker.token !== token) { + throw new Error(`Refusing to remove fixture without matching ownership marker: ${projectUri.fsPath}`); + } +} + +function isPathWithin(parent: string, candidate: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +function pathsEqual(first: string, second: string): boolean { + const firstPath = path.resolve(vscode.Uri.file(first).fsPath); + const secondPath = path.resolve(vscode.Uri.file(second).fsPath); + return normalizePath(firstPath) === normalizePath(secondPath); +} + +async function canonicalPathsEqual(first: string, second: string): Promise { + const [firstPath, secondPath] = await Promise.all([canonicalPath(first), canonicalPath(second)]); + return normalizePath(firstPath) === normalizePath(secondPath); +} + +async function canonicalPath(value: string): Promise { + return await fs.realpath(path.resolve(vscode.Uri.file(value).fsPath)); +} + +async function pathExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch (error) { + if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') { + return false; + } + throw error; + } +} + +async function assertPathMissing(uri: vscode.Uri, message: string): Promise { + if (await pathExists(uri)) { + throw new Error(message); + } +} + +async function withTimeout(operation: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${message} after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +function sanitizeName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9-]/g, '-'); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 21875c071..6db997c96 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -2,48 +2,44 @@ 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 { normalizePath } from '../../common/utils/pathUtils'; -import { PythonProjectSettings } from '../../internal.api'; +import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants'; import { ENVS_EXTENSION_ID } from '../constants'; import { waitForCondition } from '../testUtils'; +import { + createCondaFixtureProvider, + createEnvironmentFixture, + createVenvFixtureProvider, + EnvironmentFixture, + EnvironmentFixtureProvider, +} from './environmentFixture'; type PackageManagerId = `${string}:${string}`; interface PackageManagerProfile { - environmentManagerId: string; name: string; packageName: string; packageManagerId: PackageManagerId; - projectDirectory: string; - prerequisite?(api: PythonEnvironmentApi): Promise; - existingEnvironmentPathVariable?: string; + provider: EnvironmentFixtureProvider; supportsVersionLookup(packages: Package[]): boolean; } const profiles: PackageManagerProfile[] = [ { - environmentManagerId: VENV_MANAGER_ID, name: 'Pip', packageName: 'requests', packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, - projectDirectory: 'pip', - prerequisite: async (api) => - (await api.getEnvironments('global')).some((environment) => environment.version.startsWith('3.')), + provider: createVenvFixtureProvider(), supportsVersionLookup: (packages) => { const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; }, }, { - environmentManagerId: CONDA_MANAGER_ID, name: 'Conda', packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, - projectDirectory: 'conda', - existingEnvironmentPathVariable: 'VSC_PYTHON_PACKAGE_CONDA_ENV', + provider: createCondaFixtureProvider(), supportsVersionLookup: () => true, }, ]; @@ -56,12 +52,6 @@ const deferredProfiles = { pipWithUv: 'uv-backed Pip selection uses a machine-scoped setting and is unstable within one extension host.', } as const; -function pathsEqual(first: string, second: string): boolean { - const firstPath = path.resolve(vscode.Uri.file(first).fsPath); - const secondPath = path.resolve(vscode.Uri.file(second).fsPath); - return normalizePath(firstPath) === normalizePath(secondPath); -} - suite('Package Manager profile coverage', function () { this.timeout(60_000); @@ -99,17 +89,13 @@ suite('Package Manager profile coverage', function () { for (const profile of profiles) { suite(`${profile.name} Package Manager`, function () { - this.timeout(300_000); + this.timeout(600_000); let api: PythonEnvironmentApi; let environment: PythonEnvironment | undefined; - let project: PythonProject | undefined; - let workspaceUri: vscode.Uri; + let fixture: EnvironmentFixture | undefined; let previousAlwaysUseUv: boolean | undefined; - let previousPythonProjects: PythonProjectSettings[] | undefined; let alwaysUseUvUpdated = false; - let createdEnvironment = false; - let pythonProjectsUpdated = false; suiteSetup(async function () { if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { this.skip(); @@ -127,8 +113,7 @@ for (const profile of profiles) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert.ok(workspaceFolder, 'Integration test workspace not found'); - workspaceUri = workspaceFolder.uri; - const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); if (profile.packageManagerId === DEFAULT_PACKAGE_MANAGER_ID) { previousAlwaysUseUv = config.inspect('alwaysUseUv')?.globalValue; @@ -136,76 +121,16 @@ for (const profile of profiles) { alwaysUseUvUpdated = true; } - if (profile.prerequisite && !(await profile.prerequisite(api))) { - this.skip(); - return; - } - - if (profile.existingEnvironmentPathVariable) { - const environmentPath = process.env[profile.existingEnvironmentPathVariable]; - assert.ok( - environmentPath, - `Missing environment variable: ${profile.existingEnvironmentPathVariable}`, - ); - - await api.refreshEnvironments(undefined); - await waitForCondition( - async () => { - environment = (await api.getEnvironments('all')).find( - (candidate) => - candidate.envId.managerId === profile.environmentManagerId && - pathsEqual(candidate.environmentPath.fsPath, environmentPath), - ); - return environment !== undefined; - }, - 30_000, - `${profile.name} test environment was not discovered: ${environmentPath}`, - 1_000, - ); - return; - } - - const projectUri = vscode.Uri.joinPath( - workspaceUri, - `.package-manager-test-${profile.projectDirectory}-${process.pid}`, - ); - await vscode.workspace.fs.createDirectory(projectUri); - project = { + fixture = await createEnvironmentFixture(api, workspaceFolder, { name: `${profile.name} Package Manager Test`, - uri: projectUri, - }; - previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; - const pythonProjects = config.get('pythonProjects', []); - const projectSetting: PythonProjectSettings = { - path: path.relative(workspaceUri.fsPath, projectUri.fsPath).replace(/\\/g, '/'), - envManager: profile.environmentManagerId, - packageManager: profile.packageManagerId, - workspace: workspaceFolder.name, - }; - await config.update( - 'pythonProjects', - [...pythonProjects, projectSetting], - vscode.ConfigurationTarget.WorkspaceFolder, - ); - pythonProjectsUpdated = true; - await waitForCondition( - () => - api - .getPythonProjects() - .some((registeredProject) => registeredProject.uri.toString() === projectUri.toString()), - 10_000, - `Python project was not registered: ${projectUri.fsPath}`, - ); - - await api.refreshEnvironments(projectUri); - - environment = await api.createEnvironment(projectUri, { quickCreate: true }); - createdEnvironment = environment !== undefined; - assert.ok(environment, `${profile.name} failed to create an environment after prerequisites passed`); + packageManagerId: profile.packageManagerId, + provider: profile.provider, + }); + environment = fixture.environment; assert.strictEqual( environment.envId.managerId, - profile.environmentManagerId, - `Expected an environment created by ${profile.environmentManagerId}`, + profile.provider.managerId, + `Expected an environment created by ${profile.provider.managerId}`, ); }); @@ -272,57 +197,15 @@ for (const profile of profiles) { suiteTeardown(async () => { try { - if (environment && createdEnvironment) { - const environmentPath = environment.environmentPath; - await api.removeEnvironment(environment, { runHeadless: true }); - await assert.rejects( - async () => vscode.workspace.fs.stat(environmentPath), - (error: unknown) => - error instanceof vscode.FileSystemError && error.code === 'FileNotFound', - `Environment was not removed: ${environmentPath.fsPath}`, - ); + if (fixture) { + await fixture.dispose(); } } finally { - const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); - try { - if (project) { - await api.setEnvironment(project.uri, undefined); - } - 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 { - try { - if (alwaysUseUvUpdated) { - await config.update( - 'alwaysUseUv', - previousAlwaysUseUv, - vscode.ConfigurationTarget.Global, - ); - } - } finally { - if (project) { - await vscode.workspace.fs.delete(project.uri, { - recursive: true, - useTrash: false, - }); - } - } + if (alwaysUseUvUpdated) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'Integration test workspace not found during teardown'); + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri); + await config.update('alwaysUseUv', previousAlwaysUseUv, vscode.ConfigurationTarget.Global); } } }); From 676400620f0d1d42263ba52498941a1e367460f0 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 12:49:45 -0700 Subject: [PATCH 13/20] test: allow unavailable pip versions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/test/integration/packageManager.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 6db997c96..b2aec8974 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -189,9 +189,11 @@ for (const profile of profiles) { } const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); - // The API does not yet distinguish an unsupported lookup from a command or network failure. - // Supported profiles must remain strict until that result contract can be made explicit. - assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); + // Accept undefined until the API can distinguish unsupported lookups from command or network failures. + // TODO: Add that result distinction and make supported lookups strict in a follow-up PR. + if (versions === undefined) { + return; + } assert.ok(versions.length > 0, 'No package versions available'); }); From ebb222d930cea60b74b6f145045dbdc28a449244 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 13:26:20 -0700 Subject: [PATCH 14/20] test: harden fixture cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/test/integration/environmentFixture.ts | 66 +++++++++++++++++----- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/src/test/integration/environmentFixture.ts b/src/test/integration/environmentFixture.ts index 03e968526..02a0ae0bf 100644 --- a/src/test/integration/environmentFixture.ts +++ b/src/test/integration/environmentFixture.ts @@ -17,6 +17,7 @@ import { waitForCondition } from '../testUtils'; const OWNERSHIP_FILE_NAME = '.python-envs-test-owner.json'; const COMMAND_TIMEOUT_MS = 180_000; const DISCOVERY_TIMEOUT_MS = 60_000; +const API_REMOVAL_SETTLE_TIMEOUT_MS = 10_000; export interface EnvironmentFixtureProvider { readonly environmentDirectory: string; @@ -74,16 +75,13 @@ export async function createEnvironmentFixture( let projectSettingAdded = false; let environmentCreated = false; let environment: PythonEnvironment | undefined; - let disposed = false; + let disposePromise: Promise | undefined; let markerWritten = false; let projectRootCreated = false; - const dispose = async (): Promise => { - if (disposed) { - return; - } - disposed = true; + const cleanup = async (): Promise => { const cleanupErrors: Error[] = []; + let environmentRemovalPending = false; if (markerWritten && (environmentCreated || (await pathExists(prefix)))) { let ownershipVerified = false; @@ -94,27 +92,59 @@ export async function createEnvironmentFixture( cleanupErrors.push(toError(error)); } if (ownershipVerified && environment) { + let apiRemovalSettled = false; + const apiRemoval = api + .removeEnvironment(environment, { runHeadless: true }) + .finally(() => { + apiRemovalSettled = true; + }); try { await withTimeout( - api.removeEnvironment(environment, { runHeadless: true }), + apiRemoval, COMMAND_TIMEOUT_MS, `${request.name} API environment removal timed out`, ); } catch (error) { cleanupErrors.push(toError(error)); + if (!apiRemovalSettled) { + try { + await withTimeout( + apiRemoval, + API_REMOVAL_SETTLE_TIMEOUT_MS, + `${request.name} API environment removal did not settle after timing out`, + ); + } catch (settleError) { + if (apiRemovalSettled) { + cleanupErrors.push(toError(settleError)); + } else { + environmentRemovalPending = true; + cleanupErrors.push( + new Error( + `${request.name} direct cleanup was skipped because API removal is still running`, + ), + ); + } + } + } } } if (ownershipVerified && (await pathExists(prefix))) { cleanupErrors.push( new Error(`${request.name} API removal left the environment on disk: ${prefix.fsPath}`), ); - try { - await request.provider.remove(prefix); - await assertPathMissing(prefix, `${request.name} environment was not removed`); - } catch (error) { - cleanupErrors.push(toError(error)); + if (!environmentRemovalPending) { + try { + await request.provider.remove(prefix); + await assertPathMissing(prefix, `${request.name} environment was not removed`); + } catch (error) { + cleanupErrors.push(toError(error)); + } } } + if (ownershipVerified && !(await pathExists(prefix))) { + environmentCreated = false; + environment = undefined; + } } try { @@ -143,7 +173,7 @@ export async function createEnvironmentFixture( } } - if (projectRootCreated && (await pathExists(projectUri))) { + if (!environmentRemovalPending && projectRootCreated && (await pathExists(projectUri))) { try { if (markerWritten) { await verifyOwnership(projectUri, markerUri, token, prefix); @@ -161,6 +191,16 @@ export async function createEnvironmentFixture( } }; + const dispose = (): Promise => { + if (!disposePromise) { + disposePromise = cleanup().catch((error) => { + disposePromise = undefined; + throw error; + }); + } + return disposePromise; + }; + try { await assertPathMissing(projectUri, `Fixture directory already exists: ${projectUri.fsPath}`); await vscode.workspace.fs.createDirectory(projectUri); From 0601d345034b25de09fb15e28bba73ce72ab2f7f Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 13:30:34 -0700 Subject: [PATCH 15/20] test: preserve package refresh failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/test/common/testUtils.unit.test.ts | 9 ++++++ .../packageManager.integration.test.ts | 2 ++ ...ageManagerHeadlessConformance.unit.test.ts | 28 +++++++++++-------- src/test/testUtils.ts | 9 ++++-- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/test/common/testUtils.unit.test.ts b/src/test/common/testUtils.unit.test.ts index 526367a63..68471e399 100644 --- a/src/test/common/testUtils.unit.test.ts +++ b/src/test/common/testUtils.unit.test.ts @@ -71,6 +71,15 @@ suite('Test Utilities', () => { ); assert.ok(counter >= 3); }); + + test('should preserve condition errors when retries are disabled', async () => { + const conditionError = new Error('Package refresh failed'); + + await assert.rejects( + () => waitForCondition(() => Promise.reject(conditionError), 1000, 'Should not time out', 10, false), + (error: unknown) => error === conditionError, + ); + }); }); suite('retryUntilSuccess', () => { diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index b2aec8974..b03b5d40b 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -156,6 +156,7 @@ for (const profile of profiles) { 30_000, 'Package not installed', 1_000, + false, ); const directPackageNames = await vscode.commands.executeCommand( @@ -176,6 +177,7 @@ for (const profile of profiles) { 30_000, 'Package not uninstalled', 1_000, + false, ); } }); diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index 375e4dd58..9fece6d33 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -38,7 +38,7 @@ suite('Package manager headless conformance', () => { const condaPicker = sinon.stub(condaUtils, 'getCommonCondaPackagesToInstall'); const poetryInput = sinon.stub(windowApis, 'showInputBox'); - for (const manager of createManagers()) { + for (const manager of createManagers().all) { await manager.manage(environment, { install: [], runHeadless: true }); } @@ -61,7 +61,7 @@ suite('Package manager headless conformance', () => { const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); - for (const manager of createManagers()) { + for (const manager of createManagers().all) { await assert.rejects( manager.manage(environment, { install: ['requests'], runHeadless: true }), (error: unknown) => error === operationError, @@ -92,7 +92,7 @@ suite('Package manager headless conformance', () => { sinon.stub(PipPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); - const manager = createManagers()[0]; + const manager = createManagers().pip; await manager.manage(environment, { install: ['requests'], runHeadless: true }); await flushImmediate(); @@ -110,7 +110,7 @@ suite('Package manager headless conformance', () => { sinon.stub(condaUtils, 'runCondaExecutable').rejects(refreshError); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); - const manager = createManagers()[1]; + const manager = createManagers().conda; await assert.rejects( manager.manage(environment, { install: ['requests'], runHeadless: true }), @@ -141,7 +141,7 @@ suite('Package manager headless conformance', () => { }); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); - const manager = createManagers()[2]; + const manager = createManagers().poetry; await manager.manage(environment, { install: ['requests'], runHeadless: true }); await flushImmediate(); @@ -152,7 +152,12 @@ suite('Package manager headless conformance', () => { assert.ok(showErrorMessageWithLogs.notCalled); }); - function createManagers(): PackageManager[] { + function createManagers(): { + pip: PackageManager; + conda: PackageManager; + poetry: PackageManager; + all: PackageManager[]; + } { const api = { createPackageItem: sinon.stub(), getPythonProjects: sinon.stub().returns([]), @@ -163,11 +168,12 @@ suite('Package manager headless conformance', () => { info: sinon.stub(), show: sinon.stub(), } as unknown as LogOutputChannel; - return [ - new PipPackageManager(api, log, { getProjectsByEnvironment: sinon.stub().returns([]) } as unknown as VenvManager), - new CondaPackageManager(api, log), - new PoetryPackageManager(api, log, {} as PoetryManager), - ]; + const pip = new PipPackageManager(api, log, { + getProjectsByEnvironment: sinon.stub().returns([]), + } as unknown as VenvManager); + const conda = new CondaPackageManager(api, log); + const poetry = new PoetryPackageManager(api, log, {} as PoetryManager); + return { pip, conda, poetry, all: [pip, conda, poetry] }; } async function flushImmediate(): Promise { diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 87df07a5b..c920e6537 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -28,6 +28,7 @@ export function sleep(ms: number): Promise { * @param timeoutMs - Maximum time to wait (default: 10 seconds) * @param errorMessage - Error message if condition is not met * @param pollIntervalMs - How often to check condition (default: 100ms) + * @param retryOnError - Whether rejected conditions should be retried (default: true) * * @example * // Wait for extension to activate @@ -50,6 +51,7 @@ export async function waitForCondition( timeoutMs: number = 10_000, errorMessage: string | (() => string) = 'Condition not met within timeout', pollIntervalMs: number = 100, + retryOnError: boolean = true, ): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -61,8 +63,11 @@ export async function waitForCondition( resolve(); return; } - } catch { - // Condition threw - keep waiting + } catch (error) { + if (!retryOnError) { + reject(error); + return; + } } if (Date.now() - startTime >= timeoutMs) { From 26250c5d8f4512446042712b8a2edd40067789a0 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 14:12:45 -0700 Subject: [PATCH 16/20] fix: distinguish unsupported version lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/api.ts | 17 ++- src/common/errors/NotSupportedError.ts | 29 ++++ src/features/envCommands.ts | 17 ++- src/features/pythonApi.ts | 5 +- src/internal.api.ts | 10 +- src/managers/builtin/pipPackageManager.ts | 133 ++++++++++-------- src/managers/conda/condaPackageManager.ts | 42 +++--- src/managers/poetry/poetryPackageManager.ts | 6 +- .../packageManager.integration.test.ts | 35 +++-- .../builtin/pipPackageManager.unit.test.ts | 77 +++++++++- ...ageManagerHeadlessConformance.unit.test.ts | 37 ++++- .../conda/condaPackageManager.unit.test.ts | 17 +++ 12 files changed, 308 insertions(+), 117 deletions(-) diff --git a/src/api.ts b/src/api.ts index 2779d27b0..78d2f9c81 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,10 @@ import type { import { extensions } from 'vscode'; export type { Pep440Version } from '@renovatebot/pep440'; +export { + isPackageVersionLookupNotSupportedError, + PackageVersionLookupNotSupportedError, +} from './common/errors/NotSupportedError'; /* * Do not introduce any breaking changes to this API. @@ -749,8 +753,11 @@ export interface PackageManager { * Retrieves the list of available versions for a given package. * @param environment - The Python environment context for the lookup. * @param packageName - The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), - * or `undefined` if this manager does not support version listing. + * Managers that do not support version listing should reject with + * {@link PackageVersionLookupNotSupportedError}. + * + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). + * `undefined` remains supported for compatibility with existing manager implementations. */ getPackageAvailableVersions?( environment: PythonEnvironment, @@ -1140,12 +1147,12 @@ export interface PythonPackageGetterApi { * Get the list of available versions for a package, newest first. * * Support depends on the package manager backing the environment. Managers that do - * not implement version lookup resolve to `undefined`. + * not implement version lookup reject with {@link PackageVersionLookupNotSupportedError}. * * @param environment The Python Environment context for the lookup. * @param packageName The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), - * or `undefined` if the package manager does not support version listing. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). + * `undefined` remains supported for compatibility with existing manager implementations. */ getPackageAvailableVersions( environment: PythonEnvironment, diff --git a/src/common/errors/NotSupportedError.ts b/src/common/errors/NotSupportedError.ts index da1677058..7a8859232 100644 --- a/src/common/errors/NotSupportedError.ts +++ b/src/common/errors/NotSupportedError.ts @@ -11,3 +11,32 @@ export class RemoveEnvironmentNotSupported extends BaseError { super('NotSupported', message); } } + +/** + * Indicates that a package manager cannot list available package versions. + */ +export class PackageVersionLookupNotSupportedError extends BaseError { + readonly code = 'PackageVersionLookupNotSupported'; + + constructor(message: string) { + super('NotSupported', message); + } +} + +/** + * Checks whether an error represents unsupported package version lookup. + * + * The stable code check supports errors crossing extension bundle boundaries, + * where `instanceof` may not use the same class constructor. + */ +export function isPackageVersionLookupNotSupportedError( + error: unknown, +): error is PackageVersionLookupNotSupportedError { + return ( + error instanceof PackageVersionLookupNotSupportedError || + (typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'PackageVersionLookupNotSupported') + ); +} diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 1de8a13a6..8d8f2d43e 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -12,6 +12,8 @@ import { } from 'vscode'; import { CreateEnvironmentOptions, + isPackageVersionLookupNotSupportedError, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, @@ -363,10 +365,17 @@ export async function managePackageVersion(context: unknown, em: EnvironmentMana let version: string | undefined; // Try to fetch available versions for a QuickPick experience - const availableVersions = await withProgress( - { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, - () => packageManager.getPackageAvailableVersions(environment, pkg.name), - ); + let availableVersions: Pep440Version[] | undefined; + try { + availableVersions = await withProgress( + { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, + () => packageManager.getPackageAvailableVersions(environment, pkg.name), + ); + } catch (error) { + if (!isPackageVersionLookupNotSupportedError(error)) { + throw error; + } + } if (availableVersions && availableVersions.length > 0) { const items = availableVersions.map((v) => ({ diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index e93ed0cdb..edc7258de 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -33,6 +33,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../api'; +import { PackageVersionLookupNotSupportedError } from '../common/errors/NotSupportedError'; import { traceError, traceInfo } from '../common/logging'; import { pickEnvironmentManager } from '../common/pickers/managers'; import { timeout } from '../common/utils/asyncUtils'; @@ -326,7 +327,9 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { - return Promise.resolve(undefined); + throw new PackageVersionLookupNotSupportedError( + `No package manager supports version lookup for: ${context.envId.id}`, + ); } return manager.getPackageAvailableVersions(context, packageName); } diff --git a/src/internal.api.ts b/src/internal.api.ts index 6d41cb5c3..d9e0f6715 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -31,7 +31,11 @@ import { SetEnvironmentScope, } from './api'; import { ISSUES_URL } from './common/constants'; -import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from './common/errors/NotSupportedError'; +import { + CreateEnvironmentNotSupported, + PackageVersionLookupNotSupportedError, + RemoveEnvironmentNotSupported, +} from './common/errors/NotSupportedError'; import { traceWarn } from './common/logging'; import { StopWatch } from './common/stopWatch'; import { EventNames } from './common/telemetry/constants'; @@ -403,7 +407,9 @@ export class InternalPackageManager implements PackageManager { ): Promise { return this.manager.getPackageAvailableVersions ? this.manager.getPackageAvailableVersions(environment, packageName) - : Promise.resolve(undefined); + : Promise.reject( + new PackageVersionLookupNotSupportedError(`Package version lookup not supported by: ${this.id}`), + ); } getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index c8bedc24c..bc65c24f2 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -21,6 +21,7 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { PackageVersionLookupNotSupportedError } from '../../common/errors/NotSupportedError'; import { showErrorMessage, withProgress } from '../../common/window.apis'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { runPython, runUV, shouldUseUv } from './helpers'; @@ -162,21 +163,7 @@ export class PipPackageManager implements PackageManager, Disposable { async getVersion(environment: PythonEnvironment): Promise { try { const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const result = await runUV(['--version'], undefined, this.log); - // "uv X.Y.Z" - const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/); - return match ? (parse(match[1]) ?? undefined) : undefined; - } - const result = await runPython( - environment.execInfo?.run?.executable ?? 'python', - ['-m', 'pip', '--version'], - undefined, - this.log, - ); - // "pip X.Y.Z from /path/to/pip (python X.Y)" - const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); - return match ? (parse(match[1]) ?? undefined) : undefined; + return await this.getVersionOrThrow(environment, useUv); } catch { return undefined; } @@ -185,54 +172,69 @@ export class PipPackageManager implements PackageManager, Disposable { async getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, - ): Promise { - try { - const python = environment.execInfo?.run?.executable; - if (!python) { - return undefined; - } + ): Promise { + const python = environment.execInfo?.run?.executable; + if (!python) { + throw new Error(`Python executable is unavailable for environment: ${environment.envId.id}`); + } - const baseVersion = parse(environment.version)?.base_version; - if (!baseVersion) { - return undefined; - } - // uv - Run pip via `uv tool run pip` - const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const output = await runUV( - ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); - } + const baseVersion = getPythonVersionForPackageLookup(environment.version); + if (!baseVersion) { + throw new Error(`Python version is unavailable for environment: ${environment.envId.id}`); + } + // uv - Run pip via `uv tool run pip` + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + if (useUv) { + const output = await runUV( + ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], + undefined, + this.log, + ); + return requireAvailableVersions(parsePipIndexVersionsJson(output), 'uv'); + } - // 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, '25.1') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); - } + const pipVersion = await this.getVersionOrThrow(environment, false); + // pip >= 25.1 - use `pip index versions --json` to get available versions in a machine readable format. + if (compare(pipVersion.public, '25.1') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], + undefined, + this.log, + ); + return requireAvailableVersions(parsePipIndexVersionsJson(output), 'pip'); + } - 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); - } + if (compare(pipVersion.public, '21.2') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], + undefined, + this.log, + ); + return requireAvailableVersions(parsePipIndexVersionsText(output), 'pip'); + } - // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. - } catch { - return undefined; + throw new PackageVersionLookupNotSupportedError( + `Package version lookup requires pip 21.2 or newer; found ${pipVersion.public}`, + ); + } + + private async getVersionOrThrow(environment: PythonEnvironment, useUv: boolean): Promise { + const result = useUv + ? await runUV(['--version'], undefined, this.log) + : await runPython( + environment.execInfo?.run?.executable ?? 'python', + ['-m', 'pip', '--version'], + undefined, + this.log, + ); + const match = result.match(useUv ? /^uv\s+(\d+\.\d+(?:\.\d+)*)/ : /^pip\s+(\d+\.\d+(?:\.\d+)*)/); + const version = match ? parse(match[1]) : null; + if (!version) { + throw new Error(`Unable to parse ${useUv ? 'uv' : 'pip'} version from: ${result.trim()}`); } + return version; } dispose(): void { @@ -252,6 +254,21 @@ export class PipPackageManager implements PackageManager, Disposable { } } +function requireAvailableVersions( + versions: Pep440Version[] | undefined, + packageManager: 'pip' | 'uv', +): Pep440Version[] { + if (!versions) { + throw new Error(`Unable to parse available package versions from ${packageManager} output`); + } + return versions; +} + +function getPythonVersionForPackageLookup(version: string): string | undefined { + const match = version.match(/^\s*(\d+)\.(\d+)(?:\.(\d+))?/); + return match ? [match[1], match[2], match[3]].filter((segment) => segment !== undefined).join('.') : undefined; +} + /** * Parses JSON output from `pip index versions --json`. * Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 8f33c09a6..2b5b35bae 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -189,30 +189,26 @@ export class CondaPackageManager implements PackageManager, Disposable { async getPackageAvailableVersions( _environment: PythonEnvironment, packageName: string, - ): Promise { - try { - const output = await runCondaExecutable(['search', packageName, '--json'], this.log); - const parsed = JSON.parse(output); - if (parsed && typeof parsed === 'object' && Array.isArray(parsed[packageName])) { - const uniqueVersions = new Map(); - parsed[packageName] - .filter((entry: { version?: string }) => !!entry.version?.trim()) - .map((entry: { version?: string }) => parse(entry.version!)) - .filter((v: Pep440Version | null): v is Pep440Version => v !== null) - .forEach((version: Pep440Version) => { - if (!uniqueVersions.has(version.public)) { - uniqueVersions.set(version.public, version); - } - }); - - return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => - rcompare(a.public, b.public), - ); - } - return undefined; - } catch { - return undefined; + ): Promise { + const output = await runCondaExecutable(['search', packageName, '--json'], this.log); + const parsed = JSON.parse(output); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed[packageName])) { + throw new Error(`Conda returned invalid package version data for: ${packageName}`); } + const uniqueVersions = new Map(); + parsed[packageName] + .filter((entry: { version?: string }) => !!entry.version?.trim()) + .map((entry: { version?: string }) => parse(entry.version!)) + .filter((v: Pep440Version | null): v is Pep440Version => v !== null) + .forEach((version: Pep440Version) => { + if (!uniqueVersions.has(version.public)) { + uniqueVersions.set(version.public, version); + } + }); + + return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => + rcompare(a.public, b.public), + ); } getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] { diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 09696885b..aa8e746c5 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -24,6 +24,7 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { PackageVersionLookupNotSupportedError } from '../../common/errors/NotSupportedError'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; import { normalizePackageName } from '../builtin/utils'; @@ -176,11 +177,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { async getPackageAvailableVersions( _environment: PythonEnvironment, _packageName: string, - ): Promise { + ): Promise { // Poetry doesn't have a native "list available versions" command. // Poetry 2.x supports `poetry search` but it was disabled on PyPI. - // Return undefined to indicate this manager doesn't support version listing. - return undefined; + throw new PackageVersionLookupNotSupportedError('Poetry does not support package version lookup'); } formatInstallSpec(packageName: string, version: string): string { diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index b03b5d40b..b74b6d467 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -1,8 +1,12 @@ import * as vscode from 'vscode'; -import { compare } from '@renovatebot/pep440'; import assert from 'assert'; -import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { + isPackageVersionLookupNotSupportedError, + Package, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../api'; import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants'; import { ENVS_EXTENSION_ID } from '../constants'; import { waitForCondition } from '../testUtils'; @@ -21,7 +25,6 @@ interface PackageManagerProfile { packageName: string; packageManagerId: PackageManagerId; provider: EnvironmentFixtureProvider; - supportsVersionLookup(packages: Package[]): boolean; } const profiles: PackageManagerProfile[] = [ @@ -30,17 +33,12 @@ const profiles: PackageManagerProfile[] = [ packageName: 'requests', packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, provider: createVenvFixtureProvider(), - supportsVersionLookup: (packages) => { - const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; - return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; - }, }, { name: 'Conda', packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, provider: createCondaFixtureProvider(), - supportsVersionLookup: () => true, }, ]; @@ -183,19 +181,18 @@ for (const profile of profiles) { }); test(`${profile.name} Package Manager should list available package versions`, async function () { - const packages = await api.getPackages(environment!, { skipCache: true }); - assert.ok(packages, 'Unable to list packages before version lookup'); - if (!profile.supportsVersionLookup(packages)) { - this.skip(); - return; + let versions; + try { + versions = await api.getPackageAvailableVersions(environment!, profile.packageName); + } catch (error) { + if (isPackageVersionLookupNotSupportedError(error)) { + this.skip(); + return; + } + throw error; } - const versions = await api.getPackageAvailableVersions(environment!, profile.packageName); - // Accept undefined until the API can distinguish unsupported lookups from command or network failures. - // TODO: Add that result distinction and make supported lookups strict in a follow-up PR. - if (versions === undefined) { - return; - } + assert.ok(versions, `${profile.name} unexpectedly returned no package versions`); assert.ok(versions.length > 0, 'No package versions available'); }); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 549bdd2de..75f34d791 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -4,7 +4,13 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import { + isPackageVersionLookupNotSupportedError, + Package, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../../api'; +import * as helpers from '../../../managers/builtin/helpers'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; import * as builtinUtils from '../../../managers/builtin/utils'; import { VenvManager } from '../../../managers/builtin/venvManager'; @@ -60,4 +66,73 @@ suite('PipPackageManager', () => { assert.strictEqual(secondResult, undefined); assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); }); + + test('reports version lookup as unsupported for pip older than 21.2', async () => { + const manager = createManager(); + const environment = createEnvironment(); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'runPython').resolves('pip 20.3.4 from /path/to/pip (python 3.12)'); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests'), + isPackageVersionLookupNotSupportedError, + ); + }); + + test('propagates version lookup command failures for supported pip', async () => { + const manager = createManager(); + const environment = createEnvironment(); + const lookupError = new Error('pip index failed'); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const runPython = sinon.stub(helpers, 'runPython'); + runPython.onFirstCall().resolves('pip 25.1 from /path/to/pip (python 3.12)'); + runPython.onSecondCall().rejects(lookupError); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests'), + (error: unknown) => error === lookupError, + ); + }); + + test('normalizes discovered Python versions for pip lookup', async () => { + const manager = createManager(); + const environment = { + ...createEnvironment(), + version: '3.13.14.final.0', + }; + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const runPython = sinon.stub(helpers, 'runPython'); + runPython.onFirstCall().resolves('pip 25.1 from /path/to/pip (python 3.13)'); + runPython.onSecondCall().resolves(JSON.stringify({ versions: ['2.32.5'] })); + + await manager.getPackageAvailableVersions(environment, 'requests'); + + assert.deepStrictEqual(runPython.secondCall.args[1], [ + '-m', + 'pip', + 'index', + 'versions', + 'requests', + '--json', + '--python-version', + '3.13.14', + ]); + }); + + function createManager(): PipPackageManager { + return new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, + {} as VenvManager, + ); + } + + function createEnvironment(): PythonEnvironment { + return { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + execInfo: { run: { executable: 'python', args: [] } }, + version: '3.12.0', + } as unknown as PythonEnvironment; + } }); diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index 9fece6d33..e185b0a95 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -4,11 +4,17 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { PackageManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import { + isPackageVersionLookupNotSupportedError, + PackageManager, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../../api'; import * as childProcessApis from '../../../common/childProcess.apis'; import * as errorUtils from '../../../common/errors/utils'; import * as windowApis from '../../../common/window.apis'; import * as workspaceApis from '../../../common/workspace.apis'; +import { InternalPackageManager } from '../../../internal.api'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; import * as pipUtils from '../../../managers/builtin/pipUtils'; import * as builtinUtils from '../../../managers/builtin/utils'; @@ -152,6 +158,35 @@ suite('Package manager headless conformance', () => { assert.ok(showErrorMessageWithLogs.notCalled); }); + test('reports missing version lookup implementations as unsupported', async () => { + const manager = new InternalPackageManager('test:unsupported', { + name: 'unsupported', + manage: sinon.stub().resolves(), + refresh: sinon.stub().resolves(), + getPackages: sinon.stub().resolves([]), + }); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'requests'), + isPackageVersionLookupNotSupportedError, + ); + }); + + test('recognizes unsupported lookup errors across module boundaries', () => { + assert.ok( + isPackageVersionLookupNotSupportedError({ + code: 'PackageVersionLookupNotSupported', + }), + ); + }); + + test('reports Poetry version lookup as unsupported', async () => { + await assert.rejects( + createManagers().poetry.getPackageAvailableVersions!(environment, 'requests'), + isPackageVersionLookupNotSupportedError, + ); + }); + function createManagers(): { pip: PackageManager; conda: PackageManager; diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts index ea6614daf..e23631bbc 100644 --- a/src/test/managers/conda/condaPackageManager.unit.test.ts +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -38,4 +38,21 @@ suite('CondaPackageManager', () => { assert.ok(logError.calledOnce); assert.ok(showErrorMessageWithLogs.notCalled); }); + + test('propagates package version lookup failures', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + } as PythonEnvironment; + const manager = new CondaPackageManager( + {} as PythonEnvironmentApi, + { error: sinon.stub() } as unknown as LogOutputChannel, + ); + const lookupError = new Error('conda search failed'); + sinon.stub(condaUtils, 'runCondaExecutable').rejects(lookupError); + + await assert.rejects( + manager.getPackageAvailableVersions(environment, 'flask'), + (error: unknown) => error === lookupError, + ); + }); }); From 76c45afd325dbf54065e2f96c968af5e7bd344c8 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 14:18:57 -0700 Subject: [PATCH 17/20] test: harden package integration retries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/test/common/testUtils.unit.test.ts | 22 +++++++++++++ src/test/integration/environmentFixture.ts | 32 ++++++++++++++----- .../environmentFixture.unit.test.ts | 30 +++++++++++++++++ .../packageManager.integration.test.ts | 6 ++-- src/test/testUtils.ts | 9 ++++++ 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 src/test/integration/environmentFixture.unit.test.ts diff --git a/src/test/common/testUtils.unit.test.ts b/src/test/common/testUtils.unit.test.ts index 68471e399..01c75e9ed 100644 --- a/src/test/common/testUtils.unit.test.ts +++ b/src/test/common/testUtils.unit.test.ts @@ -80,6 +80,28 @@ suite('Test Utilities', () => { (error: unknown) => error === conditionError, ); }); + + test('should retry and preserve the last condition error at timeout', async () => { + const conditionError = new Error('Package refresh failed'); + let attempts = 0; + + await assert.rejects( + () => + waitForCondition( + () => { + attempts++; + return Promise.reject(conditionError); + }, + 50, + 'Should preserve the refresh error', + 10, + true, + true, + ), + (error: unknown) => error === conditionError, + ); + assert.ok(attempts > 1, 'The rejected condition should be retried before timing out'); + }); }); suite('retryUntilSuccess', () => { diff --git a/src/test/integration/environmentFixture.ts b/src/test/integration/environmentFixture.ts index 02a0ae0bf..41ef6616c 100644 --- a/src/test/integration/environmentFixture.ts +++ b/src/test/integration/environmentFixture.ts @@ -75,9 +75,19 @@ export async function createEnvironmentFixture( let projectSettingAdded = false; let environmentCreated = false; let environment: PythonEnvironment | undefined; + let apiRemovalSettled = false; let disposePromise: Promise | undefined; let markerWritten = false; let projectRootCreated = false; + const getApiRemoval = createSingleFlightOperation(() => { + if (!environment) { + return Promise.resolve(); + } + apiRemovalSettled = false; + return api.removeEnvironment(environment, { runHeadless: true }).finally(() => { + apiRemovalSettled = true; + }); + }); const cleanup = async (): Promise => { const cleanupErrors: Error[] = []; @@ -92,15 +102,10 @@ export async function createEnvironmentFixture( cleanupErrors.push(toError(error)); } if (ownershipVerified && environment) { - let apiRemovalSettled = false; - const apiRemoval = api - .removeEnvironment(environment, { runHeadless: true }) - .finally(() => { - apiRemovalSettled = true; - }); + const apiRemovalPromise = getApiRemoval(); try { await withTimeout( - apiRemoval, + apiRemovalPromise, COMMAND_TIMEOUT_MS, `${request.name} API environment removal timed out`, ); @@ -109,7 +114,7 @@ export async function createEnvironmentFixture( if (!apiRemovalSettled) { try { await withTimeout( - apiRemoval, + apiRemovalPromise, API_REMOVAL_SETTLE_TIMEOUT_MS, `${request.name} API environment removal did not settle after timing out`, ); @@ -537,6 +542,17 @@ async function withTimeout(operation: Promise, timeoutMs: number, message: } } +/** + * Returns a function that starts an asynchronous operation at most once and shares its promise. + */ +export function createSingleFlightOperation(operation: () => Promise): () => Promise { + let promise: Promise | undefined; + return () => { + promise ??= operation(); + return promise; + }; +} + function sanitizeName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9-]/g, '-'); } diff --git a/src/test/integration/environmentFixture.unit.test.ts b/src/test/integration/environmentFixture.unit.test.ts new file mode 100644 index 000000000..3b8f1859e --- /dev/null +++ b/src/test/integration/environmentFixture.unit.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { createSingleFlightOperation } from './environmentFixture'; + +suite('Environment fixture helpers', () => { + test('shares a delayed operation across retries', async () => { + let calls = 0; + let resolveOperation: (() => void) | undefined; + const getOperation = createSingleFlightOperation( + () => + new Promise((resolve) => { + calls++; + resolveOperation = resolve; + }), + ); + + const first = getOperation(); + const retry = getOperation(); + + assert.strictEqual(retry, first); + assert.strictEqual(calls, 1); + assert.ok(resolveOperation); + resolveOperation(); + await Promise.all([first, retry]); + assert.strictEqual(getOperation(), first); + assert.strictEqual(calls, 1); + }); +}); diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index b74b6d467..62c858399 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -154,7 +154,8 @@ for (const profile of profiles) { 30_000, 'Package not installed', 1_000, - false, + true, + true, ); const directPackageNames = await vscode.commands.executeCommand( @@ -175,7 +176,8 @@ for (const profile of profiles) { 30_000, 'Package not uninstalled', 1_000, - false, + true, + true, ); } }); diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index c920e6537..3bb62ccde 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -29,6 +29,7 @@ export function sleep(ms: number): Promise { * @param errorMessage - Error message if condition is not met * @param pollIntervalMs - How often to check condition (default: 100ms) * @param retryOnError - Whether rejected conditions should be retried (default: true) + * @param rejectWithLastError - Whether a timeout after rejected conditions should preserve the last error * * @example * // Wait for extension to activate @@ -52,13 +53,16 @@ export async function waitForCondition( errorMessage: string | (() => string) = 'Condition not met within timeout', pollIntervalMs: number = 100, retryOnError: boolean = true, + rejectWithLastError: boolean = false, ): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); + let lastError: unknown; const checkCondition = async () => { try { const result = await condition(); + lastError = undefined; if (result) { resolve(); return; @@ -68,9 +72,14 @@ export async function waitForCondition( reject(error); return; } + lastError = error; } if (Date.now() - startTime >= timeoutMs) { + if (rejectWithLastError && lastError !== undefined) { + reject(lastError); + return; + } const msg = typeof errorMessage === 'function' ? errorMessage() : errorMessage; reject(new Error(`${msg} (waited ${timeoutMs}ms)`)); return; From a0fe872d78cac86baf2a5134317ade2fa651e013 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 14:41:53 -0700 Subject: [PATCH 18/20] test: assert version lookup capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- .../packageManager.integration.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index 62c858399..bb3da1e19 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; +import { compare } from '@renovatebot/pep440'; import assert from 'assert'; import { isPackageVersionLookupNotSupportedError, @@ -25,6 +26,7 @@ interface PackageManagerProfile { packageName: string; packageManagerId: PackageManagerId; provider: EnvironmentFixtureProvider; + supportsVersionLookup(packages: Package[]): boolean | undefined; } const profiles: PackageManagerProfile[] = [ @@ -33,12 +35,17 @@ const profiles: PackageManagerProfile[] = [ packageName: 'requests', packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, provider: createVenvFixtureProvider(), + supportsVersionLookup: (packages) => { + const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; + return pipVersion === undefined ? undefined : compare(pipVersion, '21.2') >= 0; + }, }, { name: 'Conda', packageName: 'flask', packageManagerId: CONDA_MANAGER_ID, provider: createCondaFixtureProvider(), + supportsVersionLookup: () => true, }, ]; @@ -183,17 +190,36 @@ for (const profile of profiles) { }); test(`${profile.name} Package Manager should list available package versions`, async function () { + const packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages before version lookup'); + const supportsVersionLookup = profile.supportsVersionLookup(packages); + assert.notStrictEqual( + supportsVersionLookup, + undefined, + `${profile.name} version lookup capability could not be determined`, + ); + let versions; try { versions = await api.getPackageAvailableVersions(environment!, profile.packageName); } catch (error) { if (isPackageVersionLookupNotSupportedError(error)) { + assert.strictEqual( + supportsVersionLookup, + false, + `${profile.name} unexpectedly reported version lookup as unsupported`, + ); this.skip(); return; } throw error; } + assert.strictEqual( + supportsVersionLookup, + true, + `${profile.name} returned versions despite declaring lookup unsupported`, + ); assert.ok(versions, `${profile.name} unexpectedly returned no package versions`); assert.ok(versions.length > 0, 'No package versions available'); }); From afac6d21ad2c13a36f57c9d9418a0bc94e6b8a91 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 14:47:34 -0700 Subject: [PATCH 19/20] fix: keep version lookup error internal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e --- src/api.ts | 17 +++++------------ src/features/envCommands.ts | 2 +- .../packageManager.integration.test.ts | 8 ++------ .../builtin/pipPackageManager.unit.test.ts | 8 ++------ ...ckageManagerHeadlessConformance.unit.test.ts | 8 ++------ 5 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/api.ts b/src/api.ts index 78d2f9c81..2779d27b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,10 +18,6 @@ import type { import { extensions } from 'vscode'; export type { Pep440Version } from '@renovatebot/pep440'; -export { - isPackageVersionLookupNotSupportedError, - PackageVersionLookupNotSupportedError, -} from './common/errors/NotSupportedError'; /* * Do not introduce any breaking changes to this API. @@ -753,11 +749,8 @@ export interface PackageManager { * Retrieves the list of available versions for a given package. * @param environment - The Python environment context for the lookup. * @param packageName - The name of the package to look up. - * Managers that do not support version listing should reject with - * {@link PackageVersionLookupNotSupportedError}. - * - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). - * `undefined` remains supported for compatibility with existing manager implementations. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), + * or `undefined` if this manager does not support version listing. */ getPackageAvailableVersions?( environment: PythonEnvironment, @@ -1147,12 +1140,12 @@ export interface PythonPackageGetterApi { * Get the list of available versions for a package, newest first. * * Support depends on the package manager backing the environment. Managers that do - * not implement version lookup reject with {@link PackageVersionLookupNotSupportedError}. + * not implement version lookup resolve to `undefined`. * * @param environment The Python Environment context for the lookup. * @param packageName The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). - * `undefined` remains supported for compatibility with existing manager implementations. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), + * or `undefined` if the package manager does not support version listing. */ getPackageAvailableVersions( environment: PythonEnvironment, diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 8d8f2d43e..bbbdaea14 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -12,7 +12,6 @@ import { } from 'vscode'; import { CreateEnvironmentOptions, - isPackageVersionLookupNotSupportedError, Pep440Version, PythonEnvironment, PythonEnvironmentApi, @@ -20,6 +19,7 @@ import { PythonProjectCreator, PythonProjectCreatorOptions, } from '../api'; +import { isPackageVersionLookupNotSupportedError } from '../common/errors/NotSupportedError'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; import { EnvironmentManagers, diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index bb3da1e19..f832c9275 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -2,13 +2,9 @@ import * as vscode from 'vscode'; import { compare } from '@renovatebot/pep440'; import assert from 'assert'; -import { - isPackageVersionLookupNotSupportedError, - Package, - PythonEnvironment, - PythonEnvironmentApi, -} from '../../api'; +import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants'; +import { isPackageVersionLookupNotSupportedError } from '../../common/errors/NotSupportedError'; import { ENVS_EXTENSION_ID } from '../constants'; import { waitForCondition } from '../testUtils'; import { diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 75f34d791..3bee115f8 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -4,12 +4,8 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { - isPackageVersionLookupNotSupportedError, - Package, - PythonEnvironment, - PythonEnvironmentApi, -} from '../../../api'; +import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import { isPackageVersionLookupNotSupportedError } from '../../../common/errors/NotSupportedError'; import * as helpers from '../../../managers/builtin/helpers'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; import * as builtinUtils from '../../../managers/builtin/utils'; diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index e185b0a95..4fd261fe6 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -4,13 +4,9 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { - isPackageVersionLookupNotSupportedError, - PackageManager, - PythonEnvironment, - PythonEnvironmentApi, -} from '../../../api'; +import { PackageManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import * as childProcessApis from '../../../common/childProcess.apis'; +import { isPackageVersionLookupNotSupportedError } from '../../../common/errors/NotSupportedError'; import * as errorUtils from '../../../common/errors/utils'; import * as windowApis from '../../../common/window.apis'; import * as workspaceApis from '../../../common/workspace.apis'; From eb998c545d6967cb20fb98fdfcb3f9bcf48d4ad9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 20 Aug 2026 09:38:52 -0700 Subject: [PATCH 20/20] fix: stabilize pip package integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb5f0ccb-cc13-4185-af62-e49f660c239a --- .../instructions/testing-workflow.instructions.md | 4 ++++ src/managers/builtin/utils.ts | 2 +- .../builtin/pipPackageRefresh.unit.test.ts | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 68958773b..7cf42a888 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -16,6 +16,10 @@ This guide covers the full testing lifecycle: 4. **🛠️ Fixing Problems** - Resolve compilation and runtime issues 5. **✅ Validation** - Ensure coverage and resilience +## Learnings + +- Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1). + ### When to Use This Guide **User Requests Testing:** diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index f6ff2903a..cdabf6d1e 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -200,7 +200,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne try { return await runPython( environment.execInfo.run.executable, - ['-m', 'pip', 'list', '--format=json', ...(args ?? [])], + ['-m', 'pip', 'list', '--format=json', '--disable-pip-version-check', ...(args ?? [])], undefined, log, undefined, diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts index dff10003c..fe2349cc9 100644 --- a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -50,4 +50,19 @@ suite('Pip package refresh', () => { assert.strictEqual(result, undefined); assert.ok(showErrorMessageWithLogsStub.notCalled); }); + + test('disables the pip version check when listing packages', async () => { + const runPythonStub = helpers.runPython as sinon.SinonStub; + runPythonStub.resolves('[]'); + + await refreshPipPackages(environment, log); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], [ + '-m', + 'pip', + 'list', + '--format=json', + '--disable-pip-version-check', + ]); + }); });