Skip to content

Commit 76aca52

Browse files
committed
Add unit tests for getUvPythonPath and getAvailablePythonVersions
Address PR review comment: cover JSON parsing, version prefix matching, empty output, chunked stdout, process errors, and non-zero exit code scenarios.
1 parent 083a1da commit 76aca52

1 file changed

Lines changed: 343 additions & 0 deletions

File tree

src/test/managers/builtin/uvPythonInstaller.unit.test.ts

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from 'assert';
22
import * as sinon from 'sinon';
33
import { LogOutputChannel } from 'vscode';
4+
import * as childProcessApis from '../../../common/childProcess.apis';
45
import { UvInstallStrings } from '../../../common/localize';
56
import * as persistentState from '../../../common/persistentState';
67
import { EventNames } from '../../../common/telemetry/constants';
@@ -9,11 +10,15 @@ import * as windowApis from '../../../common/window.apis';
910
import * as helpers from '../../../managers/builtin/helpers';
1011
import {
1112
clearDontAskAgain,
13+
getAvailablePythonVersions,
14+
getUvPythonPath,
1215
isDontAskAgainSet,
1316
promptInstallPythonViaUv,
1417
UV_INSTALL_PYTHON_DONT_ASK_KEY,
18+
UvPythonVersion,
1519
} from '../../../managers/builtin/uvPythonInstaller';
1620
import { createMockLogOutputChannel } from '../../mocks/helper';
21+
import { MockChildProcess } from '../../mocks/mockChildProcess';
1722

