diff --git a/package-lock.json b/package-lock.json index 212fa155..1d919925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -795,7 +795,6 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1481,7 +1480,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1525,7 +1523,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1716,7 +1713,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -2411,7 +2407,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5123,7 +5118,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5249,7 +5243,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -5296,7 +5289,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6094,7 +6086,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -6590,8 +6581,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-attributes": { "version": "1.9.5", @@ -6621,7 +6611,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6754,7 +6743,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", "dev": true, - "peer": true, "requires": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -7236,7 +7224,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9197,8 +9184,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { "version": "1.0.6", @@ -9284,7 +9270,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "requires": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -9316,7 +9301,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index cf1e0485..cab80c21 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -49,6 +49,8 @@ import { runInBackground } from './execution/runInBackground'; import { EnvVarManager } from './execution/envVariableManager'; import { checkUri } from '../common/utils/pathUtils'; import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; +import { activeTextEditor } from '../common/window.apis'; +import { getWorkspaceFolders } from '../common/workspace.apis'; class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly _onDidChangeEnvironments = new EventEmitter(); @@ -214,7 +216,24 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { return this.envManagers.setEnvironment(currentScope, environment); } async getEnvironment(scope: GetEnvironmentScope): Promise { - const currentScope = checkUri(scope) as GetEnvironmentScope; + let currentScope = checkUri(scope) as GetEnvironmentScope; + + // When scope is undefined, try to determine the appropriate scope from context + if (currentScope === undefined) { + // First, check if there's an active text editor with a valid document + const activeDoc = activeTextEditor()?.document; + if (activeDoc && !activeDoc.isUntitled && activeDoc.uri.scheme === 'file') { + currentScope = activeDoc.uri; + } else { + // If no active editor, check if there's a single workspace folder + const workspaceFolders = getWorkspaceFolders(); + if (workspaceFolders && workspaceFolders.length === 1) { + currentScope = workspaceFolders[0].uri; + } + // Otherwise currentScope remains undefined, which will return the global environment + } + } + await waitForEnvManager(currentScope ? [currentScope] : undefined); return this.envManagers.getEnvironment(currentScope); } diff --git a/src/test/features/pythonApi.getEnvironment.unit.test.ts b/src/test/features/pythonApi.getEnvironment.unit.test.ts new file mode 100644 index 00000000..50544b05 --- /dev/null +++ b/src/test/features/pythonApi.getEnvironment.unit.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Python API getEnvironment Unit Tests + * + * This test suite validates the getEnvironment API functionality including: + * - Returning environment for specific scope (Uri) + * - Smart scope detection when scope is undefined: + * - Using active text editor's document URI + * - Using single workspace folder URI + * - Falling back to global environment + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as typeMoq from 'typemoq'; +import { EventEmitter, TextDocument, TextEditor, Uri, WorkspaceFolder } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentVariablesEventArgs, + PythonEnvironment, +} from '../../api'; +import * as extensionApis from '../../common/extension.apis'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import { PythonEnvironmentManagers } from '../../features/envManagers'; +import { getPythonApi, setPythonApi } from '../../features/pythonApi'; +import { TerminalManager } from '../../features/terminal/terminalManager'; +import { EnvVarManager } from '../../features/execution/envVariableManager'; +import * as managerReady from '../../features/common/managerReady'; +import { ProjectCreators, PythonProjectManager } from '../../internal.api'; +import { setupNonThenable } from '../mocks/helper'; + +suite('PythonApi.getEnvironment Tests', () => { + let envManagers: typeMoq.IMock; + let projectManager: typeMoq.IMock; + let projectCreators: typeMoq.IMock; + let terminalManager: typeMoq.IMock; + let envVarManager: typeMoq.IMock; + let mockEnvironment: typeMoq.IMock; + let getExtensionStub: sinon.SinonStub; + let activeTextEditorStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + + setup(() => { + // Mock extension APIs + const mockPythonExtension = { + id: 'ms-python.python', + extensionPath: '/mock/python/extension', + }; + const mockEnvsExtension = { + id: 'ms-python.vscode-python-envs', + extensionPath: '/mock/envs/extension', + }; + + getExtensionStub = sinon.stub(extensionApis, 'getExtension'); + getExtensionStub.withArgs('ms-python.python').returns(mockPythonExtension as any); + getExtensionStub.withArgs('ms-python.vscode-python-envs').returns(mockEnvsExtension as any); + + sinon.stub(extensionApis, 'allExtensions').returns([mockPythonExtension, mockEnvsExtension] as any); + + // Stub the manager ready functions to avoid hanging + sinon.stub(managerReady, 'waitForEnvManager').resolves(); + sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); + sinon.stub(managerReady, 'waitForAllEnvManagers').resolves(); + + // Create mocks + envManagers = typeMoq.Mock.ofType(); + projectManager = typeMoq.Mock.ofType(); + projectCreators = typeMoq.Mock.ofType(); + terminalManager = typeMoq.Mock.ofType(); + envVarManager = typeMoq.Mock.ofType(); + + // Setup event emitters + const onDidChangeEnvironmentEmitter = new EventEmitter(); + + envManagers + .setup((e) => e.onDidChangeEnvironmentFiltered) + .returns(() => onDidChangeEnvironmentEmitter.event); + setupNonThenable(envManagers); + setupNonThenable(projectManager); + setupNonThenable(projectCreators); + setupNonThenable(terminalManager); + + const onDidChangeEnvVarsEmitter = new EventEmitter(); + envVarManager + .setup((e) => e.onDidChangeEnvironmentVariables) + .returns(() => onDidChangeEnvVarsEmitter.event); + setupNonThenable(envVarManager); + + // Mock environment + mockEnvironment = typeMoq.Mock.ofType(); + mockEnvironment.setup((e) => e.envId).returns(() => ({ id: 'test-env', managerId: 'test-mgr' })); + mockEnvironment.setup((e) => e.displayName).returns(() => 'Test Environment'); + setupNonThenable(mockEnvironment); + + // Setup a default return for all getEnvironment calls + envManagers + .setup((e) => e.getEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(mockEnvironment.object)); + + // Stub window and workspace APIs + activeTextEditorStub = sinon.stub(windowApis, 'activeTextEditor'); + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + + // Initialize API + setPythonApi( + envManagers.object, + projectManager.object, + projectCreators.object, + terminalManager.object, + envVarManager.object, + ); + }); + + teardown(() => { + sinon.restore(); + }); + + test('getEnvironment with explicit URI scope returns environment for that scope', async () => { + const testUri = Uri.file('/test/workspace/file.py'); + + envManagers + .setup((e) => e.getEnvironment(testUri)) + .returns(() => Promise.resolve(mockEnvironment.object)) + .verifiable(typeMoq.Times.once()); + + const api = await getPythonApi(); + const result = await api.getEnvironment(testUri); + + assert.strictEqual(result, mockEnvironment.object); + envManagers.verifyAll(); + }); + + test('getEnvironment with undefined scope uses active text editor URI when available', async () => { + const testUri = Uri.file('/test/workspace/file.py'); + const mockDoc: Partial = { + uri: testUri, + isUntitled: false, + }; + + const mockEditor: Partial = { + document: mockDoc as TextDocument, + }; + + activeTextEditorStub.returns(mockEditor as TextEditor); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + // Verify the stub was called with a non-undefined value (should be testUri) + sinon.assert.called(activeTextEditorStub); + }); + + test('getEnvironment with undefined scope uses workspace folder when no active editor', async () => { + const workspaceUri = Uri.file('/test/workspace'); + const mockWorkspaceFolder: Partial = { + uri: workspaceUri, + name: 'test-workspace', + index: 0, + }; + + activeTextEditorStub.returns(undefined); + getWorkspaceFoldersStub.returns([mockWorkspaceFolder as WorkspaceFolder]); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + sinon.assert.called(getWorkspaceFoldersStub); + }); + + test('getEnvironment with undefined scope falls back to global when no editor or workspace', async () => { + activeTextEditorStub.returns(undefined); + getWorkspaceFoldersStub.returns(undefined); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + }); + + test('getEnvironment with undefined scope ignores untitled documents', async () => { + const workspaceUri = Uri.file('/test/workspace'); + const mockWorkspaceFolder: Partial = { + uri: workspaceUri, + name: 'test-workspace', + index: 0, + }; + + const mockDoc: Partial = { + isUntitled: true, + }; + + const mockEditor: Partial = { + document: mockDoc as TextDocument, + }; + + activeTextEditorStub.returns(mockEditor as TextEditor); + getWorkspaceFoldersStub.returns([mockWorkspaceFolder as WorkspaceFolder]); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + sinon.assert.called(getWorkspaceFoldersStub); + }); + + test('getEnvironment with undefined scope ignores non-file scheme documents', async () => { + const workspaceUri = Uri.file('/test/workspace'); + const mockWorkspaceFolder: Partial = { + uri: workspaceUri, + name: 'test-workspace', + index: 0, + }; + + const mockDoc: Partial = { + uri: Uri.parse('git:/test/file.py'), + isUntitled: false, + }; + + const mockEditor: Partial = { + document: mockDoc as TextDocument, + }; + + activeTextEditorStub.returns(mockEditor as TextEditor); + getWorkspaceFoldersStub.returns([mockWorkspaceFolder as WorkspaceFolder]); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + sinon.assert.called(getWorkspaceFoldersStub); + }); + + test('getEnvironment with undefined scope falls back to global when multiple workspaces', async () => { + const workspace1: Partial = { + uri: Uri.file('/workspace1'), + name: 'workspace1', + index: 0, + }; + + const workspace2: Partial = { + uri: Uri.file('/workspace2'), + name: 'workspace2', + index: 1, + }; + + activeTextEditorStub.returns(undefined); + getWorkspaceFoldersStub.returns([workspace1 as WorkspaceFolder, workspace2 as WorkspaceFolder]); + + const api = await getPythonApi(); + const result = await api.getEnvironment(undefined); + + assert.strictEqual(result, mockEnvironment.object); + }); +});