diff --git a/api/src/main.ts b/api/src/main.ts index d66f6bdc..958755df 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -7,6 +7,7 @@ import { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -687,6 +688,16 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; /** * Event that is fired when packages change. */ diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index b44e79b2..c45ae1cb 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -7,6 +7,7 @@ import { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -616,6 +617,17 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; + /** * Event that is fired when packages change. */ diff --git a/src/api.ts b/src/api.ts index 6e07a505..6bd70e8b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,6 +7,7 @@ import type { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -681,6 +682,17 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; + /** * Event that is fired when packages change. */ diff --git a/src/managers/builtin/main.ts b/src/managers/builtin/main.ts index 4fff2dae..1ae48a2e 100644 --- a/src/managers/builtin/main.ts +++ b/src/managers/builtin/main.ts @@ -4,6 +4,7 @@ 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,38 +42,8 @@ export async function registerSystemPythonFeatures( }), ); - const packageDebouncedRefresh = createSimpleDebounce(500, async () => { - const projects = await api.getPythonProjects(); - await Promise.all( - projects.map(async (project) => { - const env = await api.getEnvironment(project.uri); - if (!env) { - return; - } - try { - await api.refreshPackages(env); - } catch (ex) { - log.error( - `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, - ); - } - }), - ); - }); - const packageWatcher = createFileSystemWatcher( - '**/site-packages/*.dist-info/METADATA', - false, // don't ignore create events (pip install) - true, // ignore change events (content changes in METADATA don't affect package list) - false, // don't ignore delete events (pip uninstall) - ); disposables.push( - packageDebouncedRefresh, - packageWatcher, - packageWatcher.onDidCreate(() => { - packageDebouncedRefresh.trigger(); - }), - packageWatcher.onDidDelete(() => { - packageDebouncedRefresh.trigger(); - }), + await registerPackageWatcherForManager(envManager, pkgManager, log), + await registerPackageWatcherForManager(venvManager, pkgManager, log), ); } diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts new file mode 100644 index 00000000..413ce17b --- /dev/null +++ b/src/managers/common/packageWatcher.ts @@ -0,0 +1,125 @@ +import * as path from 'path'; +import { Disposable, LogOutputChannel, RelativePattern } from 'vscode'; +import { EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; +import { traceVerbose } from '../../common/logging'; +import { createSimpleDebounce } from '../../common/utils/debounce'; +import { createFileSystemWatcher } from '../../common/workspace.apis'; + +/** + * Derives the file system watch targets for a given Python environment. + * + * Targets include site-packages `.dist-info/METADATA` files 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. + * Empty if the environment has no `sysPrefix` or discoverable paths. + */ +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 +} + +/** + * 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. + * + * @param env - The Python environment to watch. + * @param packageManager - The package manager to call refresh on when changes occur. + * @param log - Logger for diagnostic messages. + * @returns A disposable that removes the watcher when disposed. + */ +export function watchPackageChangesForEnvironment( + env: PythonEnvironment, + packageManager: PackageManager, + log: LogOutputChannel, +): Disposable { + // Watch targets + const watchTargets = [ + ...getDefaultPackageWatchTargets(env), + ...(packageManager.getPackageWatchTargets?.(env) ?? []), + ]; + if (watchTargets.length === 0) { + traceVerbose(log, `No watch targets for environment ${env.envId}`); + return new Disposable(() => undefined); + } + // Debounced refresh function + const debouncedRefresh = createSimpleDebounce(500, async () => { + traceVerbose(log, `Package change detected for environment ${env.envId.id}, refreshing packages.`); + packageManager.refresh(env).catch((ex) => { + log.error( + `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, + ); + }); + }); + // Create watchers + const disposables: Disposable[] = []; + for (const target of watchTargets) { + const watcher = createFileSystemWatcher( + target, + false, // create -> install + false, // change -> ignore + false, // delete -> uninstall + ); + disposables.push( + watcher, + watcher.onDidCreate(debouncedRefresh.trigger), + watcher.onDidDelete(debouncedRefresh.trigger), + ); + } + + return new Disposable(() => disposables.forEach((d) => d.dispose())); +} + +/** + * Registers automatic file system watchers for the active environment managed by a given manager. + * + * 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. + * + * @param envManager - The environment manager whose active environment should be watched. + * @param packageManager - The package manager to call refresh on when changes occur. + * @param log - Logger for diagnostic and error messages. + * @returns A disposable that removes all watchers and subscriptions when disposed. + */ +export async function registerPackageWatcherForManager( + envManager: EnvironmentManager, + packageManager: PackageManager, + log: LogOutputChannel, +): Promise { + // One watcher per environment id. + const watchers = new Map(); + + const addWatcher = (env: PythonEnvironment): void => { + if (!watchers.has(env.envId.id)) { + watchers.set(env.envId.id, watchPackageChangesForEnvironment(env, packageManager, log)); + } + }; + + const removeWatcher = (envId: string): void => { + watchers.get(envId)?.dispose(); + watchers.delete(envId); + }; + + const envChangeDisposable = envManager.onDidChangeEnvironment?.((changes) => { + if (changes.new) { + addWatcher(changes.new); + } + if (changes.old) { + removeWatcher(changes.old.envId.id); + } + }); + + return new Disposable(() => { + envChangeDisposable?.dispose(); + watchers.forEach((watcher) => watcher.dispose()); + watchers.clear(); + }); +} diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 27c26a4f..dc585480 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { CancellationError, Disposable, @@ -6,6 +7,7 @@ import { LogOutputChannel, MarkdownString, ProgressLocation, + RelativePattern, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -150,6 +152,14 @@ export class CondaPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } + getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] { + if (!environment.sysPrefix) { + return []; + } + + return [new RelativePattern(path.join(environment.sysPrefix, 'conda-meta'), '**/*.json')]; + } + dispose() { this._onDidChangePackages.dispose(); this.packages.clear(); diff --git a/src/managers/conda/main.ts b/src/managers/conda/main.ts index 00ce3d16..19cd5d43 100644 --- a/src/managers/conda/main.ts +++ b/src/managers/conda/main.ts @@ -6,6 +6,7 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { getPythonApi } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; import { NativePythonFinder } from '../common/nativePythonFinder'; +import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { notifyMissingManagerIfDefault } from '../common/utils'; import { CondaEnvManager } from './condaEnvManager'; import { CondaPackageManager } from './condaPackageManager'; @@ -55,6 +56,7 @@ export async function registerCondaFeatures( packageManager, api.registerEnvironmentManager(envManager), api.registerPackageManager(packageManager), + await registerPackageWatcherForManager(envManager, packageManager, log), ); } catch (ex) { await notifyMissingManagerIfDefault('ms-python.python:conda', projectManager, api); diff --git a/src/managers/poetry/main.ts b/src/managers/poetry/main.ts index c893f55b..341578ec 100644 --- a/src/managers/poetry/main.ts +++ b/src/managers/poetry/main.ts @@ -4,6 +4,7 @@ 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'; @@ -24,5 +25,6 @@ export async function registerPoetryFeatures( pkgManager, api.registerEnvironmentManager(envManager), api.registerPackageManager(pkgManager), + await registerPackageWatcherForManager(envManager, pkgManager, outputChannel), ); } diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts new file mode 100644 index 00000000..b28f08ac --- /dev/null +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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 * as workspaceApis from '../../../common/workspace.apis'; +import { + registerPackageWatcherForManager, + watchPackageChangesForEnvironment, +} from '../../../managers/common/packageWatcher'; + +suite('Package Watcher', () => { + let sandbox: sinon.SinonSandbox; + let createFileSystemWatcherStub: sinon.SinonStub; + let mockLogOutputChannel: Partial; + + setup(() => { + sandbox = sinon.createSandbox(); + mockLogOutputChannel = { + error: sandbox.stub(), + warn: sandbox.stub(), + info: sandbox.stub(), + debug: sandbox.stub(), + }; + createFileSystemWatcherStub = sandbox.stub(workspaceApis, 'createFileSystemWatcher'); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createMockEnvironment(overrides?: Partial): PythonEnvironment { + const envId: PythonEnvironmentId = { + id: 'test-env-id', + managerId: 'test-manager', + ...overrides?.envId, + }; + + return { + envId, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + environmentPath: Uri.file('/path/to/env'), + version: '3.11.0', + sysPrefix: '/path/to/env', + execInfo: { + run: { executable: '/path/to/env/bin/python' }, + }, + ...overrides, + } as unknown as PythonEnvironment; + } + + function createMockWatcher() { + const onDidCreateEmitter = new EventEmitter(); + const onDidDeleteEmitter = new EventEmitter(); + const onDidChangeEmitter = new EventEmitter(); + + return { + onDidCreate: onDidCreateEmitter.event, + onDidDelete: onDidDeleteEmitter.event, + onDidChange: onDidChangeEmitter.event, + dispose: sandbox.stub(), + _createEmitter: onDidCreateEmitter, + _deleteEmitter: onDidDeleteEmitter, + _changeEmitter: onDidChangeEmitter, + }; + } + + function createMockPackageManager(): Partial { + return { + refresh: sandbox.stub().resolves([]), + }; + } + + 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(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Default should create watcher for site-packages metadata. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create 1 watcher (site-packages)'); + }); + + test('should create correct watch patterns on Windows', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + try { + const env = createMockEnvironment({ sysPrefix: 'C:\\Users\\test\\env' }); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const pattern = firstCall.args[0] as RelativePattern; + + 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', + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + + test('should create correct watch patterns on POSIX', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + try { + const env = createMockEnvironment({ sysPrefix: '/home/test/env' }); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const pattern = firstCall.args[0] as RelativePattern; + + 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', + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + + test('should append package-manager-provided watch targets to defaults', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment({ sysPrefix: '/path/to/env' }); + const packageManager = createMockPackageManager(); + (packageManager as PackageManager).getPackageWatchTargets = () => [ + new RelativePattern('/path/to/env/conda-meta', '**/*.json'), + ]; + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2, 'Should watch default and custom targets'); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const firstPattern = firstCall.args[0] as RelativePattern; + const secondCall = createFileSystemWatcherStub.getCall(1); + const secondPattern = secondCall.args[0] as RelativePattern; + + assert.ok( + firstPattern.pattern.endsWith('site-packages/**/*.dist-info/METADATA'), + 'Should keep default site-packages watcher', + ); + assert.ok(secondPattern.baseUri.fsPath.includes('conda-meta'), 'Should append conda-meta target'); + assert.strictEqual(secondPattern.pattern, '**/*.json', 'Should watch JSON files in conda-meta'); + }); + + test('should call packageManager.refresh on file create', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watcher is created and create events are observed. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create watcher for site-packages'); + assert.strictEqual(createFileSystemWatcherStub.getCall(0).args[1], false, 'Should watch create events'); + }); + + test('should call packageManager.refresh on file delete', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watcher is created and delete events are observed. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create watcher for site-packages'); + assert.strictEqual(createFileSystemWatcherStub.getCall(0).args[3], false, 'Should watch delete events'); + }); + + test('should debounce multiple rapid file events', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watcher is created with event handlers for debouncing. + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 1, + 'Should create watcher with debounced event handlers', + ); + }); + + test('should dispose watchers when disposable is disposed', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + const disposable = watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + disposable.dispose(); + + // Should dispose all watchers + assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Watcher should be disposed'); + }); + + test('should return empty disposable when environment has no sysPrefix', () => { + const env = createMockEnvironment({ sysPrefix: undefined }); + const packageManager = createMockPackageManager(); + + const disposable = watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + assert.ok(disposable, 'Should return a disposable'); + // Should not create any watchers + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 0, + 'Should not create watchers when sysPrefix is missing', + ); + }); + }); + + suite('registerPackageWatcherForManager', () => { + test('should create watcher for active environment on startup', async () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + await 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 packageManager = createMockPackageManager(); + + const disposable = await 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', + ); + + // 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 () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + const disposable = await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Simulate environment change to setup watcher + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: undefined, + }); + + disposable.dispose(); + + // Should dispose watchers + assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Watchers should be disposed'); + }); + + 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(); + + const disposable = await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Set watcher for env1 + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: undefined, + }); + + const initialCallCount = createFileSystemWatcherStub.callCount; + + // Fire another change for the same environment + 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', + ); + + disposable.dispose(); + }); + }); +});