11import assert from 'assert' ;
22import * as sinon from 'sinon' ;
33import { LogOutputChannel } from 'vscode' ;
4+ import * as childProcessApis from '../../../common/childProcess.apis' ;
45import { UvInstallStrings } from '../../../common/localize' ;
56import * as persistentState from '../../../common/persistentState' ;
67import { EventNames } from '../../../common/telemetry/constants' ;
@@ -9,11 +10,15 @@ import * as windowApis from '../../../common/window.apis';
910import * as helpers from '../../../managers/builtin/helpers' ;
1011import {
1112 clearDontAskAgain ,
13+ getAvailablePythonVersions ,
14+ getUvPythonPath ,
1215 isDontAskAgainSet ,
1316 promptInstallPythonViaUv ,
1417 UV_INSTALL_PYTHON_DONT_ASK_KEY ,
18+ UvPythonVersion ,
1519} from '../../../managers/builtin/uvPythonInstaller' ;
1620import { createMockLogOutputChannel } from '../../mocks/helper' ;
21+ import { MockChildProcess } from '../../mocks/mockChildProcess' ;
1722
1823suite ( '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