|
| 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 | +}); |
0 commit comments