Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

67 changes: 67 additions & 0 deletions docs/condaUtils-test-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Test Plan: src/managers/conda/condaUtils.ts

Below are focused, high-value test bullets. Trivial items (constants, simple getters/setters, thin wrappers) are intentionally omitted.

## Conda Resolution & Execution
- getCondaExecutable(): resolves from cache → persistent state → PATH → native managers; caches successful path; throws when none found.
- getConda(): respects `python.condaPath` setting (untildify), otherwise delegates to `getCondaExecutable()`.
- _runConda(): spawns process, logs stdout/stderr, returns stdout on success; handles CancellationToken (kills process, rejects with cancellation); propagates non-zero exit as error.
- runCondaExecutable(): uses `getCondaExecutable()` regardless of setting; verify differing behavior from `runConda()` when `python.condaPath` is set to invalid path.

## Prefix Discovery & Defaults
- getCondaInfo(): runs `conda info --envs --json` and parses output; handles malformed JSON.
- getPrefixes(): uses cache when present; otherwise loads from `getCondaInfo()`; persists to state.
- getDefaultCondaPrefix(): returns first prefix if available; otherwise falls back to `~/.conda/envs`.

## Version Metadata
- getVersion(root): parses `conda-meta/python-3*.json` to extract Python version; throws when not found; handles multiple python files consistently.

## Shell Activation Maps
- buildShellActivationMapForConda():
- No sourcing info → default `conda activate/deactivate` for all shells.
- Local and global sourcing scripts present → prefers local; uses `source <path> <envIdentifier>` on Unix shells.
- Windows path: uses `conda-hook.ps1` when available; cmd and bash paths normalized; falls back on errors.
- generateShellActivationMapFromConfig(): produces consistent maps for `GITBASH`, `CMD`, `BASH`, `SH`, `ZSH`, `PWSH` with provided templates.
- windowsExceptionGenerateConfig(): builds per-shell activation with ps1 hook; verifies logging and path normalization.

## Environment Info Builders & Mapping
- getNamedCondaPythonInfo(): builds `PythonEnvironmentInfo` for named envs; activation `conda activate <name>`; includes shell maps.
- getPrefixesCondaPythonInfo(): builds info for prefix envs; activation uses provided `conda` path with prefix; includes shell maps.
- nativeToPythonEnv():
- Base env → named builder with `base`.
- Prefix env outside known prefixes → prefix builder.
- Named env within prefixes → named builder with `name` or basename.
- No-python (missing executable/version) → `getCondaWithoutPython()` path.

## Path Resolution & Refresh
- resolveCondaPath(): returns mapped `PythonEnvironment` for conda-native; returns undefined for non-conda; handles resolver errors gracefully.
- refreshCondaEnvs():
- Uses `getConda()`; if unavailable, discovers `conda` from native managers and calls `setConda()`.
- Converts native conda envs to `PythonEnvironment[]` and sorts; logs error and returns [] when no `conda` found.

## Project Helpers & UX
- pickPythonVersion(): aggregates global env versions, trims via `trimVersionToMajorMinor`, sorts by major/minor; quick-pick selection yields version; handles no versions.
- getLocation(): returns fsPath for single URI; with none or multiple URIs shows picker and returns chosen path; handles cancel.

## Environment Creation & Deletion
- createNamedCondaEnvironment(): creates env with `conda create --name <name> [python=...]`; shows progress; environment appears via native refresh.
- createPrefixCondaEnvironment(): creates env at prefix path; optional python version; returns environment on success.
- generateName(fsPath): returns unique name within 5 attempts; respects existing folder check.
- quickCreateConda(): creates prefix env and installs additional packages when provided; progress + final environment verified.
- deleteCondaEnvironment(): removes env via `conda env remove --prefix`; native refresh no longer lists it.

