Skip to content

Commit eed9f46

Browse files
committed
implement runUV tests
1 parent a2b8549 commit eed9f46

File tree

5 files changed

+170
-37
lines changed

5 files changed

+170
-37
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,6 @@ envConfig.inspect
574574
- Untestable Node.js APIs → Create proxy abstraction functions (use function overloads to preserve intelligent typing while making functions mockable)
575575

576576
## 🧠 Agent Learnings
577+
578+
- Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1)
579+
- Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1)

src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as childProcessApis from '../../../common/childProcess.apis';
55
import { EventNames } from '../../../common/telemetry/constants';
66
import * as telemetrySender from '../../../common/telemetry/sender';
77
import { isUvInstalled, resetUvInstallationCache } from '../../../managers/builtin/helpers';
8+
import { createMockLogOutputChannel } from '../../mocks/helper';
89
import { MockChildProcess } from '../../mocks/mockChildProcess';
910

1011
suite('Helpers - isUvInstalled', () => {
@@ -16,24 +17,7 @@ suite('Helpers - isUvInstalled', () => {
1617
// Reset UV installation cache before each test to ensure clean state
1718
resetUvInstallationCache();
1819

19-
// Create a mock for LogOutputChannel
20-
mockLog = {
21-
info: sinon.stub(),
22-
error: sinon.stub(),
23-
warn: sinon.stub(),
24-
append: sinon.stub(),
25-
debug: sinon.stub(),
26-
trace: sinon.stub(),
27-
show: sinon.stub(),
28-
hide: sinon.stub(),
29-
dispose: sinon.stub(),
30-
clear: sinon.stub(),
31-
replace: sinon.stub(),
32-
appendLine: sinon.stub(),
33-
name: 'test-log',
34-
logLevel: 1,
35-
onDidChangeLogLevel: sinon.stub() as LogOutputChannel['onDidChangeLogLevel'],
36-
} as unknown as LogOutputChannel;
20+
mockLog = createMockLogOutputChannel();
3721

3822
// Stub childProcess.apis spawnProcess
3923
spawnStub = sinon.stub(childProcessApis, 'spawnProcess');
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { CancellationError, CancellationTokenSource, LogOutputChannel } from 'vscode';
4+
import * as childProcessApis from '../../../common/childProcess.apis';
5+
import { runUV } from '../../../managers/builtin/helpers';
6+
import { createMockLogOutputChannel } from '../../mocks/helper';
7+
import { MockChildProcess } from '../../mocks/mockChildProcess';
8+
9+
suite('Helpers - runUV', () => {
10+
let mockLog: LogOutputChannel;
11+
let spawnStub: sinon.SinonStub;
12+
13+
setup(() => {
14+
mockLog = createMockLogOutputChannel();
15+
spawnStub = sinon.stub(childProcessApis, 'spawnProcess');
16+
});
17+
18+
teardown(() => {
19+
sinon.restore();
20+
});
21+
22+
test('should resolve with stdout when command succeeds', async () => {
23+
const mockProcess = new MockChildProcess('uv', ['pip', 'list']);
24+
spawnStub.withArgs('uv', ['pip', 'list']).returns(mockProcess);
25+
26+
const resultPromise = runUV(['pip', 'list'], undefined, mockLog);
27+
28+
setTimeout(() => {
29+
mockProcess.stdout?.emit('data', Buffer.from('package1==1.0.0\n'));
30+
mockProcess.stdout?.emit('data', Buffer.from('package2==2.0.0\n'));
31+
mockProcess.emit('exit', 0, null);
32+
(mockProcess as unknown as { emit: (event: string) => void }).emit('close');
33+
}, 10);
34+
35+
const result = await resultPromise;
36+
37+
assert.strictEqual(result, 'package1==1.0.0\npackage2==2.0.0\n');
38+
});
39+
40+
test('should reject when command exits with non-zero code', async () => {
41+
const mockProcess = new MockChildProcess('uv', ['pip', 'install', 'nonexistent']);
42+
spawnStub.withArgs('uv', ['pip', 'install', 'nonexistent']).returns(mockProcess);
43+
44+
const resultPromise = runUV(['pip', 'install', 'nonexistent'], undefined, mockLog);
45+
46+
setTimeout(() => {
47+
mockProcess.stderr?.emit('data', Buffer.from('error: package not found\n'));
48+
mockProcess.emit('exit', 1, null);
49+
}, 10);
50+
51+
await assert.rejects(resultPromise, Error);
52+
});
53+
54+
test('should reject when process spawn fails', async () => {
55+
const mockProcess = new MockChildProcess('uv', ['pip', 'list']);
56+
spawnStub.withArgs('uv', ['pip', 'list']).returns(mockProcess);
57+
58+
const resultPromise = runUV(['pip', 'list'], undefined, mockLog);
59+
60+
setTimeout(() => {
61+
mockProcess.emit('error', new Error('spawn uv ENOENT'));
62+
}, 10);
63+
64+
await assert.rejects(resultPromise, Error);
65+
});
66+
67+
test('should handle cancellation token', async () => {
68+
const mockProcess = new MockChildProcess('uv', ['venv', 'create']);
69+
spawnStub.withArgs('uv', ['venv', 'create']).returns(mockProcess);
70+
71+
const tokenSource = new CancellationTokenSource();
72+
const resultPromise = runUV(['venv', 'create'], undefined, mockLog, tokenSource.token);
73+
74+
setTimeout(() => {
75+
tokenSource.cancel();
76+
}, 10);
77+
78+
await assert.rejects(resultPromise, (err: Error) => {
79+
assert.ok(err instanceof CancellationError);
80+
return true;
81+
});
82+
});
83+
84+
test('should use provided working directory', async () => {
85+
const mockProcess = new MockChildProcess('uv', ['pip', 'list']);
86+
const cwd = '/test/directory';
87+
spawnStub.withArgs('uv', ['pip', 'list'], { cwd }).returns(mockProcess);
88+
89+
const resultPromise = runUV(['pip', 'list'], cwd, mockLog);
90+
91+
setTimeout(() => {
92+
mockProcess.stdout?.emit('data', Buffer.from('output\n'));
93+
mockProcess.emit('exit', 0, null);
94+
(mockProcess as unknown as { emit: (event: string) => void }).emit('close');
95+
}, 10);
96+
97+
await resultPromise;
98+
99+
assert.ok(spawnStub.calledWith('uv', ['pip', 'list'], { cwd }));
100+
});
101+
102+
test('should work without logger', async () => {
103+
const mockProcess = new MockChildProcess('uv', ['--version']);
104+
spawnStub.withArgs('uv', ['--version']).returns(mockProcess);
105+
106+
const resultPromise = runUV(['--version']);
107+
108+
setTimeout(() => {
109+
mockProcess.stdout?.emit('data', Buffer.from('uv 0.1.0\n'));
110+
mockProcess.emit('exit', 0, null);
111+
(mockProcess as unknown as { emit: (event: string) => void }).emit('close');
112+
}, 10);
113+
114+
const result = await resultPromise;
115+
116+
assert.strictEqual(result, 'uv 0.1.0\n');
117+
});
118+
119+
test('should concatenate multiple stdout chunks correctly', async () => {
120+
const mockProcess = new MockChildProcess('uv', ['pip', 'list']);
121+
spawnStub.withArgs('uv', ['pip', 'list']).returns(mockProcess);
122+
123+
const resultPromise = runUV(['pip', 'list'], undefined, mockLog);
124+
125+
setTimeout(() => {
126+
mockProcess.stdout?.emit('data', Buffer.from('line1\n'));
127+
mockProcess.stdout?.emit('data', Buffer.from('line2\n'));
128+
mockProcess.stdout?.emit('data', Buffer.from('line3\n'));
129+
mockProcess.emit('exit', 0, null);
130+
(mockProcess as unknown as { emit: (event: string) => void }).emit('close');
131+
}, 10);
132+
133+
const result = await resultPromise;
134+
135+
assert.strictEqual(result, 'line1\nline2\nline3\n');
136+
});
137+
});

src/test/managers/builtin/helpers.shouldUseUv.unit.test.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as persistentState from '../../../common/persistentState';
66
import * as workspaceApis from '../../../common/workspace.apis';
77
import { resetUvInstallationCache, shouldUseUv } from '../../../managers/builtin/helpers';
88
import * as uvEnvironments from '../../../managers/builtin/uvEnvironments';
9+
import { createMockLogOutputChannel } from '../../mocks/helper';
910
import { MockChildProcess } from '../../mocks/mockChildProcess';
1011

1112
interface MockWorkspaceConfig {
@@ -47,25 +48,7 @@ suite('Helpers - shouldUseUv', () => {
4748
// Mock UV-related functions
4849
getUvEnvironmentsStub = sinon.stub(uvEnvironments, 'getUvEnvironments');
4950

50-
// No default behaviors set - each test configures what it needs
51-
// Create a more complete mock for LogOutputChannel
52-
mockLog = {
53-
info: sinon.stub(),
54-
error: sinon.stub(),
55-
warn: sinon.stub(),
56-
append: sinon.stub(),
57-
debug: sinon.stub(),
58-
trace: sinon.stub(),
59-
show: sinon.stub(),
60-
hide: sinon.stub(),
61-
dispose: sinon.stub(),
62-
clear: sinon.stub(),
63-
replace: sinon.stub(),
64-
appendLine: sinon.stub(),
65-
name: 'test-log',
66-
logLevel: 1,
67-
onDidChangeLogLevel: sinon.stub() as LogOutputChannel['onDidChangeLogLevel'],
68-
} as unknown as LogOutputChannel;
51+
mockLog = createMockLogOutputChannel();
6952

7053
// Stub childProcess.apis spawnProcess
7154
spawnStub = sinon.stub(childProcessApis, 'spawnProcess');

src/test/mocks/helper.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
// Copyright (c) Microsoft Corporation. All rights reserved.
33
// Licensed under the MIT License.
4+
import * as sinon from 'sinon';
45
import { Readable } from 'stream';
56
import * as TypeMoq from 'typemoq';
67
import * as common from 'typemoq/Common/_all';
8+
import { LogOutputChannel } from 'vscode';
79

810
export class FakeReadableStream extends Readable {
911
_read(_size: unknown): void | null {
@@ -12,6 +14,30 @@ export class FakeReadableStream extends Readable {
1214
}
1315
}
1416

17+
/**
18+
* Creates a mock LogOutputChannel for testing.
19+
* @returns A mock LogOutputChannel with stubbed methods
20+
*/
21+
export function createMockLogOutputChannel(): LogOutputChannel {
22+
return {
23+
info: sinon.stub(),
24+
error: sinon.stub(),
25+
warn: sinon.stub(),
26+
append: sinon.stub(),
27+
debug: sinon.stub(),
28+
trace: sinon.stub(),
29+
show: sinon.stub(),
30+
hide: sinon.stub(),
31+
dispose: sinon.stub(),
32+
clear: sinon.stub(),
33+
replace: sinon.stub(),
34+
appendLine: sinon.stub(),
35+
name: 'test-log',
36+
logLevel: 1,
37+
onDidChangeLogLevel: sinon.stub() as LogOutputChannel['onDidChangeLogLevel'],
38+
} as unknown as LogOutputChannel;
39+
}
40+
1541
/**
1642
* Type helper for accessing the `.then` property on mocks.
1743
* Used to prevent TypeMoq from treating mocks as thenables (Promise-like objects).

0 commit comments

Comments
 (0)