From de91d2d00f0215e1c8856b92832957915b10b56a Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:13:05 -0800 Subject: [PATCH 01/13] bump: update version to 1.21.0 in package.json (#1242) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b996ecb..097880ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-python-envs", - "version": "1.20.0", + "version": "1.21.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-python-envs", - "version": "1.20.0", + "version": "1.21.0", "dependencies": { "@iarna/toml": "^2.2.5", "@vscode/extension-telemetry": "^0.9.7", diff --git a/package.json b/package.json index df3b086a..e5200a9a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-python-envs", "displayName": "Python Environments", "description": "Provides a unified python environment experience", - "version": "1.20.0", + "version": "1.21.0", "publisher": "ms-python", "preview": true, "engines": { From 3fe2b794216a1c87c4065d741999433c59a88580 Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:24:35 -0800 Subject: [PATCH 02/13] fix bug: getDedicatedTerminal errors on string value for supported terminalKey (#1231) fixes https://github.com/microsoft/vscode-python-environments/issues/1230 --- src/features/terminal/terminalManager.ts | 32 ++--- .../terminal/terminalManager.unit.test.ts | 116 ++++++++++++++++-- 2 files changed, 127 insertions(+), 21 deletions(-) diff --git a/src/features/terminal/terminalManager.ts b/src/features/terminal/terminalManager.ts index 9da1ecd5..5a6e9ff2 100644 --- a/src/features/terminal/terminalManager.ts +++ b/src/features/terminal/terminalManager.ts @@ -59,12 +59,7 @@ export interface TerminalInit { } export interface TerminalManager - extends TerminalEnvironment, - TerminalInit, - TerminalActivation, - TerminalCreation, - TerminalGetters, - Disposable {} + extends TerminalEnvironment, TerminalInit, TerminalActivation, TerminalCreation, TerminalGetters, Disposable {} export class TerminalManagerImpl implements TerminalManager { private disposables: Disposable[] = []; @@ -321,7 +316,7 @@ export class TerminalManagerImpl implements TerminalManager { private dedicatedTerminals = new Map(); async getDedicatedTerminal( - terminalKey: Uri, + terminalKey: Uri | string, project: Uri | PythonProject, environment: PythonEnvironment, createNew: boolean = false, @@ -336,17 +331,26 @@ export class TerminalManagerImpl implements TerminalManager { } const puri = project instanceof Uri ? project : project.uri; - const config = getConfiguration('python', terminalKey); + const config = getConfiguration('python', terminalKey instanceof Uri ? terminalKey : puri); const projectStat = await fsapi.stat(puri.fsPath); const projectDir = projectStat.isDirectory() ? puri.fsPath : path.dirname(puri.fsPath); - const uriStat = await fsapi.stat(terminalKey.fsPath); - const uriDir = uriStat.isDirectory() ? terminalKey.fsPath : path.dirname(terminalKey.fsPath); - const cwd = config.get('terminal.executeInFileDir', false) ? uriDir : projectDir; + let cwd: string; + let name: string; + + if (terminalKey instanceof Uri) { + const uriStat = await fsapi.stat(terminalKey.fsPath); + const uriDir = uriStat.isDirectory() ? terminalKey.fsPath : path.dirname(terminalKey.fsPath); + cwd = config.get('terminal.executeInFileDir', false) ? uriDir : projectDir; + // Follow Python extension's naming: 'Python: {filename}' for dedicated terminals + const fileName = path.basename(terminalKey.fsPath).replace('.py', ''); + name = `Python: ${fileName}`; + } else { + // When terminalKey is a string, use project directory and the string as name + cwd = projectDir; + name = `Python: ${terminalKey}`; + } - // Follow Python extension's naming: 'Python: {filename}' for dedicated terminals - const fileName = path.basename(terminalKey.fsPath).replace('.py', ''); - const name = `Python: ${fileName}`; const newTerminal = await this.create(environment, { cwd, name }); this.dedicatedTerminals.set(key, newTerminal); diff --git a/src/test/features/terminal/terminalManager.unit.test.ts b/src/test/features/terminal/terminalManager.unit.test.ts index 3e1862d2..8d26547c 100644 --- a/src/test/features/terminal/terminalManager.unit.test.ts +++ b/src/test/features/terminal/terminalManager.unit.test.ts @@ -6,16 +6,22 @@ import * as fsapi from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { Disposable, Event, EventEmitter, Progress, Terminal, TerminalOptions, Uri, WorkspaceConfiguration } from 'vscode'; +import { + Disposable, + Event, + EventEmitter, + Progress, + Terminal, + TerminalOptions, + Uri, + WorkspaceConfiguration, +} from 'vscode'; import { PythonEnvironment } from '../../../api'; import * as windowApis from '../../../common/window.apis'; import * as workspaceApis from '../../../common/workspace.apis'; import * as activationUtils from '../../../features/common/activation'; import * as shellDetector from '../../../features/common/shellDetector'; -import { - ShellEnvsProvider, - ShellStartupScriptProvider, -} from '../../../features/terminal/shells/startupProvider'; +import { ShellEnvsProvider, ShellStartupScriptProvider } from '../../../features/terminal/shells/startupProvider'; import { DidChangeTerminalActivationStateEvent, TerminalActivationInternal, @@ -309,13 +315,109 @@ suite('TerminalManager - terminal naming', () => { try { await terminalManager.getProjectTerminal(projectUri, env); + assert.strictEqual(optionsList[0]?.name, 'Python', 'Project terminal should use the Python title'); + } finally { + await fsapi.remove(tempRoot); + } + }); + + // Regression test for https://github.com/microsoft/vscode-python-environments/issues/1230 + test('getDedicatedTerminal with string key uses string as terminal name', async () => { + mockGetAutoActivationType.returns(terminalUtils.ACT_TYPE_OFF); + terminalManager = createTerminalManager(); + const env = createMockEnvironment(); + + const optionsList: TerminalOptions[] = []; + createTerminalStub.callsFake((options) => { + optionsList.push(options); + return mockTerminal as Terminal; + }); + + const tempRoot = await fsapi.mkdtemp(path.join(os.tmpdir(), 'py-envs-')); + const projectPath = path.join(tempRoot, 'project'); + await fsapi.ensureDir(projectPath); + const projectUri = Uri.file(projectPath); + + const config = { get: sinon.stub().returns(false) } as unknown as WorkspaceConfiguration; + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + try { + await terminalManager.getDedicatedTerminal('my-terminal-key', projectUri, env); + assert.strictEqual( optionsList[0]?.name, - 'Python', - 'Project terminal should use the Python title', + 'Python: my-terminal-key', + 'Dedicated terminal with string key should use the string in the title', + ); + assert.strictEqual( + Uri.file(optionsList[0]?.cwd as string).fsPath, + Uri.file(projectPath).fsPath, + 'Dedicated terminal with string key should use project directory as cwd', ); } finally { await fsapi.remove(tempRoot); } }); + + // Regression test for https://github.com/microsoft/vscode-python-environments/issues/1230 + test('getDedicatedTerminal with string key reuses terminal for same key', async () => { + mockGetAutoActivationType.returns(terminalUtils.ACT_TYPE_OFF); + terminalManager = createTerminalManager(); + const env = createMockEnvironment(); + + let createCount = 0; + createTerminalStub.callsFake((options) => { + createCount++; + return { ...mockTerminal, name: options.name } as Terminal; + }); + + const tempRoot = await fsapi.mkdtemp(path.join(os.tmpdir(), 'py-envs-')); + const projectPath = path.join(tempRoot, 'project'); + await fsapi.ensureDir(projectPath); + const projectUri = Uri.file(projectPath); + + const config = { get: sinon.stub().returns(false) } as unknown as WorkspaceConfiguration; + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + try { + const terminal1 = await terminalManager.getDedicatedTerminal('my-key', projectUri, env); + const terminal2 = await terminalManager.getDedicatedTerminal('my-key', projectUri, env); + + assert.strictEqual(terminal1, terminal2, 'Same string key should return the same terminal'); + assert.strictEqual(createCount, 1, 'Terminal should be created only once'); + } finally { + await fsapi.remove(tempRoot); + } + }); + + // Regression test for https://github.com/microsoft/vscode-python-environments/issues/1230 + test('getDedicatedTerminal with string key uses different terminals for different keys', async () => { + mockGetAutoActivationType.returns(terminalUtils.ACT_TYPE_OFF); + terminalManager = createTerminalManager(); + const env = createMockEnvironment(); + + let createCount = 0; + createTerminalStub.callsFake((options) => { + createCount++; + return { ...mockTerminal, name: options.name, id: createCount } as unknown as Terminal; + }); + + const tempRoot = await fsapi.mkdtemp(path.join(os.tmpdir(), 'py-envs-')); + const projectPath = path.join(tempRoot, 'project'); + await fsapi.ensureDir(projectPath); + const projectUri = Uri.file(projectPath); + + const config = { get: sinon.stub().returns(false) } as unknown as WorkspaceConfiguration; + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + try { + const terminal1 = await terminalManager.getDedicatedTerminal('key-1', projectUri, env); + const terminal2 = await terminalManager.getDedicatedTerminal('key-2', projectUri, env); + + assert.notStrictEqual(terminal1, terminal2, 'Different string keys should return different terminals'); + assert.strictEqual(createCount, 2, 'Two terminals should be created'); + } finally { + await fsapi.remove(tempRoot); + } + }); }); From b546ac4dba2f2425255048a715b897b9ba257b0e Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:30:59 -0800 Subject: [PATCH 03/13] add support for `CONDA_PREFIX` set already in vscode environment (#1243) fixes https://github.com/microsoft/vscode-python/issues/23571 --- src/common/localize.ts | 3 +++ src/extension.ts | 5 +++-- src/managers/conda/condaEnvManager.ts | 12 +++++++++++- src/managers/conda/condaUtils.ts | 4 ++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index e099463d..dd7c637f 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -168,6 +168,9 @@ export namespace CondaStrings { export const condaMissingPythonNoFix = l10n.t( 'No Python found in the selected conda environment. Please select another environment or install Python manually.', ); + export const condaCondaPrefixActive = l10n.t( + 'CONDA_PREFIX is set for this VS Code session. Selection saved for new terminals only.', + ); } export namespace PyenvStrings { diff --git a/src/extension.ts b/src/extension.ts index 75c60395..b03c5132 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -494,8 +494,9 @@ export async function activate(context: ExtensionContext): Promise { + if (process.env.CONDA_PREFIX) { + return process.env.CONDA_PREFIX; + } + const state = await getWorkspacePersistentState(); const data: { [key: string]: string } | undefined = await state.get(CONDA_WORKSPACE_KEY); if (data) { From 24ec7b84db1aefb3cc3dcc6a9297a768ff0b9de4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:30:35 -0800 Subject: [PATCH 04/13] chore(deps-dev): bump qs from 6.14.1 to 6.15.0 (#1238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.15.0.
Changelog

Sourced from qs's changelog.

6.15.0

  • [New] parse: add strictMerge option to wrap object/primitive conflicts in an array (#425, #122)
  • [Fix] duplicates option should not apply to bracket notation keys (#514)

6.14.2

  • [Fix] parse: mark overflow objects for indexed notation exceeding arrayLimit (#546)
  • [Fix] arrayLimit means max count, not max index, in combine/merge/parseArrayValue
  • [Fix] parse: throw on arrayLimit exceeded with indexed notation when throwOnLimitExceeded is true (#529)
  • [Fix] parse: enforce arrayLimit on comma-parsed values
  • [Fix] parse: fix error message to reflect arrayLimit as max index; remove extraneous comments (#545)
  • [Robustness] avoid .push, use void
  • [readme] document that addQueryPrefix does not add ? to empty output (#418)
  • [readme] clarify parseArrays and arrayLimit documentation (#543)
  • [readme] replace runkit CI badge with shields.io check-runs badge
  • [meta] fix changelog typo (arrayLengtharrayLimit)
  • [actions] fix rebase workflow permissions
Commits
  • d9b4c66 v6.15.0
  • cb41a54 [New] parse: add strictMerge option to wrap object/primitive conflicts in...
  • 88e1563 [Fix] duplicates option should not apply to bracket notation keys
  • 9d441d2 Merge backport release tags v6.0.6–v6.13.3 into main
  • 85cc8ca v6.12.5
  • ffc12aa v6.11.4
  • 0506b11 [actions] update reusable workflows
  • 6a37faf [actions] update reusable workflows
  • 8e8df5a [Fix] fix regressions from robustness refactor
  • d60bab3 v6.10.7
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.1&new-version=6.15.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/vscode-python-environments/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 097880ba..c312372c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4375,9 +4375,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "dependencies": { "side-channel": "^1.1.0" @@ -8853,9 +8853,9 @@ "dev": true }, "qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "requires": { "side-channel": "^1.1.0" From d0cb590081abc0127fbbd35aa5d9715b65bba11a Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 18 Feb 2026 08:59:13 -0800 Subject: [PATCH 05/13] chore: add execProcess wrapper and refactor poetryUtils (Fixes #1239) (#1240) This PR adds an `execProcess` wrapper to `childProcess.apis.ts` and refactors `poetryUtils.ts` to use it, improving testability. **Changes:** - Added `execProcess` function to `src/common/childProcess.apis.ts` as a wrapper around `cp.exec` with `PYTHONUTF8` handling - Refactored `src/managers/poetry/poetryUtils.ts` to use `execProcess` instead of direct `child_process` import - Fixed a pre-existing regex bug in `getPoetryVersion` that didn't handle the space in Poetry 1.x version output format - Added unit tests in `poetryUtils.unit.test.ts` demonstrating the mocking pattern for `execProcess` **Testing:** - All unit tests pass (660 passing) - Lint passes with no errors **Note:** This is a partial implementation of #1239. The acceptance criteria for auditing `fs` imports is deferred to a future PR to keep this change focused. For #1239 --- src/common/childProcess.apis.ts | 45 ++++++++++++++- src/managers/poetry/poetryUtils.ts | 13 ++--- .../managers/poetry/poetryUtils.unit.test.ts | 55 ++++++++++++++++++- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/common/childProcess.apis.ts b/src/common/childProcess.apis.ts index 7e7d1f4e..c28dc624 100644 --- a/src/common/childProcess.apis.ts +++ b/src/common/childProcess.apis.ts @@ -1,9 +1,49 @@ import * as cp from 'child_process'; +import { promisify } from 'util'; + +const cpExec = promisify(cp.exec); + +/** + * Result of execProcess - contains stdout and stderr as strings. + */ +export interface ExecResult { + stdout: string; + stderr: string; +} + +/** + * Executes a command and returns the result as a promise. + * This function abstracts cp.exec to make it easier to mock in tests. + * + * Environment handling: process.env is always inherited, with options.env merged on top. + * PYTHONUTF8='1' is set as a fallback (can be overridden by process.env or options.env). + * + * @param command The command to execute (can include arguments). + * @param options Optional execution options. + * @returns A promise that resolves with { stdout, stderr } strings. + */ +export async function execProcess(command: string, options?: cp.ExecOptions): Promise { + // Sets PYTHONUTF8='1' as fallback, then inherits process.env, then merges options.env overrides + const env = { + PYTHONUTF8: '1', + ...process.env, + ...options?.env, + }; + // Force encoding: 'utf8' to guarantee string output (cp.exec can return Buffers otherwise) + const result = await cpExec(command, { ...options, env, encoding: 'utf8' }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} /** * Spawns a new process using the specified command and arguments. * This function abstracts cp.spawn to make it easier to mock in tests. * + * Environment handling: process.env is always inherited, with options.env merged on top. + * PYTHONUTF8='1' is set as a fallback (can be overridden by process.env or options.env). + * * When stdio: 'pipe' is used, returns ChildProcessWithoutNullStreams. * Otherwise returns the standard ChildProcess. */ @@ -24,10 +64,11 @@ export function spawnProcess( args: string[], options?: cp.SpawnOptions, ): cp.ChildProcess | cp.ChildProcessWithoutNullStreams { - // Set PYTHONUTF8=1; user-provided PYTHONUTF8 values take precedence. + // Sets PYTHONUTF8='1' as fallback, then inherits process.env, then merges options.env overrides const env = { PYTHONUTF8: '1', - ...(options?.env ?? process.env), + ...process.env, + ...options?.env, }; return cp.spawn(command, args, { ...options, env }); } diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index 217f5885..bac3d40e 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { Uri } from 'vscode'; import which from 'which'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; +import { execProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; @@ -190,7 +191,7 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise { try { - const { stdout } = await exec(`"${poetry}" --version`); + const { stdout } = await execProcess(`"${poetry}" --version`); // Handle both formats: // Old: "Poetry version 1.5.1" // New: "Poetry (version 2.1.3)" traceInfo(`Poetry version output: ${stdout.trim()}`); - const match = stdout.match(/Poetry (?:version|[\(\s]+version[\s\)]+)([0-9]+\.[0-9]+\.[0-9]+)/i); + const match = stdout.match(/Poetry (?:version |[\(\s]+version[\s\)]+)([0-9]+\.[0-9]+\.[0-9]+)/i); return match ? match[1] : undefined; } catch { return undefined; diff --git a/src/test/managers/poetry/poetryUtils.unit.test.ts b/src/test/managers/poetry/poetryUtils.unit.test.ts index c57bf557..e1bd44e0 100644 --- a/src/test/managers/poetry/poetryUtils.unit.test.ts +++ b/src/test/managers/poetry/poetryUtils.unit.test.ts @@ -1,9 +1,14 @@ import assert from 'node:assert'; import * as sinon from 'sinon'; -import { isPoetryVirtualenvsInProject, nativeToPythonEnv } from '../../../managers/poetry/poetryUtils'; -import * as utils from '../../../managers/common/utils'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; +import * as childProcessApis from '../../../common/childProcess.apis'; import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder'; +import * as utils from '../../../managers/common/utils'; +import { + getPoetryVersion, + isPoetryVirtualenvsInProject, + nativeToPythonEnv, +} from '../../../managers/poetry/poetryUtils'; suite('isPoetryVirtualenvsInProject', () => { test('should return false when env var is not set', () => { @@ -157,3 +162,49 @@ suite('nativeToPythonEnv - POETRY_VIRTUALENVS_IN_PROJECT integration', () => { assert.strictEqual(capturedInfo!.group, undefined, 'Non-global path should not be global'); }); }); + +suite('getPoetryVersion - childProcess.apis mocking pattern', () => { + let execProcessStub: sinon.SinonStub; + + setup(() => { + execProcessStub = sinon.stub(childProcessApis, 'execProcess'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should parse Poetry 1.x version format', async () => { + execProcessStub.resolves({ stdout: 'Poetry version 1.5.1\n', stderr: '' }); + + const version = await getPoetryVersion('/usr/bin/poetry'); + + assert.strictEqual(version, '1.5.1'); + assert.ok(execProcessStub.calledOnce); + assert.ok(execProcessStub.calledWith('"/usr/bin/poetry" --version')); + }); + + test('should parse Poetry 2.x version format', async () => { + execProcessStub.resolves({ stdout: 'Poetry (version 2.1.3)\n', stderr: '' }); + + const version = await getPoetryVersion('/usr/bin/poetry'); + + assert.strictEqual(version, '2.1.3'); + }); + + test('should return undefined when command fails', async () => { + execProcessStub.rejects(new Error('Command not found')); + + const version = await getPoetryVersion('/nonexistent/poetry'); + + assert.strictEqual(version, undefined); + }); + + test('should return undefined when output does not match expected format', async () => { + execProcessStub.resolves({ stdout: 'unexpected output', stderr: '' }); + + const version = await getPoetryVersion('/usr/bin/poetry'); + + assert.strictEqual(version, undefined); + }); +}); From 7b0486fcea86e35a7ed3a91a2bad2d6d287a11c6 Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:12:28 -0800 Subject: [PATCH 06/13] fix pipenv and poetry setting (#1244) ensure that pipenvPath and poetryPath are supported as settings fixes https://github.com/microsoft/vscode-python-environments/issues/912 and https://github.com/microsoft/vscode-python-environments/issues/918 --- src/managers/pipenv/pipenvUtils.ts | 27 ++-- src/managers/poetry/poetryUtils.ts | 23 ++- .../conda/condaUtils.getConda.unit.test.ts | 146 ++++++++++++++++++ .../pipenv/pipenvUtils.getPipenv.unit.test.ts | 112 ++++++++++++++ .../poetry/poetryUtils.getPoetry.unit.test.ts | 113 ++++++++++++++ 5 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 src/test/managers/conda/condaUtils.getConda.unit.test.ts create mode 100644 src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts create mode 100644 src/test/managers/poetry/poetryUtils.getPoetry.unit.test.ts diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index 659cbff3..f6875c29 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -66,6 +66,17 @@ function getPipenvPathFromSettings(): string | undefined { } export async function getPipenv(native?: NativePythonFinder): Promise { + // Priority 1: Settings (if explicitly set and valid) + const settingPath = getPipenvPathFromSettings(); + if (settingPath) { + if (await fs.exists(untildify(settingPath))) { + traceInfo(`Using pipenv from settings: ${settingPath}`); + return untildify(settingPath); + } + traceInfo(`Pipenv path from settings does not exist: ${settingPath}`); + } + + // Priority 2: In-memory cache if (pipenvPath) { if (await fs.exists(untildify(pipenvPath))) { return untildify(pipenvPath); @@ -73,6 +84,7 @@ export async function getPipenv(native?: NativePythonFinder): Promise(PIPENV_PATH_KEY); if (storedPath) { @@ -84,18 +96,7 @@ export async function getPipenv(native?: NativePythonFinder): Promise('python', 'poetryPath'); + return poetryPath ? poetryPath : undefined; +} + export async function clearPoetryCache(): Promise { // Reset in-memory cache poetryPath = undefined; @@ -113,6 +119,17 @@ export async function setPoetryForWorkspaces(fsPath: string[], envPath: string | } export async function getPoetry(native?: NativePythonFinder): Promise { + // Priority 1: Settings (if explicitly set and valid) + const settingPath = getPoetryPathFromSettings(); + if (settingPath) { + if (await fs.exists(untildify(settingPath))) { + traceInfo(`Using poetry from settings: ${settingPath}`); + return untildify(settingPath); + } + traceInfo(`Poetry path from settings does not exist: ${settingPath}`); + } + + // Priority 2: In-memory cache if (poetryPath) { if (await fs.exists(untildify(poetryPath))) { return untildify(poetryPath); @@ -120,6 +137,7 @@ export async function getPoetry(native?: NativePythonFinder): Promise(POETRY_PATH_KEY); if (storedPath) { @@ -137,14 +155,14 @@ export async function getPoetry(native?: NativePythonFinder): Promise { + let getConfigurationStub: sinon.SinonStub; + let mockConfig: { get: sinon.SinonStub }; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub }; + let getWorkspacePersistentStateStub: sinon.SinonStub; + + setup(async () => { + // Clear in-memory cache before each test + await clearCondaCache(); + + mockConfig = { + get: sinon.stub(), + }; + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getConfigurationStub.withArgs('python').returns(mockConfig); + sinon.stub(logging, 'traceInfo'); + + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Priority 1: Settings path is used first when set', async () => { + // Arrange: Settings returns a valid path + const settingsPath = '/custom/path/to/conda'; + mockConfig.get.withArgs('condaPath').returns(settingsPath); + + // Act + const result = await getConda(); + + // Assert: Should use settings path immediately (no existence check for settings in getConda) + assert.strictEqual(result, settingsPath); + // Verify persistent state was NOT called (settings took priority) + assert.ok(!mockState.get.called, 'Persistent state should not be checked when settings is set'); + }); + + test('Settings check happens before any other source', async () => { + // Arrange: Settings returns empty (no setting) + mockConfig.get.withArgs('condaPath').returns(''); + mockState.get.withArgs(CONDA_PATH_KEY).resolves(undefined); + + // Act + try { + await getConda(); + } catch { + // Expected to throw when nothing found + } + + // Assert: Configuration was accessed first + assert.ok(getConfigurationStub.calledWith('python'), 'Configuration should be checked'); + assert.ok(mockConfig.get.calledWith('condaPath'), 'Settings should be checked'); + }); + + test('Persistent state is checked when settings is empty', async () => { + // Arrange: No settings + mockConfig.get.withArgs('condaPath').returns(''); + + // Persistent state returns undefined too + mockState.get.withArgs(CONDA_PATH_KEY).resolves(undefined); + + // Act + try { + await getConda(); + } catch { + // Expected to throw when nothing found + } + + // Assert: Both settings and persistent state were checked + assert.ok(mockConfig.get.calledWith('condaPath'), 'Settings should be checked first'); + assert.ok(mockState.get.calledWith(CONDA_PATH_KEY), 'Persistent state should be checked'); + }); + + test('Settings path takes priority over cache', async () => { + // Arrange: First set up so something would be cached + // We can't easily test the cache without fs stubs, but we can verify + // that settings is always checked first + + // Now set a settings path + const settingsPath = '/custom/conda'; + mockConfig.get.withArgs('condaPath').returns(settingsPath); + + // Act + const result = await getConda(); + + // Assert: Should use settings + assert.strictEqual(result, settingsPath); + }); + + test('Settings with non-empty value is used regardless of validity', async () => { + // This is key behavior: getConda() returns settings immediately without checking existence + // The caller is responsible for validating the path if needed + const settingsPath = '/nonexistent/conda'; + mockConfig.get.withArgs('condaPath').returns(settingsPath); + + // Act + const result = await getConda(); + + // Assert: Should return settings path directly + assert.strictEqual(result, settingsPath); + }); + + test('Code checks settings first in the function body', () => { + // This is a structural test - verify the function checks settings at the top + // by inspecting that getConfiguration is called synchronously + // before any async operations + + // Arrange + mockConfig.get.withArgs('condaPath').returns('/some/path'); + + // Start the call (don't await) + const promise = getConda(); + + // Assert: Settings was checked synchronously (before promise resolves) + assert.ok(getConfigurationStub.called, 'Configuration should be checked synchronously at function start'); + + // Clean up + return promise; + }); +}); diff --git a/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts b/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts new file mode 100644 index 00000000..1ffa934e --- /dev/null +++ b/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts @@ -0,0 +1,112 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import * as logging from '../../../common/logging'; +import * as persistentState from '../../../common/persistentState'; +import * as settingHelpers from '../../../features/settings/settingHelpers'; +import { clearPipenvCache, getPipenv, PIPENV_PATH_KEY } from '../../../managers/pipenv/pipenvUtils'; + +/** + * Tests for getPipenv prioritization. + * + * The priority order should be: + * 1. Settings (python.pipenvPath) - if set and valid + * 2. In-memory cache + * 3. Persistent state + * 4. PATH lookup (which) + * 5. Native finder + * + * These tests verify the correct order by checking which functions are called and in what order. + */ +suite('Pipenv Utils - getPipenv prioritization', () => { + let getSettingStub: sinon.SinonStub; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub }; + let getWorkspacePersistentStateStub: sinon.SinonStub; + + setup(() => { + // Clear in-memory cache before each test + clearPipenvCache(); + + getSettingStub = sinon.stub(settingHelpers, 'getSettingWorkspaceScope'); + sinon.stub(logging, 'traceInfo'); + + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Settings check happens before any other source', async () => { + // Arrange: Settings returns undefined (no setting) + getSettingStub.withArgs('python', 'pipenvPath').returns(undefined); + mockState.get.withArgs(PIPENV_PATH_KEY).resolves(undefined); + + // Act + await getPipenv(); + + // Assert: Settings function was called + assert.ok(getSettingStub.calledWith('python', 'pipenvPath'), 'Settings should be checked'); + + // Settings should be checked BEFORE persistent state is accessed + // getPipenv() checks settings synchronously at the start, then does async work + // We verify by checking that settings was called before any persistent state access + assert.ok(getSettingStub.called, 'Settings should be checked'); + // If persistent state was accessed, settings must have been checked first + if (mockState.get.called) { + assert.ok( + getSettingStub.calledBefore(getWorkspacePersistentStateStub), + 'Settings should be checked before persistent state', + ); + } + }); + + test('When settings returns a path, it is checked before cache', async () => { + // Arrange: Settings returns a path + const settingsPath = '/custom/path/to/pipenv'; + getSettingStub.withArgs('python', 'pipenvPath').returns(settingsPath); + + // Act + await getPipenv(); + + // Assert: Settings was checked first + assert.ok(getSettingStub.calledWith('python', 'pipenvPath'), 'Settings should be checked'); + }); + + test('Persistent state is checked when settings returns undefined', async () => { + // Arrange: No settings + getSettingStub.withArgs('python', 'pipenvPath').returns(undefined); + + // Persistent state returns undefined too + mockState.get.withArgs(PIPENV_PATH_KEY).resolves(undefined); + + // Act + await getPipenv(); + + // Assert: Both settings and persistent state were checked + assert.ok(getSettingStub.calledWith('python', 'pipenvPath'), 'Settings should be checked first'); + assert.ok(mockState.get.calledWith(PIPENV_PATH_KEY), 'Persistent state should be checked'); + }); + + test('Code checks settings first in the function body', () => { + // This is a structural test - verify the function checks settings at the top + // by inspecting that getSettingWorkspaceScope is called synchronously + // before any async operations + + // Arrange + getSettingStub.withArgs('python', 'pipenvPath').returns(undefined); + + // Start the call (don't await) + const promise = getPipenv(); + + // Assert: Settings was checked synchronously (before promise resolves) + assert.ok(getSettingStub.called, 'Settings should be checked synchronously at function start'); + + // Clean up + return promise; + }); +}); diff --git a/src/test/managers/poetry/poetryUtils.getPoetry.unit.test.ts b/src/test/managers/poetry/poetryUtils.getPoetry.unit.test.ts new file mode 100644 index 00000000..7ae865ac --- /dev/null +++ b/src/test/managers/poetry/poetryUtils.getPoetry.unit.test.ts @@ -0,0 +1,113 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import * as logging from '../../../common/logging'; +import * as persistentState from '../../../common/persistentState'; +import * as settingHelpers from '../../../features/settings/settingHelpers'; +import { clearPoetryCache, getPoetry, POETRY_PATH_KEY } from '../../../managers/poetry/poetryUtils'; + +/** + * Tests for getPoetry prioritization. + * + * The priority order should be: + * 1. Settings (python.poetryPath) - if set and valid + * 2. In-memory cache + * 3. Persistent state + * 4. PATH lookup (which) + * 5. Known locations + * 6. Native finder + * + * These tests verify the correct order by checking which functions are called and in what order. + */ +suite('Poetry Utils - getPoetry prioritization', () => { + let getSettingStub: sinon.SinonStub; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub }; + let getWorkspacePersistentStateStub: sinon.SinonStub; + + setup(() => { + // Clear in-memory cache before each test + clearPoetryCache(); + + getSettingStub = sinon.stub(settingHelpers, 'getSettingWorkspaceScope'); + sinon.stub(logging, 'traceInfo'); + + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Settings check happens before any other source', async () => { + // Arrange: Settings returns undefined (no setting) + getSettingStub.withArgs('python', 'poetryPath').returns(undefined); + mockState.get.withArgs(POETRY_PATH_KEY).resolves(undefined); + + // Act + await getPoetry(); + + // Assert: Settings function was called + assert.ok(getSettingStub.calledWith('python', 'poetryPath'), 'Settings should be checked'); + + // Settings should be checked BEFORE persistent state is accessed + // getPoetry() checks settings synchronously at the start, then does async work + // We verify by checking that settings was called before any persistent state access + assert.ok(getSettingStub.called, 'Settings should be checked'); + // If persistent state was accessed, settings must have been checked first + if (mockState.get.called) { + assert.ok( + getSettingStub.calledBefore(getWorkspacePersistentStateStub), + 'Settings should be checked before persistent state', + ); + } + }); + + test('When settings returns a path, it is checked before cache', async () => { + // Arrange: Settings returns a path + const settingsPath = '/custom/path/to/poetry'; + getSettingStub.withArgs('python', 'poetryPath').returns(settingsPath); + + // Act + await getPoetry(); + + // Assert: Settings was checked first + assert.ok(getSettingStub.calledWith('python', 'poetryPath'), 'Settings should be checked'); + }); + + test('Persistent state is checked when settings returns undefined', async () => { + // Arrange: No settings + getSettingStub.withArgs('python', 'poetryPath').returns(undefined); + + // Persistent state returns undefined too + mockState.get.withArgs(POETRY_PATH_KEY).resolves(undefined); + + // Act + await getPoetry(); + + // Assert: Both settings and persistent state were checked + assert.ok(getSettingStub.calledWith('python', 'poetryPath'), 'Settings should be checked first'); + assert.ok(mockState.get.calledWith(POETRY_PATH_KEY), 'Persistent state should be checked'); + }); + + test('Code checks settings first in the function body', () => { + // This is a structural test - verify the function checks settings at the top + // by inspecting that getSettingWorkspaceScope is called synchronously + // before any async operations + + // Arrange + getSettingStub.withArgs('python', 'poetryPath').returns(undefined); + + // Start the call (don't await) + const promise = getPoetry(); + + // Assert: Settings was checked synchronously (before promise resolves) + assert.ok(getSettingStub.called, 'Settings should be checked synchronously at function start'); + + // Clean up + return promise; + }); +}); From 7a045014eb4a7683afbb651e67bd7a5355bd9f3e Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:20:32 -0800 Subject: [PATCH 07/13] Add Rust toolchain setup to push-check workflow (#1245) should fix build failures on main --- .github/workflows/push-check.yml | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index b6dd35d1..6c2171ae 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -89,6 +89,49 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + 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@v4 + 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 (Unix) + if: runner.os != 'Windows' + 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: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: @@ -139,6 +182,49 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + 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@v4 + 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 (Unix) + if: runner.os != 'Windows' + 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: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: @@ -189,6 +275,49 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + 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@v4 + 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 (Unix) + if: runner.os != 'Windows' + 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: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: From 33e8098331f018e708bb4062f5b97878f1ce3871 Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:35:17 -0800 Subject: [PATCH 08/13] Add integration test (#1227) --- .github/workflows/pr-check.yml | 54 ++- .github/workflows/push-check.yml | 6 +- .vscode-test.mjs | 20 +- .vscode/settings.json | 7 +- package.json | 1 + .../envCreation.integration.test.ts | 257 ++++++++++++ .../envDiscovery.integration.test.ts | 250 +++++++++++ .../envManagerApi.integration.test.ts | 165 -------- .../interpreterSelection.integration.test.ts | 317 ++++++++++++++ .../multiWorkspace.integration.test.ts | 196 +++++++++ .../packageManagement.integration.test.ts | 396 ++++++++++++++++++ .../pythonProjects.integration.test.ts | 366 ++++++++++++++++ .../settingsBehavior.integration.test.ts | 203 +++++++++ .../terminalActivation.integration.test.ts | 348 +++++++++++++++ src/test/integration/test-workspace/.gitkeep | 0 .../test-workspace/.vscode/settings.json | 3 + .../integration-tests.code-workspace | 11 + .../test-workspace/project-a/.gitkeep | 0 .../project-a/.vscode/settings.json | 3 + .../test-workspace/project-b/.gitkeep | 0 .../project-b/.vscode/settings.json | 9 + 21 files changed, 2439 insertions(+), 173 deletions(-) create mode 100644 src/test/integration/envCreation.integration.test.ts create mode 100644 src/test/integration/envDiscovery.integration.test.ts delete mode 100644 src/test/integration/envManagerApi.integration.test.ts create mode 100644 src/test/integration/interpreterSelection.integration.test.ts create mode 100644 src/test/integration/multiroot/multiWorkspace.integration.test.ts create mode 100644 src/test/integration/packageManagement.integration.test.ts create mode 100644 src/test/integration/pythonProjects.integration.test.ts create mode 100644 src/test/integration/settingsBehavior.integration.test.ts create mode 100644 src/test/integration/terminalActivation.integration.test.ts create mode 100644 src/test/integration/test-workspace/.gitkeep create mode 100644 src/test/integration/test-workspace/.vscode/settings.json create mode 100644 src/test/integration/test-workspace/integration-tests.code-workspace create mode 100644 src/test/integration/test-workspace/project-a/.gitkeep create mode 100644 src/test/integration/test-workspace/project-a/.vscode/settings.json create mode 100644 src/test/integration/test-workspace/project-b/.gitkeep create mode 100644 src/test/integration/test-workspace/project-b/.vscode/settings.json diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 3e2e446d..0a3fc323 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -175,7 +175,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14'] steps: - name: Checkout @@ -268,7 +268,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14'] steps: - name: Checkout @@ -352,3 +352,53 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test + + integration-tests-multiroot: + name: Integration Tests (Multi-Root) + runs-on: ${{ matrix.os }} + needs: [smoke-tests] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.10', '3.12', '3.14'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: npm ci + + - name: Compile Extension + run: npm run compile + + - name: Compile Tests + run: npm run compile-tests + + - name: Configure Test Settings + run: | + mkdir -p .vscode-test/user-data/User + echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json + shell: bash + + - name: Run Integration Tests Multi-Root (Linux) + if: runner.os == 'Linux' + uses: GabrielBB/xvfb-action@v1 + with: + run: npm run integration-test-multiroot + + - name: Run Integration Tests Multi-Root (non-Linux) + if: runner.os != 'Linux' + run: npm run integration-test-multiroot diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 6c2171ae..e00f0aaf 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -83,7 +83,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14'] steps: - name: Checkout @@ -176,7 +176,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14'] steps: - name: Checkout @@ -269,7 +269,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14'] steps: - name: Checkout diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 6bd41d01..79b6d242 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -45,7 +45,8 @@ export default defineConfig([ }, { label: 'integrationTests', - files: 'out/test/integration/**/*.integration.test.js', + files: 'out/test/integration/*.integration.test.js', + workspaceFolder: 'src/test/integration/test-workspace/project-a', mocha: { ui: 'tdd', timeout: 60000, @@ -61,6 +62,23 @@ export default defineConfig([ // the native Python tools (pet binary). We use inspect() for // useEnvironmentsExtension check, so Python extension's default is ignored. }, + { + label: 'integrationTestsMultiRoot', + files: 'out/test/integration/multiroot/*.integration.test.js', + workspaceFolder: 'src/test/integration/test-workspace/integration-tests.code-workspace', + mocha: { + ui: 'tdd', + timeout: 60000, + retries: 1, + }, + env: { + VSC_PYTHON_INTEGRATION_TEST: '1', + }, + launchArgs: [ + `--user-data-dir=${userDataDir}`, + '--disable-workspace-trust', + ], + }, { label: 'extensionTests', files: 'out/test/**/*.test.js', diff --git a/.vscode/settings.json b/.vscode/settings.json index ed95cc87..031b2d19 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,8 +26,11 @@ }, "prettier.tabWidth": 4, "python-envs.defaultEnvManager": "ms-python.python:venv", - "python-envs.pythonProjects": [], "git.branchRandomName.enable": true, "git.branchProtection": ["main"], - "git.branchProtectionPrompt": "alwaysCommitToNewBranch" + "git.branchProtectionPrompt": "alwaysCommitToNewBranch", + "chat.tools.terminal.autoApprove": { + "npx tsc": true, + "mkdir": true + } } diff --git a/package.json b/package.json index e5200a9a..50317b48 100644 --- a/package.json +++ b/package.json @@ -685,6 +685,7 @@ "smoke-test": "vscode-test --label smokeTests", "e2e-test": "vscode-test --label e2eTests --install-extensions ms-python.python", "integration-test": "vscode-test --label integrationTests --install-extensions ms-python.python", + "integration-test-multiroot": "vscode-test --label integrationTestsMultiRoot --install-extensions ms-python.python", "vsce-package": "vsce package -o ms-python-envs-insiders.vsix" }, "devDependencies": { diff --git a/src/test/integration/envCreation.integration.test.ts b/src/test/integration/envCreation.integration.test.ts new file mode 100644 index 00000000..eeec3471 --- /dev/null +++ b/src/test/integration/envCreation.integration.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Environment Creation + * + * PURPOSE: + * Verify that environment creation works correctly through the API, + * respecting configured managers and options. + * + * WHAT THIS TESTS: + * 1. createEnvironment API is available and callable + * 2. Creation respects defaultEnvManager setting + * 3. Created environments appear in discovery + * 4. Environment removal works correctly + * + * NOTE: These tests may create actual virtual environments on disk. + * Tests that create environments should clean up after themselves. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { sleep, waitForCondition } from '../testUtils'; + +suite('Integration: Environment Creation', function () { + this.timeout(120_000); // Environment creation can be slow + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + assert.ok(typeof api.createEnvironment === 'function', 'createEnvironment should be a function'); + assert.ok(typeof api.removeEnvironment === 'function', 'removeEnvironment should be a function'); + }); + + // ========================================================================= + // ENVIRONMENT CREATION BEHAVIOR TESTS + // These tests verify actual user-facing creation and removal workflows. + // ========================================================================= + + /** + * Test: Created environment appears in discovery + * + * BEHAVIOR TESTED: User creates an environment via quickCreate, + * then the environment should be discoverable via getEnvironments. + */ + test('Created environment appears in discovery', async function () { + // --- SETUP: Ensure we have prerequisites --- + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const globalEnvs = await api.getEnvironments('global'); + if (globalEnvs.length === 0) { + console.log('No global Python installations found, skipping creation test'); + this.skip(); + return; + } + + const workspaceUri = workspaceFolders[0].uri; + let createdEnv: PythonEnvironment | undefined; + + try { + // --- ACTION: User creates environment --- + createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + + if (!createdEnv) { + console.log('Environment creation returned undefined (may require user input)'); + this.skip(); + return; + } + + // --- VERIFY: Created environment is discoverable --- + await api.refreshEnvironments(workspaceUri); + const environments = await api.getEnvironments(workspaceUri); + + const found = environments.some( + (env) => + env.envId.id === createdEnv!.envId.id || + env.environmentPath.fsPath === createdEnv!.environmentPath.fsPath, + ); + + assert.ok(found, 'Created environment should appear in discovery'); + } finally { + // Cleanup: Remove the created environment + if (createdEnv) { + try { + await api.removeEnvironment(createdEnv); + } catch (e) { + console.warn('Cleanup warning - failed to remove environment:', e); + } + } + } + }); + + /** + * Test: Environment removal removes from discovery + * + * BEHAVIOR TESTED: User removes an environment, then it should + * no longer appear in discovery results. + */ + test('Removed environment disappears from discovery', async function () { + // --- SETUP: Create an environment to remove --- + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const globalEnvs = await api.getEnvironments('global'); + if (globalEnvs.length === 0) { + this.skip(); + return; + } + + const workspaceUri = workspaceFolders[0].uri; + let createdEnv: PythonEnvironment | undefined; + + try { + createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + if (!createdEnv) { + this.skip(); + return; + } + + const envId = createdEnv.envId.id; + + // --- ACTION: User removes environment --- + await api.removeEnvironment(createdEnv); + createdEnv = undefined; + + await sleep(1000); + + // --- VERIFY: Environment is no longer discoverable --- + await api.refreshEnvironments(workspaceUri); + const environments = await api.getEnvironments(workspaceUri); + + const stillExists = environments.some((env) => env.envId.id === envId); + + assert.ok(!stillExists, 'Removed environment should not appear in discovery'); + } finally { + // Cleanup in case removal failed + if (createdEnv) { + try { + await api.removeEnvironment(createdEnv); + } catch (e) { + console.warn('Cleanup warning - failed to remove environment:', e); + } + } + } + }); + + /** + * Test: Creating environment for 'global' scope + * + * The 'global' scope should allow creating environments not tied to a workspace. + * Note: This may require special permissions or configurations. + */ + test('Global scope creation is handled', async function () { + // This test verifies the API handles global scope correctly + let createdEnv: PythonEnvironment | undefined; + + try { + // Attempt global creation - this may prompt for user input + // so we use quickCreate and expect it might return undefined + createdEnv = await api.createEnvironment('global', { quickCreate: true }); + + if (createdEnv) { + // If creation succeeded, verify the environment has valid structure + assert.ok(createdEnv.envId, 'Created global env must have envId'); + assert.ok(createdEnv.envId.id, 'Created global env must have envId.id'); + assert.ok(createdEnv.environmentPath, 'Created global env must have environmentPath'); + } else { + // quickCreate returned undefined - skip this test as feature not available + console.log('Global creation not supported with quickCreate, skipping'); + this.skip(); + return; + } + } finally { + // Cleanup: try to remove if created, but handle dialog errors in test mode + if (createdEnv) { + try { + await api.removeEnvironment(createdEnv); + } catch (e) { + // Ignore dialog errors in test mode - VS Code blocks dialogs + if (!String(e).includes('DialogService')) { + throw e; + } + console.log('Skipping cleanup for global environment (dialog blocked in tests)'); + } + } + } + }); + + /** + * Test: Creation returns properly structured environment + * + * A successfully created environment should have all required fields. + */ + test('Created environment has proper structure', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const globalEnvs = await api.getEnvironments('global'); + if (globalEnvs.length === 0) { + this.skip(); + return; + } + + const workspaceUri = workspaceFolders[0].uri; + let createdEnv: PythonEnvironment | undefined; + + try { + createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + + if (!createdEnv) { + this.skip(); + return; + } + + // Verify structure + assert.ok(createdEnv.envId, 'Created env must have envId'); + assert.ok(createdEnv.envId.id, 'envId must have id'); + assert.ok(createdEnv.envId.managerId, 'envId must have managerId'); + assert.ok(createdEnv.name, 'Created env must have name'); + assert.ok(createdEnv.displayName, 'Created env must have displayName'); + assert.ok(createdEnv.environmentPath, 'Created env must have environmentPath'); + } finally { + if (createdEnv) { + try { + await api.removeEnvironment(createdEnv); + } catch (e) { + console.warn('Cleanup warning - failed to remove environment:', e); + } + } + } + }); +}); diff --git a/src/test/integration/envDiscovery.integration.test.ts b/src/test/integration/envDiscovery.integration.test.ts new file mode 100644 index 00000000..12efb35a --- /dev/null +++ b/src/test/integration/envDiscovery.integration.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Environment Discovery + * + * PURPOSE: + * Verify that environment discovery correctly finds and reports Python + * environments based on configuration settings and search paths. + * + * WHAT THIS TESTS: + * 1. Discovery respects workspaceSearchPaths setting + * 2. Discovery respects globalSearchPaths setting + * 3. Refresh clears stale cache and finds new environments + * 4. Different scopes (all, global) return appropriate environments + * + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { DidChangeEnvironmentsEventArgs, PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { TestEventHandler, waitForCondition } from '../testUtils'; + +suite('Integration: Environment Discovery', function () { + this.timeout(60_000); + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + assert.ok(typeof api.getEnvironments === 'function', 'getEnvironments method not available'); + }); + + /** + * Test: Discovery returns environments after refresh + * + * Verifies that refreshing environments triggers discovery and + * the API returns a valid array of environments. + */ + test('Refresh triggers discovery and returns environments', async function () { + // Trigger refresh + await api.refreshEnvironments(undefined); + + // Get environments after refresh + const environments = await api.getEnvironments('all'); + + // Should return an array + assert.ok(Array.isArray(environments), 'Expected environments to be an array'); + + // Log count for debugging (may be 0 if no Python installed) + console.log(`Discovered ${environments.length} environments`); + }); + + /** + * Test: Global scope returns only global environments + * + * Global environments are system-wide Python installations that serve + * as bases for virtual environments. + */ + test('Global scope returns base Python installations', async function () { + const globalEnvs = await api.getEnvironments('global'); + const allEnvs = await api.getEnvironments('all'); + + assert.ok(Array.isArray(globalEnvs), 'Global scope should return array'); + assert.ok(Array.isArray(allEnvs), 'All scope should return array'); + + // Global should be subset of or equal to all + assert.ok( + globalEnvs.length <= allEnvs.length, + `Global envs (${globalEnvs.length}) should not exceed all envs (${allEnvs.length})`, + ); + }); + + /** + * Test: Change events fire during refresh + * + * When environments are discovered or removed, the onDidChangeEnvironments + * event should fire. + */ + test('onDidChangeEnvironments fires during refresh', async function () { + const handler = new TestEventHandler( + api.onDidChangeEnvironments, + 'onDidChangeEnvironments', + ); + + try { + // First, check if we have environments on this system + const preCheckEnvs = await api.getEnvironments('all'); + + if (preCheckEnvs.length === 0) { + // No environments discovered - can't test events + console.log('No environments available to test event firing'); + this.skip(); + return; + } + + // Reset handler RIGHT BEFORE the action we're testing + handler.reset(); + + // Trigger refresh - this should fire events for discovered environments + await api.refreshEnvironments(undefined); + + // Wait for events to propagate (discovery is async) + await handler.assertFiredAtLeast(1, 10_000); + + // Verify event has valid structure + // DidChangeEnvironmentsEventArgs is an array of {kind, environment} + const events = handler.first; + assert.ok(events, 'Event should have a value'); + assert.ok(Array.isArray(events), 'Event should be an array'); + assert.ok(events.length > 0, 'Should have received environment change events'); + + // Each event item should have kind and environment properties + const firstItem = events[0]; + assert.ok('kind' in firstItem, 'Event item should have kind property'); + assert.ok('environment' in firstItem, 'Event item should have environment property'); + } finally { + handler.dispose(); + } + }); + + /** + * Test: Environments have valid structure + * + * Each discovered environment should have the required properties + * for the extension to work correctly. + */ + test('Discovered environments have valid structure', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + for (const env of environments) { + // Must have envId + assert.ok(env.envId, 'Environment must have envId'); + assert.ok(env.envId.id, 'envId must have id'); + assert.ok(env.envId.managerId, 'envId must have managerId'); + + // Must have basic info + assert.ok(typeof env.name === 'string', 'Environment must have name'); + assert.ok(typeof env.displayName === 'string', 'Environment must have displayName'); + assert.ok(typeof env.version === 'string', 'Environment must have version'); + + // Must have environment path + assert.ok(env.environmentPath, 'Environment must have environmentPath'); + assert.ok(env.environmentPath instanceof vscode.Uri, 'environmentPath must be a Uri'); + } + }); + + /** + * Test: resolveEnvironment returns full details + * + * The resolveEnvironment method should return complete environment + * information including execution info. + */ + test('resolveEnvironment returns execution info', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Pick first environment and resolve it + const env = environments[0]; + const resolved = await api.resolveEnvironment(env.environmentPath); + + if (!resolved) { + // Environment could not be resolved - this might be expected for broken envs + console.log('Environment could not be resolved:', env.displayName); + this.skip(); + return; + } + + // Resolved environment should have execInfo + assert.ok(resolved.execInfo, 'Resolved environment should have execInfo'); + assert.ok(resolved.execInfo.run, 'execInfo should have run configuration'); + assert.ok(resolved.execInfo.run.executable, 'run should have executable path'); + }); + + /** + * Test: Workspace-scoped discovery finds workspace environments + * + * When a workspace folder is open and contains environments, + * querying with the workspace URI should find them. + */ + test('Workspace scope returns workspace environments', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const workspaceUri = workspaceFolders[0].uri; + const workspaceEnvs = await api.getEnvironments(workspaceUri); + + assert.ok(Array.isArray(workspaceEnvs), 'Workspace scope should return array'); + + // Log for debugging + console.log(`Found ${workspaceEnvs.length} environments in workspace`); + }); + + /** + * Test: Multiple refreshes are idempotent + * + * Calling refresh multiple times should not cause duplicate + * environments or errors. Counts should be strictly equal. + */ + test('Multiple refreshes do not create duplicates', async function () { + // First refresh + await api.refreshEnvironments(undefined); + const firstCount = (await api.getEnvironments('all')).length; + + // Second refresh + await api.refreshEnvironments(undefined); + const secondCount = (await api.getEnvironments('all')).length; + + // Third refresh + await api.refreshEnvironments(undefined); + const thirdCount = (await api.getEnvironments('all')).length; + + // Counts should be strictly equal for idempotent refresh + assert.strictEqual( + firstCount, + secondCount, + `Refresh should be idempotent: first=${firstCount}, second=${secondCount}`, + ); + assert.strictEqual( + secondCount, + thirdCount, + `Refresh should be idempotent: second=${secondCount}, third=${thirdCount}`, + ); + }); +}); diff --git a/src/test/integration/envManagerApi.integration.test.ts b/src/test/integration/envManagerApi.integration.test.ts deleted file mode 100644 index 596b2a66..00000000 --- a/src/test/integration/envManagerApi.integration.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -/** - * Integration Test: Environment Manager + API - * - * PURPOSE: - * Verify that the environment manager component correctly exposes data - * through the extension API. This tests the integration between internal - * managers and the public API surface. - * - * WHAT THIS TESTS: - * 1. API reflects environment manager state - * 2. Changes through API update manager state - * 3. Events fire when state changes - * - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { ENVS_EXTENSION_ID } from '../constants'; -import { TestEventHandler, waitForCondition } from '../testUtils'; - -suite('Integration: Environment Manager + API', function () { - // Shorter timeout for faster feedback - this.timeout(45_000); - - // The API is FLAT - methods are directly on the api object, not nested - let api: { - getEnvironments(scope: 'all' | 'global'): Promise; - refreshEnvironments(scope: undefined): Promise; - onDidChangeEnvironments?: vscode.Event; - }; - - suiteSetup(async function () { - // Set a shorter timeout for setup specifically - this.timeout(20_000); - - const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); - assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); - - if (!extension.isActive) { - await extension.activate(); - await waitForCondition(() => extension.isActive, 15_000, 'Extension did not activate'); - } - - api = extension.exports; - assert.ok(typeof api?.getEnvironments === 'function', 'getEnvironments method not available'); - }); - - /** - * Test: API and manager stay in sync after refresh - * - */ - test('API reflects manager state after refresh', async function () { - // Get initial state (verify we can call API before refresh) - await api.getEnvironments('all'); - - // Trigger refresh - await api.refreshEnvironments(undefined); - - // Get state after refresh - const afterRefresh = await api.getEnvironments('all'); - - // Verify we got an actual array back (not undefined, null, or other type) - assert.ok(Array.isArray(afterRefresh), `Expected environments array, got ${typeof afterRefresh}`); - - // Verify the API returns consistent data on repeated calls - const secondCall = await api.getEnvironments('all'); - assert.strictEqual(afterRefresh.length, secondCall.length, 'Repeated API calls should return consistent data'); - }); - - /** - * Test: Events fire when environments change - * - */ - test('Change events fire on refresh', async function () { - // Skip if event is not available - if (!api.onDidChangeEnvironments) { - this.skip(); - return; - } - - const handler = new TestEventHandler(api.onDidChangeEnvironments, 'onDidChangeEnvironments'); - - try { - // Trigger a refresh which should fire events - await api.refreshEnvironments(undefined); - - // Assert that at least one event fired during refresh - await handler.assertFiredAtLeast(1, 5000); - const event = handler.first; - assert.ok(event !== undefined, 'Event should have a value'); - } finally { - handler.dispose(); - } - }); - - /** - * Test: Global vs all environments are different scopes - * - */ - test('Different scopes return appropriate environments', async function () { - const allEnvs = await api.getEnvironments('all'); - const globalEnvs = await api.getEnvironments('global'); - - // Both should return arrays - assert.ok(Array.isArray(allEnvs), 'all scope should return array'); - assert.ok(Array.isArray(globalEnvs), 'global scope should return array'); - - // Global should be subset of or equal to all - // (all includes global + workspace-specific) - assert.ok( - globalEnvs.length <= allEnvs.length, - `Global envs (${globalEnvs.length}) should not exceed all envs (${allEnvs.length})`, - ); - }); - - /** - * Test: Environment objects are properly structured - * - */ - test('Environment objects have consistent structure', async function () { - const environments = await api.getEnvironments('all'); - - if (environments.length === 0) { - this.skip(); - return; - } - - // Check each environment has basic required properties with valid values - for (const env of environments) { - const e = env as Record; - - // Must have some form of identifier - assert.ok('id' in e || 'envId' in e, 'Environment must have id or envId'); - - // If it has an id, it should be a non-empty string - if ('id' in e) { - assert.strictEqual(typeof e.id, 'string', 'Environment id should be a string'); - assert.ok((e.id as string).length > 0, 'Environment id should not be empty'); - } - - // If it has envId, verify it's a valid object with required properties - if ('envId' in e && e.envId !== null && e.envId !== undefined) { - const envId = e.envId as Record; - assert.strictEqual(typeof envId, 'object', 'envId should be an object'); - assert.ok('id' in envId, 'envId should have an id property'); - assert.ok('managerId' in envId, 'envId should have a managerId property'); - assert.strictEqual(typeof envId.id, 'string', 'envId.id should be a string'); - assert.ok((envId.id as string).length > 0, 'envId.id should not be empty'); - } - - // Verify name is a non-empty string if present - if ('name' in e && e.name !== undefined) { - assert.strictEqual(typeof e.name, 'string', 'Environment name should be a string'); - } - - // Verify displayName is a non-empty string if present - if ('displayName' in e && e.displayName !== undefined) { - assert.strictEqual(typeof e.displayName, 'string', 'Environment displayName should be a string'); - } - } - }); -}); diff --git a/src/test/integration/interpreterSelection.integration.test.ts b/src/test/integration/interpreterSelection.integration.test.ts new file mode 100644 index 00000000..886e9ede --- /dev/null +++ b/src/test/integration/interpreterSelection.integration.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Interpreter Selection Priority + * + * PURPOSE: + * Verify that interpreter selection follows the correct priority order + * and respects user configuration. + * + * WHAT THIS TESTS: + * 1. Projects settings override other sources + * 2. Explicit setEnvironment overrides auto-discovery + * 3. Auto-discovery prefers workspace-local environments + * 4. getEnvironment returns consistent results + * + * Priority order (from docs): + * 1. pythonProjects[] - project-specific config + * 2. defaultEnvManager - if explicitly set + * 3. python.defaultInterpreterPath - legacy setting + * 4. Auto-discovery - workspace-local .venv, then global + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { DidChangeEnvironmentEventArgs, PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { TestEventHandler, waitForCondition } from '../testUtils'; + +suite('Integration: Interpreter Selection Priority', function () { + this.timeout(60_000); + + let api: PythonEnvironmentApi; + let originalEnv: import('../../api').PythonEnvironment | undefined; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + + // Save original state for restoration + originalEnv = await api.getEnvironment(undefined); + }); + + // Reset to original state after each test to prevent state pollution + teardown(async function () { + try { + if (originalEnv) { + await api.setEnvironment(undefined, originalEnv); + } else { + await api.setEnvironment(undefined, undefined); + } + } catch { + // Ignore errors during reset + } + }); + + /** + * Test: getEnvironment without scope returns global selection + * + * When no scope is specified, should return the currently active + * global environment. + */ + test('getEnvironment without scope returns global selection', async function () { + const env = await api.getEnvironment(undefined); + + // May be undefined if no environment is selected/available + if (env) { + assert.ok(env.envId, 'Environment should have envId'); + assert.ok(env.displayName, 'Environment should have displayName'); + } + }); + + /** + * Test: setEnvironment persists selection + * + * After calling setEnvironment, subsequent getEnvironment calls + * should return the same environment. + */ + test('setEnvironment persists selection', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const envToSet = environments[0]; + + // Set environment globally + await api.setEnvironment(undefined, envToSet); + + // Get and verify + const retrieved = await api.getEnvironment(undefined); + + assert.ok(retrieved, 'Should have environment after setting'); + assert.strictEqual(retrieved.envId.id, envToSet.envId.id, 'Retrieved environment should match set environment'); + }); + + /** + * Test: Project-scoped selection is independent of global + * + * Setting an environment for a specific project should not affect + * the global selection or other projects. + */ + test('Project selection is independent of global', async function () { + const environments = await api.getEnvironments('all'); + const projects = api.getPythonProjects(); + + if (environments.length < 2 || projects.length === 0) { + this.skip(); + return; + } + + const globalEnv = environments[0]; + const projectEnv = environments[1]; + const project = projects[0]; + + // Set global environment + await api.setEnvironment(undefined, globalEnv); + + // Set different environment for project + await api.setEnvironment(project.uri, projectEnv); + + // Verify global is unchanged + const globalRetrieved = await api.getEnvironment(undefined); + assert.ok(globalRetrieved, 'Global should have environment'); + assert.strictEqual(globalRetrieved.envId.id, globalEnv.envId.id, 'Global selection should be unchanged'); + + // Verify project has its own selection + const projectRetrieved = await api.getEnvironment(project.uri); + assert.ok(projectRetrieved, 'Project should have environment'); + assert.strictEqual(projectRetrieved.envId.id, projectEnv.envId.id, 'Project should have its own selection'); + }); + + /** + * Test: Change event fires with correct old/new values + * + * The onDidChangeEnvironment event should include both the old + * and new environment values. + */ + test('Change event includes old and new values', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length < 2) { + this.skip(); + return; + } + + const oldEnv = environments[0]; + const newEnv = environments[1]; + + // Set initial environment + await api.setEnvironment(undefined, oldEnv); + + const handler = new TestEventHandler( + api.onDidChangeEnvironment, + 'onDidChangeEnvironment', + ); + + try { + // Change to new environment + await api.setEnvironment(undefined, newEnv); + + // Wait for event - use 15s timeout for CI stability + await handler.assertFired(15_000); + + const event = handler.last; + assert.ok(event, 'Event should have fired'); + assert.ok(event.new, 'Event should have new environment'); + assert.strictEqual(event.new.envId.id, newEnv.envId.id, 'New should match set environment'); + } finally { + handler.dispose(); + } + }); + + /** + * Test: File URI inherits project environment + * + * When querying for a file within a project, should return + * the project's environment. + */ + test('File inherits project environment', async function () { + const environments = await api.getEnvironments('all'); + const projects = api.getPythonProjects(); + + if (environments.length === 0 || projects.length === 0) { + this.skip(); + return; + } + + const project = projects[0]; + const env = environments[0]; + + // Set project environment + await api.setEnvironment(project.uri, env); + + // Query for a file inside the project + const fileUri = vscode.Uri.joinPath(project.uri, 'subdir', 'script.py'); + const fileEnv = await api.getEnvironment(fileUri); + + assert.ok(fileEnv, 'File should inherit project environment'); + assert.strictEqual(fileEnv.envId.id, env.envId.id, 'File should use project environment'); + }); + + /** + * Test: Selection is consistent across multiple calls + * + * Calling getEnvironment multiple times should return the same result. + */ + test('Selection is consistent across calls', async function () { + const env1 = await api.getEnvironment(undefined); + const env2 = await api.getEnvironment(undefined); + const env3 = await api.getEnvironment(undefined); + + if (!env1) { + // No environment selected - that's consistent + assert.strictEqual(env2, undefined, 'Should consistently return undefined'); + assert.strictEqual(env3, undefined, 'Should consistently return undefined'); + return; + } + + assert.ok(env2, 'Second call should return environment'); + assert.ok(env3, 'Third call should return environment'); + + assert.strictEqual(env1.envId.id, env2.envId.id, 'First and second should match'); + assert.strictEqual(env2.envId.id, env3.envId.id, 'Second and third should match'); + }); + + /** + * Test: Setting same environment doesn't fire extra events + * + * Setting the same environment twice should not fire change event + * on the second call. This ensures idempotent behavior. + */ + test('Setting same environment is idempotent', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + // Set environment first time + await api.setEnvironment(undefined, env); + + // Wait for any async config changes to settle before testing idempotency + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify the environment was actually set + const currentEnv = await api.getEnvironment(undefined); + assert.ok(currentEnv, 'Environment should be set before idempotency test'); + assert.strictEqual(currentEnv.envId.id, env.envId.id, 'Environment should match what we just set'); + + const handler = new TestEventHandler( + api.onDidChangeEnvironment, + 'onDidChangeEnvironment', + ); + + try { + // Set same environment again + await api.setEnvironment(undefined, env); + + // Wait for any potential events to fire + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Idempotent behavior: no event should fire when setting same environment + assert.strictEqual(handler.fired, false, 'No event should fire when setting the same environment'); + } finally { + handler.dispose(); + } + }); + + /** + * Test: Clearing selection falls back to auto-discovery + * + * After clearing an explicit selection, auto-discovery should + * provide a fallback environment. The system should find an + * auto-discovered environment (e.g., .venv in workspace). + */ + test('Clearing selection falls back to auto-discovery', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Set an explicit environment + await api.setEnvironment(undefined, environments[0]); + const beforeClear = await api.getEnvironment(undefined); + assert.ok(beforeClear, 'Should have environment before clearing'); + + // Clear the selection + await api.setEnvironment(undefined, undefined); + + // Get environment - should return auto-discovered environment + const autoEnv = await api.getEnvironment(undefined); + + // Auto-discovery should provide a fallback environment + assert.ok(autoEnv, 'Auto-discovery should provide a fallback environment after clearing'); + assert.ok(autoEnv.envId, 'Auto-discovered env must have envId'); + assert.ok(autoEnv.envId.id, 'Auto-discovered env must have envId.id'); + assert.ok(autoEnv.displayName, 'Auto-discovered env must have displayName'); + }); +}); diff --git a/src/test/integration/multiroot/multiWorkspace.integration.test.ts b/src/test/integration/multiroot/multiWorkspace.integration.test.ts new file mode 100644 index 00000000..0e0c138b --- /dev/null +++ b/src/test/integration/multiroot/multiWorkspace.integration.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Multi-Root Workspace + * + * PURPOSE: + * Verify behavior that requires multiple workspace folders open simultaneously. + * These tests run in a multi-root workspace (.code-workspace) with 2+ folders. + * + * WHAT THIS TESTS: + * 1. Different projects can have independent environment selections + * 2. setEnvironment handles URI arrays across projects + * 3. Settings scope is isolated between workspace folders + * 4. Environment creation with multiple URI scopes + * + * NOTE: These tests require a multi-root workspace with at least 2 workspace folders. + * They will skip if run in a single-folder workspace. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import { ENVS_EXTENSION_ID } from '../../constants'; +import { waitForCondition } from '../../testUtils'; + +suite('Integration: Multi-Root Workspace', function () { + this.timeout(120_000); + + let api: PythonEnvironmentApi; + let originalProjectEnvs: Map; + + suiteSetup(async function () { + this.timeout(30_000); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length < 2) { + this.skip(); + return; + } + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + + // Save original state for restoration + originalProjectEnvs = new Map(); + const projects = api.getPythonProjects(); + for (const project of projects) { + const env = await api.getEnvironment(project.uri); + originalProjectEnvs.set(project.uri.toString(), env); + } + }); + + suiteTeardown(async function () { + if (!originalProjectEnvs) { + return; + } + // Restore original state + for (const [uriStr, env] of originalProjectEnvs) { + try { + const uri = vscode.Uri.parse(uriStr); + await api.setEnvironment(uri, env); + } catch { + // Best effort restore + } + } + }); + + /** + * Test: Multiple projects can have different environments + * + * In a multi-project workspace, each project can have its own environment. + */ + test('Different projects can have different environments', async function () { + const projects = api.getPythonProjects(); + const environments = await api.getEnvironments('all'); + + if (projects.length < 2 || environments.length < 2) { + this.skip(); + return; + } + + const project1 = projects[0]; + const project2 = projects[1]; + const env1 = environments[0]; + const env2 = environments[1]; + + // Set different environments for different projects + await api.setEnvironment(project1.uri, env1); + await api.setEnvironment(project2.uri, env2); + + // Verify each project has its assigned environment + const retrieved1 = await api.getEnvironment(project1.uri); + const retrieved2 = await api.getEnvironment(project2.uri); + + assert.ok(retrieved1, 'Project 1 should have environment'); + assert.ok(retrieved2, 'Project 2 should have environment'); + assert.strictEqual(retrieved1.envId.id, env1.envId.id, 'Project 1 should have env1'); + assert.strictEqual(retrieved2.envId.id, env2.envId.id, 'Project 2 should have env2'); + }); + + /** + * Test: setEnvironment handles URI arrays across projects + * + * setEnvironment should handle array of URIs for multi-select scenarios. + */ + test('setEnvironment handles URI array', async function () { + const environments = await api.getEnvironments('all'); + const projects = api.getPythonProjects(); + + if (environments.length === 0 || projects.length < 2) { + this.skip(); + return; + } + + const env = environments[0]; + const uris = projects.slice(0, 2).map((p) => p.uri); + + // Set environment for multiple URIs at once + await api.setEnvironment(uris, env); + + // Verify both projects have the environment + for (const uri of uris) { + const retrieved = await api.getEnvironment(uri); + assert.ok(retrieved, `URI ${uri.fsPath} should have environment`); + assert.strictEqual(retrieved.envId.id, env.envId.id, `URI ${uri.fsPath} should have set environment`); + } + }); + + /** + * Test: Workspace folder settings scope is respected + * + * Settings at workspace folder level should be independently accessible + * across different folders. + */ + test('Workspace folder settings scope is respected', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders!; + + const config1 = vscode.workspace.getConfiguration('python-envs', workspaceFolders[0].uri); + const config2 = vscode.workspace.getConfiguration('python-envs', workspaceFolders[1].uri); + + // Both should be independently accessible via inspect() + const inspect1 = config1.inspect('pythonProjects'); + const inspect2 = config2.inspect('pythonProjects'); + + // Assert inspect returns valid results for both folders + assert.ok(inspect1, 'Should be able to inspect settings for folder 1'); + assert.ok(inspect2, 'Should be able to inspect settings for folder 2'); + + // Assert the inspection objects have the expected structure + assert.ok('key' in inspect1, 'Inspection should have key property'); + assert.ok('key' in inspect2, 'Inspection should have key property'); + assert.strictEqual(inspect1.key, 'python-envs.pythonProjects', 'Key should be python-envs.pythonProjects'); + assert.strictEqual(inspect2.key, 'python-envs.pythonProjects', 'Key should be python-envs.pythonProjects'); + }); + + /** + * Test: Creation with multiple URIs selects manager + * + * When passing multiple workspace folder URIs, the API should handle + * manager selection and create an environment. + */ + test('Multiple URI scope creation is handled', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders!; + const uris = workspaceFolders.map((f) => f.uri); + let createdEnv: PythonEnvironment | undefined; + + try { + // This may prompt for manager selection - quickCreate should handle it + createdEnv = await api.createEnvironment(uris, { quickCreate: true }); + + if (createdEnv) { + // Verify created environment has valid structure + assert.ok(createdEnv.envId, 'Multi-URI created env must have envId'); + assert.ok(createdEnv.environmentPath, 'Multi-URI created env must have environmentPath'); + } else { + // quickCreate returned undefined - skip this test as feature not available + this.skip(); + return; + } + } finally { + // Cleanup: always try to remove if created + if (createdEnv) { + await api.removeEnvironment(createdEnv); + } + } + }); +}); diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts new file mode 100644 index 00000000..5998b6a1 --- /dev/null +++ b/src/test/integration/packageManagement.integration.test.ts @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Package Management + * + * PURPOSE: + * Verify that package management works correctly for different + * environment types and managers. + * + * WHAT THIS TESTS: + * 1. getPackages returns packages for environments + * 2. Package installation via API + * 3. Package uninstallation via API + * 4. Refresh updates package list + * 5. Events fire when packages change + * + * NOTE: Some tests may install/uninstall actual packages. + * These should use safe test packages that don't have side effects. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { DidChangePackagesEventArgs, PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { sleep, TestEventHandler, waitForCondition } from '../testUtils'; + +suite('Integration: Package Management', function () { + this.timeout(120_000); // Package operations can be slow + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + }); + + /** + * Test: Package management APIs are available + * + * The API should have all package management methods. + */ + test('Package management APIs are available', async function () { + assert.ok(typeof api.getPackages === 'function', 'getPackages should be a function'); + assert.ok(typeof api.refreshPackages === 'function', 'refreshPackages should be a function'); + assert.ok(typeof api.managePackages === 'function', 'managePackages should be a function'); + assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); + }); + + /** + * Test: getPackages returns array for environment + * + * For a valid environment, getPackages should return a list of packages. + */ + test('getPackages returns packages for environment', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Try to find an environment that likely has packages (not system Python) + let targetEnv = environments[0]; + for (const env of environments) { + // Prefer environments that are likely virtual envs with packages + if (env.displayName.includes('venv') || env.displayName.includes('.venv')) { + targetEnv = env; + break; + } + } + + const packages = await api.getPackages(targetEnv); + + // May be undefined if package manager not available + if (packages === undefined) { + this.skip(); + return; + } + + assert.ok(Array.isArray(packages), 'getPackages should return array'); + console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); + }); + + /** + * Test: Packages have valid structure + * + * Each package should have required properties. + */ + test('Packages have valid structure', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const packages = await api.getPackages(environments[0]); + + if (!packages || packages.length === 0) { + this.skip(); + return; + } + + for (const pkg of packages) { + assert.ok(pkg.pkgId, 'Package must have pkgId'); + assert.ok(pkg.pkgId.id, 'pkgId must have id'); + assert.ok(pkg.pkgId.managerId, 'pkgId must have managerId'); + assert.ok(pkg.pkgId.environmentId, 'pkgId must have environmentId'); + assert.ok(typeof pkg.name === 'string', 'Package must have name'); + assert.ok(pkg.name.length > 0, 'Package name should not be empty'); + assert.ok(typeof pkg.displayName === 'string', 'Package must have displayName'); + } + }); + + /** + * Test: refreshPackages updates package list + * + * After refreshing, the package list should be consistent. + * Multiple calls should return the same packages (idempotent). + */ + test('refreshPackages updates list', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + // Get initial packages + const initial = await api.getPackages(env); + + if (initial === undefined) { + this.skip(); + return; + } + + const initialCount = initial.length; + + // Refresh + await api.refreshPackages(env); + + // Get updated packages + const after = await api.getPackages(env); + + assert.ok(Array.isArray(after), 'Should return array after refresh'); + + // Package counts should be identical (no external changes during test) + assert.strictEqual( + after.length, + initialCount, + `Package count should be stable after refresh: expected ${initialCount}, got ${after.length}`, + ); + }); + + /** + * Test: getPackages returns non-empty array for environments with packages + * + * For virtual environments, at minimum pip should typically be present. + */ + test('getPackages returns packages for virtual environment', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Find a virtual environment (more likely to have pip) + const targetEnv = environments.find( + (env) => + env.displayName.includes('venv') || + env.displayName.includes('.venv') || + env.envId.managerId.includes('venv'), + ); + + if (!targetEnv) { + console.log('No virtual environment found, skipping'); + this.skip(); + return; + } + + const packages = await api.getPackages(targetEnv); + + if (packages === undefined) { + console.log('Package manager not available for:', targetEnv.displayName); + this.skip(); + return; + } + + // Virtual environments should have at least pip installed + const pipInstalled = packages.some((p) => p.name.toLowerCase() === 'pip'); + assert.ok(pipInstalled, `Virtual environment ${targetEnv.displayName} should have pip installed`); + + console.log(`Found ${packages.length} packages in ${targetEnv.displayName}`); + }); + + /** + * Test: Different environments can have different packages + * + * Package lists should be environment-specific. + */ + test('Package lists are environment-specific', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length < 2) { + this.skip(); + return; + } + + const env1 = environments[0]; + const env2 = environments[1]; + + const packages1 = await api.getPackages(env1); + const packages2 = await api.getPackages(env2); + + // Both should return valid results (or undefined for same reason) + if (packages1 === undefined || packages2 === undefined) { + this.skip(); + return; + } + + assert.ok(Array.isArray(packages1), 'Env1 packages should be array'); + assert.ok(Array.isArray(packages2), 'Env2 packages should be array'); + + console.log(`Env1 (${env1.displayName}): ${packages1.length} packages`); + console.log(`Env2 (${env2.displayName}): ${packages2.length} packages`); + }); + + /** + * Test: Package install and uninstall flow + * + * This test installs and uninstalls a small test package. + * Uses 'cowsay' as it's small and has no dependencies. + */ + test('Package install and uninstall works', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Find a virtual environment we can safely modify + const targetEnv = environments.find( + (env) => + (env.displayName.includes('venv') || env.displayName.includes('.venv')) && + env.envId.managerId.includes('venv'), + ); + + if (!targetEnv) { + console.log('No modifiable virtual environment found'); + this.skip(); + return; + } + + const testPackage = 'cowsay'; + + // Check if already installed + const initialPackages = await api.getPackages(targetEnv); + if (!initialPackages) { + console.log('Package manager not available for this environment'); + this.skip(); + return; + } + + const wasInstalled = initialPackages.some((p) => p.name.toLowerCase() === testPackage); + let packageInstalled = wasInstalled; + + try { + if (wasInstalled) { + // Uninstall first + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + packageInstalled = false; + await sleep(2000); + } + + // Install package + await api.managePackages(targetEnv, { install: [testPackage] }); + packageInstalled = true; + + // Refresh and verify + await api.refreshPackages(targetEnv); + const afterInstall = await api.getPackages(targetEnv); + + const isNowInstalled = afterInstall?.some((p) => p.name.toLowerCase() === testPackage); + assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); + + // Uninstall + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + packageInstalled = false; + + // Refresh and verify + await api.refreshPackages(targetEnv); + const afterUninstall = await api.getPackages(targetEnv); + + const isStillInstalled = afterUninstall?.some((p) => p.name.toLowerCase() === testPackage); + assert.ok(!isStillInstalled, `${testPackage} should be uninstalled after managePackages uninstall`); + } finally { + // Ensure cleanup even if assertions fail + if (packageInstalled) { + try { + await api.managePackages(targetEnv, { uninstall: [testPackage] }); + } catch { + console.log('Cleanup: failed to uninstall test package'); + } + } + } + }); + + /** + * Test: onDidChangePackages event fires + * + * When packages change, the event should fire. + */ + test('onDidChangePackages event is available', async function () { + assert.ok(api.onDidChangePackages, 'onDidChangePackages should be available'); + + // Verify it's subscribable + const handler = new TestEventHandler( + api.onDidChangePackages, + 'onDidChangePackages', + ); + + // Just verify we can subscribe without error + handler.dispose(); + }); + + /** + * Test: createPackageItem creates valid package + * + * The createPackageItem API should create properly structured packages. + */ + test('createPackageItem creates valid structure', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // This test verifies the API exists and is callable + // Full testing requires a registered package manager + assert.ok(typeof api.createPackageItem === 'function', 'createPackageItem should be a function'); + }); + + /** + * Test: getPackages returns array or undefined, never throws + * + * For any environment, getPackages should return either a valid + * array of packages or undefined (if no package manager), never throw. + */ + test('getPackages returns array or undefined for all environments', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + let arrayCount = 0; + let undefinedCount = 0; + + // Verify each environment returns valid result + for (const env of environments) { + const packages = await api.getPackages(env); + if (packages !== undefined) { + assert.ok(Array.isArray(packages), `getPackages should return array for ${env.displayName}`); + arrayCount++; + } else { + undefinedCount++; + } + } + + // Log results for visibility + console.log(`getPackages results: ${arrayCount} returned arrays, ${undefinedCount} returned undefined`); + + // At least some should return arrays (unless all envs lack package managers) + assert.ok( + arrayCount > 0 || undefinedCount === environments.length, + 'At least one environment should have a package manager, or all should return undefined consistently', + ); + }); +}); diff --git a/src/test/integration/pythonProjects.integration.test.ts b/src/test/integration/pythonProjects.integration.test.ts new file mode 100644 index 00000000..fd0feb68 --- /dev/null +++ b/src/test/integration/pythonProjects.integration.test.ts @@ -0,0 +1,366 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Python Projects + * + * PURPOSE: + * Verify that Python project management works correctly - adding projects, + * assigning environments, and persisting settings. + * + * WHAT THIS TESTS: + * 1. Adding projects via API + * 2. Removing projects via API + * 3. Project-environment associations + * 4. Events fire when projects change + * 5. Workspace folders are treated as default projects + * + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { TestEventHandler, waitForCondition } from '../testUtils'; + +suite('Integration: Python Projects', function () { + this.timeout(60_000); + + let api: PythonEnvironmentApi; + let originalProjectEnvs: Map; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + assert.ok(typeof api.getPythonProjects === 'function', 'getPythonProjects method not available'); + + // Save original state for restoration + originalProjectEnvs = new Map(); + const projects = api.getPythonProjects(); + for (const project of projects) { + const env = await api.getEnvironment(project.uri); + originalProjectEnvs.set(project.uri.toString(), env); + } + }); + + suiteTeardown(async function () { + // Restore original state + for (const [uriStr, env] of originalProjectEnvs) { + try { + const uri = vscode.Uri.parse(uriStr); + await api.setEnvironment(uri, env); + } catch { + // Ignore errors during cleanup + } + } + }); + + /** + * Test: Workspace folders are default projects + * + * When a workspace is open, the workspace folder(s) should be + * automatically treated as Python projects. + */ + test('Workspace folders appear as default projects', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const projects = api.getPythonProjects(); + assert.ok(Array.isArray(projects), 'getPythonProjects should return array'); + + // Each workspace folder should be a project + for (const folder of workspaceFolders) { + const found = projects.some( + (p) => p.uri.fsPath === folder.uri.fsPath || p.uri.toString() === folder.uri.toString(), + ); + assert.ok(found, `Workspace folder ${folder.name} should be a project`); + } + }); + + /** + * Test: getPythonProject returns correct project for URI + * + * Given a URI within a project, getPythonProject should return + * the containing project. + */ + test('getPythonProject returns project for URI', async function () { + const projects = api.getPythonProjects(); + + if (projects.length === 0) { + this.skip(); + return; + } + + const project = projects[0]; + const foundProject = api.getPythonProject(project.uri); + + assert.ok(foundProject, 'Should find project by its URI'); + assert.strictEqual(foundProject.uri.toString(), project.uri.toString(), 'Found project should match original'); + }); + + /** + * Test: getPythonProject returns undefined for unknown URI + * + * Querying a path that's not within any project should return undefined. + */ + test('getPythonProject returns undefined for unknown URI', async function () { + // Use a path that definitely won't be a project + const unknownUri = vscode.Uri.file('/nonexistent/path/that/wont/exist'); + const project = api.getPythonProject(unknownUri); + + assert.strictEqual(project, undefined, 'Should return undefined for unknown path'); + }); + + /** + * Test: Projects have required structure + * + * Each project should have the minimum required properties. + */ + test('Projects have valid structure', async function () { + const projects = api.getPythonProjects(); + + if (projects.length === 0) { + this.skip(); + return; + } + + for (const project of projects) { + assert.ok(typeof project.name === 'string', 'Project must have name'); + assert.ok(project.name.length > 0, 'Project name should not be empty'); + assert.ok(project.uri, 'Project must have URI'); + assert.ok(project.uri instanceof vscode.Uri, 'Project URI must be a Uri'); + } + }); + + /** + * Test: Environment can be set and retrieved for project + * + * After setting an environment for a project, getEnvironment should + * return that environment. + */ + test('setEnvironment and getEnvironment work for project', async function () { + const projects = api.getPythonProjects(); + + if (projects.length === 0) { + this.skip(); + return; + } + + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const project = projects[0]; + const env = environments[0]; + + // Diagnostic logging for CI debugging + console.log(`[TEST DEBUG] Project URI: ${project.uri.fsPath}`); + console.log(`[TEST DEBUG] Setting environment with envId: ${env.envId.id}`); + console.log(`[TEST DEBUG] Environment path: ${env.environmentPath?.fsPath}`); + console.log(`[TEST DEBUG] Total environments available: ${environments.length}`); + environments.forEach((e, i) => { + console.log(`[TEST DEBUG] env[${i}]: ${e.envId.id} (${e.displayName})`); + }); + + // Set environment for project + await api.setEnvironment(project.uri, env); + + // Track what getEnvironment returns during polling for diagnostics + let pollCount = 0; + let lastRetrievedId: string | undefined; + + // Wait for the environment to be retrievable with the correct ID + // This handles async persistence across platforms + // Use 15s timeout - CI runners (especially macos) can be slow with settings persistence + await waitForCondition( + async () => { + const retrieved = await api.getEnvironment(project.uri); + pollCount++; + const retrievedId = retrieved?.envId?.id; + if (retrievedId !== lastRetrievedId) { + console.log( + `[TEST DEBUG] Poll #${pollCount}: getEnvironment returned envId=${retrievedId ?? 'undefined'}`, + ); + lastRetrievedId = retrievedId; + } + return retrieved !== undefined && retrieved.envId.id === env.envId.id; + }, + 15_000, + `Environment was not set correctly. Expected envId: ${env.envId.id}, last retrieved: ${lastRetrievedId}`, + ); + + // Final verification + const retrievedEnv = await api.getEnvironment(project.uri); + assert.ok(retrievedEnv, 'Should get environment after setting'); + assert.strictEqual(retrievedEnv.envId.id, env.envId.id, 'Retrieved environment should match set environment'); + }); + + /** + * Test: onDidChangeEnvironment fires when project environment changes + * + * Setting an environment for a project should fire the change event. + */ + test('onDidChangeEnvironment fires on project environment change', async function () { + const projects = api.getPythonProjects(); + const environments = await api.getEnvironments('all'); + + if (projects.length === 0 || environments.length < 2) { + // Need at least 2 environments to guarantee a change + this.skip(); + return; + } + + const project = projects[0]; + + // Get current environment to pick a different one + const currentEnv = await api.getEnvironment(project.uri); + + // Pick an environment different from current + let targetEnv = environments[0]; + if (currentEnv && currentEnv.envId.id === targetEnv.envId.id) { + targetEnv = environments[1]; + } + + // Register handler BEFORE making the change + const handler = new TestEventHandler(api.onDidChangeEnvironment, 'onDidChangeEnvironment'); + + try { + // Set environment - this should fire the event + await api.setEnvironment(project.uri, targetEnv); + + // Wait for an event where event.new is defined (the actual change event) + // Use 15s timeout - CI runners can be slow + await waitForCondition( + () => handler.all.some((e) => e.new !== undefined), + 15_000, + 'onDidChangeEnvironment with new environment was not fired', + ); + + // Find the event with the new environment + const changeEvent = handler.all.find((e) => e.new !== undefined); + assert.ok(changeEvent, 'Should have change event with new environment'); + assert.ok(changeEvent.new, 'Event should have new environment'); + } finally { + handler.dispose(); + } + }); + + /** + * Test: Environment can be unset for project + * + * Setting undefined as environment should clear the explicit association. + * After clearing, getEnvironment may return auto-discovered env or undefined. + */ + test('setEnvironment with undefined clears association', async function () { + const projects = api.getPythonProjects(); + const environments = await api.getEnvironments('all'); + + if (projects.length === 0 || environments.length === 0) { + this.skip(); + return; + } + + const project = projects[0]; + const env = environments[0]; + + // Set environment first + await api.setEnvironment(project.uri, env); + + // Wait for it to be set + // Use 15s timeout - CI runners can be slow with settings persistence + await waitForCondition( + async () => { + const retrieved = await api.getEnvironment(project.uri); + return retrieved !== undefined && retrieved.envId.id === env.envId.id; + }, + 15_000, + 'Environment was not set before clearing', + ); + + // Verify it was set + const beforeClear = await api.getEnvironment(project.uri); + assert.ok(beforeClear, 'Environment should be set before clearing'); + assert.strictEqual(beforeClear.envId.id, env.envId.id, 'Should have the explicitly set environment'); + + // Clear environment + await api.setEnvironment(project.uri, undefined); + + // After clearing, if there's still an environment, it should either be: + // 1. undefined (no auto-discovery) + // 2. Different from the explicitly set one (auto-discovered fallback) + // 3. Same as before if it happens to be auto-discovered too (edge case) + const afterClear = await api.getEnvironment(project.uri); + + // The key assertion: the operation completed without error + // and the API behaves consistently (returns env or undefined) + if (afterClear) { + assert.ok(afterClear.envId, 'If environment returned, it must have valid envId'); + assert.ok(afterClear.envId.id, 'If environment returned, envId must have id'); + } else { + assert.strictEqual( + afterClear, + undefined, + 'Cleared association should return undefined when no auto-discovery', + ); + } + }); + + /** + * Test: File within project resolves to project environment + * + * A file path inside a project should resolve to that project's environment. + */ + test('File in project uses project environment', async function () { + const projects = api.getPythonProjects(); + const environments = await api.getEnvironments('all'); + + if (projects.length === 0 || environments.length === 0) { + this.skip(); + return; + } + + const project = projects[0]; + const env = environments[0]; + + // Set environment for project + await api.setEnvironment(project.uri, env); + + // Wait for it to be set + // Use 15s timeout - CI runners can be slow with settings persistence + await waitForCondition( + async () => { + const retrieved = await api.getEnvironment(project.uri); + return retrieved !== undefined && retrieved.envId.id === env.envId.id; + }, + 15_000, + 'Environment was not set for project', + ); + + // Create a hypothetical file path inside the project + const fileUri = vscode.Uri.joinPath(project.uri, 'some_script.py'); + + // Get environment for the file + const fileEnv = await api.getEnvironment(fileUri); + + // Should inherit project's environment + assert.ok(fileEnv, 'File should get environment from project'); + assert.strictEqual(fileEnv.envId.id, env.envId.id, 'File should use project environment'); + }); +}); diff --git a/src/test/integration/settingsBehavior.integration.test.ts b/src/test/integration/settingsBehavior.integration.test.ts new file mode 100644 index 00000000..d86e0eaf --- /dev/null +++ b/src/test/integration/settingsBehavior.integration.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Settings Behavior + * + * PURPOSE: + * Verify that the extension's settings-related APIs work correctly + * and interact properly with VS Code's settings system. + * + * WHAT THIS TESTS: + * 1. Environment variables API works correctly + * 2. Extension correctly defines required settings in package.json + * 3. Settings and API work together consistently + * + * NOTE: These tests focus on extension behavior, not VS Code's + * configuration system. Tests just reading settings without + * exercising extension code belong elsewhere. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +suite('Integration: Settings Behavior', function () { + this.timeout(60_000); + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + assert.ok(typeof api.getEnvironmentVariables === 'function', 'getEnvironmentVariables should be a function'); + assert.ok(api.onDidChangeEnvironmentVariables, 'onDidChangeEnvironmentVariables should be available'); + }); + + // ========================================================================= + // EXTENSION SETTINGS SCHEMA TESTS + // Verify that settings defined in package.json are accessible. + // ========================================================================= + + /** + * Test: Extension settings are defined in package.json + * + * The python-envs configuration section should be accessible with expected types. + * This verifies our package.json contributes the correct settings schema. + */ + test('Extension settings are defined in package.json', async function () { + const config = vscode.workspace.getConfiguration('python-envs'); + + assert.ok(config, 'python-envs configuration should be accessible'); + + // Check some expected settings exist and have correct types + const defaultEnvManager = config.get('defaultEnvManager'); + const defaultPackageManager = config.get('defaultPackageManager'); + + // Assert settings have expected types (string or undefined) + assert.ok( + typeof defaultEnvManager === 'string' || defaultEnvManager === undefined, + `defaultEnvManager should be string or undefined, got ${typeof defaultEnvManager}`, + ); + assert.ok( + typeof defaultPackageManager === 'string' || defaultPackageManager === undefined, + `defaultPackageManager should be string or undefined, got ${typeof defaultPackageManager}`, + ); + + console.log('defaultEnvManager:', defaultEnvManager); + console.log('defaultPackageManager:', defaultPackageManager); + }); + + /** + * Test: pythonProjects setting structure + * + * The pythonProjects setting should have the correct structure when set. + * This validates our package.json schema for pythonProjects. + */ + test('pythonProjects setting has correct structure', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const config = vscode.workspace.getConfiguration('python-envs', workspaceFolders[0].uri); + const projects = + config.get>('pythonProjects'); + + if (projects && projects.length > 0) { + for (const project of projects) { + assert.ok(typeof project.path === 'string', 'Project should have path'); + } + } + + console.log('pythonProjects:', JSON.stringify(projects, null, 2)); + }); + + // ========================================================================= + // ENVIRONMENT VARIABLES API TESTS + // These tests verify the extension's getEnvironmentVariables API behavior. + // ========================================================================= + + /** + * Test: getEnvironmentVariables returns object + * + * The method should return an environment variables object. + */ + test('getEnvironmentVariables returns variables', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const envVars = await api.getEnvironmentVariables(workspaceFolders[0].uri); + + assert.ok(typeof envVars === 'object', 'Should return object'); + + // Should have some common environment variables + const hasPath = 'PATH' in envVars || 'Path' in envVars; + console.log('Has PATH:', hasPath); + }); + + /** + * Test: getEnvironmentVariables with overrides + * + * Passing overrides should merge them into the result. + */ + test('getEnvironmentVariables applies overrides', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const testVar = 'TEST_INTEGRATION_VAR'; + const testValue = 'test_value_12345'; + + const envVars = await api.getEnvironmentVariables(workspaceFolders[0].uri, [{ [testVar]: testValue }]); + + assert.strictEqual(envVars[testVar], testValue, 'Override should be applied'); + }); + + /** + * Test: getEnvironmentVariables with undefined uri + * + * Calling with undefined uri should return global environment. + */ + test('getEnvironmentVariables works with undefined uri', async function () { + const envVars = await api.getEnvironmentVariables(undefined as unknown as vscode.Uri); + + assert.ok(typeof envVars === 'object', 'Should return object for undefined uri'); + }); + + // ========================================================================= + // SETTINGS + API INTEGRATION TESTS + // These tests verify settings and API work together correctly. + // ========================================================================= + + /** + * Test: Settings and API are connected + * + * Verify that settings API and environment API both work and return consistent data. + */ + test('Settings and API are connected', async function () { + // Get current environment from API + const currentEnv = await api.getEnvironment(undefined); + + // Get current settings + const config = vscode.workspace.getConfiguration('python-envs'); + const pythonProjects = config.get('pythonProjects'); + + // Assert we can read settings + assert.ok( + pythonProjects === undefined || Array.isArray(pythonProjects), + 'pythonProjects should be undefined or array', + ); + + // If we have an environment, verify it has valid structure + if (currentEnv) { + assert.ok(currentEnv.envId, 'Current environment should have envId'); + assert.ok(currentEnv.displayName, 'Current environment should have displayName'); + } + + // This test verifies both APIs are working together without errors + console.log('Current env:', currentEnv?.displayName ?? 'none'); + console.log('Projects setting type:', Array.isArray(pythonProjects) ? 'array' : typeof pythonProjects); + }); +}); diff --git a/src/test/integration/terminalActivation.integration.test.ts b/src/test/integration/terminalActivation.integration.test.ts new file mode 100644 index 00000000..450d1964 --- /dev/null +++ b/src/test/integration/terminalActivation.integration.test.ts @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Terminal Activation + * + * PURPOSE: + * Verify that terminal creation and activation work correctly + * with different environments and settings. + * + * WHAT THIS TESTS: + * 1. Terminal creation API + * 2. Terminal runs with correct environment + * 3. runInTerminal executes commands + * 4. runInDedicatedTerminal uses consistent terminal + * + * NOTE: Terminal tests interact with real VS Code terminals. + * Tests should be careful about cleanup. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironmentApi, PythonTerminalCreateOptions, PythonTerminalExecutionOptions } from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +suite('Integration: Terminal Activation', function () { + this.timeout(60_000); + + let api: PythonEnvironmentApi; + const createdTerminals: vscode.Terminal[] = []; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + }); + + suiteTeardown(async function () { + // Clean up created terminals + for (const terminal of createdTerminals) { + terminal.dispose(); + } + createdTerminals.length = 0; + }); + + /** + * Test: Terminal APIs are available + * + * The API should have all terminal-related methods. + */ + test('Terminal APIs are available', async function () { + assert.ok(typeof api.createTerminal === 'function', 'createTerminal should be a function'); + assert.ok(typeof api.runInTerminal === 'function', 'runInTerminal should be a function'); + assert.ok(typeof api.runInDedicatedTerminal === 'function', 'runInDedicatedTerminal should be a function'); + assert.ok(typeof api.runAsTask === 'function', 'runAsTask should be a function'); + assert.ok(typeof api.runInBackground === 'function', 'runInBackground should be a function'); + }); + + /** + * Test: createTerminal creates a terminal + * + * The createTerminal method should create a new VS Code terminal. + */ + test('createTerminal creates new terminal', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + const initialTerminalCount = vscode.window.terminals.length; + + const options: PythonTerminalCreateOptions = { + name: 'Test Terminal', + }; + + const terminal = await api.createTerminal(env, options); + createdTerminals.push(terminal); + + assert.ok(terminal, 'createTerminal should return a terminal'); + assert.ok( + terminal.name.includes('Test Terminal') || terminal.name.includes('Python'), + 'Terminal should have appropriate name', + ); + + // Verify terminal was created + assert.ok(vscode.window.terminals.length >= initialTerminalCount, 'Terminal count should increase'); + }); + + /** + * Test: Terminal can be created with custom options + * + * Various terminal options should be respected. + */ + test('createTerminal respects options', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const options: PythonTerminalCreateOptions = { + name: 'Custom Options Terminal', + hideFromUser: false, + }; + + const terminal = await api.createTerminal(env, options); + createdTerminals.push(terminal); + + assert.ok(terminal, 'Terminal should be created'); + // Name may include environment info, but should contain our custom name + console.log('Created terminal name:', terminal.name); + }); + + /** + * Test: runInTerminal returns terminal + * + * runInTerminal should execute in a terminal and return it. + */ + test('runInTerminal returns terminal', async function () { + const environments = await api.getEnvironments('all'); + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (environments.length === 0 || !workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const options: PythonTerminalExecutionOptions = { + cwd: workspaceFolders[0].uri, + args: ['--version'], + show: false, + }; + + const terminal = await api.runInTerminal(env, options); + createdTerminals.push(terminal); + + assert.ok(terminal, 'runInTerminal should return terminal'); + assert.ok(terminal instanceof Object, 'Should be a terminal object'); + }); + + /** + * Test: runInDedicatedTerminal reuses terminal + * + * Multiple calls with same key should use same terminal. + */ + test('runInDedicatedTerminal reuses terminal for same key', async function () { + const environments = await api.getEnvironments('all'); + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (environments.length === 0 || !workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + const terminalKey = 'test-dedicated-terminal'; + + const options: PythonTerminalExecutionOptions = { + cwd: workspaceFolders[0].uri, + args: ['--version'], + show: false, + }; + + // First call + const terminal1 = await api.runInDedicatedTerminal(terminalKey, env, options); + createdTerminals.push(terminal1); + + // Second call with same key + const terminal2 = await api.runInDedicatedTerminal(terminalKey, env, options); + + // Should be the same terminal (or at least same name) + assert.ok(terminal1, 'First call should return terminal'); + assert.ok(terminal2, 'Second call should return terminal'); + + // Verify same terminal is reused (terminal names should match for same key) + assert.strictEqual(terminal1.name, terminal2.name, 'Same key should reuse the same terminal'); + }); + + /** + * Test: Different keys get different terminals + * + * Different terminal keys should create different terminals. + */ + test('runInDedicatedTerminal uses different terminals for different keys', async function () { + const environments = await api.getEnvironments('all'); + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (environments.length === 0 || !workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const options: PythonTerminalExecutionOptions = { + cwd: workspaceFolders[0].uri, + args: ['--version'], + show: false, + }; + + const terminal1 = await api.runInDedicatedTerminal('key-1', env, options); + createdTerminals.push(terminal1); + + const terminal2 = await api.runInDedicatedTerminal('key-2', env, options); + if (terminal1 !== terminal2) { + createdTerminals.push(terminal2); + } + + assert.ok(terminal1, 'First terminal should exist'); + assert.ok(terminal2, 'Second terminal should exist'); + }); + + /** + * Test: createTerminal with disableActivation option + * + * When disableActivation is true, environment should not be activated. + */ + test('disableActivation option is accepted', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const options: PythonTerminalCreateOptions = { + name: 'No Activation Terminal', + disableActivation: true, + }; + + // Should not throw + const terminal = await api.createTerminal(env, options); + createdTerminals.push(terminal); + + assert.ok(terminal, 'Terminal should be created with disableActivation'); + }); + + /** + * Test: runAsTask returns TaskExecution + * + * runAsTask should start a task and return its execution. + */ + test('runAsTask returns task execution', async function () { + const environments = await api.getEnvironments('all'); + const projects = api.getPythonProjects(); + + if (environments.length === 0 || projects.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const execution = await api.runAsTask(env, { + name: 'Test Python Task', + args: ['--version'], + project: projects[0], + }); + + assert.ok(execution, 'runAsTask should return execution'); + assert.ok(execution.task, 'Execution should have task'); + assert.ok(execution.task.name, 'Task should have name'); + assert.ok(execution.task.definition, 'Task should have definition'); + + // Clean up - terminate the task + vscode.tasks.taskExecutions.forEach((t) => { + if (t === execution) { + t.terminate(); + } + }); + }); + + /** + * Test: runInBackground returns PythonProcess + * + * runInBackground should spawn a background process. + */ + test('runInBackground returns process', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Find an environment with Python executable + const targetEnv = environments[0]; + + const process = await api.runInBackground(targetEnv, { + args: ['--version'], + }); + + assert.ok(process, 'runInBackground should return process'); + assert.ok(process.stdout, 'Process should have stdout'); + assert.ok(process.stderr, 'Process should have stderr'); + assert.ok(typeof process.kill === 'function', 'Process should have kill method'); + + // Clean up + process.kill(); + }); + + /** + * Test: createTerminal succeeds for any valid environment + * + * Verifies that createTerminal returns a valid terminal object + * for any discovered environment without throwing. + */ + test('createTerminal succeeds for any environment', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + + const terminal = await api.createTerminal(env, { + name: 'Env Check Terminal', + }); + createdTerminals.push(terminal); + + // Verify terminal was created successfully + assert.ok(terminal, 'createTerminal should return a terminal'); + assert.ok(terminal.name, 'Terminal should have a name'); + console.log('Created terminal for:', env.displayName); + }); +}); diff --git a/src/test/integration/test-workspace/.gitkeep b/src/test/integration/test-workspace/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/integration/test-workspace/.vscode/settings.json b/src/test/integration/test-workspace/.vscode/settings.json new file mode 100644 index 00000000..c9ebf2d2 --- /dev/null +++ b/src/test/integration/test-workspace/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/src/test/integration/test-workspace/integration-tests.code-workspace b/src/test/integration/test-workspace/integration-tests.code-workspace new file mode 100644 index 00000000..fca712c5 --- /dev/null +++ b/src/test/integration/test-workspace/integration-tests.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "project-a", + }, + { + "path": "project-b", + }, + ], + "settings": {}, +} diff --git a/src/test/integration/test-workspace/project-a/.gitkeep b/src/test/integration/test-workspace/project-a/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/integration/test-workspace/project-a/.vscode/settings.json b/src/test/integration/test-workspace/project-a/.vscode/settings.json new file mode 100644 index 00000000..c9ebf2d2 --- /dev/null +++ b/src/test/integration/test-workspace/project-a/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/src/test/integration/test-workspace/project-b/.gitkeep b/src/test/integration/test-workspace/project-b/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/integration/test-workspace/project-b/.vscode/settings.json b/src/test/integration/test-workspace/project-b/.vscode/settings.json new file mode 100644 index 00000000..4988ec07 --- /dev/null +++ b/src/test/integration/test-workspace/project-b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} \ No newline at end of file From 0b9d32735568866ac4edfde7bfdded0526d78ae1 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 18 Feb 2026 17:04:51 -0800 Subject: [PATCH 09/13] Add platform-specific default paths for Poetry cache and virtualenvs (#1218) Fixes https://github.com/microsoft/vscode-python-environments/issues/1182 Fixes https://github.com/microsoft/vscode-python-environments/issues/1184 --- src/common/utils/platformUtils.ts | 4 + src/managers/poetry/poetryUtils.ts | 128 +++++-- src/test/constants.ts | 2 +- .../projectManager.initialize.unit.test.ts | 8 +- .../managers/poetry/poetryUtils.unit.test.ts | 339 ++++++++++++++++++ 5 files changed, 452 insertions(+), 29 deletions(-) diff --git a/src/common/utils/platformUtils.ts b/src/common/utils/platformUtils.ts index bc11fd8a..53245b0b 100644 --- a/src/common/utils/platformUtils.ts +++ b/src/common/utils/platformUtils.ts @@ -1,3 +1,7 @@ export function isWindows(): boolean { return process.platform === 'win32'; } + +export function isMac(): boolean { + return process.platform === 'darwin'; +} diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index 038028ce..dc10678c 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -7,8 +7,8 @@ import { execProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; -import { getUserHomeDir, untildify } from '../../common/utils/pathUtils'; -import { isWindows } from '../../common/utils/platformUtils'; +import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils'; +import { isMac, isWindows } from '../../common/utils/platformUtils'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { isNativeEnvInfo, @@ -214,14 +214,14 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise { + if (!virtualenvsPath.includes('{cache-dir}')) { + return virtualenvsPath; + } + + // Try to get the actual cache-dir from Poetry + try { + const { stdout } = await execProcess(`"${poetry}" config cache-dir`); + if (stdout) { + const cacheDir = stdout.trim(); + if (cacheDir && path.isAbsolute(cacheDir)) { + const resolved = virtualenvsPath.replace('{cache-dir}', cacheDir); + return path.normalize(resolved); + } + } + } catch (e) { + traceError('Error getting Poetry cache-dir config', e); + } + + // Fall back to platform-specific default cache dir + const defaultCacheDir = getDefaultPoetryCacheDir(); + if (defaultCacheDir) { + const resolved = virtualenvsPath.replace('{cache-dir}', defaultCacheDir); + return path.normalize(resolved); + } + + // Cannot resolve the placeholder - return undefined instead of unresolved path + return undefined; +} + export async function getPoetryVersion(poetry: string): Promise { try { const { stdout } = await execProcess(`"${poetry}" --version`); @@ -274,8 +356,8 @@ export async function nativeToPythonEnv( const displayName = info.displayName || `poetry (${sv})`; // Check if this is a global Poetry virtualenv by checking if it's in Poetry's virtualenvs directory - // We need to use path.normalize() to ensure consistent path format comparison - const normalizedPrefix = path.normalize(info.prefix); + // We use normalizePath() for case-insensitive path comparison on Windows + const normalizedPrefix = normalizePath(info.prefix); // Determine if the environment is in Poetry's global virtualenvs directory let isGlobalPoetryEnv = false; @@ -284,19 +366,17 @@ export async function nativeToPythonEnv( if (!isPoetryVirtualenvsInProject() || !info.project) { const virtualenvsPath = poetryVirtualenvsPath; // Use the cached value if available if (virtualenvsPath) { - const normalizedVirtualenvsPath = path.normalize(virtualenvsPath); + const normalizedVirtualenvsPath = normalizePath(virtualenvsPath); isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedVirtualenvsPath); } else { - // Fall back to checking the default location if we haven't cached the path yet - const homeDir = getUserHomeDir(); - if (homeDir) { - const defaultPath = path.normalize(path.join(homeDir, '.cache', 'pypoetry', 'virtualenvs')); - isGlobalPoetryEnv = normalizedPrefix.startsWith(defaultPath); + // Fall back to checking the platform-specific default location if we haven't cached the path yet + const defaultPath = getDefaultPoetryVirtualenvsPath(); + if (defaultPath) { + const normalizedDefaultPath = normalizePath(defaultPath); + isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedDefaultPath); // Try to get the actual path asynchronously for next time - getPoetryVirtualenvsPath(_poetry).catch((e) => - traceError(`Error getting Poetry virtualenvs path: ${e}`), - ); + getPoetryVirtualenvsPath(_poetry).catch((e) => traceError('Error getting Poetry virtualenvs path', e)); } } } diff --git a/src/test/constants.ts b/src/test/constants.ts index e930ad60..bd7f65ed 100644 --- a/src/test/constants.ts +++ b/src/test/constants.ts @@ -26,7 +26,7 @@ export function isMultiRootTest(): boolean { return false; } try { - // eslint-disable-next-line @typescript-eslint/no-require-imports + const vscode = require('vscode'); return Array.isArray(vscode.workspace.workspaceFolders) && vscode.workspace.workspaceFolders.length > 1; } catch { diff --git a/src/test/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts index 545e22ea..84e0c9fc 100644 --- a/src/test/features/projectManager.initialize.unit.test.ts +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -310,8 +310,8 @@ suite('Project Manager Initialization - Settings Preservation', () => { test('adding a workspace folder should NOT write project settings', async () => { const mockConfig = new MockWorkspaceConfiguration(); (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { - if (key === 'pythonProjects') return [] as unknown as T; - if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + if (key === 'pythonProjects') {return [] as unknown as T;} + if (key === 'defaultEnvManager') {return 'ms-python.python:venv' as T;} return defaultValue; }; mockConfig.update = () => Promise.resolve(); @@ -347,8 +347,8 @@ suite('Project Manager Initialization - Settings Preservation', () => { test('removing a workspace folder should NOT write additional settings', async () => { const mockConfig = new MockWorkspaceConfiguration(); (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { - if (key === 'pythonProjects') return [] as unknown as T; - if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + if (key === 'pythonProjects') {return [] as unknown as T;} + if (key === 'defaultEnvManager') {return 'ms-python.python:venv' as T;} return defaultValue; }; mockConfig.update = () => Promise.resolve(); diff --git a/src/test/managers/poetry/poetryUtils.unit.test.ts b/src/test/managers/poetry/poetryUtils.unit.test.ts index e1bd44e0..6a740b88 100644 --- a/src/test/managers/poetry/poetryUtils.unit.test.ts +++ b/src/test/managers/poetry/poetryUtils.unit.test.ts @@ -1,13 +1,22 @@ import assert from 'node:assert'; +import path from 'node:path'; import * as sinon from 'sinon'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; import * as childProcessApis from '../../../common/childProcess.apis'; +import * as persistentState from '../../../common/persistentState'; +import * as pathUtils from '../../../common/utils/pathUtils'; +import * as platformUtils from '../../../common/utils/platformUtils'; import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder'; import * as utils from '../../../managers/common/utils'; import { + clearPoetryCache, + getDefaultPoetryCacheDir, + getDefaultPoetryVirtualenvsPath, getPoetryVersion, + getPoetryVirtualenvsPath, isPoetryVirtualenvsInProject, nativeToPythonEnv, + POETRY_VIRTUALENVS_PATH_KEY, } from '../../../managers/poetry/poetryUtils'; suite('isPoetryVirtualenvsInProject', () => { @@ -208,3 +217,333 @@ suite('getPoetryVersion - childProcess.apis mocking pattern', () => { assert.strictEqual(version, undefined); }); }); + +suite('getDefaultPoetryCacheDir', () => { + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let originalLocalAppData: string | undefined; + let originalAppData: string | undefined; + + setup(() => { + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + + // Save original env vars + originalLocalAppData = process.env.LOCALAPPDATA; + originalAppData = process.env.APPDATA; + }); + + teardown(() => { + sinon.restore(); + // Restore original env vars + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + }); + + test('Windows: uses LOCALAPPDATA when available', () => { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache')); + }); + + test('Windows: falls back to APPDATA when LOCALAPPDATA is not set', () => { + isWindowsStub.returns(true); + delete process.env.LOCALAPPDATA; + process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming'; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Roaming', 'pypoetry', 'Cache')); + }); + + test('Windows: returns undefined when neither LOCALAPPDATA nor APPDATA is set', () => { + isWindowsStub.returns(true); + delete process.env.LOCALAPPDATA; + delete process.env.APPDATA; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, undefined); + }); + + test('macOS: uses ~/Library/Caches/pypoetry', () => { + isWindowsStub.returns(false); + isMacStub.returns(true); + getUserHomeDirStub.returns('/Users/test'); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry')); + }); + + test('Linux: uses ~/.cache/pypoetry', () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry')); + }); + + test('returns undefined when home directory is not available (non-Windows)', () => { + isWindowsStub.returns(false); + getUserHomeDirStub.returns(undefined); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, undefined); + }); +}); + +suite('getDefaultPoetryVirtualenvsPath', () => { + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let originalLocalAppData: string | undefined; + + setup(() => { + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + originalLocalAppData = process.env.LOCALAPPDATA; + }); + + teardown(() => { + sinon.restore(); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + }); + + test('appends virtualenvs to cache directory', () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs')); + }); + + test('Windows: returns correct virtualenvs path', () => { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs')); + }); + + test('macOS: returns correct virtualenvs path', () => { + isWindowsStub.returns(false); + isMacStub.returns(true); + getUserHomeDirStub.returns('/Users/test'); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry', 'virtualenvs')); + }); + + test('returns undefined when cache dir is not available', () => { + isWindowsStub.returns(false); + getUserHomeDirStub.returns(undefined); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, undefined); + }); +}); + +suite('getPoetryVirtualenvsPath - {cache-dir} placeholder resolution', () => { + let execProcessStub: sinon.SinonStub; + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let getWorkspacePersistentStateStub: sinon.SinonStub; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub }; + + setup(async () => { + execProcessStub = sinon.stub(childProcessApis, 'execProcess'); + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + + // Create a mock state object to track persistence + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + + // Clear Poetry cache before each test + await clearPoetryCache(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('resolves {cache-dir} placeholder when poetry config cache-dir succeeds', async () => { + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config returns the actual path + execProcessStub.onSecondCall().resolves({ stdout: '/home/test/.cache/pypoetry\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, path.join('/home/test/.cache/pypoetry', 'virtualenvs')); + // Verify the resolved path was persisted + assert.ok( + mockState.set.calledWith( + POETRY_VIRTUALENVS_PATH_KEY, + path.join('/home/test/.cache/pypoetry', 'virtualenvs'), + ), + ); + }); + + test('falls back to platform default when poetry config cache-dir fails', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default cache dir + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + // The resolved path should still be persisted + assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, expectedPath)); + }); + + test('falls back to platform default when poetry config cache-dir returns non-absolute path', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir returns a relative path (invalid) + execProcessStub.onSecondCall().resolves({ stdout: 'relative/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default cache dir + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('does not persist path when placeholder cannot be resolved and no platform default', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns(undefined); // No home dir available + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default (which returns undefined when home is not available) + assert.strictEqual(result, undefined); + // Path should NOT be persisted when unresolved + assert.ok(!mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, sinon.match.any)); + }); + + test('handles virtualenvs.path without {cache-dir} placeholder (absolute path)', async () => { + // virtualenvs.path returns an absolute path directly + execProcessStub.onFirstCall().resolves({ stdout: '/custom/virtualenvs/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, '/custom/virtualenvs/path'); + // Should be persisted + assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, '/custom/virtualenvs/path')); + }); + + test('falls back to platform default when virtualenvs.path returns non-absolute path without placeholder', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // virtualenvs.path returns a relative path (not valid) + execProcessStub.onFirstCall().resolves({ stdout: 'relative/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('uses cached value from persistent state', async () => { + mockState.get.resolves('/cached/virtualenvs/path'); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, '/cached/virtualenvs/path'); + // Should not call exec since we have a cached value + assert.ok(!execProcessStub.called); + }); + + test('handles virtualenvs.path config command failure', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // virtualenvs.path config fails + execProcessStub.onFirstCall().rejects(new Error('Config command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('Windows: resolves {cache-dir} with platform default when cache-dir query fails', async () => { + const originalLocalAppData = process.env.LOCALAPPDATA; + try { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('C:\\poetry\\poetry.exe'); + + const expectedPath = path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + } finally { + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + } + }); +}); From c3842201bac5dabe3cbfb08919bbad9d63dda0a4 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 18 Feb 2026 18:37:25 -0800 Subject: [PATCH 10/13] fix: use conda.sh for Git Bash activation on Windows (Fixes #1247) (#1248) ## Summary On Windows with Git Bash as the default terminal, the extension was attempting to activate conda environments using `activate.bat` (CMD batch script), which fails in bash: ```bash $ source C:/Tools/miniforge3/Scripts/activate.bat pipes bash: C:/Tools/miniforge3/Scripts/activate.bat: line 1: syntax error near unexpected token `(' ``` ## Root Cause The `windowsExceptionGenerateConfig` function was using the same `sourceInitPath` (which is `activate.bat` on Windows) for all shells, including Git Bash. ## Fix - Pass `conda.sh` path to `windowsExceptionGenerateConfig` for proper bash-based shell activation - Use two commands for bash when `conda.sh` is available: `source ` + `conda activate ` - Skip Git Bash activation entirely when `conda.sh` is unavailable and `sourceInitPath` is `.bat` - Refactored `shellSourcingScripts` from array with fragile indices to typed `ShellSourcingScripts` interface ## Changes - [condaUtils.ts](src/managers/conda/condaUtils.ts): Extract and pass `condaShPath` to `windowsExceptionGenerateConfig`, use two-command activation for bash, skip activation when only `.bat` is available - [condaSourcingUtils.ts](src/managers/conda/condaSourcingUtils.ts): Add `ShellSourcingScripts` interface replacing fragile array indices - [condaUtils.windowsActivation.unit.test.ts](src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts): Add unit tests for Windows shell activation ## Testing - Pre-commit checks pass (lint, tsc, unit tests) - Manual testing needed on Windows with Git Bash + conda Fixes #1247 --- .../terminal/shells/common/shellUtils.ts | 5 + src/managers/conda/condaSourcingUtils.ts | 47 ++-- src/managers/conda/condaUtils.ts | 35 ++- .../shells/common/shellUtils.unit.test.ts | 26 ++- .../condaUtils.windowsActivation.unit.test.ts | 201 ++++++++++++++++++ 5 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts diff --git a/src/features/terminal/shells/common/shellUtils.ts b/src/features/terminal/shells/common/shellUtils.ts index 09867f29..766fd65a 100644 --- a/src/features/terminal/shells/common/shellUtils.ts +++ b/src/features/terminal/shells/common/shellUtils.ts @@ -24,6 +24,11 @@ const shellDelimiterByShell = new Map([ ]); export function getShellCommandAsString(shell: string, command: PythonCommandRunConfiguration[]): string { + // Return empty string for empty command arrays (e.g., when activation is intentionally skipped) + if (command.length === 0) { + return ''; + } + const delimiter = shellDelimiterByShell.get(shell) ?? defaultShellDelimiter; const parts = []; for (const cmd of command) { diff --git a/src/managers/conda/condaSourcingUtils.ts b/src/managers/conda/condaSourcingUtils.ts index a8f888b4..76bf7dad 100644 --- a/src/managers/conda/condaSourcingUtils.ts +++ b/src/managers/conda/condaSourcingUtils.ts @@ -6,6 +6,19 @@ import * as path from 'path'; import { traceError, traceInfo, traceVerbose } from '../../common/logging'; import { isWindows } from '../../common/utils/platformUtils'; +/** + * Shell-specific sourcing scripts for conda activation. + * Each field is optional since not all scripts may be available on all systems. + */ +export interface ShellSourcingScripts { + /** PowerShell hook script (conda-hook.ps1) */ + ps1?: string; + /** Bash/sh initialization script (conda.sh) */ + sh?: string; + /** Windows CMD batch file (activate.bat) */ + cmd?: string; +} + /** * Represents the status of conda sourcing in the current environment */ @@ -16,14 +29,14 @@ export class CondaSourcingStatus { * @param condaFolder Path to the conda installation folder (derived from condaPath) * @param isActiveOnLaunch Whether conda was activated before VS Code launch * @param globalSourcingScript Path to the global sourcing script (if exists) - * @param shellSourcingScripts List of paths to shell-specific sourcing scripts + * @param shellSourcingScripts Shell-specific sourcing scripts (if found) */ constructor( public readonly condaPath: string, public readonly condaFolder: string, public isActiveOnLaunch?: boolean, public globalSourcingScript?: string, - public shellSourcingScripts?: string[], + public shellSourcingScripts?: ShellSourcingScripts, ) {} /** @@ -40,15 +53,23 @@ export class CondaSourcingStatus { lines.push(`├─ Global Sourcing Script: ${this.globalSourcingScript}`); } - if (this.shellSourcingScripts?.length) { - lines.push('└─ Shell-specific Sourcing Scripts:'); - this.shellSourcingScripts.forEach((script, index, array) => { - const isLast = index === array.length - 1; - if (script) { - // Only include scripts that exist - lines.push(` ${isLast ? '└─' : '├─'} ${script}`); - } - }); + if (this.shellSourcingScripts) { + const scripts = this.shellSourcingScripts; + const entries = [ + scripts.ps1 && `PowerShell: ${scripts.ps1}`, + scripts.sh && `Bash/sh: ${scripts.sh}`, + scripts.cmd && `CMD: ${scripts.cmd}`, + ].filter(Boolean); + + if (entries.length > 0) { + lines.push('└─ Shell-specific Sourcing Scripts:'); + entries.forEach((entry, index, array) => { + const isLast = index === array.length - 1; + lines.push(` ${isLast ? '└─' : '├─'} ${entry}`); + }); + } else { + lines.push('└─ No Shell-specific Sourcing Scripts Found'); + } } else { lines.push('└─ No Shell-specific Sourcing Scripts Found'); } @@ -120,7 +141,7 @@ export async function findGlobalSourcingScript(condaFolder: string): Promise { +export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStatus): Promise { const logs: string[] = []; logs.push('=== Conda Sourcing Shell Script Search ==='); @@ -170,7 +191,7 @@ export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStat traceVerbose(logs.join('\n')); } - return [ps1Script, shScript, cmdActivate] as string[]; + return { ps1: ps1Script, sh: shScript, cmd: cmdActivate }; } /** diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index ee45911b..a34bbf87 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -512,10 +512,16 @@ async function buildShellActivationMapForConda( // P3: Handle Windows specifically ;this is carryover from vscode-python if (isWindows()) { logs.push('✓ Using Windows-specific activation configuration'); + // Get conda.sh for bash-based shells on Windows (e.g., Git Bash) + const condaShPath = envManager.sourcingInformation.shellSourcingScripts?.sh; + if (!condaShPath) { + logs.push('conda.sh not found, using preferred sourcing script path for bash activation'); + } shellMaps = await windowsExceptionGenerateConfig( preferredSourcingPath, envIdentifier, envManager.sourcingInformation.condaFolder, + condaShPath, ); return shellMaps; } @@ -576,10 +582,16 @@ async function generateShellActivationMapFromConfig( return { shellActivation, shellDeactivation }; } -async function windowsExceptionGenerateConfig( +/** + * Generates shell-specific activation configuration for Windows. + * Handles PowerShell, CMD, and Git Bash with appropriate scripts. + * @internal Exported for testing + */ +export async function windowsExceptionGenerateConfig( sourceInitPath: string, prefix: string, condaFolder: string, + condaShPath?: string, ): Promise { const shellActivation: Map = new Map(); const shellDeactivation: Map = new Map(); @@ -593,7 +605,26 @@ async function windowsExceptionGenerateConfig( const pwshActivate = [{ executable: activation }, { executable: 'conda', args: ['activate', quotedPrefix] }]; const cmdActivate = [{ executable: sourceInitPath }, { executable: 'conda', args: ['activate', quotedPrefix] }]; - const bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }]; + // When condaShPath is available, it is an initialization script (conda.sh) and does not + // itself activate an environment. In that case, first source conda.sh, then + // run "conda activate ". + // When falling back to sourceInitPath, only emit a bash "source" command if the script + // is bash-compatible; on Windows, sourceInitPath may point to "activate.bat", which + // cannot be sourced by Git Bash, so in that case we skip emitting a Git Bash activation. + let bashActivate: PythonCommandRunConfiguration[]; + if (condaShPath) { + bashActivate = [ + { executable: 'source', args: [condaShPath.replace(/\\/g, '/')] }, + { executable: 'conda', args: ['activate', quotedPrefix] }, + ]; + } else if (sourceInitPath.toLowerCase().endsWith('.bat')) { + traceVerbose( + `Skipping Git Bash activation fallback because sourceInitPath is a batch script: ${sourceInitPath}`, + ); + bashActivate = []; + } else { + bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }]; + } traceVerbose( `Windows activation commands: PowerShell: ${JSON.stringify(pwshActivate)}, diff --git a/src/test/features/terminal/shells/common/shellUtils.unit.test.ts b/src/test/features/terminal/shells/common/shellUtils.unit.test.ts index 97281b80..861eb82a 100644 --- a/src/test/features/terminal/shells/common/shellUtils.unit.test.ts +++ b/src/test/features/terminal/shells/common/shellUtils.unit.test.ts @@ -99,9 +99,7 @@ suite('Shell Utils', () => { }); suite('getShellCommandAsString', () => { - const sampleCommand: PythonCommandRunConfiguration[] = [ - { executable: 'source', args: ['/path/to/activate'] }, - ]; + const sampleCommand: PythonCommandRunConfiguration[] = [{ executable: 'source', args: ['/path/to/activate'] }]; suite('leading space for history ignore', () => { test('should add leading space for bash commands', () => { @@ -184,5 +182,27 @@ suite('Shell Utils', () => { assert.ok(!result.startsWith(' '), 'Fish command should not start with a leading space'); }); }); + + suite('empty command handling', () => { + test('should return empty string for empty command array (bash)', () => { + const result = getShellCommandAsString(ShellConstants.BASH, []); + assert.strictEqual(result, '', 'Empty command array should return empty string'); + }); + + test('should return empty string for empty command array (gitbash)', () => { + const result = getShellCommandAsString(ShellConstants.GITBASH, []); + assert.strictEqual(result, '', 'Empty command array should return empty string'); + }); + + test('should return empty string for empty command array (pwsh)', () => { + const result = getShellCommandAsString(ShellConstants.PWSH, []); + assert.strictEqual(result, '', 'Empty command array should return empty string'); + }); + + test('should return empty string for empty command array (cmd)', () => { + const result = getShellCommandAsString(ShellConstants.CMD, []); + assert.strictEqual(result, '', 'Empty command array should return empty string'); + }); + }); }); }); diff --git a/src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts b/src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts new file mode 100644 index 00000000..ef5c7d93 --- /dev/null +++ b/src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts @@ -0,0 +1,201 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { ShellConstants } from '../../../features/common/shellConstants'; +import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils'; +import { windowsExceptionGenerateConfig } from '../../../managers/conda/condaUtils'; + +/** + * Tests for windowsExceptionGenerateConfig - Windows shell activation commands. + * + * Key behavior tested: + * - Git Bash uses conda.sh (initialization script) + conda activate when condaShPath is available + * - Git Bash skips activation when condaShPath is not available and sourceInitPath is .bat + * - Git Bash falls back to source when condaShPath is not available and source is not .bat + * - PowerShell uses ps1 hook + conda activate + * - CMD uses activate.bat + conda activate + */ +suite('Conda Utils - windowsExceptionGenerateConfig', () => { + let getCondaHookPs1PathStub: sinon.SinonStub; + + setup(() => { + // Mock getCondaHookPs1Path to avoid filesystem access + getCondaHookPs1PathStub = sinon.stub(condaSourcingUtils, 'getCondaHookPs1Path'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('Git Bash activation with conda.sh', () => { + test('Uses source conda.sh + conda activate when condaShPath is provided', async () => { + // Arrange + getCondaHookPs1PathStub.resolves('C:\\conda\\shell\\condabin\\conda-hook.ps1'); + const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat'; + const prefix = 'myenv'; + const condaFolder = 'C:\\conda'; + const condaShPath = 'C:\\conda\\etc\\profile.d\\conda.sh'; + + // Act + const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath); + + // Assert + const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH); + assert.ok(gitBashActivation, 'Git Bash activation should be defined'); + assert.strictEqual(gitBashActivation.length, 2, 'Should have 2 commands: source conda.sh + conda activate'); + + // First command: source conda.sh (no env arg - it's an initialization script) + assert.strictEqual(gitBashActivation[0].executable, 'source'); + assert.deepStrictEqual(gitBashActivation[0].args, ['C:/conda/etc/profile.d/conda.sh']); + + // Second command: conda activate + assert.strictEqual(gitBashActivation[1].executable, 'conda'); + assert.deepStrictEqual(gitBashActivation[1].args, ['activate', 'myenv']); + }); + + test('Skips Git Bash activation when condaShPath is undefined and sourceInitPath is .bat', async () => { + // Arrange: sourceInitPath is a .bat file which Git Bash cannot source + getCondaHookPs1PathStub.resolves(undefined); + const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat'; + const prefix = 'myenv'; + const condaFolder = 'C:\\conda'; + const condaShPath = undefined; // Not available + + // Act + const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath); + + // Assert: Git Bash activation should be empty since .bat cannot be sourced + const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH); + assert.ok(gitBashActivation, 'Git Bash activation should be defined'); + assert.strictEqual( + gitBashActivation.length, + 0, + 'Git Bash activation should be empty when sourceInitPath is .bat', + ); + }); + + test('Falls back to single source command when condaShPath is undefined and source is not .bat', async () => { + // Arrange: sourceInitPath is a bash-compatible script (no .bat extension) + getCondaHookPs1PathStub.resolves(undefined); + const sourceInitPath = 'C:\\conda\\Scripts\\activate'; // No .bat extension + const prefix = 'myenv'; + const condaFolder = 'C:\\conda'; + const condaShPath = undefined; // Not available + + // Act + const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath); + + // Assert + const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH); + assert.ok(gitBashActivation, 'Git Bash activation should be defined'); + assert.strictEqual(gitBashActivation.length, 1, 'Should have 1 command when source is bash-compatible'); + + // Single command: source + assert.strictEqual(gitBashActivation[0].executable, 'source'); + assert.deepStrictEqual(gitBashActivation[0].args, ['C:/conda/Scripts/activate', 'myenv']); + }); + + test('Converts Windows backslashes to forward slashes for bash', async () => { + // Arrange + getCondaHookPs1PathStub.resolves(undefined); + const condaShPath = 'C:\\Tools\\miniforge3\\etc\\profile.d\\conda.sh'; + + // Act + const result = await windowsExceptionGenerateConfig( + 'C:\\Tools\\miniforge3\\Scripts\\activate.bat', + 'pipes', + 'C:\\Tools\\miniforge3', + condaShPath, + ); + + // Assert + const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH); + assert.ok(gitBashActivation, 'Git Bash activation should be defined'); + // Verify forward slashes are used + const sourcePath = gitBashActivation[0].args?.[0]; + assert.ok(sourcePath, 'Source path should be defined'); + assert.ok(!sourcePath.includes('\\'), 'Path should not contain backslashes'); + assert.ok(sourcePath.includes('/'), 'Path should contain forward slashes'); + }); + }); + + suite('PowerShell activation', () => { + test('Uses ps1 hook when available', async () => { + // Arrange + const ps1HookPath = 'C:\\conda\\shell\\condabin\\conda-hook.ps1'; + getCondaHookPs1PathStub.resolves(ps1HookPath); + + // Act + const result = await windowsExceptionGenerateConfig( + 'C:\\conda\\Scripts\\activate.bat', + 'myenv', + 'C:\\conda', + undefined, + ); + + // Assert + const pwshActivation = result.shellActivation.get(ShellConstants.PWSH); + assert.ok(pwshActivation, 'PowerShell activation should be defined'); + assert.strictEqual(pwshActivation.length, 2, 'Should have 2 commands'); + assert.strictEqual(pwshActivation[0].executable, ps1HookPath); + assert.strictEqual(pwshActivation[1].executable, 'conda'); + assert.deepStrictEqual(pwshActivation[1].args, ['activate', 'myenv']); + }); + + test('Falls back to sourceInitPath when ps1 hook not found', async () => { + // Arrange + getCondaHookPs1PathStub.resolves(undefined); + const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat'; + + // Act + const result = await windowsExceptionGenerateConfig(sourceInitPath, 'myenv', 'C:\\conda', undefined); + + // Assert + const pwshActivation = result.shellActivation.get(ShellConstants.PWSH); + assert.ok(pwshActivation, 'PowerShell activation should be defined'); + assert.strictEqual(pwshActivation[0].executable, sourceInitPath); + }); + }); + + suite('CMD activation', () => { + test('Uses activate.bat + conda activate', async () => { + // Arrange + getCondaHookPs1PathStub.resolves(undefined); + const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat'; + + // Act + const result = await windowsExceptionGenerateConfig(sourceInitPath, 'myenv', 'C:\\conda', undefined); + + // Assert + const cmdActivation = result.shellActivation.get(ShellConstants.CMD); + assert.ok(cmdActivation, 'CMD activation should be defined'); + assert.strictEqual(cmdActivation.length, 2, 'Should have 2 commands'); + assert.strictEqual(cmdActivation[0].executable, sourceInitPath); + assert.strictEqual(cmdActivation[1].executable, 'conda'); + assert.deepStrictEqual(cmdActivation[1].args, ['activate', 'myenv']); + }); + }); + + suite('Deactivation commands', () => { + test('All shells use conda deactivate', async () => { + // Arrange + getCondaHookPs1PathStub.resolves(undefined); + + // Act + const result = await windowsExceptionGenerateConfig( + 'C:\\conda\\Scripts\\activate.bat', + 'myenv', + 'C:\\conda', + undefined, + ); + + // Assert: All shells should have conda deactivate + for (const shell of [ShellConstants.GITBASH, ShellConstants.CMD, ShellConstants.PWSH]) { + const deactivation = result.shellDeactivation.get(shell); + assert.ok(deactivation, `${shell} deactivation should be defined`); + assert.strictEqual(deactivation.length, 1, `${shell} should have 1 deactivation command`); + assert.strictEqual(deactivation[0].executable, 'conda'); + assert.deepStrictEqual(deactivation[0].args, ['deactivate']); + } + }); + }); +}); From 7dfce4b9d13ff8da67061757e81c05e1848a7e7c Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 18 Feb 2026 21:50:31 -0800 Subject: [PATCH 11/13] feat: add WORKON_HOME and XDG_DATA_HOME support for pipenv (Fixes #1185) (#1249) Adds support for `WORKON_HOME` and `XDG_DATA_HOME` environment variables when discovering pipenv virtualenv directories. ## Changes - Added `getPipenvVirtualenvDirs()` function to `pipenvUtils.ts` that returns directories where pipenv virtualenvs may be stored - Added comprehensive unit tests for the new function - Added verbose tracing for debugging discovery issues ## Discovery Priority The function checks these locations in priority order: 1. `WORKON_HOME` (if set and exists) - commonly shared with virtualenvwrapper 2. `XDG_DATA_HOME/virtualenvs` (if XDG_DATA_HOME is set and path exists) 3. `~/.local/share/virtualenvs` (Linux/macOS default) 4. `~/.virtualenvs` (Windows default) ## Testing - 8 unit tests covering all env var scenarios, priority ordering, deduplication, and tilde expansion - Tests use real temp directories for filesystem operations (since `fs.existsSync` cannot be stubbed) - Cross-platform compatible (tested on Windows, patterns work for Linux/macOS) Fixes #1185 --- src/managers/pipenv/pipenvUtils.ts | 58 ++++++ ...Utils.getPipenvVirtualenvDirs.unit.test.ts | 175 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/test/managers/pipenv/pipenvUtils.getPipenvVirtualenvDirs.unit.test.ts diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index f6875c29..4c2bacf8 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -1,6 +1,8 @@ // Utility functions for Pipenv environment management +import * as nativeFs from 'fs'; import * as fs from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import { Uri } from 'vscode'; import which from 'which'; @@ -285,3 +287,59 @@ export async function setPipenvForWorkspaces(fsPath: string[], envPath: string | }); await state.set(PIPENV_WORKSPACE_KEY, data); } + +/** + * Get the directories where pipenv virtualenvs may be stored. + * + * Pipenv can store virtualenvs in multiple locations with this priority: + * 1. WORKON_HOME (if set) - commonly shared with virtualenvwrapper + * 2. XDG_DATA_HOME/virtualenvs (Linux, if XDG_DATA_HOME is set) + * 3. ~/.local/share/virtualenvs (Linux/macOS default) + * 4. ~/.virtualenvs (Windows default) + * + * @returns Array of existing virtualenv directories + */ +export function getPipenvVirtualenvDirs(): string[] { + const dirs: string[] = []; + + // WORKON_HOME takes precedence (shared with virtualenvwrapper) + const workonHome = process.env.WORKON_HOME; + if (workonHome) { + const resolved = untildify(workonHome); + if (nativeFs.existsSync(resolved)) { + dirs.push(resolved); + traceVerbose(`Pipenv: WORKON_HOME found at ${resolved}`); + } else { + traceVerbose(`Pipenv: WORKON_HOME set but does not exist: ${resolved}`); + } + } + + // XDG_DATA_HOME/virtualenvs (primarily Linux, but check on all platforms) + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome) { + const xdgVenvs = path.join(untildify(xdgDataHome), 'virtualenvs'); + if (nativeFs.existsSync(xdgVenvs) && !dirs.includes(xdgVenvs)) { + dirs.push(xdgVenvs); + traceVerbose(`Pipenv: XDG_DATA_HOME/virtualenvs found at ${xdgVenvs}`); + } + } + + // Platform-specific defaults + if (process.platform === 'linux' || process.platform === 'darwin') { + // Linux/macOS: ~/.local/share/virtualenvs + const defaultUnix = path.join(os.homedir(), '.local', 'share', 'virtualenvs'); + if (nativeFs.existsSync(defaultUnix) && !dirs.includes(defaultUnix)) { + dirs.push(defaultUnix); + traceVerbose(`Pipenv: Platform default found at ${defaultUnix}`); + } + } else if (process.platform === 'win32') { + // Windows: ~/.virtualenvs + const defaultWin = path.join(os.homedir(), '.virtualenvs'); + if (nativeFs.existsSync(defaultWin) && !dirs.includes(defaultWin)) { + dirs.push(defaultWin); + traceVerbose(`Pipenv: Platform default found at ${defaultWin}`); + } + } + + return dirs; +} diff --git a/src/test/managers/pipenv/pipenvUtils.getPipenvVirtualenvDirs.unit.test.ts b/src/test/managers/pipenv/pipenvUtils.getPipenvVirtualenvDirs.unit.test.ts new file mode 100644 index 00000000..f4c7bdcf --- /dev/null +++ b/src/test/managers/pipenv/pipenvUtils.getPipenvVirtualenvDirs.unit.test.ts @@ -0,0 +1,175 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getPipenvVirtualenvDirs } from '../../../managers/pipenv/pipenvUtils'; + +/** + * Tests for getPipenvVirtualenvDirs. + * + * The function should return directories where pipenv virtualenvs are stored, + * checking these locations in priority order: + * 1. WORKON_HOME (if set and exists) + * 2. XDG_DATA_HOME/virtualenvs (if XDG_DATA_HOME is set and path exists) + * 3. ~/.local/share/virtualenvs (Linux/macOS default) + * 4. ~/.virtualenvs (Windows default) + * + * These tests use real temp directories for filesystem operations since + * native fs.existsSync cannot be stubbed (non-configurable property). + */ +suite('Pipenv Utils - getPipenvVirtualenvDirs', () => { + let originalEnv: NodeJS.ProcessEnv; + let tempDir: string; + + setup(() => { + // Save original env + originalEnv = { ...process.env }; + + // Clear relevant env vars + delete process.env.WORKON_HOME; + delete process.env.XDG_DATA_HOME; + + // Create a temp directory for tests + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pipenv-test-')); + }); + + teardown(() => { + // Restore original env + process.env = originalEnv; + + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('Returns WORKON_HOME when set and exists', () => { + const workonPath = path.join(tempDir, 'workon_home'); + fs.mkdirSync(workonPath); + process.env.WORKON_HOME = workonPath; + + const dirs = getPipenvVirtualenvDirs(); + + assert.ok(dirs.includes(workonPath), 'WORKON_HOME should be included'); + }); + + test('Ignores WORKON_HOME when set but does not exist', () => { + const workonPath = path.join(tempDir, 'nonexistent_workon'); + // Don't create the directory + process.env.WORKON_HOME = workonPath; + + const dirs = getPipenvVirtualenvDirs(); + + assert.ok(!dirs.includes(workonPath), 'Non-existent WORKON_HOME should not be included'); + }); + + test('Returns XDG_DATA_HOME/virtualenvs when set and exists', () => { + const xdgBase = path.join(tempDir, 'xdg_data'); + const xdgVenvs = path.join(xdgBase, 'virtualenvs'); + fs.mkdirSync(xdgBase); + fs.mkdirSync(xdgVenvs); + process.env.XDG_DATA_HOME = xdgBase; + + const dirs = getPipenvVirtualenvDirs(); + + assert.ok(dirs.includes(xdgVenvs), 'XDG_DATA_HOME/virtualenvs should be included'); + }); + + test('Ignores XDG_DATA_HOME when virtualenvs subdir does not exist', () => { + const xdgBase = path.join(tempDir, 'xdg_data_novenvs'); + fs.mkdirSync(xdgBase); + // Don't create virtualenvs subdir + process.env.XDG_DATA_HOME = xdgBase; + + const dirs = getPipenvVirtualenvDirs(); + + const xdgVenvs = path.join(xdgBase, 'virtualenvs'); + assert.ok(!dirs.includes(xdgVenvs), 'Non-existent XDG_DATA_HOME/virtualenvs should not be included'); + }); + + test('WORKON_HOME takes precedence and appears first', () => { + const workonPath = path.join(tempDir, 'workon'); + const xdgBase = path.join(tempDir, 'xdg'); + const xdgVenvs = path.join(xdgBase, 'virtualenvs'); + + fs.mkdirSync(workonPath); + fs.mkdirSync(xdgBase); + fs.mkdirSync(xdgVenvs); + + process.env.WORKON_HOME = workonPath; + process.env.XDG_DATA_HOME = xdgBase; + + const dirs = getPipenvVirtualenvDirs(); + + assert.strictEqual(dirs[0], workonPath, 'WORKON_HOME should be first'); + assert.ok(dirs.includes(xdgVenvs), 'XDG path should also be included'); + }); + + test('Does not include duplicate paths', () => { + // This test only makes sense on non-Windows platforms where + // XDG_DATA_HOME/virtualenvs might match the default path + if (process.platform === 'win32') { + return; + } + + // Create a unique path that will be used for both XDG and checked for duplicates + const venvBase = path.join(tempDir, 'unique_venvs'); + const virtualenvsPath = path.join(venvBase, 'virtualenvs'); + fs.mkdirSync(venvBase); + fs.mkdirSync(virtualenvsPath); + + // Set XDG_DATA_HOME to the same base + process.env.XDG_DATA_HOME = venvBase; + + const dirs = getPipenvVirtualenvDirs(); + + // Count occurrences of the path + const count = dirs.filter((d) => d === virtualenvsPath).length; + assert.strictEqual(count, 1, 'Path should not be duplicated'); + }); + + test('Returns multiple directories when all exist', () => { + const workonPath = path.join(tempDir, 'workon_multi'); + const xdgBase = path.join(tempDir, 'xdg_multi'); + const xdgPath = path.join(xdgBase, 'virtualenvs'); + + fs.mkdirSync(workonPath); + fs.mkdirSync(xdgBase); + fs.mkdirSync(xdgPath); + + process.env.WORKON_HOME = workonPath; + process.env.XDG_DATA_HOME = xdgBase; + + const dirs = getPipenvVirtualenvDirs(); + + assert.ok(dirs.length >= 2, 'Should return at least two directories'); + assert.strictEqual(dirs[0], workonPath, 'WORKON_HOME should be first'); + assert.ok(dirs.includes(xdgPath), 'XDG path should be included'); + }); + + test('Handles tilde expansion in WORKON_HOME', () => { + // Create the target directory in user's home + const customVenvsName = `.pipenv-test-tilde-${Date.now()}`; + const expandedPath = path.join(os.homedir(), customVenvsName); + let created = false; + + try { + fs.mkdirSync(expandedPath); + created = true; + // Use path.sep for cross-platform compatibility + process.env.WORKON_HOME = `~${path.sep}${customVenvsName}`; + + const dirs = getPipenvVirtualenvDirs(); + + // Normalize paths for comparison since untildify might produce different path formats + const normalizedDirs = dirs.map((d) => path.normalize(d)); + const normalizedExpected = path.normalize(expandedPath); + assert.ok(normalizedDirs.includes(normalizedExpected), 'Tilde-expanded path should be included'); + } finally { + // Clean up - only if directory was successfully created + if (created && fs.existsSync(expandedPath)) { + fs.rmSync(expandedPath, { recursive: true, force: true }); + } + } + }); +}); From b64508701e80d6c9425d255233dd1dcb8b2813a1 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 22 Feb 2026 14:23:26 -0800 Subject: [PATCH 12/13] Go back to vscode engine 1.106.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c312372c..ab946f07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "webpack-cli": "^5.1.1" }, "engines": { - "vscode": "^1.110.0-20260204" + "vscode": "^1.106.0" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 50317b48..b88c2c16 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "publisher": "ms-python", "preview": true, "engines": { - "vscode": "^1.110.0-20260204" + "vscode": "^1.106.0" }, "categories": [ "Other" From c4c43344445d512c7c000fd92b3b098e16cbc235 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 22 Feb 2026 14:51:03 -0800 Subject: [PATCH 13/13] Revert vscode engine, taskExecutionTerminal propsed api --- package.json | 3 +- src/features/terminal/utils.ts | 7 +-- .../terminal/activateMenuButton.unit.test.ts | 57 ------------------- ...vscode.proposed.taskExecutionTerminal.d.ts | 15 ----- 4 files changed, 4 insertions(+), 78 deletions(-) delete mode 100644 src/test/features/terminal/activateMenuButton.unit.test.ts delete mode 100644 src/vscode.proposed.taskExecutionTerminal.d.ts diff --git a/package.json b/package.json index b88c2c16..dc66c260 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ ], "enabledApiProposals": [ "terminalShellEnv", - "terminalDataWriteEvent", - "taskExecutionTerminal" + "terminalDataWriteEvent" ], "capabilities": { "untrustedWorkspaces": { diff --git a/src/features/terminal/utils.ts b/src/features/terminal/utils.ts index d279a11e..83744808 100644 --- a/src/features/terminal/utils.ts +++ b/src/features/terminal/utils.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { Disposable, env, tasks, Terminal, TerminalOptions, Uri } from 'vscode'; +import { Disposable, env, Terminal, TerminalOptions, Uri } from 'vscode'; import { PythonEnvironment, PythonProject, PythonProjectEnvironmentApi, PythonProjectGetterApi } from '../../api'; import { timeout } from '../../common/utils/asyncUtils'; import { createSimpleDebounce } from '../../common/utils/debounce'; @@ -129,9 +129,8 @@ function detectsCommonPromptPattern(terminalData: string): boolean { } export function isTaskTerminal(terminal: Terminal): boolean { - // Use tasks.taskExecutions API to check if terminal is associated with a task - // See: https://github.com/microsoft/vscode/issues/234440 - return tasks.taskExecutions.some((execution) => execution.terminal === terminal); + // TODO: Need API for core for this https://github.com/microsoft/vscode/issues/234440 + return terminal.name.toLowerCase().includes('task'); } export function getTerminalCwd(terminal: Terminal): string | undefined { diff --git a/src/test/features/terminal/activateMenuButton.unit.test.ts b/src/test/features/terminal/activateMenuButton.unit.test.ts deleted file mode 100644 index 07c3fb84..00000000 --- a/src/test/features/terminal/activateMenuButton.unit.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as assert from 'assert'; -import * as sinon from 'sinon'; -import { Terminal } from 'vscode'; -import { PythonEnvironment } from '../../../api'; -import * as commandApi from '../../../common/command.api'; -import * as activation from '../../../features/common/activation'; -import { setActivateMenuButtonContext } from '../../../features/terminal/activateMenuButton'; -import * as utils from '../../../features/terminal/utils'; - -suite('Terminal - Activate Menu Button', () => { - let executeCommandStub: sinon.SinonStub; - let isTaskTerminalStub: sinon.SinonStub; - let isActivatableEnvironmentStub: sinon.SinonStub; - - const mockTerminal = { name: 'test-terminal' } as Terminal; - const mockEnv = {} as PythonEnvironment; // Stubbed, so no properties needed - - setup(() => { - executeCommandStub = sinon.stub(commandApi, 'executeCommand').resolves(); - isTaskTerminalStub = sinon.stub(utils, 'isTaskTerminal'); - isActivatableEnvironmentStub = sinon.stub(activation, 'isActivatableEnvironment'); - }); - - teardown(() => { - sinon.restore(); - }); - - test('should show activate icon when isTaskTerminal returns false', async () => { - // Arrange: terminal is NOT a task terminal, env is activatable - isTaskTerminalStub.returns(false); - isActivatableEnvironmentStub.returns(true); - - // Act - await setActivateMenuButtonContext(mockTerminal, mockEnv); - - // Assert: icon should be shown (pythonTerminalActivation = true) - assert.ok( - executeCommandStub.calledWith('setContext', 'pythonTerminalActivation', true), - 'Should set pythonTerminalActivation to true for non-task terminal', - ); - }); - - test('should hide activate icon when isTaskTerminal returns true', async () => { - // Arrange: terminal IS a task terminal (even if env is activatable) - isTaskTerminalStub.returns(true); - isActivatableEnvironmentStub.returns(true); - - // Act - await setActivateMenuButtonContext(mockTerminal, mockEnv); - - // Assert: icon should be hidden (pythonTerminalActivation = false) - assert.ok( - executeCommandStub.calledWith('setContext', 'pythonTerminalActivation', false), - 'Should set pythonTerminalActivation to false for task terminal', - ); - }); -}); diff --git a/src/vscode.proposed.taskExecutionTerminal.d.ts b/src/vscode.proposed.taskExecutionTerminal.d.ts deleted file mode 100644 index 549a9b12..00000000 --- a/src/vscode.proposed.taskExecutionTerminal.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// https://github.com/microsoft/vscode/issues/234440 - -declare module 'vscode' { - export interface TaskExecution { - /** - * The terminal associated with this task execution, if any. - */ - terminal?: Terminal; - } -}