## Package Management
- refreshPackages(): parses `conda list -p <prefix>` into `Package[]`; ignores commented lines; handles malformed lines gracefully.
- managePackages(): performs uninstall then install; calls `refreshPackages()`; handles empty sets; ensures idempotency.
- getCommonPackages(): loads common installables from file; handles missing file or invalid JSON.
- selectCommonPackagesOrSkip(): builds quick-pick items from common vs installed; supports multi-select and optional Skip; returns install/uninstall sets or undefined on cancel.
- getCommonCondaPackagesToInstall(): integrates common and installed; returns user-chosen sets.

## No-Python Handling
- installPython(): installs Python into “no-python” env via `conda install --prefix <sysPrefix> python`; refreshes native finder; returns updated environment.
- checkForNoPythonCondaEnvironment(): detects `version === 'no-python'`; offers path to `installPython()`; returns updated environment or original when already has Python.

## Cross-Cutting Behaviors
- Logging: ensure `traceInfo`/`traceVerbose` are invoked at key decision points (resolution, activation map generation, refresh).
- Cancellation: verify cancellation paths in `_runConda()` don’t leak processes and propagate `CancellationError`.
- Error Handling: confirm thrown errors include actionable messages and do not duplicate notifications.
144 changes: 144 additions & 0 deletions docs/condaUtils-testing-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# condaUtils Testing Implementation Summary

## Overview
This document summarizes the implementation of unit tests for `src/managers/conda/condaUtils.ts` based on the test plan in `docs/condaUtils-test-plan.md`.

## Tests Implemented (17 tests)

### 1. Configuration and Settings (4 tests)
- ✅ `getCondaPathSetting()`: Tests configuration reading with tilde expansion
- Returns conda path from python.condaPath setting
- Untildifies paths with tilde
- Returns undefined when not set
- Handles non-string values

### 2. Version Utilities (6 tests)
- ✅ `trimVersionToMajorMinor()`: Tests version string parsing
- Trims to major.minor.patch format
- Handles extra segments
- Handles two-part versions
- Returns original for single-part versions
- Returns original for non-standard versions
- Handles version with leading 'v'

### 3. Name and Path Utilities (7 tests)
- ✅ `getName()`: Tests URI to project name conversion
- Returns undefined when no URIs provided
- Returns undefined for empty array
- Returns undefined for multiple URIs
- Returns project name for single URI
- Returns project name for single-element array
- Returns undefined when project not found

- ✅ `generateName()`: Tests unique name generation
- Generates unique name with env_ prefix

## Testing Challenges

### Node.js Module Stubbing
Many functions in `condaUtils.ts` directly use Node.js built-in modules:
- `fs-extra` (pathExists, readdir, readJsonSync, etc.)
- `child_process` (spawn)
- `which` module

**Issue**: Modern versions of these modules cannot be stubbed directly with Sinon due to non-configurable property descriptors.

**Current Solutions**:
1. Focus on pure functions that don't require module mocking
2. Use integration-style tests where feasible
3. Test functions that only depend on wrapper APIs (workspace.apis)

### Functions Requiring Wrapper Abstractions

The following functions from the test plan require wrapper functions to be testable:

#### Conda Resolution & Execution
- `getCondaExecutable()` - uses `fse.pathExists`, `which`, `spawn`
- `getConda()` - delegates to `getCondaExecutable()`
- `_runConda()` - uses `ch.spawn`
- `runCondaExecutable()` - uses `getCondaExecutable()` and `spawn`

#### Prefix Discovery & Defaults
- `getCondaInfo()` - uses `runConda()`
- `getPrefixes()` - uses `getCondaInfo()`
- `getDefaultCondaPrefix()` - uses `getPrefixes()`

#### Version Metadata
- `getVersion()` - uses `fse.readdir`, `fse.readJsonSync`

#### Shell Activation Maps
- `buildShellActivationMapForConda()` - complex logic with file operations
- `generateShellActivationMapFromConfig()` - testable (pure function)
- `windowsExceptionGenerateConfig()` - uses `getCondaHookPs1Path()`

#### Environment Info Builders
- `getNamedCondaPythonInfo()` - uses `buildShellActivationMapForConda()`
- `getPrefixesCondaPythonInfo()` - uses `buildShellActivationMapForConda()`
- `nativeToPythonEnv()` - uses info builders

