Skip to content

Commit 9947c27

Browse files
authored
Add unit tests for conda lazy registration flow and failure handling
1 parent 4f04e72 commit 9947c27

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import assert from 'assert';
3+
import * as sinon from 'sinon';
4+
import { PythonEnvironmentApi } from '../../../api';
5+
import * as logging from '../../../common/logging';
6+
import { EventNames } from '../../../common/telemetry/constants';
7+
import * as telemetrySender from '../../../common/telemetry/sender';
8+
import * as windowApis from '../../../common/window.apis';
9+
import { PythonProjectManager } from '../../../internal.api';
10+
import * as commonUtils from '../../../managers/common/utils';
11+
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
12+
import { CondaEnvManager } from '../../../managers/conda/condaEnvManager';
13+
import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils';
14+
import * as condaUtils from '../../../managers/conda/condaUtils';
15+
import { makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment';
16+
17+
/**
18+
* Tests for the lazy-registration flow on CondaEnvManager.initialize().
19+
* Covers:
20+
* - success path (conda found locally / via settings / via PET)
21+
* - tool_not_found path (no conda, notify missing-default)
22+
* - error path (refresh throws)
23+
* - telemetry emission for all three outcomes
24+
* - sourcing information construction (and graceful handling of its failure)
25+
* - idempotency of initialize()
26+
*/
27+
suite('CondaEnvManager.initialize - lazy registration flow', () => {
28+
let getCondaStub: sinon.SinonStub;
29+
let getCondaPathSettingStub: sinon.SinonStub;
30+
let refreshCondaEnvsStub: sinon.SinonStub;
31+
let constructSourcingStub: sinon.SinonStub;
32+
let notifyMissingStub: sinon.SinonStub;
33+
let sendTelemetryStub: sinon.SinonStub;
34+
let withProgressStub: sinon.SinonStub;
35+
36+
setup(() => {
37+
getCondaStub = sinon.stub(condaUtils, 'getConda');
38+
getCondaPathSettingStub = sinon.stub(condaUtils, 'getCondaPathSetting').returns(undefined);
39+
refreshCondaEnvsStub = sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]);
40+
sinon.stub(condaUtils, 'getCondaForGlobal').resolves(undefined);
41+
constructSourcingStub = sinon.stub(condaSourcingUtils, 'constructCondaSourcingStatus');
42+
notifyMissingStub = sinon.stub(commonUtils, 'notifyMissingManagerIfDefault').resolves();
43+
sendTelemetryStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
44+
withProgressStub = sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => {
45+
return await (task as any)({ report: sinon.stub() }, { isCancellationRequested: false } as any);
46+
});
47+
sinon.stub(logging, 'traceInfo');
48+
sinon.stub(logging, 'traceError');
49+
});
50+
51+
teardown(() => {
52+
sinon.restore();
53+
});
54+
55+
function createManager(opts?: {
56+
projectManager?: PythonProjectManager;
57+
api?: Partial<PythonEnvironmentApi>;
58+
}): CondaEnvManager {
59+
const api = {
60+
getPythonProjects: sinon.stub().returns([]),
61+
getPythonProject: sinon.stub().returns(undefined),
62+
...opts?.api,
63+
} as any as PythonEnvironmentApi;
64+
return new CondaEnvManager(
65+
{} as NativePythonFinder,
66+
api,
67+
{ info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any,
68+
opts?.projectManager,
69+
);
70+
}
71+
72+
function getLazyInitTelemetry(): any | undefined {
73+
const call = sendTelemetryStub
74+
.getCalls()
75+
.find((c) => c.args[0] === EventNames.MANAGER_LAZY_INIT);
76+
return call?.args[2];
77+
}
78+
79+
test('success path: conda found via local lookup → toolSource=local, registers sourcing info', async () => {
80+
getCondaStub.resolves('/usr/bin/conda');
81+
const sourcing = { toString: () => 'sourcing' } as any;
82+
constructSourcingStub.resolves(sourcing);
83+
refreshCondaEnvsStub.resolves([makeEnv('base', '/opt/miniconda3', '3.11.0')]);
84+
85+
const mgr = createManager();
86+
await mgr.initialize();
87+
88+
// refresh was invoked (i.e. the work was actually done)
89+
assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'refreshCondaEnvs should be called once');
90+
// sourcing info is constructed using the resolved conda path
91+
assert.strictEqual(constructSourcingStub.callCount, 1);
92+
assert.strictEqual(constructSourcingStub.firstCall.args[0], '/usr/bin/conda');
93+
assert.strictEqual(mgr.sourcingInformation, sourcing);
94+
// missing-default notification is NOT shown when conda was found
95+
assert.strictEqual(notifyMissingStub.callCount, 0);
96+
97+
const props = getLazyInitTelemetry();
98+
assert.deepStrictEqual(
99+
{ managerName: props.managerName, result: props.result, envCount: props.envCount, toolSource: props.toolSource },
100+
{ managerName: 'conda', result: 'success', envCount: 1, toolSource: 'local' },
101+
);
102+
});
103+
104+
test('success path: conda from explicit setting → toolSource=settings', async () => {
105+
getCondaPathSettingStub.returns('/opt/conda/bin/conda');
106+
getCondaStub.resolves('/opt/conda/bin/conda');
107+
constructSourcingStub.resolves({ toString: () => '' } as any);
108+
109+
const mgr = createManager();
110+
await mgr.initialize();
111+
112+
const props = getLazyInitTelemetry();
113+
assert.strictEqual(props.toolSource, 'settings');
114+
assert.strictEqual(props.result, 'success');
115+
});
116+
117+
test('success path: conda discovered via PET after refresh → toolSource=pet', async () => {
118+
// pre-refresh: getConda throws (not found locally)
119+
getCondaStub.onFirstCall().rejects(new Error('Conda not found'));
120+
// post-refresh: getConda resolves (PET persisted the path)
121+
getCondaStub.onSecondCall().resolves('/home/user/miniconda3/bin/conda');
122+
constructSourcingStub.resolves({ toString: () => '' } as any);
123+
124+
const mgr = createManager();
125+
await mgr.initialize();
126+
127+
// both pre- and post-refresh lookups happened
128+
assert.strictEqual(getCondaStub.callCount, 2, 'getConda should be called twice (pre/post refresh)');
129+
assert.strictEqual(constructSourcingStub.callCount, 1);
130+
131+
const props = getLazyInitTelemetry();
132+
assert.strictEqual(props.result, 'success');
133+
assert.strictEqual(props.toolSource, 'pet');
134+
assert.strictEqual(notifyMissingStub.callCount, 0);
135+
});
136+
137+
test('tool_not_found: conda not found pre- or post-refresh → notifies and emits tool_not_found telemetry', async () => {
138+
getCondaStub.rejects(new Error('Conda not found'));
139+
const projectManager = {} as PythonProjectManager;
140+
141+
const mgr = createManager({ projectManager });
142+
await mgr.initialize();
143+
144+
// refresh was still attempted (PET may have run, but didn't surface conda)
145+
assert.strictEqual(refreshCondaEnvsStub.callCount, 1);
146+
// sourcing info is NOT constructed when conda isn't found
147+
assert.strictEqual(constructSourcingStub.callCount, 0);
148+
assert.strictEqual(mgr.sourcingInformation, undefined);
149+
// missing-default notification was shown
150+
assert.strictEqual(notifyMissingStub.callCount, 1);
151+
assert.strictEqual(notifyMissingStub.firstCall.args[0], 'ms-python.python:conda');
152+
assert.strictEqual(notifyMissingStub.firstCall.args[1], projectManager);
153+
154+
const props = getLazyInitTelemetry();
155+
assert.strictEqual(props.result, 'tool_not_found');
156+
assert.strictEqual(props.toolSource, 'none');
157+
assert.strictEqual(props.envCount, 0);
158+
});
159+
160+
test('tool_not_found without projectManager: skips missing-default notification', async () => {
161+
getCondaStub.rejects(new Error('Conda not found'));
162+
163+
const mgr = createManager(); // no projectManager
164+
await mgr.initialize();
165+
166+
assert.strictEqual(notifyMissingStub.callCount, 0, 'no notify call when projectManager is absent');
167+
const props = getLazyInitTelemetry();
168+
assert.strictEqual(props.result, 'tool_not_found');
169+
});
170+
171+
test('error path: refreshCondaEnvs throws → result=error, errorType is classified, no throw to caller', async () => {
172+
getCondaStub.resolves('/usr/bin/conda');
173+
refreshCondaEnvsStub.rejects(new Error('boom'));
174+
175+
const mgr = createManager();
176+
await assert.doesNotReject(mgr.initialize(), 'initialize() must never throw to its caller');
177+
178+
const props = getLazyInitTelemetry();
179+
assert.strictEqual(props.result, 'error');
180+
assert.ok(props.errorType, 'errorType should be set on error path');
181+
// sourcing info not populated when refresh fails
182+
assert.strictEqual(mgr.sourcingInformation, undefined);
183+
});
184+
185+
test('error path: sourcing status failure is swallowed and does not flip result to error', async () => {
186+
getCondaStub.resolves('/usr/bin/conda');
187+
constructSourcingStub.rejects(new Error('sourcing-failed'));
188+
189+
const mgr = createManager();
190+
await mgr.initialize();
191+
192+
const props = getLazyInitTelemetry();
193+
// refresh succeeded, so overall result is still success
194+
assert.strictEqual(props.result, 'success');
195+
// but sourcingInformation is not set
196+
assert.strictEqual(mgr.sourcingInformation, undefined);
197+
});
198+
199+
test('idempotency: concurrent initialize() calls share a single run', async () => {
200+
getCondaStub.resolves('/usr/bin/conda');
201+
constructSourcingStub.resolves({ toString: () => '' } as any);
202+
203+
const mgr = createManager();
204+
await Promise.all([mgr.initialize(), mgr.initialize(), mgr.initialize()]);
205+
206+
assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'refresh should run exactly once');
207+
// telemetry should fire exactly once across concurrent + sequential calls
208+
const lazyInitCalls = sendTelemetryStub
209+
.getCalls()
210+
.filter((c) => c.args[0] === EventNames.MANAGER_LAZY_INIT);
211+
assert.strictEqual(lazyInitCalls.length, 1);
212+
213+
// a subsequent call after completion is also a no-op
214+
await mgr.initialize();
215+
assert.strictEqual(refreshCondaEnvsStub.callCount, 1);
216+
});
217+
218+
test('no PET refresh is triggered before initialize(): construction alone does no work', () => {
219+
// Simply constructing the manager must not call into discovery.
220+
createManager();
221+
assert.strictEqual(getCondaStub.callCount, 0);
222+
assert.strictEqual(refreshCondaEnvsStub.callCount, 0);
223+
assert.strictEqual(constructSourcingStub.callCount, 0);
224+
assert.strictEqual(withProgressStub.callCount, 0);
225+
});
226+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import assert from 'assert';
3+
import * as sinon from 'sinon';
4+
import { Disposable, LogOutputChannel } from 'vscode';
5+
import { PythonEnvironmentApi } from '../../../api';
6+
import * as logging from '../../../common/logging';
7+
import * as pythonApi from '../../../features/pythonApi';
8+
import { PythonProjectManager } from '../../../internal.api';
9+
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
10+
import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils';
11+
import * as condaUtils from '../../../managers/conda/condaUtils';
12+
import { registerCondaFeatures } from '../../../managers/conda/main';
13+
14+
/**
15+
* Tests for the unconditional, lazy registration entrypoint.
16+
*
17+
* The lazy-registration contract is:
18+
* 1. Conda manager is ALWAYS registered (so it shows up in the picker), regardless of
19+
* whether the conda CLI exists on the machine.
20+
* 2. registerCondaFeatures does NO PET / sourcing-status work at activation time —
21+
* that cost is deferred to CondaEnvManager.initialize() on first use.
22+
* 3. Both the env manager and the package manager are pushed onto disposables and
23+
* registered with the api.
24+
*/
25+
suite('registerCondaFeatures - unconditional lazy registration', () => {
26+
let getCondaStub: sinon.SinonStub;
27+
let getCondaPathSettingStub: sinon.SinonStub;
28+
let refreshCondaEnvsStub: sinon.SinonStub;
29+
let constructSourcingStub: sinon.SinonStub;
30+
let getPythonApiStub: sinon.SinonStub;
31+
let registerEnvManagerStub: sinon.SinonStub;
32+
let registerPackageManagerStub: sinon.SinonStub;
33+
34+
setup(() => {
35+
// Stubs on every discovery side-effect: if any of these fire, the test fails
36+
// because activation is no longer lazy.
37+
getCondaStub = sinon.stub(condaUtils, 'getConda');
38+
getCondaPathSettingStub = sinon.stub(condaUtils, 'getCondaPathSetting').returns(undefined);
39+
refreshCondaEnvsStub = sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]);
40+
constructSourcingStub = sinon.stub(condaSourcingUtils, 'constructCondaSourcingStatus');
41+
42+
registerEnvManagerStub = sinon.stub().returns({ dispose: sinon.stub() });
43+
registerPackageManagerStub = sinon.stub().returns({ dispose: sinon.stub() });
44+
const api = {
45+
registerEnvironmentManager: registerEnvManagerStub,
46+
registerPackageManager: registerPackageManagerStub,
47+
} as any as PythonEnvironmentApi;
48+
getPythonApiStub = sinon.stub(pythonApi, 'getPythonApi').resolves(api);
49+
50+
sinon.stub(logging, 'traceInfo');
51+
sinon.stub(logging, 'traceError');
52+
});
53+
54+
teardown(() => {
55+
sinon.restore();
56+
});
57+
58+
const log = { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any as LogOutputChannel;
59+
const nativeFinder = {} as NativePythonFinder;
60+
const projectManager = {} as PythonProjectManager;
61+
62+
test('registers env manager and package manager unconditionally', async () => {
63+
const disposables: Disposable[] = [];
64+
65+
await registerCondaFeatures(nativeFinder, disposables, log, projectManager);
66+
67+
assert.strictEqual(getPythonApiStub.callCount, 1, 'should fetch the python api');
68+
assert.strictEqual(registerEnvManagerStub.callCount, 1, 'env manager must be registered');
69+
assert.strictEqual(registerPackageManagerStub.callCount, 1, 'package manager must be registered');
70+
// env manager + package manager + their two registration disposables
71+
assert.strictEqual(disposables.length, 4, 'four disposables expected');
72+
});
73+
74+
test('does NOT call getConda / refreshCondaEnvs / constructCondaSourcingStatus at registration', async () => {
75+
await registerCondaFeatures(nativeFinder, [], log, projectManager);
76+
77+
// These are the hot-path / PET-triggering calls that the previous (non-lazy)
78+
// implementation made during activation. Their absence is the whole point of
79+
// the lazy-registration change.
80+
assert.strictEqual(getCondaStub.callCount, 0, 'getConda must not be invoked at registration');
81+
assert.strictEqual(refreshCondaEnvsStub.callCount, 0, 'refreshCondaEnvs must not be invoked at registration');
82+
assert.strictEqual(
83+
constructSourcingStub.callCount,
84+
0,
85+
'constructCondaSourcingStatus must not be invoked at registration',
86+
);
87+
// We also don't inspect the conda path setting at registration time.
88+
assert.strictEqual(getCondaPathSettingStub.callCount, 0);
89+
});
90+
91+
test('registers even when conda would not be found (no early-return on missing tool)', async () => {
92+
// Even if getConda would throw, registration must still succeed. We don't actually
93+
// call it during registerCondaFeatures, but we configure the stub to reject so a
94+
// regression that re-introduces the call would also fail this test.
95+
getCondaStub.rejects(new Error('Conda not found'));
96+
const disposables: Disposable[] = [];
97+
98+
await registerCondaFeatures(nativeFinder, disposables, log, projectManager);
99+
100+
assert.strictEqual(registerEnvManagerStub.callCount, 1);
101+
assert.strictEqual(registerPackageManagerStub.callCount, 1);
102+
assert.strictEqual(disposables.length, 4);
103+
});
104+
});

0 commit comments

Comments
 (0)