diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index be56e4a8..33db9fc6 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -576,4 +576,3 @@ envConfig.inspect ## 🧠 Agent Learnings - Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1) - Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1) - 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/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts new file mode 100644 index 00000000..2a95310e --- /dev/null +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -0,0 +1,644 @@ +// 1. Imports - group logically +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import { CancellationError, CancellationToken, LogOutputChannel, Progress } from 'vscode'; +import { DidChangePackagesEventArgs, Package, PackageChangeKind, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as winapi from '../../../common/window.apis'; +import * as condaUtils from '../../../managers/conda/condaUtils'; + +// 2. Function under test +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; + +suite('CondaPackageManager Unit Tests', () => { + let mockApi: PythonEnvironmentApi; + let mockLog: LogOutputChannel; + let packageManager: CondaPackageManager; + let withProgressStub: sinon.SinonStub; + let managePackagesStub: sinon.SinonStub; + let refreshPackagesStub: sinon.SinonStub; + let getCommonCondaPackagesToInstallStub: sinon.SinonStub; + + setup(() => { + // Create minimal mocks + mockApi = {} as PythonEnvironmentApi; + mockLog = { + error: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + } as unknown as LogOutputChannel; + + // Stub external dependencies + withProgressStub = sinon.stub(winapi, 'withProgress'); + managePackagesStub = sinon.stub(condaUtils, 'managePackages'); + refreshPackagesStub = sinon.stub(condaUtils, 'refreshPackages'); + getCommonCondaPackagesToInstallStub = sinon.stub(condaUtils, 'getCommonCondaPackagesToInstall'); + + // Create package manager instance + packageManager = new CondaPackageManager(mockApi, mockLog); + }); + + teardown(() => { + sinon.restore(); + packageManager.dispose(); + }); + + suite('getChanges function', () => { + test('should detect package additions when refreshing empty environment', () => { + // Mock - Set up environment and mock responses + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const after: Package[] = [ + { name: 'package1', version: '1.0.0' } as Package, + { name: 'package2', version: '2.0.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(after); + + // Run - Execute refresh + return packageManager.refresh(env).then(() => { + // Assert - Verify changes detected correctly + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.changes.length, 2, 'Should detect 2 additions'); + assert.strictEqual(firedEvent!.changes[0].kind, PackageChangeKind.add); + assert.strictEqual(firedEvent!.changes[1].kind, PackageChangeKind.add); + }); + }); + + test('should detect package removals when packages are uninstalled', () => { + // Mock - Set up environment with existing packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const before: Package[] = [ + { name: 'package1', version: '1.0.0' } as Package, + { name: 'package2', version: '2.0.0' } as Package, + ]; + const after: Package[] = []; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(before); + + // Run - First refresh to set initial state + return packageManager.refresh(env).then(() => { + firedEvent = undefined; + refreshPackagesStub.resolves(after); + + // Run - Second refresh with empty packages + return packageManager.refresh(env).then(() => { + // Assert - Verify removals detected + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.changes.length, 2, 'Should detect 2 removals'); + assert.strictEqual(firedEvent!.changes[0].kind, PackageChangeKind.remove); + assert.strictEqual(firedEvent!.changes[1].kind, PackageChangeKind.remove); + }); + }); + }); + + test('should detect both package additions and removals when packages change', () => { + // Mock - Set up environment with one package + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const before: Package[] = [ + { name: 'old-package', version: '1.0.0' } as Package, + ]; + const after: Package[] = [ + { name: 'new-package', version: '2.0.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(before); + + // Run - First refresh to set initial state + return packageManager.refresh(env).then(() => { + firedEvent = undefined; + refreshPackagesStub.resolves(after); + + // Run - Second refresh with different packages + return packageManager.refresh(env).then(() => { + // Assert - Verify both additions and removals + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.changes.length, 2, 'Should detect 1 removal and 1 addition'); + + const removeChanges = firedEvent!.changes.filter(c => c.kind === PackageChangeKind.remove); + const addChanges = firedEvent!.changes.filter(c => c.kind === PackageChangeKind.add); + + assert.strictEqual(removeChanges.length, 1, 'Should have 1 removal'); + assert.strictEqual(addChanges.length, 1, 'Should have 1 addition'); + assert.strictEqual(removeChanges[0].pkg.name, 'old-package'); + assert.strictEqual(addChanges[0].pkg.name, 'new-package'); + }); + }); + }); + }); + + suite('CondaPackageManager.manage', () => { + test('should install packages when install list provided', async () => { + // Mock - Set up environment and install packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + managePackagesStub.resolves(packages); + + // Run - Execute manage with install option + await packageManager.manage(env, { install: ['numpy'] }); + + // Assert - Verify managePackages called correctly + assert.ok(managePackagesStub.called, 'managePackages should be called'); + const args = managePackagesStub.firstCall.args; + assert.strictEqual(args[0], env); + assert.deepStrictEqual(args[1].install, ['numpy']); + }); + + test('should uninstall packages when uninstall list provided', async () => { + // Mock - Set up environment and uninstall packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = []; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + managePackagesStub.resolves(packages); + + // Run - Execute manage with uninstall option + await packageManager.manage(env, { uninstall: ['numpy'] }); + + // Assert - Verify managePackages called correctly + assert.ok(managePackagesStub.called, 'managePackages should be called'); + const args = managePackagesStub.firstCall.args; + assert.strictEqual(args[0], env); + assert.deepStrictEqual(args[1].uninstall, ['numpy']); + }); + + test('should prompt user for packages when none specified', async () => { + // Mock - Set up environment without packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'pytest', version: '7.0.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + getCommonCondaPackagesToInstallStub.resolves({ install: ['pytest'], uninstall: [] }); + managePackagesStub.resolves(packages); + + // Run - Execute manage without packages (empty install array triggers prompt) + await packageManager.manage(env, { install: [] }); + + // Assert - Verify user was prompted + assert.ok(getCommonCondaPackagesToInstallStub.called, 'Should prompt user for packages'); + assert.ok(managePackagesStub.called, 'managePackages should be called'); + }); + + test('should not install when user cancels package selection', async () => { + // Mock - Set up cancellation scenario + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + getCommonCondaPackagesToInstallStub.resolves(undefined); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + // Run - Execute manage without packages (user cancels, empty install array triggers prompt) + await packageManager.manage(env, { install: [] }); + + // Assert - Verify operation was cancelled + assert.ok(getCommonCondaPackagesToInstallStub.called, 'Should prompt user'); + assert.ok(!managePackagesStub.called, 'Should not call managePackages when user cancels'); + }); + + test('should emit onDidChangePackages event after successful package management', async () => { + // Mock - Set up environment and event listener + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const afterPackages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + managePackagesStub.resolves(afterPackages); + + // Run - Execute manage + await packageManager.manage(env, { install: ['numpy'] }); + + // Assert - Verify event was emitted + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.environment, env); + assert.strictEqual(firedEvent!.manager, packageManager); + }); + + test('should log errors when package management fails', async () => { + // Mock - Set up error scenario + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const error = new Error('Installation failed'); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + managePackagesStub.rejects(error); + + // Run - Execute manage with error + await packageManager.manage(env, { install: ['numpy'] }); + + // Assert - Verify error was logged + assert.ok((mockLog.error as sinon.SinonStub).called, 'Should log error'); + }); + + test('should re-throw CancellationError without logging', async () => { + // Mock - Set up cancellation scenario + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + managePackagesStub.rejects(new CancellationError()); + + // Run & Assert - Verify CancellationError is propagated + await assert.rejects( + async () => packageManager.manage(env, { install: ['numpy'] }), + CancellationError, + 'Should re-throw CancellationError' + ); + }); + }); + + suite('CondaPackageManager.refresh', () => { + test('should fetch packages and update internal cache', async () => { + // Mock - Set up environment with packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + { name: 'pandas', version: '1.3.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Execute refresh + await packageManager.refresh(env); + + // Assert - Verify packages fetched and cached + assert.ok(refreshPackagesStub.called, 'refreshPackages should be called'); + + const cached = await packageManager.getPackages(env); + assert.strictEqual(cached?.length, 2); + }); + + test('should emit onDidChangePackages event when packages are modified', async () => { + // Mock - Set up environment and event listener + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Execute refresh + await packageManager.refresh(env); + + // Assert - Verify event was emitted + assert.ok(firedEvent, 'Event should be fired when packages change'); + }); + + test('should not emit event when package list remains unchanged', async () => { + // Mock - Set up environment with no packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = []; + + let eventCount = 0; + packageManager.onDidChangePackages(() => { + eventCount++; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Execute refresh twice with no changes + await packageManager.refresh(env); + await packageManager.refresh(env); + + // Assert - Verify event was not emitted + assert.strictEqual(eventCount, 0, 'Should not emit event when no changes'); + }); + }); + + suite('CondaPackageManager.getPackages', () => { + test('should use cached packages when available', async () => { + // Mock - Set up environment with cached packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + await packageManager.refresh(env); + refreshPackagesStub.resetHistory(); + + // Run - Get packages from cache + const result = await packageManager.getPackages(env); + + // Assert - Verify cache was used + assert.strictEqual(result?.length, 1); + assert.ok(!refreshPackagesStub.called, 'Should not call refresh when packages are cached'); + }); + + test('should trigger refresh when packages not in cache', async () => { + // Mock - Set up environment without cached packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Get packages without cache + const result = await packageManager.getPackages(env); + + // Assert - Verify refresh was triggered + assert.ok(refreshPackagesStub.called, 'Should call refresh when packages not cached'); + assert.strictEqual(result?.length, 1); + }); + + test('should maintain separate package caches for different environments', async () => { + // Mock - Set up two different environments + const env1: PythonEnvironment = { + envId: { id: 'env1' }, + } as PythonEnvironment; + + const env2: PythonEnvironment = { + envId: { id: 'env2' }, + } as PythonEnvironment; + + const packages1: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + const packages2: Package[] = [ + { name: 'pandas', version: '1.3.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.onFirstCall().resolves(packages1); + refreshPackagesStub.onSecondCall().resolves(packages2); + + // Run - Get packages for both environments + const result1 = await packageManager.getPackages(env1); + const result2 = await packageManager.getPackages(env2); + + // Assert - Verify each environment has its own cache + assert.strictEqual(result1?.length, 1); + assert.strictEqual(result1?.[0].name, 'numpy'); + assert.strictEqual(result2?.length, 1); + assert.strictEqual(result2?.[0].name, 'pandas'); + }); + }); + + suite('CondaPackageManager.dispose', () => { + test('should clean up event emitter on disposal', () => { + // Mock - Spy on dispose method + const disposeStub = sinon.stub(packageManager['_onDidChangePackages'], 'dispose'); + + // Run - Dispose package manager + packageManager.dispose(); + + // Assert - Verify cleanup occurred + assert.ok(disposeStub.called, 'Should dispose event emitter'); + }); + + test('should clear all cached packages on disposal', () => { + // Mock - Set up environment with cached packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Cache packages then dispose + return packageManager.refresh(env).then(() => { + packageManager.dispose(); + + // Assert - Verify cache was cleared + assert.strictEqual(packageManager['packages'].size, 0, 'Should clear packages cache'); + }); + }); + }); + + suite('Event emission', () => { + test('should include environment, manager, and changes in emitted event', async () => { + // Mock - Set up environment and event listener + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Trigger event + await packageManager.refresh(env); + + // Assert - Verify event contains all required information + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.environment, env, 'Event should include environment'); + assert.strictEqual(firedEvent!.manager, packageManager, 'Event should include manager'); + assert.ok(Array.isArray(firedEvent!.changes), 'Event should include changes array'); + }); + + test('should notify all registered listeners when event fires', async () => { + // Mock - Set up environment and multiple listeners + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + ]; + + let listener1Called = false; + let listener2Called = false; + + packageManager.onDidChangePackages(() => { + listener1Called = true; + }); + + packageManager.onDidChangePackages(() => { + listener2Called = true; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Trigger event + await packageManager.refresh(env); + + // Assert - Verify all listeners were notified + assert.ok(listener1Called, 'First listener should be called'); + assert.ok(listener2Called, 'Second listener should be called'); + }); + + test('should provide detailed package change information in event', async () => { + // Mock - Set up environment with multiple packages + const env: PythonEnvironment = { + envId: { id: 'test-env' }, + } as PythonEnvironment; + + const packages: Package[] = [ + { name: 'numpy', version: '1.21.0' } as Package, + { name: 'pandas', version: '1.3.0' } as Package, + ]; + + let firedEvent: DidChangePackagesEventArgs | undefined; + packageManager.onDidChangePackages((e) => { + firedEvent = e; + }); + + withProgressStub.callsFake(async (_options, callback) => { + return await callback({} as Progress<{ message?: string }>, {} as CancellationToken); + }); + + refreshPackagesStub.resolves(packages); + + // Run - Trigger event with multiple packages + await packageManager.refresh(env); + + // Assert - Verify event contains detailed change information + assert.ok(firedEvent, 'Event should be fired'); + assert.strictEqual(firedEvent!.changes.length, 2, 'Should include all package changes'); + assert.strictEqual(firedEvent!.changes[0].kind, PackageChangeKind.add); + assert.strictEqual(firedEvent!.changes[0].pkg.name, 'numpy'); + assert.strictEqual(firedEvent!.changes[1].kind, PackageChangeKind.add); + assert.strictEqual(firedEvent!.changes[1].pkg.name, 'pandas'); + }); + }); +});