#### Path Resolution & Refresh
- `resolveCondaPath()` - uses native finder
- `refreshCondaEnvs()` - uses `getConda()`, native finder

#### Environment Creation & Deletion
- `createNamedCondaEnvironment()` - uses `runCondaExecutable()`, file operations
- `createPrefixCondaEnvironment()` - uses `runCondaExecutable()`, file operations
- `quickCreateConda()` - uses `runCondaExecutable()`
- `deleteCondaEnvironment()` - uses `runCondaExecutable()`

#### Package Management
- `refreshPackages()` - uses `runCondaExecutable()`
- `managePackages()` - uses `runCondaExecutable()`
- `getCommonPackages()` - uses `fse.readFile`
- `selectCommonPackagesOrSkip()` - testable (UI logic)
- `getCommonCondaPackagesToInstall()` - uses `getCommonPackages()`

#### No-Python Handling
- `installPython()` - uses `runCondaExecutable()`
- `checkForNoPythonCondaEnvironment()` - uses `installPython()`

## Recommendations for Future Testing

### Option 1: Create Wrapper Abstractions
Create abstraction layer for file system and process operations:
```typescript
// src/managers/conda/condaWrappers.ts
export interface FileSystemOperations {
pathExists(path: string): Promise<boolean>;
readdir(path: string): Promise<string[]>;
readJsonSync(path: string): any;
}

export interface ProcessOperations {
spawn(command: string, args: string[], options: any): ChildProcess;
}
```

This would allow:
- Easy mocking in unit tests
- Better separation of concerns
- Improved testability

### Option 2: Integration Tests
Use VS Code's extension test framework for integration tests that:
- Run actual conda commands (when conda is available)
- Test full workflows
- Validate end-to-end behavior

### Option 3: Hybrid Approach
- Unit tests for pure functions (current implementation)
- Wrapper abstractions for I/O operations
- Integration tests for critical paths

## Test Results

All 257 tests passing (17 new condaUtils tests added):
```
condaUtils - Configuration and Settings (4 tests)
condaUtils - Version Utilities (6 tests)
condaUtils - Name and Path Utilities (7 tests)
```

## Conclusion

This initial implementation provides test coverage for the pure, easily testable functions in condaUtils. To achieve comprehensive coverage as outlined in the test plan, the codebase would benefit from wrapper abstractions around Node.js modules or a shift toward integration testing for I/O-heavy functions.
179 changes: 179 additions & 0 deletions src/test/managers/conda/condaUtils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import assert from 'assert';
import * as os from 'os';
import * as sinon from 'sinon';
import {
getCondaPathSetting,
trimVersionToMajorMinor,
generateName,
getName,
} from '../../../managers/conda/condaUtils';
import * as workspaceApis from '../../../common/workspace.apis';
import { Uri } from 'vscode';
import { PythonEnvironmentApi } from '../../../api';

/**
* Tests for condaUtils.ts
*
* Note: This test suite focuses on pure functions that don't require mocking Node.js modules.
* Functions that directly interact with file system (fse) or child processes (ch.spawn)
* are challenging to test in unit tests without wrapper abstractions.
*
* Based on the test plan in docs/condaUtils-test-plan.md, we're testing:
* - getCondaPathSetting: Configuration reading with tilde expansion
* - trimVersionToMajorMinor: Version string parsing
* - generateName: Unique name generation logic
* - getName: URI to name conversion
*/

