diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts index 50ffe61e4..f1c3c5908 100644 --- a/src/managers/builtin/commands/uninstall.ts +++ b/src/managers/builtin/commands/uninstall.ts @@ -25,7 +25,8 @@ export class PipUninstallCommand extends UninstallCommand { /** * UV uninstall command. - * Parsed command: `uv pip uninstall -y --python ` + * Parsed command: `uv pip uninstall --python ` + * Note: `uv pip uninstall` is non-interactive by default and has no `-y`/`--yes` option. * Official documentation: https://docs.astral.sh/uv/pip/ */ export class UvUninstallCommand extends UninstallCommand { diff --git a/src/test/managers/builtin/commands.unit.test.ts b/src/test/managers/builtin/commands.unit.test.ts new file mode 100644 index 000000000..69c9ad9b6 --- /dev/null +++ b/src/test/managers/builtin/commands.unit.test.ts @@ -0,0 +1,137 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PipAvailableVersionsCommand, UvAvailableVersionsCommand } from '../../../managers/builtin/commands/availableVersions'; +import { PipInstallCommand, UvInstallCommand } from '../../../managers/builtin/commands/install'; +import { PipListCommand, UvListCommand } from '../../../managers/builtin/commands/list'; +import { PipListDirectNamesCommand, UvListDirectNamesCommand } from '../../../managers/builtin/commands/listDirectNames'; +import { PipUninstallCommand, UvUninstallCommand } from '../../../managers/builtin/commands/uninstall'; +import { PipVersionCommand, UvVersionCommand } from '../../../managers/builtin/commands/version'; +import * as helpers from '../../../managers/builtin/helpers'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Pip and UV commands', () => { + let mockLog: LogOutputChannel; + let runPythonStub: sinon.SinonStub; + let runUvStub: sinon.SinonStub; + + setup(() => { + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runPythonStub = sinon.stub(helpers, 'runPython').resolves(''); + runUvStub = sinon.stub(helpers, 'runUV').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + // Mutation commands have no output to parse; unit coverage is a smoke test that + // execute() resolves. Their concrete command strings are exercised by integration tests. + suite('mutation commands execute without error', () => { + test('PipInstallCommand', async () => { + const command = new PipInstallCommand({ pythonExecutable: 'python', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('UvInstallCommand', async () => { + const command = new UvInstallCommand({ pythonExecutable: 'python', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PipUninstallCommand', async () => { + const command = new PipUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('UvUninstallCommand', async () => { + const command = new UvUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + }); + + test('PipAvailableVersionsCommand parses versions and filters prereleases', async () => { + runPythonStub.resolves(JSON.stringify({ versions: ['2.0.0rc1', '1.0.0'] })); + const command = new PipAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute({ + packageName: 'package', + pythonVersion: '3.13.1', + includePrerelease: false, + }); + + assert.deepStrictEqual(result.map((v) => v.public), ['1.0.0']); + }); + + test('UvAvailableVersionsCommand parses versions from embedded JSON', async () => { + runUvStub.resolves(`Some preamble\n${JSON.stringify({ versions: ['1.0.0'] })}`); + const command = new UvAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute({ packageName: 'package', pythonVersion: '3.13.1' }); + + assert.deepStrictEqual(result.map((v) => v.public), ['1.0.0']); + }); + + test('PipListCommand parses packages and drops entries missing a version', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }, { name: 'broken' }])); + const command = new PipListCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual( + result.map((p) => ({ name: p.name, version: p.version })), + [{ name: 'package', version: '1.0.0' }], + ); + }); + + test('UvListCommand parses packages from JSON', async () => { + runUvStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new UvListCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual( + result.map((p) => ({ name: p.name, version: p.version })), + [{ name: 'package', version: '1.0.0' }], + ); + }); + + test('PipListDirectNamesCommand returns normalized names', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'Flask_Thing', version: '1.0.0' }])); + const command = new PipListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual([...result], ['flask-thing']); + }); + + test('UvListDirectNamesCommand keeps top-level packages and skips indented dependencies', async () => { + runUvStub.resolves(['Flask_Thing 1.0.0', '├── dependency 2.0.0', '└── another-dep 3.0.0'].join('\n')); + const command = new UvListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual([...result], ['flask-thing']); + }); + + test('PipVersionCommand parses the version from pip --version output', async () => { + runPythonStub.resolves('pip 24.0 from /site-packages/pip (python 3.13)'); + const command = new PipVersionCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.strictEqual(result?.public, '24.0'); + }); + + test('UvVersionCommand parses the version from uv --version output', async () => { + runUvStub.resolves('uv 0.4.20 (abcdef 2024-01-01)'); + const command = new UvVersionCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.strictEqual(result?.public, '0.4.20'); + }); +}); diff --git a/src/test/managers/conda/commands.unit.test.ts b/src/test/managers/conda/commands.unit.test.ts new file mode 100644 index 000000000..5a2c09cd4 --- /dev/null +++ b/src/test/managers/conda/commands.unit.test.ts @@ -0,0 +1,84 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { CondaAvailableVersionsCommand } from '../../../managers/conda/commands/availableVersions'; +import { CondaInstallCommand } from '../../../managers/conda/commands/install'; +import { CondaListCommand } from '../../../managers/conda/commands/list'; +import { CondaUninstallCommand } from '../../../managers/conda/commands/uninstall'; +import { CondaVersionCommand } from '../../../managers/conda/commands/version'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Conda commands', () => { + let mockLog: LogOutputChannel; + let runCondaStub: sinon.SinonStub; + + setup(() => { + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runCondaStub = sinon.stub(condaUtils, 'runCondaExecutable').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + // Mutation commands have no output to parse; unit coverage is a smoke test that + // execute() resolves. Their concrete command strings are exercised by integration tests. + suite('mutation commands execute without error', () => { + test('CondaInstallCommand', async () => { + const command = new CondaInstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('CondaUninstallCommand', async () => { + const command = new CondaUninstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + }); + + test('CondaAvailableVersionsCommand parses versions keyed by package name', async () => { + runCondaStub.resolves(JSON.stringify({ package: [{ version: '1.0.0' }, { version: '2.0.0' }] })); + const command = new CondaAvailableVersionsCommand({ pythonExecutable: 'conda', log: mockLog }); + + const result = await command.execute({ packageName: 'package', pythonVersion: '' }); + + assert.deepStrictEqual(result.map((v) => v.public), ['1.0.0', '2.0.0']); + }); + + test('CondaListCommand parses packages and drops entries missing a version', async () => { + runCondaStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }, { name: 'broken' }])); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + + const result = await command.execute(); + + assert.deepStrictEqual( + result.map((p) => ({ name: p.name, version: p.version })), + [{ name: 'package', version: '1.0.0' }], + ); + }); + + test('CondaVersionCommand parses the version from conda --version output', async () => { + runCondaStub.resolves('conda 24.1.2'); + const command = new CondaVersionCommand({ pythonExecutable: 'conda', log: mockLog }); + + const result = await command.execute(); + + assert.strictEqual(result?.public, '24.1.2'); + }); +}); diff --git a/src/test/managers/poetry/commands.unit.test.ts b/src/test/managers/poetry/commands.unit.test.ts new file mode 100644 index 000000000..37bb132e4 --- /dev/null +++ b/src/test/managers/poetry/commands.unit.test.ts @@ -0,0 +1,73 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PoetryAddCommand } from '../../../managers/poetry/commands/add'; +import { PoetryRemoveCommand } from '../../../managers/poetry/commands/remove'; +import * as poetryRunner from '../../../managers/poetry/commands/runPoetry'; +import { PoetryShowCommand } from '../../../managers/poetry/commands/show'; +import { PoetryShowTopLevelCommand } from '../../../managers/poetry/commands/showTopLevel'; +import { PoetryVersionCommand } from '../../../managers/poetry/commands/version'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Poetry commands', () => { + let mockLog: LogOutputChannel; + let runPoetryStub: sinon.SinonStub; + + setup(() => { + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runPoetryStub = sinon.stub(poetryRunner, 'runPoetry').resolves(''); + sinon.stub(poetryUtils, 'getPoetryVersion').resolves('1.8.2'); + }); + + teardown(() => { + sinon.restore(); + }); + + // Mutation commands have no output to parse; unit coverage is a smoke test that + // execute() resolves. Their concrete command strings are exercised by integration tests. + suite('mutation commands execute without error', () => { + test('PoetryAddCommand', async () => { + const command = new PoetryAddCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PoetryRemoveCommand', async () => { + const command = new PoetryRemoveCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + }); + + test('PoetryShowCommand parses name, version and description', async () => { + runPoetryStub.resolves(['requests 2.31.0 Python HTTP for Humans.', ''].join('\n')); + const command = new PoetryShowCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual( + result.map((p) => ({ name: p.name, version: p.version, description: p.description })), + [{ name: 'requests', version: '2.31.0', description: '2.31.0 - Python HTTP for Humans.' }], + ); + }); + + test('PoetryShowTopLevelCommand returns normalized names', async () => { + runPoetryStub.resolves(['Flask_Thing', 'requests'].join('\n')); + const command = new PoetryShowTopLevelCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual([...result], ['flask-thing', 'requests']); + }); + + test('PoetryVersionCommand parses the poetry version', async () => { + const command = new PoetryVersionCommand({ pythonExecutable: 'poetry', log: mockLog }); + + const result = await command.execute(); + + assert.strictEqual(result?.public, '1.8.2'); + }); +});