1823
suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
1924
let mockLog: LogOutputChannel;
@@ -173,3 +178,341 @@ suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => {
173178
// NOTE: Installation functions (installUv, installPythonViaUv, installPythonWithUv) require
174179
// VS Code's Task API which cannot be fully mocked in unit tests.
175180
// These should be tested via integration tests in a real VS Code environment.
181+
182+
/**
183+
* Helper to build a UvPythonVersion object for testing.
184+
*/
185+
function makeUvPythonVersion(overrides: Partial<UvPythonVersion> & { version: string }): UvPythonVersion {
186+
const parts = overrides.version.split('.').map(Number);
187+
return {
188+
key: overrides.key ?? `cpython-${overrides.version}`,
189+
version: overrides.version,
190+
version_parts: overrides.version_parts ?? { major: parts[0], minor: parts[1], patch: parts[2] ?? 0 },
191+
path: overrides.path ?? null,
192+
url: overrides.url ?? null,
193+
os: overrides.os ?? 'linux',
194+
variant: overrides.variant ?? 'default',
195+
implementation: overrides.implementation ?? 'cpython',
196+
arch: overrides.arch ?? 'x86_64',
197+
};
198+
}
199+
200+
suite('uvPythonInstaller - getUvPythonPath', () => {
201+
let spawnStub: sinon.SinonStub;
202+
203+
setup(() => {
204+
spawnStub = sinon.stub(childProcessApis, 'spawnProcess');
205+
});
206+
207+
teardown(() => {
208+
sinon.restore();
209+
});
210+
211+
test('should return the latest installed Python path when no version specified', async () => {
212+
const versions: UvPythonVersion[] = [
213+
makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }),
214+
makeUvPythonVersion({ version: '3.12.8', path: '/usr/bin/python3.12' }),
215+
];
216+
217+
const mockProcess = new MockChildProcess('uv', [
218+
'python',
219+
'list',
220+
'--only-installed',
221+
'--managed-python',
222+
'--output-format',
223+
'json',
224+
]);
225+
spawnStub.returns(mockProcess);
226+
227+
const resultPromise = getUvPythonPath();
228+
229+
setTimeout(() => {
230+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
231+
mockProcess.emit('exit', 0, null);
232+
}, 10);
233+
234+
const result = await resultPromise;
235+
236+
assert.strictEqual(result, '/usr/bin/python3.13', 'Should return the first (latest) installed Python');
237+
});
238+
239+
test('should return matching Python path when version is specified', async () => {
240+
const versions: UvPythonVersion[] = [
241+
makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }),
242+
makeUvPythonVersion({ version: '3.12.8', path: '/usr/bin/python3.12' }),
243+
];
244+
245+
const mockProcess = new MockChildProcess('uv', [
246+
'python',
247+
'list',
248+
'--only-installed',
249+
'--managed-python',
250+
'--output-format',
251+
'json',
252+
]);
253+
spawnStub.returns(mockProcess);
254+
255+
const resultPromise = getUvPythonPath('3.12');
256+
257+
setTimeout(() => {
258+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
259+
mockProcess.emit('exit', 0, null);
260+
}, 10);
261+
262+
const result = await resultPromise;
263+
264+
assert.strictEqual(result, '/usr/bin/python3.12', 'Should return the matching version');
265+
});
266+
267+
test('should return undefined when specified version is not found', async () => {
268+
const versions: UvPythonVersion[] = [makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' })];
269+
270+
const mockProcess = new MockChildProcess('uv', [
271+
'python',
272+
'list',
273+
'--only-installed',
274+
'--managed-python',
275+
'--output-format',
276+
'json',
277+
]);
278+
spawnStub.returns(mockProcess);
279+
280+
const resultPromise = getUvPythonPath('3.11');
281+
282+
setTimeout(() => {
283+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
284+
mockProcess.emit('exit', 0, null);
285+
}, 10);
286+
287+
const result = await resultPromise;
288+
289+
assert.strictEqual(result, undefined, 'Should return undefined when version not found');
290+
});
291+
292+
test('should return undefined when no Pythons are installed', async () => {
293+
const mockProcess = new MockChildProcess('uv', [
294+
'python',
295+
'list',
296+
'--only-installed',
297+
'--managed-python',
298+
'--output-format',
299+
'json',
300+
]);
301+
spawnStub.returns(mockProcess);
302+
303+
const resultPromise = getUvPythonPath();
304+
305+
setTimeout(() => {
306+
mockProcess.stdout?.emit('data', JSON.stringify([]));
307+
mockProcess.emit('exit', 0, null);
308+
}, 10);
309+
310+
const result = await resultPromise;
311+
312+
assert.strictEqual(result, undefined, 'Should return undefined for empty versions list');
313+
});
314+
315+
test('should return undefined when process exits with non-zero code', async () => {
316+
const mockProcess = new MockChildProcess('uv', [
317+
'python',
318+
'list',
319+
'--only-installed',
320+
'--managed-python',
321+
'--output-format',
322+
'json',
323+
]);
324+
spawnStub.returns(mockProcess);
325+
326+
const resultPromise = getUvPythonPath();
327+
328+
setTimeout(() => {
329+
mockProcess.emit('exit', 1, null);
330+
}, 10);
331+
332+
const result = await resultPromise;
333+
334+
assert.strictEqual(result, undefined, 'Should return undefined on non-zero exit');
335+
});
336+
337+
test('should return undefined when process emits error', async () => {
338+
const mockProcess = new MockChildProcess('uv', [
339+
'python',
340+
'list',
341+
'--only-installed',
342+
'--managed-python',
343+
'--output-format',
344+
'json',
345+
]);
346+
spawnStub.returns(mockProcess);
347+
348+
const resultPromise = getUvPythonPath();
349+
350+
setTimeout(() => {
351+
mockProcess.emit('error', new Error('spawn uv ENOENT'));
352+
}, 10);
353+
354+
const result = await resultPromise;
355+
356+
assert.strictEqual(result, undefined, 'Should return undefined on process error');
357+
});
358+
359+
test('should return undefined when output is invalid JSON', async () => {
360+
const mockProcess = new MockChildProcess('uv', [
361+
'python',
362+
'list',
363+
'--only-installed',
364+
'--managed-python',
365+
'--output-format',
366+
'json',
367+
]);
368+
spawnStub.returns(mockProcess);
369+
370+
const resultPromise = getUvPythonPath();
371+
372+
setTimeout(() => {
373+
mockProcess.stdout?.emit('data', 'not valid json{{{');
374+
mockProcess.emit('exit', 0, null);
375+
}, 10);
376+
377+
const result = await resultPromise;
378+
379+
assert.strictEqual(result, undefined, 'Should return undefined on JSON parse failure');
380+
});
381+
382+
test('should skip versions without a path', async () => {
383+
const versions: UvPythonVersion[] = [
384+
makeUvPythonVersion({ version: '3.13.1', path: null }),
385+
makeUvPythonVersion({ version: '3.12.8', path: '/usr/bin/python3.12' }),
386+
];
387+
388+
const mockProcess = new MockChildProcess('uv', [
389+
'python',
390+
'list',
391+
'--only-installed',
392+
'--managed-python',
393+
'--output-format',
394+
'json',
395+
]);
396+
spawnStub.returns(mockProcess);
397+
398+
const resultPromise = getUvPythonPath();
399+
400+
setTimeout(() => {
401+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
402+
mockProcess.emit('exit', 0, null);
403+
}, 10);
404+
405+
const result = await resultPromise;
406+
407+
assert.strictEqual(result, '/usr/bin/python3.12', 'Should skip entries with null path');
408+
});
409+
410+
test('should handle chunked stdout data', async () => {
411+
const versions: UvPythonVersion[] = [makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' })];
412+
const fullJson = JSON.stringify(versions);
413+
const mid = Math.floor(fullJson.length / 2);
414+
415+
const mockProcess = new MockChildProcess('uv', [
416+
'python',
417+
'list',
418+
'--only-installed',
419+
'--managed-python',
420+
'--output-format',
421+
'json',
422+
]);
423+
spawnStub.returns(mockProcess);
424+
425+
const resultPromise = getUvPythonPath();
426+
427+
setTimeout(() => {
428+
mockProcess.stdout?.emit('data', fullJson.slice(0, mid));
429+
mockProcess.stdout?.emit('data', fullJson.slice(mid));
430+
mockProcess.emit('exit', 0, null);
431+
}, 10);
432+
433+
const result = await resultPromise;
434+
435+
assert.strictEqual(result, '/usr/bin/python3.13', 'Should correctly reassemble chunked data');
436+
});
437+
});
438+
439+
suite('uvPythonInstaller - getAvailablePythonVersions', () => {
440+
let spawnStub: sinon.SinonStub;
441+
442+
setup(() => {
443+
spawnStub = sinon.stub(childProcessApis, 'spawnProcess');
444+
});
445+
446+
teardown(() => {
447+
sinon.restore();
448+
});
449+
450+
test('should return all versions from uv python list', async () => {
451+
const versions: UvPythonVersion[] = [
452+
makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }),
453+
makeUvPythonVersion({ version: '3.12.8', path: null }),
454+
];
455+
456+
const mockProcess = new MockChildProcess('uv', ['python', 'list', '--output-format', 'json']);
457+
spawnStub.returns(mockProcess);
458+
459+
const resultPromise = getAvailablePythonVersions();
460+
461+
setTimeout(() => {
462+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
463+
mockProcess.emit('exit', 0, null);
464+
}, 10);
465+
466+
const result = await resultPromise;
467+
468+
assert.strictEqual(result.length, 2, 'Should return all versions');
469+
assert.strictEqual(result[0].version, '3.13.1');
470+
assert.strictEqual(result[1].version, '3.12.8');
471+
});
472+
473+
test('should return empty array on process error', async () => {
474+
const mockProcess = new MockChildProcess('uv', ['python', 'list', '--output-format', 'json']);
475+
spawnStub.returns(mockProcess);
476+
477+
const resultPromise = getAvailablePythonVersions();
478+
479+
setTimeout(() => {
480+
mockProcess.emit('error', new Error('spawn uv ENOENT'));
481+
}, 10);
482+
483+
const result = await resultPromise;
484+
485+
assert.deepStrictEqual(result, [], 'Should return empty array on error');
486+
});
487+
488+
test('should return empty array on non-zero exit code', async () => {
489+
const mockProcess = new MockChildProcess('uv', ['python', 'list', '--output-format', 'json']);
490+
spawnStub.returns(mockProcess);
491+
492+
const resultPromise = getAvailablePythonVersions();
493+
494+
setTimeout(() => {
495+
mockProcess.emit('exit', 1, null);
496+
}, 10);
497+
498+
const result = await resultPromise;
499+
500+
assert.deepStrictEqual(result, [], 'Should return empty array on non-zero exit');
501+
});
502+
503+
test('should return empty array on invalid JSON output', async () => {
504+
const mockProcess = new MockChildProcess('uv', ['python', 'list', '--output-format', 'json']);
505+
spawnStub.returns(mockProcess);
506+
507+
const resultPromise = getAvailablePythonVersions();
508+
509+
setTimeout(() => {
510+
mockProcess.stdout?.emit('data', '{{invalid json');
511+
mockProcess.emit('exit', 0, null);
512+
}, 10);
513+
514+
const result = await resultPromise;
515+
516+
assert.deepStrictEqual(result, [], 'Should return empty array on JSON parse failure');
517+
});
518+
});

0 commit comments

Comments
 (0)