suite('condaUtils - Configuration and Settings', () => {
let getConfigurationStub: sinon.SinonStub;

setup(() => {
getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration');
});

teardown(() => {
sinon.restore();
});

suite('getCondaPathSetting', () => {
test('should return conda path from python.condaPath setting', () => {
const mockConfig = { get: sinon.stub().withArgs('condaPath').returns('/custom/conda') };
getConfigurationStub.withArgs('python').returns(mockConfig);

const result = getCondaPathSetting();

assert.strictEqual(result, '/custom/conda');
});

test('should untildify conda path with tilde', () => {
const mockConfig = { get: sinon.stub().withArgs('condaPath').returns('~/miniconda3/bin/conda') };
getConfigurationStub.withArgs('python').returns(mockConfig);

const result = getCondaPathSetting();

assert.ok(result);
assert.ok(!result.includes('~'), 'Should expand tilde');
assert.ok(result.includes(os.homedir()), 'Should include home directory');
});

test('should return undefined when condaPath not set', () => {
const mockConfig = { get: sinon.stub().withArgs('condaPath').returns(undefined) };
getConfigurationStub.withArgs('python').returns(mockConfig);

const result = getCondaPathSetting();

assert.strictEqual(result, undefined);
});

test('should return value as-is when non-string value provided', () => {
const mockConfig = { get: sinon.stub().withArgs('condaPath').returns(123) };
getConfigurationStub.withArgs('python').returns(mockConfig);

const result = getCondaPathSetting();

assert.strictEqual(result, 123);
});
});
});

suite('condaUtils - Version Utilities', () => {
suite('trimVersionToMajorMinor', () => {
test('should trim version to major.minor.patch', () => {
const result = trimVersionToMajorMinor('3.11.5');
assert.strictEqual(result, '3.11.5');
});

test('should trim version with extra segments', () => {
const result = trimVersionToMajorMinor('3.11.5.post1+abc123');
assert.strictEqual(result, '3.11.5');
});

test('should handle two-part versions', () => {
const result = trimVersionToMajorMinor('3.11');
assert.strictEqual(result, '3.11');
});

test('should return original for single-part versions', () => {
const result = trimVersionToMajorMinor('3');
assert.strictEqual(result, '3');
});

test('should return original for non-standard versions', () => {
const result = trimVersionToMajorMinor('latest');
assert.strictEqual(result, 'latest');
});

test('should handle version with leading v', () => {
const result = trimVersionToMajorMinor('v3.11.5');
assert.strictEqual(result, 'v3.11.5');
});
});
});

suite('condaUtils - Name and Path Utilities', () => {
suite('getName', () => {
let mockApi: { getPythonProject: sinon.SinonStub };

setup(() => {
mockApi = {
getPythonProject: sinon.stub(),
};
});

test('should return undefined when no URIs provided', () => {
const result = getName(mockApi as unknown as PythonEnvironmentApi, undefined);
assert.strictEqual(result, undefined);
});

test('should return undefined when empty array provided', () => {
const result = getName(mockApi as unknown as PythonEnvironmentApi, []);
assert.strictEqual(result, undefined);
});

test('should return undefined when multiple URIs provided', () => {
const uris = [Uri.file('/path1'), Uri.file('/path2')];
const result = getName(mockApi as unknown as PythonEnvironmentApi, uris);
assert.strictEqual(result, undefined);
});

test('should return project name for single URI', () => {
const uri = Uri.file('/workspace/project');
mockApi.getPythonProject.withArgs(uri).returns({ name: 'my-project', uri });

const result = getName(mockApi as unknown as PythonEnvironmentApi, uri);

assert.strictEqual(result, 'my-project');
});

test('should return project name for single-element array', () => {
const uri = Uri.file('/workspace/project');
mockApi.getPythonProject.withArgs(uri).returns({ name: 'my-project', uri });

const result = getName(mockApi as unknown as PythonEnvironmentApi, [uri]);

assert.strictEqual(result, 'my-project');
});

test('should return undefined when project not found', () => {
const uri = Uri.file('/workspace/project');
mockApi.getPythonProject.withArgs(uri).returns(undefined);

const result = getName(mockApi as unknown as PythonEnvironmentApi, uri);

assert.strictEqual(result, undefined);
});
});

suite('generateName', () => {
test('should generate unique name with env_ prefix', async () => {
// This is an integration-style test since we can't easily mock fs.exists
// We just verify the format and that it returns a string
const result = await generateName('/some/path');

assert.ok(result, 'Should return a name');
assert.ok(result!.startsWith('env_'), 'Should start with env_ prefix');
assert.ok(result!.length > 4, 'Should have random suffix');
});
});
});
Loading