1313 * ROUNDTRIP (per manager, all via the public API):
1414 * 1. list packages -> getPackages (baseline; test package absent)
1515 * 2. install test package -> managePackages({ install })
16- * 3. list again -> getPackages ( test package now present)
17- * 4. list direct packages -> getPackages filtered by !isTransitive
16+ * 3. refresh + list -> refreshPackages (enriched, test package now present)
17+ * 4. direct vs transitive -> installed package is direct; its deps are transitive
1818 * 4b. available versions -> getPackageAvailableVersions (when supported)
1919 * 5. uninstall test package -> managePackages({ uninstall })
2020 * 6. list again -> getPackages (test package absent again)
2121 *
2222 * NOTES:
23+ * - The test package (flask) is chosen because it is available from every common
24+ * manager's default sources (PyPI for pip/uv/poetry/pipenv, and conda's default
25+ * `main` channel for conda) and pulls in real transitive dependencies (jinja2,
26+ * werkzeug, click, ...). That lets the roundtrip exercise a genuine install on
27+ * every manager AND verify the direct-vs-transitive classification.
2328 * - "Available versions" is exercised via getPackageAvailableVersions when the
2429 * running PythonEnvironmentApi surfaces it. That getter was added to the public
2530 * API; the call is capability-guarded so this test compiles and runs regardless
2631 * of whether the active API build includes it, and gracefully skips the check
2732 * for managers that resolve to `undefined` (no version listing support).
28- * - The install step is best-effort: a manager-agnostic test cannot guarantee the
29- * test package is installable from every manager's configured sources (e.g. conda
30- * channels may not carry a PyPI-only package, and some managers surface install
31- * failures as notifications rather than throwing). When the package does not appear
32- * after install, that manager is skipped rather than failed; managers that can
33- * install it still run the full lifecycle with assertions.
34- * - Direct (non-transitive) packages are derived from Package.isTransitive,
35- * which IS part of the public API.
36- * - The test package (cowsay) is small and dependency-free so a successful
37- * install shows up as a direct package with no transitive fan-out.
33+ * - Transitive classification comes from Package.isTransitive, which is populated by
34+ * the enriched refreshPackages result (getPackages({skipCache}) re-lists WITHOUT
35+ * enrichment). Managers that implement direct-name detection (pip/uv/poetry) mark
36+ * dependencies isTransitive=true; managers that don't (conda) leave it undefined,
37+ * so the transitive-dependency assertion is conditional on the manager classifying it.
3838 */
3939
4040import * as assert from 'assert' ;
@@ -44,8 +44,13 @@ import { normalizePackageName } from '../../managers/builtin/utils';
4444import { ENVS_EXTENSION_ID } from '../constants' ;
4545import { waitForCondition } from '../testUtils' ;
4646
47- /** Small, dependency-free package used for the install/uninstall roundtrip. */
48- const TEST_PACKAGE = 'cowsay' ;
47+ /**
48+ * Package used for the install/uninstall roundtrip. flask is available from every
49+ * common manager's default source (PyPI and conda's default `main` channel) and pulls
50+ * in transitive dependencies, so the roundtrip can verify both installation and the
51+ * direct-vs-transitive classification on every manager.
52+ */
53+ const TEST_PACKAGE = 'flask' ;
4954
5055/** True when a package with the given (normalized) name is in the list. */
5156function hasPackage ( packages : Package [ ] | undefined , name : string ) : boolean {
@@ -66,16 +71,14 @@ type AvailableVersionsCapable = {
6671} ;
6772
6873/**
69- * Runs the full lifecycle roundtrip for a single environment/manager.
70- * Returns `true` when the full lifecycle was exercised (install succeeded and all
71- * assertions ran), or `false` when the manager could not install the test package
72- * from its configured sources and the roundtrip was skipped.
74+ * Runs the full lifecycle roundtrip for a single environment/manager. Throws (via
75+ * assertion) on any failure; returns normally when the full lifecycle succeeded.
7376 */
74- async function runRoundtrip ( api : PythonEnvironmentApi , env : PythonEnvironment , managerId : string ) : Promise < boolean > {
77+ async function runRoundtrip ( api : PythonEnvironmentApi , env : PythonEnvironment , managerId : string ) : Promise < void > {
7578 const baseline = await api . getPackages ( env , { skipCache : true } ) ;
7679 if ( baseline === undefined ) {
7780 // No usable package manager for this environment.
78- return false ;
81+ return ;
7982 }
8083
8184 // Avoid clobbering a pre-existing install of the test package.
@@ -86,34 +89,38 @@ async function runRoundtrip(api: PythonEnvironmentApi, env: PythonEnvironment, m
8689
8790 let installed = false ;
8891 try {
89- // 2. Install (best-effort). Some managers cannot install the test package from
90- // their configured sources, and some surface that failure as a notification
91- // rather than throwing, so success is verified by the next list rather than assumed.
92+ // 2. Install.
9293 await api . managePackages ( env , { install : [ TEST_PACKAGE ] } ) ;
93-
94- // 3. List again -> present?
95- await api . refreshPackages ( env ) ;
96- const afterInstall = await api . getPackages ( env , { skipCache : true } ) ;
97-
98- // If the package did not appear, this manager cannot install it from its
99- // configured sources (e.g. conda channels lacking a PyPI-only package). Treat
100- // that as a graceful skip rather than a hard failure so the test stays
101- // manager-agnostic; managers that can install it still assert the full lifecycle.
102- if ( ! hasPackage ( afterInstall , TEST_PACKAGE ) ) {
103- console . log (
104- `[${ managerId } ] ${ TEST_PACKAGE } could not be installed from this manager's configured sources; skipping roundtrip` ,
105- ) ;
106- return false ;
107- }
10894 installed = true ;
10995
110- // 4. Direct (non-transitive) packages should include the directly-installed package.
111- const direct = ( afterInstall ?? [ ] ) . filter ( ( p ) => p . isTransitive !== true ) ;
112- assert . ok (
113- hasPackage ( direct , TEST_PACKAGE ) ,
96+ // 3. Refresh and read the ENRICHED package list from the refresh result:
97+ // getPackages({skipCache}) re-lists without transitive enrichment, so the
98+ // refreshPackages return value is what carries Package.isTransitive.
99+ const afterInstall = ( await api . refreshPackages ( env ) ) ?? ( await api . getPackages ( env , { skipCache : true } ) ) ;
100+ assert . ok ( hasPackage ( afterInstall , TEST_PACKAGE ) , `[${ managerId } ] ${ TEST_PACKAGE } should be installed` ) ;
101+
102+ // 4. The installed package is a direct (non-transitive) dependency.
103+ const installedPkg = ( afterInstall ?? [ ] ) . find (
104+ ( p ) => normalizePackageName ( p . name ) === normalizePackageName ( TEST_PACKAGE ) ,
105+ ) ;
106+ assert . notStrictEqual (
107+ installedPkg ?. isTransitive ,
108+ true ,
114109 `[${ managerId } ] ${ TEST_PACKAGE } should be reported as a direct (non-transitive) package` ,
115110 ) ;
116111
112+ // 4a. Transitive-dependency detection. flask pulls in dependencies (jinja2,
113+ // werkzeug, ...). Managers that classify direct vs transitive (pip/uv/poetry)
114+ // mark those deps isTransitive=true; managers that don't (conda) leave it
115+ // undefined, so this assertion is conditional on the manager classifying at all.
116+ const classifiesTransitivity = ( afterInstall ?? [ ] ) . some ( ( p ) => p . isTransitive !== undefined ) ;
117+ if ( classifiesTransitivity ) {
118+ assert . ok (
119+ ( afterInstall ?? [ ] ) . some ( ( p ) => p . isTransitive === true ) ,
120+ `[${ managerId } ] expected at least one transitive dependency of ${ TEST_PACKAGE } to be detected` ,
121+ ) ;
122+ }
123+
117124 // 4b. Available versions (optional API capability). Only asserted when the
118125 // running API surfaces the getter and the manager supports version listing.
119126 const versionsApi = api as unknown as AvailableVersionsCapable ;
@@ -135,7 +142,6 @@ async function runRoundtrip(api: PythonEnvironmentApi, env: PythonEnvironment, m
135142 await api . refreshPackages ( env ) ;
136143 const afterUninstall = await api . getPackages ( env , { skipCache : true } ) ;
137144 assert . ok ( ! hasPackage ( afterUninstall , TEST_PACKAGE ) , `[${ managerId } ] ${ TEST_PACKAGE } should be uninstalled` ) ;
138- return true ;
139145 } finally {
140146 // Best-effort cleanup so a mid-roundtrip failure never leaves the env dirty.
141147 if ( installed ) {
@@ -149,7 +155,7 @@ async function runRoundtrip(api: PythonEnvironmentApi, env: PythonEnvironment, m
149155}
150156
151157suite ( 'Integration: Package Manager Roundtrip' , function ( ) {
152- this . timeout ( 180_000 ) ; // Install/uninstall across multiple managers can be slow.
158+ this . timeout ( 300_000 ) ; // Install/uninstall across multiple managers can be slow (conda solving flask + deps) .
153159
154160 let api : PythonEnvironmentApi ;
155161
@@ -222,13 +228,9 @@ suite('Integration: Package Manager Roundtrip', function () {
222228 continue ;
223229 }
224230
225- const exercisedManager = await runRoundtrip ( api , env , managerId ) ;
226- if ( exercisedManager ) {
227- exercised ++ ;
228- console . log ( `[${ managerId } ] roundtrip passed (${ env . displayName } )` ) ;
229- } else {
230- console . log ( `[${ managerId } ] roundtrip skipped (${ env . displayName } )` ) ;
231- }
231+ await runRoundtrip ( api , env , managerId ) ;
232+ exercised ++ ;
233+ console . log ( `[${ managerId } ] roundtrip passed (${ env . displayName } )` ) ;
232234 } catch ( e ) {
233235 failures . push ( `[${ managerId } ] ${ e instanceof Error ? e . message : String ( e ) } ` ) ;
234236 }
0 commit comments