diff --git a/src/extension.ts b/src/extension.ts index c4c2e2a9..2db4e64a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -86,7 +86,6 @@ import { cleanupStartupScripts } from './features/terminal/shellStartupSetupHand import { TerminalActivationImpl } from './features/terminal/terminalActivationState'; import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInjector'; import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; -import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; import { openSearchSettings } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; @@ -97,8 +96,8 @@ import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; -import { registerSystemPythonFeatures } from './managers/builtin/main'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; +import { registerSystemPythonFeatures } from './managers/builtin/main'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; import { createNativePythonFinder, @@ -106,6 +105,7 @@ import { getNativePythonToolsVersion, NativePythonFinder, } from './managers/common/nativePythonFinder'; +import { registerPackageWatchers } from './managers/common/packageWatcher'; import { IDisposable } from './managers/common/types'; import { registerCondaFeatures } from './managers/conda/main'; import { registerPipenvFeatures } from './managers/pipenv/main'; @@ -670,6 +670,8 @@ export async function activate(context: ExtensionContext): Promise { - // First try to get the environment that's tracked as activated in this terminal - const activatedEnv = terminalEnv.getEnvironment(terminal); - if (activatedEnv) { - traceVerbose(`Using terminal's activated environment: ${activatedEnv.displayName}`); - return activatedEnv; - } - - // Fall back to heuristics based on terminal cwd and workspace - traceVerbose('No activated environment tracked for terminal, using heuristic lookup'); - return getEnvironmentForTerminal(api, terminal); -} - -/** - * Registers a manager-agnostic terminal watcher that listens for package-modifying - * commands and triggers a refresh on the appropriate package manager for the - * currently selected environment. - * - * This ensures that regardless of what command the user runs (pip, conda, etc.), - * the refresh is performed using the configured package manager for the workspace's - * selected environment. - */ -export function registerTerminalPackageWatcher( - api: PythonEnvironmentApi, - terminalEnv: TerminalEnvironment, - log: LogOutputChannel, - disposables: Disposable[], -): void { - disposables.push( - onDidEndTerminalShellExecution(async (e) => { - const commandLine = e.execution.commandLine.value; - const terminal = e.terminal; - - if (isPackageModifyingCommand(commandLine)) { - traceVerbose(`Package-modifying command detected: ${commandLine}`); - - try { - // Get the environment for this terminal - prioritizes activated env over workspace selection - const env = await getEnvironmentForPackageRefresh(terminal, terminalEnv, api); - - if (env) { - traceVerbose( - `Refreshing packages for environment: ${env.displayName} (${env.envId.managerId})`, - ); - // This delegates to the correct package manager based on the environment - await api.refreshPackages(env); - } else { - traceVerbose('No environment found for terminal, skipping package refresh'); - } - } catch (error) { - log.error(`Error refreshing packages after terminal command: ${error}`); - } - } - }), - ); -} diff --git a/src/internal.api.ts b/src/internal.api.ts index 04a198ac..4f5dcd32 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -1,5 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, Uri } from 'vscode'; +import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -380,6 +380,10 @@ export class InternalPackageManager implements PackageManager { return this.manager.getPackages(environment, options); } + getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] { + return this.manager.getPackageWatchTargets?.(environment) ?? []; + } + onDidChangePackages(handler: (e: DidChangePackagesEventArgs) => void): Disposable { return this.manager.onDidChangePackages ? this.manager.onDidChangePackages(handler) : new Disposable(() => {}); } diff --git a/src/managers/builtin/main.ts b/src/managers/builtin/main.ts index 0cdd0800..d09e43d9 100644 --- a/src/managers/builtin/main.ts +++ b/src/managers/builtin/main.ts @@ -4,7 +4,6 @@ import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher, onDidDeleteFiles } from '../../common/workspace.apis'; import { getPythonApi } from '../../features/pythonApi'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { PipPackageManager } from './pipPackageManager'; import { SysPythonManager } from './sysPythonManager'; import { VenvManager } from './venvManager'; @@ -41,9 +40,4 @@ export async function registerSystemPythonFeatures( venvDebouncedRefresh.trigger(); }), ); - - disposables.push( - registerPackageWatcherForManager(envManager, pkgManager, log), - registerPackageWatcherForManager(venvManager, pkgManager, log), - ); } diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index c579f8c8..89a1842a 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -1,13 +1,14 @@ import * as path from 'path'; -import { Disposable, LogOutputChannel, RelativePattern } from 'vscode'; -import { EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; +import { Disposable, LogOutputChannel, RelativePattern, Uri } from 'vscode'; +import { PackageManager, PythonEnvironment } from '../../api'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher, getConfiguration } from '../../common/workspace.apis'; +import { EnvironmentManagers } from '../../internal.api'; /** * Derives the file system watch targets for a given Python environment. * - * Targets include site-packages `.dist-info/METADATA` files for pip-style installs. + * Targets include site-packages `.dist-info` directories and their contents for pip-style installs. * * @param env - The Python environment to derive watch targets for. * @returns An array of RelativePattern objects, one per discoverable package location. @@ -17,17 +18,20 @@ function getDefaultPackageWatchTargets(env: PythonEnvironment): RelativePattern[ if (!env.sysPrefix) { return []; } - return process.platform === 'win32' - ? [new RelativePattern(path.join(env.sysPrefix, 'Lib'), 'site-packages/**/*.dist-info/METADATA')] // Windows - : [new RelativePattern(path.join(env.sysPrefix, 'lib'), 'python*/site-packages/**/*.dist-info/METADATA')]; // Unix-like + + const isWindows = process.platform === 'win32'; + const libraryPath = path.join(env.sysPrefix, isWindows ? 'Lib' : 'lib'); + const pattern = isWindows + ? 'site-packages/{*.dist-info,*.dist-info/**}' + : 'python*/site-packages/{*.dist-info,*.dist-info/**}'; + return [new RelativePattern(libraryPath, pattern)]; } /** * Creates a file system watcher for package changes in a single environment. * - * Monitors default site-packages locations and any manager-specific extra locations - * for install/uninstall operations. - * and triggers a debounced package refresh when changes are detected. + * Monitors default site-packages and manager-specific locations, then triggers a + * debounced package refresh when changes are detected. * * @param env - The Python environment to watch. * @param packageManager - The package manager to call refresh on when changes occur. @@ -39,7 +43,6 @@ export function watchPackageChangesForEnvironment( packageManager: PackageManager, log: LogOutputChannel, ): Disposable { - // Watch targets const watchTargets = [ ...getDefaultPackageWatchTargets(env), ...(packageManager.getPackageWatchTargets?.(env) ?? []), @@ -48,83 +51,111 @@ export function watchPackageChangesForEnvironment( log.debug(`No watch targets for environment ${env.envId.id}`); return new Disposable(() => undefined); } - // Debounced refresh function - const debouncedRefresh = createSimpleDebounce(500, async () => { + + const debouncedRefresh = createSimpleDebounce(500, () => { log.debug(`Package change detected for environment ${env.envId.id}, refreshing packages.`); - packageManager.refresh(env).catch((ex) => { + void packageManager.refresh(env).catch((ex) => { log.error( `Failed to refresh packages for environment ${env.envId.id}: ${ex instanceof Error ? ex.message : String(ex)}`, ); }); }); - // Create watchers const disposables: Disposable[] = [debouncedRefresh]; const trigger = debouncedRefresh.trigger.bind(debouncedRefresh); + for (const target of watchTargets) { - const watcher = createFileSystemWatcher( - target, - false, // create -> install - true, // change -> ignore - false, // delete -> uninstall - ); + const watcher = createFileSystemWatcher(target, false, false, false); + log.debug(`Watching for package changes in environment ${env.envId.id} at ${target.pattern}`); disposables.push( watcher, + watcher.onDidChange(trigger), watcher.onDidCreate(trigger), watcher.onDidDelete(trigger), ); } - return new Disposable(() => disposables.forEach((d) => d.dispose())); + return Disposable.from(...disposables); } /** - * Registers automatic file system watchers for the active environment managed by a given manager. + * Registers package watchers for every active environment, regardless of manager type. * - * Creates per-environment watchers that are attached when the active environment changes - * and detached when it changes to a different environment. Ensures package changes - * (installs/uninstalls) in the active environment are detected and trigger a refresh. + * A watcher is shared when the same environment is active in multiple scopes and is + * disposed only after the final scope stops using that environment. * - * @param envManager - The environment manager whose active environment should be watched. - * @param packageManager - The package manager to call refresh on when changes occur. + * @param envManagers - The central environment and package manager registry. * @param log - Logger for diagnostic and error messages. * @returns A disposable that removes all watchers and subscriptions when disposed. */ -export function registerPackageWatcherForManager( - envManager: EnvironmentManager, - packageManager: PackageManager, - log: LogOutputChannel, -): Disposable { +export function registerPackageWatchers(envManagers: EnvironmentManagers, log: LogOutputChannel): Disposable { const packageWatchersEnabled = getConfiguration('python-envs').get('packageWatchers', true); if (!packageWatchersEnabled) { return new Disposable(() => undefined); } - // One watcher per environment id. - const watchers = new Map(); + const activeWatcherByScope = new Map(); + const sharedWatchers = new Map(); + + const releaseScope = (scopeKey: string): void => { + const watcherKey = activeWatcherByScope.get(scopeKey); + if (!watcherKey) { + return; + } - const addWatcher = (env: PythonEnvironment): void => { - if (!watchers.has(env.envId.id)) { - watchers.set(env.envId.id, watchPackageChangesForEnvironment(env, packageManager, log)); + activeWatcherByScope.delete(scopeKey); + const watcher = sharedWatchers.get(watcherKey); + if (!watcher) { + return; + } + + watcher.references -= 1; + if (watcher.references === 0) { + watcher.disposable.dispose(); + sharedWatchers.delete(watcherKey); } }; - const removeWatcher = (envId: string): void => { - watchers.get(envId)?.dispose(); - watchers.delete(envId); + const watchEnvironment = (scopeKey: string, scope: Uri | undefined, environment: PythonEnvironment): void => { + const selectedPackageManager = + envManagers.getPackageManager(scope) ?? envManagers.getPackageManager(environment); + if (!selectedPackageManager) { + releaseScope(scopeKey); + log.debug(`No package manager found for environment ${environment.envId.id}`); + return; + } + + const watcherKey = `${environment.envId.managerId}:${environment.envId.id}:${selectedPackageManager.id}`; + if (activeWatcherByScope.get(scopeKey) === watcherKey) { + return; + } + + releaseScope(scopeKey); + + const sharedWatcher = sharedWatchers.get(watcherKey); + if (sharedWatcher) { + sharedWatcher.references += 1; + } else { + sharedWatchers.set(watcherKey, { + disposable: watchPackageChangesForEnvironment(environment, selectedPackageManager, log), + references: 1, + }); + } + activeWatcherByScope.set(scopeKey, watcherKey); }; - const envChangeDisposable = envManager.onDidChangeEnvironment?.((changes) => { + const environmentChangeDisposable = envManagers.onDidChangeActiveEnvironment((changes) => { + const scopeKey = changes.uri?.toString() ?? 'global'; if (changes.new) { - addWatcher(changes.new); - } - if (changes.old && changes.old.envId.id !== changes.new?.envId.id) { - removeWatcher(changes.old.envId.id); + watchEnvironment(scopeKey, changes.uri, changes.new); + } else { + releaseScope(scopeKey); } }); return new Disposable(() => { - envChangeDisposable?.dispose(); - watchers.forEach((watcher) => watcher.dispose()); - watchers.clear(); + environmentChangeDisposable.dispose(); + sharedWatchers.forEach(({ disposable }) => disposable.dispose()); + sharedWatchers.clear(); + activeWatcherByScope.clear(); }); } diff --git a/src/managers/poetry/main.ts b/src/managers/poetry/main.ts index 685ba28c..c893f55b 100644 --- a/src/managers/poetry/main.ts +++ b/src/managers/poetry/main.ts @@ -4,7 +4,6 @@ import { traceInfo } from '../../common/logging'; import { getPythonApi } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { PoetryManager } from './poetryManager'; import { PoetryPackageManager } from './poetryPackageManager'; @@ -25,6 +24,5 @@ export async function registerPoetryFeatures( pkgManager, api.registerEnvironmentManager(envManager), api.registerPackageManager(pkgManager), - registerPackageWatcherForManager(envManager, pkgManager, outputChannel), ); } diff --git a/src/test/features/terminal/terminalPackageWatcher.unit.test.ts b/src/test/features/terminal/terminalPackageWatcher.unit.test.ts deleted file mode 100644 index efa516a8..00000000 --- a/src/test/features/terminal/terminalPackageWatcher.unit.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as assert from 'assert'; -import * as sinon from 'sinon'; -import { Disposable, EventEmitter, Terminal, TerminalShellExecutionEndEvent, Uri } from 'vscode'; -import { PythonEnvironment } from '../../../api'; -import * as logging from '../../../common/logging'; -import * as windowApis from '../../../common/window.apis'; -import { TerminalEnvironment } from '../../../features/terminal/terminalActivationState'; -import { - getEnvironmentForPackageRefresh, - isPackageModifyingCommand, - registerTerminalPackageWatcher, -} from '../../../features/terminal/terminalPackageWatcher'; -import * as terminalUtils from '../../../features/terminal/utils'; - -/** - * Creates a mock PythonEnvironment for testing. - * Uses Uri.file().fsPath for cross-platform path compatibility. - */ -function createMockEnvironment(overrides?: Partial): PythonEnvironment { - const envPath = Uri.file('test-env').fsPath; - const pythonPath = Uri.file('test-env/bin/python').fsPath; - return { - envId: { id: 'test-env-id', managerId: 'ms-python.python:venv' }, - name: 'Test Environment', - displayName: 'Test Environment', - shortDisplayName: 'TestEnv', - displayPath: envPath, - version: '3.9.0', - environmentPath: Uri.file(pythonPath), - sysPrefix: envPath, - execInfo: { - run: { executable: pythonPath }, - }, - ...overrides, - }; -} - -/** - * Creates a mock Terminal for testing. - * Uses Uri.file() for cross-platform path compatibility. - */ -function createMockTerminal(overrides?: Partial): Terminal { - return { - name: 'Test Terminal', - processId: Promise.resolve(1234), - creationOptions: {}, - exitStatus: undefined, - state: { isInteractedWith: true }, - shellIntegration: { - cwd: Uri.file('.'), - executeCommand: () => ({}) as any, - }, - sendText: () => {}, - show: () => {}, - hide: () => {}, - dispose: () => {}, - ...overrides, - } as Terminal; -} - -suite('terminalPackageWatcher - isPackageModifyingCommand', () => { - suite('pip commands', () => { - test('should detect "pip install package"', () => { - // Run - const result = isPackageModifyingCommand('pip install requests'); - - // Assert - assert.strictEqual(result, true, 'pip install should be detected as package-modifying'); - }); - - test('should detect "pip3 install package"', () => { - // Run - const result = isPackageModifyingCommand('pip3 install requests'); - - // Assert - assert.strictEqual(result, true, 'pip3 install should be detected as package-modifying'); - }); - - test('should detect "python -m pip install package"', () => { - // Run - const result = isPackageModifyingCommand('python -m pip install requests'); - - // Assert - assert.strictEqual(result, true, 'python -m pip install should be detected as package-modifying'); - }); - - test('should detect "python3 -m pip install package"', () => { - // Run - const result = isPackageModifyingCommand('python3 -m pip install requests'); - - // Assert - assert.strictEqual(result, true, 'python3 -m pip install should be detected as package-modifying'); - }); - - test('should detect "pip uninstall package"', () => { - // Run - const result = isPackageModifyingCommand('pip uninstall requests'); - - // Assert - assert.strictEqual(result, true, 'pip uninstall should be detected as package-modifying'); - }); - - test('should detect "pip install -r requirements.txt"', () => { - // Run - const result = isPackageModifyingCommand('pip install -r requirements.txt'); - - // Assert - assert.strictEqual(result, true, 'pip install -r should be detected as package-modifying'); - }); - }); - - suite('uv commands', () => { - test('should detect "uv pip install package"', () => { - // Run - const result = isPackageModifyingCommand('uv pip install requests'); - - // Assert - assert.strictEqual(result, true, 'uv pip install should be detected as package-modifying'); - }); - - test('should detect "uv pip uninstall package"', () => { - // Run - const result = isPackageModifyingCommand('uv pip uninstall requests'); - - // Assert - assert.strictEqual(result, true, 'uv pip uninstall should be detected as package-modifying'); - }); - }); - - suite('conda commands', () => { - test('should detect "conda install package"', () => { - // Run - const result = isPackageModifyingCommand('conda install numpy'); - - // Assert - assert.strictEqual(result, true, 'conda install should be detected as package-modifying'); - }); - - test('should detect "conda remove package"', () => { - // Run - const result = isPackageModifyingCommand('conda remove numpy'); - - // Assert - assert.strictEqual(result, true, 'conda remove should be detected as package-modifying'); - }); - - test('should detect "conda uninstall package"', () => { - // Run - const result = isPackageModifyingCommand('conda uninstall numpy'); - - // Assert - assert.strictEqual(result, true, 'conda uninstall should be detected as package-modifying'); - }); - - test('should detect "mamba install package"', () => { - // Run - const result = isPackageModifyingCommand('mamba install numpy'); - - // Assert - assert.strictEqual(result, true, 'mamba install should be detected as package-modifying'); - }); - - test('should detect "micromamba install package"', () => { - // Run - const result = isPackageModifyingCommand('micromamba install numpy'); - - // Assert - assert.strictEqual(result, true, 'micromamba install should be detected as package-modifying'); - }); - }); - - suite('poetry commands', () => { - test('should detect "poetry add package"', () => { - // Run - const result = isPackageModifyingCommand('poetry add requests'); - - // Assert - assert.strictEqual(result, true, 'poetry add should be detected as package-modifying'); - }); - - test('should detect "poetry remove package"', () => { - // Run - const result = isPackageModifyingCommand('poetry remove requests'); - - // Assert - assert.strictEqual(result, true, 'poetry remove should be detected as package-modifying'); - }); - }); - - suite('pipenv commands', () => { - test('should detect "pipenv install package"', () => { - // Run - const result = isPackageModifyingCommand('pipenv install requests'); - - // Assert - assert.strictEqual(result, true, 'pipenv install should be detected as package-modifying'); - }); - - test('should detect "pipenv uninstall package"', () => { - // Run - const result = isPackageModifyingCommand('pipenv uninstall requests'); - - // Assert - assert.strictEqual(result, true, 'pipenv uninstall should be detected as package-modifying'); - }); - }); - - suite('non-package commands', () => { - test('should not detect "pip list"', () => { - // Run - const result = isPackageModifyingCommand('pip list'); - - // Assert - assert.strictEqual(result, false, 'pip list should not be detected as package-modifying'); - }); - - test('should not detect "conda activate env"', () => { - // Run - const result = isPackageModifyingCommand('conda activate myenv'); - - // Assert - assert.strictEqual(result, false, 'conda activate should not be detected as package-modifying'); - }); - - test('should not detect "python script.py"', () => { - // Run - const result = isPackageModifyingCommand('python script.py'); - - // Assert - assert.strictEqual(result, false, 'python script.py should not be detected as package-modifying'); - }); - - test('should not detect "ls -la"', () => { - // Run - const result = isPackageModifyingCommand('ls -la'); - - // Assert - assert.strictEqual(result, false, 'ls -la should not be detected as package-modifying'); - }); - - test('should detect "echo pip install" (contains pip install substring)', () => { - // Note: This matches because regex finds "pip install" within the string - // Run - const result = isPackageModifyingCommand('echo pip install fake'); - - // Assert - assert.strictEqual(result, true, 'echo pip install should match due to substring'); - }); - }); -}); - -suite('terminalPackageWatcher - getEnvironmentForPackageRefresh', () => { - let sandbox: sinon.SinonSandbox; - let mockTerminal: Terminal; - let mockTerminalEnv: TerminalEnvironment; - let mockApi: any; - let getEnvironmentForTerminalStub: sinon.SinonStub; - - setup(() => { - sandbox = sinon.createSandbox(); - mockTerminal = createMockTerminal(); - - // Stub logging to prevent console output during tests - sandbox.stub(logging, 'traceVerbose'); - - // Stub getEnvironmentForTerminal utility - getEnvironmentForTerminalStub = sandbox.stub(terminalUtils, 'getEnvironmentForTerminal'); - }); - - teardown(() => { - sandbox.restore(); - }); - - test('should return activated environment when terminal has one tracked', async () => { - // Mock - Terminal has a tracked conda environment - const condaEnv = createMockEnvironment({ - envId: { id: 'conda-env', managerId: 'ms-python.python:conda' }, - displayName: 'Conda Environment', - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(condaEnv), - }; - - mockApi = { - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - // Run - const result = await getEnvironmentForPackageRefresh(mockTerminal, mockTerminalEnv, mockApi); - - // Assert - assert.strictEqual(result, condaEnv, 'Should return the activated conda environment'); - assert.strictEqual( - (mockTerminalEnv.getEnvironment as sinon.SinonStub).calledOnceWith(mockTerminal), - true, - 'Should query terminal environment state', - ); - assert.strictEqual( - getEnvironmentForTerminalStub.called, - false, - 'Should not call fallback when activated env exists', - ); - }); - - test('should fall back to getEnvironmentForTerminal when no activated environment', async () => { - // Mock - Terminal has no tracked activation, fallback returns venv - const venvEnv = createMockEnvironment({ - envId: { id: 'venv-env', managerId: 'ms-python.python:venv' }, - displayName: 'Venv Environment', - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(undefined), - }; - - mockApi = { - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - getEnvironmentForTerminalStub.resolves(venvEnv); - - // Run - const result = await getEnvironmentForPackageRefresh(mockTerminal, mockTerminalEnv, mockApi); - - // Assert - assert.strictEqual(result, venvEnv, 'Should return environment from fallback'); - assert.strictEqual( - (mockTerminalEnv.getEnvironment as sinon.SinonStub).calledOnceWith(mockTerminal), - true, - 'Should first check terminal activation state', - ); - assert.strictEqual(getEnvironmentForTerminalStub.calledOnce, true, 'Should call fallback'); - }); - - test('should return undefined when both activated env and fallback return undefined', async () => { - // Mock - No environment from either source - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(undefined), - }; - - mockApi = { - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - getEnvironmentForTerminalStub.resolves(undefined); - - // Run - const result = await getEnvironmentForPackageRefresh(mockTerminal, mockTerminalEnv, mockApi); - - // Assert - assert.strictEqual(result, undefined, 'Should return undefined when no environment found'); - }); - - test('should prioritize activated environment over fallback', async () => { - // Mock - Both activated env and fallback would return different envs - const activatedCondaEnv = createMockEnvironment({ - envId: { id: 'conda-activated', managerId: 'ms-python.python:conda' }, - displayName: 'Activated Conda Env', - }); - - const workspaceVenvEnv = createMockEnvironment({ - envId: { id: 'workspace-venv', managerId: 'ms-python.python:venv' }, - displayName: 'Workspace Venv', - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(activatedCondaEnv), - }; - - mockApi = { - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - // Set up fallback to return different env - should not be called - getEnvironmentForTerminalStub.resolves(workspaceVenvEnv); - - // Run - const result = await getEnvironmentForPackageRefresh(mockTerminal, mockTerminalEnv, mockApi); - - // Assert - assert.strictEqual(result, activatedCondaEnv, 'Should return activated env, not fallback'); - assert.strictEqual(result?.envId.id, 'conda-activated', 'Should be the conda-activated environment'); - assert.strictEqual( - getEnvironmentForTerminalStub.called, - false, - 'Should not call fallback when activated env exists', - ); - }); -}); - -suite('terminalPackageWatcher - registerTerminalPackageWatcher', () => { - let sandbox: sinon.SinonSandbox; - let disposables: Disposable[]; - let shellExecutionEmitter: EventEmitter; - let mockApi: any; - let mockTerminalEnv: TerminalEnvironment; - let mockLog: any; - let getEnvironmentForTerminalStub: sinon.SinonStub; - - setup(() => { - sandbox = sinon.createSandbox(); - disposables = []; - shellExecutionEmitter = new EventEmitter(); - - // Stub the window API to capture the listener - sandbox.stub(windowApis, 'onDidEndTerminalShellExecution').callsFake((listener) => { - return shellExecutionEmitter.event(listener); - }); - - // Stub logging to prevent console output during tests - sandbox.stub(logging, 'traceVerbose'); - - // Stub getEnvironmentForTerminal utility - getEnvironmentForTerminalStub = sandbox.stub(terminalUtils, 'getEnvironmentForTerminal'); - - mockLog = { - error: sandbox.stub(), - info: sandbox.stub(), - warn: sandbox.stub(), - }; - }); - - teardown(() => { - sandbox.restore(); - shellExecutionEmitter.dispose(); - disposables.forEach((d) => d.dispose()); - }); - - function createShellExecutionEvent(command: string, terminal?: Terminal): TerminalShellExecutionEndEvent { - return { - terminal: terminal ?? createMockTerminal(), - execution: { - commandLine: { - value: command, - isTrusted: true, - confidence: 1, - }, - cwd: Uri.file('.'), - read: () => ({ [Symbol.asyncIterator]: async function* () {} }), - }, - shellIntegration: {} as any, - exitCode: 0, - } as TerminalShellExecutionEndEvent; - } - - test('should call refreshPackages on pip install with venv environment', async () => { - // Mock - Terminal has venv environment - const venvEnv = createMockEnvironment({ - envId: { id: 'venv-env', managerId: 'ms-python.python:venv' }, - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(venvEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire the pip install event - shellExecutionEmitter.fire(createShellExecutionEvent('pip install requests')); - - // Wait for async processing - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual(mockApi.refreshPackages.calledOnce, true, 'Should call refreshPackages once'); - assert.strictEqual(mockApi.refreshPackages.calledWith(venvEnv), true, 'Should refresh the venv environment'); - }); - - test('should call refreshPackages on conda install with conda environment', async () => { - // Mock - Terminal has conda environment - const condaEnv = createMockEnvironment({ - envId: { id: 'conda-env', managerId: 'ms-python.python:conda' }, - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(condaEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire the conda install event - shellExecutionEmitter.fire(createShellExecutionEvent('conda install numpy')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual(mockApi.refreshPackages.calledOnce, true, 'Should call refreshPackages once'); - assert.strictEqual(mockApi.refreshPackages.calledWith(condaEnv), true, 'Should refresh the conda environment'); - }); - - test('should call refreshPackages on poetry add with poetry environment', async () => { - // Mock - Terminal has poetry environment - const poetryEnv = createMockEnvironment({ - envId: { id: 'poetry-env', managerId: 'ms-python.python:poetry' }, - }); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(poetryEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire the poetry add event - shellExecutionEmitter.fire(createShellExecutionEvent('poetry add requests')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual(mockApi.refreshPackages.calledOnce, true, 'Should call refreshPackages once'); - assert.strictEqual( - mockApi.refreshPackages.calledWith(poetryEnv), - true, - 'Should refresh the poetry environment', - ); - }); - - test('should NOT call refreshPackages on pip list', async () => { - // Mock - Terminal has venv but pip list is not package-modifying - const venvEnv = createMockEnvironment(); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(venvEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire pip list event (not package-modifying) - shellExecutionEmitter.fire(createShellExecutionEvent('pip list')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual(mockApi.refreshPackages.called, false, 'Should not call refreshPackages for pip list'); - }); - - test('should NOT call refreshPackages on python script.py', async () => { - // Mock - Terminal has venv but python script.py is not package-modifying - const venvEnv = createMockEnvironment(); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(venvEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire python script.py event (not package-modifying) - shellExecutionEmitter.fire(createShellExecutionEvent('python script.py')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual( - mockApi.refreshPackages.called, - false, - 'Should not call refreshPackages for python script.py', - ); - }); - - test('should NOT call refreshPackages when no environment found', async () => { - // Mock - No environment from any source - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(undefined), - }; - - getEnvironmentForTerminalStub.resolves(undefined); - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire pip install but no environment available - shellExecutionEmitter.fire(createShellExecutionEvent('pip install requests')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - assert.strictEqual( - mockApi.refreshPackages.called, - false, - 'Should not call refreshPackages without environment', - ); - assert.strictEqual(mockLog.error.called, false, 'Should not log error for missing env'); - }); - - test('should log error when refreshPackages throws', async () => { - // Mock - refreshPackages will throw an error - const venvEnv = createMockEnvironment(); - - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(venvEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().rejects(new Error('Refresh failed')), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire pip install event that will fail - shellExecutionEmitter.fire(createShellExecutionEvent('pip install requests')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - Use sinon.match for resilient error checking - assert.strictEqual(mockLog.error.calledOnce, true, 'Should log error once'); - assert.ok( - mockLog.error.calledWith(sinon.match(/error.*refresh.*packages/i)), - 'Should log error about refreshing packages', - ); - }); - - test('should use terminal activated conda env over workspace venv (subproject scenario)', async () => { - // Mock - Scenario: Root workspace has venv, but terminal has conda env activated - const activatedCondaEnv = createMockEnvironment({ - envId: { id: 'subproject-conda', managerId: 'ms-python.python:conda' }, - displayName: 'Subproject Conda', - }); - - // Terminal is tracking the conda env that user activated - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(activatedCondaEnv), - }; - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire pip install event - shellExecutionEmitter.fire(createShellExecutionEvent('pip install requests')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - Should use conda env from terminal activation, not workspace heuristics - assert.strictEqual(mockApi.refreshPackages.calledOnce, true, 'Should call refreshPackages once'); - assert.strictEqual( - mockApi.refreshPackages.calledWith(activatedCondaEnv), - true, - 'Should refresh the activated conda environment', - ); - assert.strictEqual(getEnvironmentForTerminalStub.called, false, 'Should not fall back to workspace heuristics'); - }); - - test('should fall back to workspace environment when terminal has no tracked activation', async () => { - // Mock - Terminal has no tracked activation, fallback provides workspace venv - const workspaceVenv = createMockEnvironment({ - envId: { id: 'workspace-venv', managerId: 'ms-python.python:venv' }, - displayName: 'Workspace Venv', - }); - - // Terminal has no tracked activation - mockTerminalEnv = { - getEnvironment: sandbox.stub().returns(undefined), - }; - - // Fallback returns workspace venv - getEnvironmentForTerminalStub.resolves(workspaceVenv); - - mockApi = { - refreshPackages: sandbox.stub().resolves(), - getEnvironment: sandbox.stub(), - getPythonProjects: sandbox.stub().returns([]), - }; - - registerTerminalPackageWatcher(mockApi, mockTerminalEnv, mockLog, disposables); - - // Run - Fire pip install event - shellExecutionEmitter.fire(createShellExecutionEvent('pip install requests')); - - await new Promise((resolve) => setImmediate(resolve)); - - // Assert - Should fall back to workspace heuristics - assert.strictEqual(mockApi.refreshPackages.calledOnce, true, 'Should call refreshPackages once'); - assert.strictEqual( - mockApi.refreshPackages.calledWith(workspaceVenv), - true, - 'Should refresh the workspace venv from fallback', - ); - assert.strictEqual(getEnvironmentForTerminalStub.calledOnce, true, 'Should use fallback heuristics'); - }); -}); diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts index d393e6f0..2cd1706c 100644 --- a/src/test/managers/common/packageWatcher.unit.test.ts +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -4,18 +4,10 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { EventEmitter, LogOutputChannel, RelativePattern, Uri } from 'vscode'; -import { - DidChangeEnvironmentEventArgs, - EnvironmentManager, - PackageManager, - PythonEnvironment, - PythonEnvironmentId, -} from '../../../api'; +import { DidChangeEnvironmentEventArgs, PackageManager, PythonEnvironment, PythonEnvironmentId } from '../../../api'; import * as workspaceApis from '../../../common/workspace.apis'; -import { - registerPackageWatcherForManager, - watchPackageChangesForEnvironment, -} from '../../../managers/common/packageWatcher'; +import { EnvironmentManagers, InternalPackageManager } from '../../../internal.api'; +import { registerPackageWatchers, watchPackageChangesForEnvironment } from '../../../managers/common/packageWatcher'; suite('Package Watcher', () => { let sandbox: sinon.SinonSandbox; @@ -84,15 +76,6 @@ suite('Package Watcher', () => { }; } - function createMockEnvironmentManager(overrides?: Partial): Partial { - const changeEmitter = new EventEmitter(); - - return { - onDidChangeEnvironment: changeEmitter.event, - ...overrides, - }; - } - suite('watchPackageChangesForEnvironment', () => { test('should create file system watchers for watch targets', () => { const mockWatcher = createMockWatcher(); @@ -109,6 +92,11 @@ suite('Package Watcher', () => { // Default should create watcher for site-packages metadata. assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create 1 watcher (site-packages)'); + assert.deepStrictEqual( + createFileSystemWatcherStub.firstCall.args.slice(1), + [false, false, false], + 'Should listen for create, change, and delete events', + ); }); test('should create correct watch patterns on Windows', () => { @@ -134,8 +122,8 @@ suite('Package Watcher', () => { assert.ok(pattern.baseUri.fsPath.includes('Lib'), 'Should use Lib for Windows'); assert.strictEqual( pattern.pattern, - 'site-packages/**/*.dist-info/METADATA', - 'Should watch .dist-info METADATA files', + 'site-packages/{*.dist-info,*.dist-info/**}', + 'Should watch .dist-info directories and their contents', ); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); @@ -165,8 +153,8 @@ suite('Package Watcher', () => { assert.ok(pattern.baseUri.fsPath.includes('lib'), 'Should use lib for POSIX'); assert.strictEqual( pattern.pattern, - 'python*/site-packages/**/*.dist-info/METADATA', - 'Should watch .dist-info METADATA files with python* glob', + 'python*/site-packages/{*.dist-info,*.dist-info/**}', + 'Should watch .dist-info directories and their contents with python* glob', ); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); @@ -197,7 +185,7 @@ suite('Package Watcher', () => { const secondPattern = secondCall.args[0] as RelativePattern; assert.ok( - firstPattern.pattern.endsWith('site-packages/**/*.dist-info/METADATA'), + firstPattern.pattern.endsWith('site-packages/{*.dist-info,*.dist-info/**}'), 'Should keep default site-packages watcher', ); assert.ok(secondPattern.baseUri.fsPath.includes('conda-meta'), 'Should append conda-meta target'); @@ -219,7 +207,7 @@ suite('Package Watcher', () => { ); // Fire a create event and advance past debounce - mockWatcher._createEmitter.fire(Uri.file('/path/to/pkg.dist-info/METADATA')); + mockWatcher._createEmitter.fire(Uri.file('/path/to/pkg.dist-info')); clock.tick(600); await clock.tickAsync(0); @@ -232,6 +220,33 @@ suite('Package Watcher', () => { clock.restore(); }); + test('should call packageManager.refresh on file change', async () => { + const clock = sandbox.useFakeTimers(); + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + mockWatcher._changeEmitter.fire(Uri.file('/path/to/pkg.dist-info/METADATA')); + clock.tick(600); + await clock.tickAsync(0); + + assert.strictEqual( + (packageManager.refresh as sinon.SinonStub).callCount, + 1, + 'Should call refresh on file change', + ); + + clock.restore(); + }); + test('should call packageManager.refresh on file delete', async () => { const clock = sandbox.useFakeTimers(); const mockWatcher = createMockWatcher(); @@ -320,163 +335,111 @@ suite('Package Watcher', () => { }); }); - suite('registerPackageWatcherForManager', () => { - test('should create watcher for active environment on startup', async () => { + suite('registerPackageWatchers', () => { + test('should watch an active environment using its scope package manager', () => { const mockWatcher = createMockWatcher(); createFileSystemWatcherStub.returns(mockWatcher); - - const env = createMockEnvironment(); - const changeEmitter = new EventEmitter(); - const envManager = createMockEnvironmentManager({ - onDidChangeEnvironment: changeEmitter.event, - }); - const packageManager = createMockPackageManager(); - - registerPackageWatcherForManager( - envManager as EnvironmentManager, - packageManager as PackageManager, - mockLogOutputChannel as LogOutputChannel, - ); - - // Simulate environment change to active environment - changeEmitter.fire({ - uri: env.environmentPath, - new: env, - old: undefined, - }); - - // Should create watchers for the environment - assert.ok(createFileSystemWatcherStub.callCount > 0, 'Should create watchers when environment is set'); - }); - - test('should create new watcher when active environment changes', async () => { - const mockWatcher1 = createMockWatcher(); - const mockWatcher2 = createMockWatcher(); - createFileSystemWatcherStub.onFirstCall().returns(mockWatcher1); - createFileSystemWatcherStub.onSecondCall().returns(mockWatcher2); - createFileSystemWatcherStub.returns(mockWatcher2); - - const env1 = createMockEnvironment({ envId: { id: 'env-1', managerId: 'test' } }); - const env2 = createMockEnvironment({ envId: { id: 'env-2', managerId: 'test' } }); - - const changeEmitter = new EventEmitter(); - const envManager = createMockEnvironmentManager({ - onDidChangeEnvironment: changeEmitter.event, - }); + const environmentChanges = new EventEmitter(); const packageManager = createMockPackageManager(); - - const disposable = registerPackageWatcherForManager( - envManager as EnvironmentManager, - packageManager as PackageManager, - mockLogOutputChannel as LogOutputChannel, - ); - - // Create initial watcher for env1 - changeEmitter.fire({ - uri: env1.environmentPath, - new: env1, - old: undefined, - }); - - const initialCallCount = createFileSystemWatcherStub.callCount; - - // Simulate environment change to env2 - changeEmitter.fire({ - uri: env2.environmentPath, - new: env2, - old: env1, - }); - - // Should create new watchers for env2 - assert.ok( - createFileSystemWatcherStub.callCount > initialCallCount, - 'Should create new watchers for new environment', + packageManager.getPackageWatchTargets = () => [new RelativePattern('/path/to/env/conda-meta', '**/*.json')]; + const internalPackageManager = new InternalPackageManager('conda', packageManager as PackageManager); + const scope = Uri.file('.'); + const envManagers = { + onDidChangeActiveEnvironment: environmentChanges.event, + getPackageManager: sandbox.stub().returns(internalPackageManager), + } as unknown as EnvironmentManagers; + const env = createMockEnvironment({ envId: { id: 'conda-env', managerId: 'conda' } }); + + registerPackageWatchers(envManagers, mockLogOutputChannel as LogOutputChannel); + environmentChanges.fire({ uri: scope, new: env, old: undefined }); + + assert.ok((envManagers.getPackageManager as sinon.SinonStub).calledWith(scope)); + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 2, + 'Should include default and manager-specific watch targets', ); - - // Old watcher should be disposed - assert.ok((mockWatcher1.dispose as sinon.SinonStub).called, 'Old watcher should be disposed'); - - disposable.dispose(); }); - test('should dispose all watchers when disposed', async () => { + test('should retain a shared environment watcher until all scopes release it', () => { const mockWatcher = createMockWatcher(); createFileSystemWatcherStub.returns(mockWatcher); - + const environmentChanges = new EventEmitter(); + const packageManager = new InternalPackageManager('pip', createMockPackageManager() as PackageManager); + const envManagers = { + onDidChangeActiveEnvironment: environmentChanges.event, + getPackageManager: sandbox.stub().returns(packageManager), + } as unknown as EnvironmentManagers; const env = createMockEnvironment(); - const changeEmitter = new EventEmitter(); - const envManager = createMockEnvironmentManager({ - onDidChangeEnvironment: changeEmitter.event, - }); - const packageManager = createMockPackageManager(); + const firstScope = Uri.file('workspace-one'); + const secondScope = Uri.file('workspace-two'); - const disposable = registerPackageWatcherForManager( - envManager as EnvironmentManager, - packageManager as PackageManager, - mockLogOutputChannel as LogOutputChannel, - ); + registerPackageWatchers(envManagers, mockLogOutputChannel as LogOutputChannel); + environmentChanges.fire({ uri: firstScope, new: env, old: undefined }); + environmentChanges.fire({ uri: secondScope, new: env, old: undefined }); - // Simulate environment change to setup watcher - changeEmitter.fire({ - uri: env.environmentPath, - new: env, - old: undefined, - }); + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should share one environment watcher'); - disposable.dispose(); + environmentChanges.fire({ uri: firstScope, new: undefined, old: env }); + assert.ok(!(mockWatcher.dispose as sinon.SinonStub).called, 'Should retain watcher for the second scope'); - // Should dispose watchers - assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Watchers should be disposed'); + environmentChanges.fire({ uri: secondScope, new: undefined, old: env }); + assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Should dispose watcher after the final scope'); }); - test('should not create duplicate watchers for same environment', async () => { - const mockWatcher = createMockWatcher(); - createFileSystemWatcherStub.returns(mockWatcher); - - const env = createMockEnvironment({ envId: { id: 'env-1', managerId: 'test' } }); - - const changeEmitter = new EventEmitter(); - const envManager = createMockEnvironmentManager({ - onDidChangeEnvironment: changeEmitter.event, - }); - const packageManager = createMockPackageManager(); + test('should stop watching an environment when the active environment changes', () => { + const firstWatcher = createMockWatcher(); + const secondWatcher = createMockWatcher(); + createFileSystemWatcherStub.onFirstCall().returns(firstWatcher); + createFileSystemWatcherStub.onSecondCall().returns(secondWatcher); + const environmentChanges = new EventEmitter(); + const packageManager = new InternalPackageManager('pip', createMockPackageManager() as PackageManager); + const envManagers = { + onDidChangeActiveEnvironment: environmentChanges.event, + getPackageManager: sandbox.stub().returns(packageManager), + } as unknown as EnvironmentManagers; + const scope = Uri.file('workspace'); + const firstEnvironment = createMockEnvironment({ envId: { id: 'env-one', managerId: 'test-manager' } }); + const secondEnvironment = createMockEnvironment({ envId: { id: 'env-two', managerId: 'test-manager' } }); + + registerPackageWatchers(envManagers, mockLogOutputChannel as LogOutputChannel); + environmentChanges.fire({ uri: scope, new: firstEnvironment, old: undefined }); + environmentChanges.fire({ uri: scope, new: secondEnvironment, old: firstEnvironment }); - const disposable = registerPackageWatcherForManager( - envManager as EnvironmentManager, - packageManager as PackageManager, - mockLogOutputChannel as LogOutputChannel, + assert.ok( + (firstWatcher.dispose as sinon.SinonStub).called, + 'Should dispose the inactive environment watcher', ); - - // Set watcher for env1 - changeEmitter.fire({ - uri: env.environmentPath, - new: env, - old: undefined, - }); - - const initialCallCount = createFileSystemWatcherStub.callCount; - - // Fire another change for the same environment (old === new) - changeEmitter.fire({ - uri: env.environmentPath, - new: env, - old: env, - }); - - // Should not create new watchers - assert.strictEqual( - createFileSystemWatcherStub.callCount, - initialCallCount, - 'Should not create duplicate watchers for same envId', + assert.ok( + !(secondWatcher.dispose as sinon.SinonStub).called, + 'Should retain the active environment watcher', ); + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); - // Existing watcher should NOT be disposed when old.id === new.id - assert.ok( - !(mockWatcher.dispose as sinon.SinonStub).called, - 'Should not dispose existing watcher when environment re-emits with same id', + test('should use separate watchers when scopes select different package managers', () => { + createFileSystemWatcherStub.returns(createMockWatcher()); + const environmentChanges = new EventEmitter(); + const firstScope = Uri.file('workspace-one'); + const secondScope = Uri.file('workspace-two'); + const firstPackageManager = new InternalPackageManager('pip', createMockPackageManager() as PackageManager); + const secondPackageManager = new InternalPackageManager( + 'conda', + createMockPackageManager() as PackageManager, ); + const envManagers = { + onDidChangeActiveEnvironment: environmentChanges.event, + getPackageManager: sandbox + .stub() + .callsFake((scope) => (scope === firstScope ? firstPackageManager : secondPackageManager)), + } as unknown as EnvironmentManagers; + const env = createMockEnvironment(); - disposable.dispose(); + registerPackageWatchers(envManagers, mockLogOutputChannel as LogOutputChannel); + environmentChanges.fire({ uri: firstScope, new: env, old: undefined }); + environmentChanges.fire({ uri: secondScope, new: env, old: undefined }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); }); }); });