diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 39495a416..51632c02c 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -7,12 +7,15 @@ import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, EnvironmentChangeKind, + EnvironmentGroupInfo, EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, IconPath, + PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentApi, + PythonEnvironmentExecutionInfo, PythonProject, QuickCreateConfig, RefreshEnvironmentsScope, @@ -95,12 +98,14 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { return this._initialized.promise; } - this._initialized = createDeferred(); + const deferred = createDeferred(); + this._initialized = deferred; const stopWatch = new StopWatch(); let result: 'success' | 'tool_not_found' | 'error' = 'success'; let envCount = 0; let toolSource = 'none'; let errorType: string | undefined; + let discoveryFailed = false; try { // Check if tool is findable before PET refresh (settings/cache/persistent state/PATH only, no PET). @@ -123,8 +128,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { title: CondaStrings.condaDiscovering, }, async () => { - this.collection = - (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? []; + const refreshed = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this); + if (refreshed === undefined) { + discoveryFailed = true; + await this.loadEnvMapPreservingCollection(); + return; + } + this.collection = refreshed; await this.loadEnvMap(); this._onDidChangeEnvironments.fire( @@ -173,7 +183,10 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + deferred.resolve(); + if (discoveryFailed && this._initialized === deferred) { + this._initialized = undefined; + } } } @@ -314,14 +327,37 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { }, async () => { this.log.info('Refreshing Conda Environments'); + const refreshed = await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this); + if (refreshed === undefined) { + await this.loadEnvMapPreservingCollection(); + return; + } const discard = this.collection.map((c) => c); - this.collection = (await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this)) ?? []; + const discovered = refreshed.map((c) => c); + this.collection = refreshed; + const appended = await this.loadEnvMap(); - await this.loadEnvMap(); + const resolvedEnvs = [...discovered, ...appended]; + const resolvedByPath = new Map( + resolvedEnvs.map((env) => [normalizePath(env.environmentPath.fsPath), env] as const), + ); + const discardedByPath = new Map( + discard.map((env) => [normalizePath(env.environmentPath.fsPath), env] as const), + ); const args = [ - ...discard.map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })), - ...this.collection.map((env) => ({ kind: EnvironmentChangeKind.add, environment: env })), + ...discard + .filter((env) => { + const current = resolvedByPath.get(normalizePath(env.environmentPath.fsPath)); + return !current || !isEquivalentEnvironment(env, current); + }) + .map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })), + ...resolvedEnvs + .filter((env) => { + const previous = discardedByPath.get(normalizePath(env.environmentPath.fsPath)); + return !previous || !isEquivalentEnvironment(previous, env); + }) + .map((env) => ({ kind: EnvironmentChangeKind.add, environment: env })), ]; this._onDidChangeEnvironments.fire(args); @@ -342,15 +378,21 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this), startBackgroundInit: () => withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => { - this.collection = - (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? []; - await this.loadEnvMap(); - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ); + const refreshed = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this); + if (refreshed === undefined) { + await this.loadEnvMapPreservingCollection(); + throw new Error('Conda background discovery failed'); + } + this.collection = refreshed; + const refreshedAdds = refreshed.map((environment) => ({ + environment, + kind: EnvironmentChangeKind.add, + })); + const appended = await this.loadEnvMap(); + this._onDidChangeEnvironments.fire([ + ...refreshedAdds, + ...appended.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })), + ]); }), }); if (fastResult) { @@ -486,7 +528,18 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { await clearCondaCache(); } - private async loadEnvMap() { + private async loadEnvMapPreservingCollection(): Promise { + const added = await this.loadEnvMap(); + const present = added.filter((environment) => this.collection.includes(environment)); + if (present.length > 0) { + this._onDidChangeEnvironments.fire( + present.map((environment) => ({ kind: EnvironmentChangeKind.add, environment })), + ); + } + } + + private async loadEnvMap(): Promise { + const appended: PythonEnvironment[] = []; this.globalEnv = undefined; this.fsPathToEnv.clear(); @@ -498,11 +551,18 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { // If the environment is not found, resolve the fsPath. Could be portable conda. if (!this.globalEnv) { - this.globalEnv = await resolveCondaPath(fsPath, this.nativeFinder, this.api, this.log, this); + const resolved = await resolveCondaPath(fsPath, this.nativeFinder, this.api, this.log, this); // If the environment is resolved, add it to the collection - if (this.globalEnv) { - this.collection.push(this.globalEnv); + if (resolved) { + const existing = this.findByExactPath(resolved.environmentPath.fsPath); + if (existing) { + this.globalEnv = existing; + } else { + this.globalEnv = resolved; + this.collection.push(resolved); + appended.push(resolved); + } } } } @@ -544,8 +604,14 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { if (resolved) { // If resolved add it to the collection - this.fsPathToEnv.set(normalizedPath, resolved); - this.collection.push(resolved); + const existing = this.findByExactPath(resolved.environmentPath.fsPath); + if (existing) { + this.fsPathToEnv.set(normalizedPath, existing); + } else { + this.fsPathToEnv.set(normalizedPath, resolved); + this.collection.push(resolved); + appended.push(resolved); + } } else { this.log.error(`Failed to resolve conda environment: ${env}`); } @@ -568,6 +634,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } } + return appended; } private fromEnvMap(uri: Uri): PythonEnvironment | undefined { @@ -612,9 +679,180 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { }); } + private findByExactPath(fsPath: string): PythonEnvironment | undefined { + const normalized = normalizePath(fsPath); + return this.collection.find((e) => normalizePath(e.environmentPath.fsPath) === normalized); + } + private findEnvironmentByName(name: string): PythonEnvironment | undefined { return this.collection.find((e) => { return e.name === name; }); } } + +function isEquivalentEnvironment(a: PythonEnvironment, b: PythonEnvironment): boolean { + return ( + a.envId.managerId === b.envId.managerId && + a.name === b.name && + a.displayName === b.displayName && + a.shortDisplayName === b.shortDisplayName && + a.displayPath === b.displayPath && + a.version === b.version && + a.description === b.description && + a.sysPrefix === b.sysPrefix && + a.error === b.error && + isSameMarkdownLike(a.tooltip, b.tooltip) && + isSameIconPath(a.iconPath, b.iconPath) && + isSameGroup(a.group, b.group) && + isSameExecInfo(a.execInfo, b.execInfo) + ); +} + +function isSameExecInfo(a: PythonEnvironmentExecutionInfo, b: PythonEnvironmentExecutionInfo): boolean { + return ( + isSameRunConfig(a.run, b.run) && + isSameRunConfig(a.activatedRun, b.activatedRun) && + isSameRunConfigArray(a.activation, b.activation) && + isSameRunConfigArray(a.deactivation, b.deactivation) && + isSameShellMap(a.shellActivation, b.shellActivation) && + isSameShellMap(a.shellDeactivation, b.shellDeactivation) + ); +} + +function isSameShellMap( + a: Map | undefined, + b: Map | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined || a.size !== b.size) { + return false; + } + for (const [key, value] of a) { + const other = b.get(key); + if (other === undefined || !isSameRunConfigArray(value, other)) { + return false; + } + } + return true; +} + +function isSameRunConfigArray( + a: PythonCommandRunConfiguration[] | undefined, + b: PythonCommandRunConfiguration[] | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined || a.length !== b.length) { + return false; + } + return a.every((value, index) => isSameRunConfig(value, b[index])); +} + +function isSameRunConfig( + a: PythonCommandRunConfiguration | undefined, + b: PythonCommandRunConfiguration | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + return a.executable === b.executable && isSameStringArray(a.args, b.args); +} + +function isSameStringArray(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined || a.length !== b.length) { + return false; + } + return a.every((value, index) => value === b[index]); +} + +function isSameGroup( + a: string | EnvironmentGroupInfo | undefined, + b: string | EnvironmentGroupInfo | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + if (typeof a === 'string' || typeof b === 'string') { + return a === b; + } + return ( + a.name === b.name && + a.description === b.description && + isSameMarkdownLike(a.tooltip, b.tooltip) && + isSameIconPath(a.iconPath, b.iconPath) + ); +} + +function isSameIconPath(a: IconPath | undefined, b: IconPath | undefined): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + if ('id' in a || 'id' in b) { + return 'id' in a && 'id' in b && a.id === b.id; + } + if ('light' in a || 'light' in b) { + return 'light' in a && 'light' in b && isSameUri(a.light, b.light) && isSameUri(a.dark, b.dark); + } + return isSameUri(a, b); +} + +function isSameMarkdownLike( + a: string | MarkdownString | undefined, + b: string | MarkdownString | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + if (typeof a === 'string' || typeof b === 'string') { + return a === b; + } + return ( + a.value === b.value && + a.supportThemeIcons === b.supportThemeIcons && + a.supportHtml === b.supportHtml && + isSameTrusted(a.isTrusted, b.isTrusted) && + isSameUri(a.baseUri, b.baseUri) + ); +} + +function isSameTrusted( + a: boolean | { readonly enabledCommands: readonly string[] } | undefined, + b: boolean | { readonly enabledCommands: readonly string[] } | undefined, +): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined || typeof a === 'boolean' || typeof b === 'boolean') { + return a === b; + } + return isSameStringArray(a.enabledCommands, b.enabledCommands); +} + +function isSameUri(a: Uri | undefined, b: Uri | undefined): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + return a.toString() === b.toString(); +} diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 06d3ec54f..41504c562 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -835,13 +835,19 @@ export async function resolveCondaPath( } } +/** + * Discovers conda environments via the native finder. Returns `undefined` when discovery fails + * (the native finder threw/rejected, or produced a non-array result) so callers can keep a + * known-good collection, or an array (including `[]`) on success — where `[]` authoritatively + * means "no conda environments". + */ export async function refreshCondaEnvs( hardRefresh: boolean, nativeFinder: NativePythonFinder, api: PythonEnvironmentApi, log: LogOutputChannel, manager: EnvironmentManager, -): Promise { +): Promise { log.info(`Refreshing conda environments (hardRefresh=${hardRefresh})`); let data: (NativeEnvInfo | NativeEnvManagerInfo)[]; @@ -850,14 +856,14 @@ export async function refreshCondaEnvs( } catch (error) { traceError('Failed to refresh native finder for conda environments', error); log.error(`Failed to refresh native finder: ${error instanceof Error ? error.message : String(error)}`); - return []; + return undefined; } // Ensure data is a valid array before proceeding if (!data || !Array.isArray(data)) { traceWarn(`Native finder returned invalid data: ${typeof data}, expected array`); log.warn(`Native finder returned invalid data type: ${typeof data}`); - return []; + return undefined; } traceVerbose(`Native finder returned ${data.length} items for conda refresh`); diff --git a/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts new file mode 100644 index 000000000..ee3ab02d3 --- /dev/null +++ b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts @@ -0,0 +1,641 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { EnvironmentChangeKind, PythonEnvironmentApi, PythonProject } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import * as windowApis from '../../../common/window.apis'; +import * as commonUtils from '../../../managers/common/utils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { CondaEnvManager } from '../../../managers/conda/condaEnvManager'; +import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { createMockPythonEnvironment, makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment'; + +suite('CondaEnvManager - result preservation on discovery failure', () => { + let getCondaStub: sinon.SinonStub; + let refreshCondaEnvsStub: sinon.SinonStub; + let getCondaForGlobalStub: sinon.SinonStub; + let getCondaForWorkspaceStub: sinon.SinonStub; + let resolveCondaPathStub: sinon.SinonStub; + + setup(() => { + getCondaStub = sinon.stub(condaUtils, 'getConda').resolves('/usr/bin/conda'); + sinon.stub(condaUtils, 'getCondaPathSetting').returns(undefined); + refreshCondaEnvsStub = sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]); + getCondaForGlobalStub = sinon.stub(condaUtils, 'getCondaForGlobal').resolves(undefined); + getCondaForWorkspaceStub = sinon.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined); + resolveCondaPathStub = sinon.stub(condaUtils, 'resolveCondaPath').resolves(undefined); + sinon.stub(condaSourcingUtils, 'constructCondaSourcingStatus').resolves({ toString: () => '' } as any); + sinon.stub(commonUtils, 'notifyMissingManagerIfDefault').resolves(); + sinon.stub(telemetrySender, 'sendTelemetryEvent'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => { + return await (task as any)({ report: sinon.stub() }, { isCancellationRequested: false } as any); + }); + sinon.stub(logging, 'traceInfo'); + sinon.stub(logging, 'traceError'); + sinon.stub(logging, 'traceVerbose'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('refresh preserves prior environments and emits no changes when discovery fails and nothing persisted resolves', async () => { + const known = [base(), envB()]; + refreshCondaEnvsStub.resolves(known); + + const mgr = createManager(); + await mgr.initialize(); + assert.strictEqual((await mgr.getEnvironments('all')).length, 2, 'precondition: two envs discovered'); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves(undefined); + await mgr.refresh(undefined); + + assert.strictEqual(events.length, 0, 'a failed refresh must not emit any environment changes'); + const after = await mgr.getEnvironments('all'); + assert.deepStrictEqual( + after.map((e) => e.name).sort(), + ['base', 'envB'], + 'the known-good collection must survive a failed refresh', + ); + }); + + test('refresh empties the collection and emits removals on a successful empty result', async () => { + const known = [base(), envB()]; + refreshCondaEnvsStub.resolves(known); + + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves([]); + await mgr.refresh(undefined); + + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove).map((e) => e.environment.name); + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add); + assert.deepStrictEqual(removed.sort(), ['base', 'envB'], 'stale environments must be removed on empty success'); + assert.strictEqual(added.length, 0, 'no environments should be added for an empty result'); + assert.strictEqual((await mgr.getEnvironments('all')).length, 0, 'collection must be emptied'); + }); + + test('refresh replaces the collection and emits removals + adds on a successful non-empty result', async () => { + refreshCondaEnvsStub.resolves([base()]); + + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + const envC = makeEnv('envC', Uri.file('/opt/miniconda3/envs/envC').fsPath, '3.10.0'); + refreshCondaEnvsStub.resolves([envC]); + await mgr.refresh(undefined); + + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove).map((e) => e.environment.name); + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + assert.deepStrictEqual(removed, ['base'], 'old environment removed'); + assert.deepStrictEqual(added, ['envC'], 'new environment added'); + assert.deepStrictEqual((await mgr.getEnvironments('all')).map((e) => e.name), ['envC']); + }); + + test('initialize leaves the collection empty and emits no changes when discovery fails and nothing persisted resolves', async () => { + refreshCondaEnvsStub.resolves(undefined); + + const mgr = createManager(); + const events = collectEvents(mgr); + await mgr.initialize(); + + assert.strictEqual(getCondaStub.called, true, 'initialize still attempts discovery'); + assert.strictEqual(events.length, 0, 'a failed initial discovery must not emit environment changes'); + assert.strictEqual((await mgr.getEnvironments('all')).length, 0, 'no environments should be registered'); + }); + + test('refresh failure preserves the collection and restores a persisted global selection, emitting only its addition', async () => { + const known = [base()]; + refreshCondaEnvsStub.resolves(known); + + const mgr = createManager(); + await mgr.initialize(); + assert.strictEqual((await mgr.getEnvironments('all')).length, 1, 'precondition: one env discovered'); + + const events = collectEvents(mgr); + + const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const persistedEnv = makeEnv('persisted', persistedGlobalPath, '3.9.0'); + getCondaForGlobalStub.resolves(persistedGlobalPath); + resolveCondaPathStub.resolves(persistedEnv); + refreshCondaEnvsStub.resolves(undefined); + + await mgr.refresh(undefined); + + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + assert.strictEqual(removed.length, 0, 'no removals on failed discovery'); + assert.deepStrictEqual(added, ['persisted'], 'only the restored persisted env is emitted as an addition'); + + const all = (await mgr.getEnvironments('all')).map((e) => e.name).sort(); + assert.deepStrictEqual(all, ['base', 'persisted'], 'old collection preserved and persisted env appended'); + assert.strictEqual(await mgr.get(undefined), persistedEnv, 'persisted global selection is retained'); + }); + + test('initialize failure restores a persisted global selection and retains it across get calls, emitting only its addition', async () => { + refreshCondaEnvsStub.resolves(undefined); + + const persistedGlobalPath = Uri.file('/opt/miniconda3').fsPath; + const persistedEnv = makeEnv('base', persistedGlobalPath, '3.12.0'); + getCondaForGlobalStub.resolves(persistedGlobalPath); + resolveCondaPathStub.resolves(persistedEnv); + + const mgr = createManager(); + const events = collectEvents(mgr); + await mgr.initialize(); + + assert.strictEqual((mgr as any)._initialized, undefined, 'a failed initialization stays retryable'); + + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + assert.strictEqual(removed.length, 0, 'no removals on failed initial discovery'); + assert.deepStrictEqual(added, ['base'], 'only the restored persisted env is emitted'); + + assert.strictEqual(await mgr.get(undefined), persistedEnv, 'first get returns the persisted selection'); + assert.strictEqual(await mgr.get(undefined), persistedEnv, 'subsequent get returns the persisted selection'); + assert.deepStrictEqual((await mgr.getEnvironments('all')).map((e) => e.name), ['base']); + }); + + test('fast/background get: refresh failure restores a persisted workspace selection and retains it across calls', async () => { + const workspaceUri = Uri.file(path.resolve('ws-conda')); + const project = { uri: workspaceUri } as PythonProject; + const api = { + getPythonProjects: sinon.stub().returns([project]), + getPythonProject: sinon.stub().returns(project), + } as any as PythonEnvironmentApi; + const mgr = new CondaEnvManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + + const persistedPath = Uri.file(path.resolve('ws-conda', '.conda')).fsPath; + const persistedEnv = makeEnv('wsenv', persistedPath, '3.10.0'); + getCondaForWorkspaceStub.resolves(persistedPath); + resolveCondaPathStub.resolves(persistedEnv); + refreshCondaEnvsStub.resolves(undefined); + sinon.stub(fs.promises, 'access').resolves(); + + const events = collectEvents(mgr); + + const first = await mgr.get(workspaceUri); + assert.strictEqual(first, persistedEnv, 'fast path returns the persisted env on first get'); + + await (mgr as any)._initialized?.promise; + await new Promise((resolve) => setImmediate(resolve)); + + const second = await mgr.get(workspaceUri); + assert.strictEqual(second, persistedEnv, 'persisted selection retained after settled init'); + + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + assert.strictEqual(removed.length, 0, 'no removals on failed background discovery'); + const wsAdds = events.filter((e) => e.kind === EnvironmentChangeKind.add && e.environment.name === 'wsenv'); + assert.strictEqual(wsAdds.length, 1, `expected exactly one add for the persisted env, got ${wsAdds.length}`); + + const wsInCollection = (await mgr.getEnvironments('all')).filter((e) => e.name === 'wsenv'); + assert.strictEqual(wsInCollection.length, 1, 'exactly one collection entry for the persisted env'); + }); + + test('failed discovery announces only environments this loadEnvMap appended when a concurrent refresh replaces the collection', async () => { + refreshCondaEnvsStub.resolves([base()]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + + const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const persistedEnv = makeEnv('persisted', persistedGlobalPath, '3.9.0'); + getCondaForGlobalStub.resolves(persistedGlobalPath); + + let releaseResolve!: (env: any) => void; + const gate = new Promise((resolve) => { + releaseResolve = resolve; + }); + resolveCondaPathStub.returns(gate); + refreshCondaEnvsStub.resolves(undefined); + + const failedRefresh = mgr.refresh(undefined); + await new Promise((resolve) => setImmediate(resolve)); + + const foreignEnv = makeEnv('foreign', Uri.file('/opt/miniconda3/envs/foreign').fsPath, '3.10.0'); + (mgr as any).collection = [foreignEnv]; + + releaseResolve(persistedEnv); + await failedRefresh; + + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + assert.deepStrictEqual(added, ['persisted'], 'only the env appended by this loadEnvMap call is announced'); + assert.strictEqual(removed.length, 0, 'the failure path never emits removals'); + + const names = (await mgr.getEnvironments('all')).map((e) => e.name).sort(); + assert.deepStrictEqual(names, ['foreign', 'persisted'], 'concurrent replacement kept; persisted appended once'); + }); + + test('two overlapping failed refreshes append a shared persisted env only once', async () => { + refreshCondaEnvsStub.resolves([base()]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + + const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + getCondaForGlobalStub.resolves(persistedGlobalPath); + refreshCondaEnvsStub.resolves(undefined); + + let releaseA!: (env: any) => void; + let releaseB!: (env: any) => void; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + const gateB = new Promise((resolve) => { + releaseB = resolve; + }); + resolveCondaPathStub.onCall(0).returns(gateA); + resolveCondaPathStub.onCall(1).returns(gateB); + + const refreshA = mgr.refresh(undefined); + const refreshB = mgr.refresh(undefined); + await new Promise((resolve) => setImmediate(resolve)); + + releaseA(makeEnv('persisted', persistedGlobalPath, '3.9.0')); + releaseB(makeEnv('persisted', persistedGlobalPath, '3.9.0')); + await Promise.all([refreshA, refreshB]); + + const persistedAdds = events.filter( + (e) => e.kind === EnvironmentChangeKind.add && e.environment.name === 'persisted', + ); + assert.strictEqual(persistedAdds.length, 1, 'the shared persisted env is announced exactly once'); + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + assert.strictEqual(removed.length, 0, 'no removals on failed discovery'); + + const persistedEntries = (await mgr.getEnvironments('all')).filter((e) => e.name === 'persisted'); + assert.strictEqual(persistedEntries.length, 1, 'exactly one collection entry for the shared persisted env'); + }); + + test('failed discovery does not announce an appended env that a concurrent successful refresh dropped from the collection', async () => { + const workspaceUri = Uri.file(path.resolve('ws-stale-add')); + const project = { uri: workspaceUri } as PythonProject; + const api = { + getPythonProjects: sinon.stub().returns([project]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + const mgr = new CondaEnvManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + + refreshCondaEnvsStub.resolves([base()]); + await mgr.initialize(); + + const events = collectEvents(mgr); + + const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const persistedEnv = makeEnv('persisted', persistedGlobalPath, '3.9.0'); + getCondaForGlobalStub.resolves(persistedGlobalPath); + resolveCondaPathStub.resolves(persistedEnv); + refreshCondaEnvsStub.resolves(undefined); + + let releaseWorkspace!: (value: any) => void; + const workspaceGate = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + getCondaForWorkspaceStub.returns(workspaceGate); + + const failedRefresh = mgr.refresh(undefined); + await new Promise((resolve) => setImmediate(resolve)); + + const foreignEnv = makeEnv('foreign', Uri.file('/opt/miniconda3/envs/foreign').fsPath, '3.10.0'); + (mgr as any).collection = [foreignEnv]; + + releaseWorkspace(undefined); + await failedRefresh; + + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + assert.deepStrictEqual(added, [], 'an appended env dropped by a concurrent refresh must not be announced'); + assert.strictEqual(removed.length, 0, 'the failure path never emits removals'); + + const names = (await mgr.getEnvironments('all')).map((e) => e.name).sort(); + assert.deepStrictEqual(names, ['foreign'], 'the concurrently-refreshed collection is left intact'); + }); + + test('successful refresh announces only its own results/appends, not an env a concurrent failed refresh appended', async () => { + const mgr = createManager(); + (mgr as any).collection = [base()]; + + const events = collectEvents(mgr); + + const persistedPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const persistedEnv = makeEnv('persisted', persistedPath, '3.9.0'); + const refreshedBase = base(); + + let releaseGlobalS!: (value: any) => void; + const globalGateS = new Promise((resolve) => { + releaseGlobalS = resolve; + }); + + refreshCondaEnvsStub.onCall(0).resolves([refreshedBase]); + refreshCondaEnvsStub.onCall(1).resolves(undefined); + getCondaForGlobalStub.onCall(0).returns(globalGateS); + getCondaForGlobalStub.onCall(1).resolves(persistedPath); + resolveCondaPathStub.resolves(persistedEnv); + + const successfulRefresh = mgr.refresh(undefined); + const failedRefresh = mgr.refresh(undefined); + await new Promise((resolve) => setImmediate(resolve)); + + releaseGlobalS(undefined); + await Promise.all([successfulRefresh, failedRefresh]); + + const persistedAdds = events.filter( + (e) => e.kind === EnvironmentChangeKind.add && e.environment.name === 'persisted', + ); + assert.strictEqual(persistedAdds.length, 1, 'the persisted env is announced once, not re-announced by success'); + + const persistedEntries = (mgr as any).collection.filter((e: any) => e.name === 'persisted'); + assert.strictEqual(persistedEntries.length, 1, 'exactly one collection entry for the persisted env'); + }); + + test('failed initialization preserves known/persisted data and stays retryable', async () => { + refreshCondaEnvsStub.resolves(undefined); + + const mgr = createManager(); + (mgr as any).collection = [base()]; + + const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const persistedEnv = makeEnv('persisted', persistedGlobalPath, '3.9.0'); + getCondaForGlobalStub.resolves(persistedGlobalPath); + resolveCondaPathStub.resolves(persistedEnv); + + const events = collectEvents(mgr); + await mgr.initialize(); + + assert.strictEqual((mgr as any)._initialized, undefined, 'a failed initialization stays retryable'); + const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove); + const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name); + assert.strictEqual(removed.length, 0, 'no removals on failed initial discovery'); + assert.deepStrictEqual(added, ['persisted'], 'only the restored persisted env is announced'); + + const names = (mgr as any).collection.map((e: any) => e.name).sort(); + assert.deepStrictEqual(names, ['base', 'persisted'], 'known env preserved and persisted env restored'); + }); + + test('concurrent initialize waiters share a single failed attempt and remain retryable', async () => { + refreshCondaEnvsStub.resolves(undefined); + + const mgr = createManager(); + const first = mgr.initialize(); + const second = mgr.initialize(); + await Promise.all([first, second]); + + assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'concurrent waiters share a single discovery attempt'); + assert.strictEqual((mgr as any)._initialized, undefined, 'the shared failed attempt stays retryable'); + }); + + test('a later get retries once after a failed initialization and stays initialized after success', async () => { + refreshCondaEnvsStub.onCall(0).resolves(undefined); + refreshCondaEnvsStub.onCall(1).resolves([base()]); + + const mgr = createManager(); + + await mgr.get(undefined); + assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'the failed initialization ran discovery once'); + assert.strictEqual((mgr as any)._initialized, undefined, 'the failed initialization is retryable'); + + await mgr.get(undefined); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'the next get retried discovery exactly once'); + assert.strictEqual((mgr as any)._initialized?.completed, true, 'a successful retry stays initialized'); + assert.deepStrictEqual( + (mgr as any).collection.map((e: any) => e.name), + ['base'], + 'the successful retry populated the collection', + ); + + await mgr.get(undefined); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'a settled initialization does not retry again'); + }); + + test('a delayed successful refresh does not remove/re-add a path a prior failed refresh already announced', async () => { + const mgr = createManager(); + (mgr as any).collection = [base()]; + + const events = collectEvents(mgr); + + const persistedPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath; + const failedEnv = createMockPythonEnvironment({ + name: 'persisted', + envPath: persistedPath, + version: '3.9.0', + id: 'persisted-failed', + }); + const successEnv = createMockPythonEnvironment({ + name: 'persisted', + envPath: persistedPath, + version: '3.9.0', + id: 'persisted-success', + }); + getCondaForGlobalStub.resolves(persistedPath); + resolveCondaPathStub.onCall(0).resolves(failedEnv); + resolveCondaPathStub.onCall(1).resolves(successEnv); + + let releaseSuccess!: (envs: any) => void; + const successGate = new Promise((resolve) => { + releaseSuccess = resolve; + }); + refreshCondaEnvsStub.onCall(0).returns(successGate); + refreshCondaEnvsStub.onCall(1).resolves(undefined); + + const successfulRefresh = mgr.refresh(undefined); + const failedRefresh = mgr.refresh(undefined); + + await failedRefresh; + assert.deepStrictEqual( + events.map((e) => `${e.kind}:${e.environment.name}`), + ['add:persisted'], + 'the failed refresh announces the restored persisted env once', + ); + + releaseSuccess([base()]); + await successfulRefresh; + + assert.deepStrictEqual( + events.map((e) => `${e.kind}:${e.environment.name}`), + ['add:persisted'], + 'the delayed successful refresh emits no remove/re-add for the continuous path', + ); + + const persistedEntries = (mgr as any).collection.filter((e: any) => e.name === 'persisted'); + assert.strictEqual(persistedEntries.length, 1, 'exactly one collection entry for the persisted path'); + const names = (mgr as any).collection.map((e: any) => e.name).sort(); + assert.deepStrictEqual(names, ['base', 'persisted'], 'final collection retains base and the single persisted entry'); + }); + + test('fast/background get: a failed background initialization stays retryable and the next get retries and succeeds', async () => { + const workspaceUri = Uri.file(path.resolve('ws-retry')); + const project = { uri: workspaceUri } as PythonProject; + const api = { + getPythonProjects: sinon.stub().returns([project]), + getPythonProject: sinon.stub().returns(project), + } as any as PythonEnvironmentApi; + const mgr = new CondaEnvManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + + const persistedPath = Uri.file(path.resolve('ws-retry', '.conda')).fsPath; + const persistedEnv = makeEnv('wsenv', persistedPath, '3.10.0'); + getCondaForWorkspaceStub.resolves(persistedPath); + resolveCondaPathStub.resolves(persistedEnv); + sinon.stub(fs.promises, 'access').resolves(); + refreshCondaEnvsStub.onCall(0).resolves(undefined); + refreshCondaEnvsStub.onCall(1).resolves([base()]); + + const first = await mgr.get(workspaceUri); + assert.strictEqual(first, persistedEnv, 'fast path returns the persisted env despite background failure'); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual((mgr as any)._initialized, undefined, 'a failed background initialization stays retryable'); + + const second = await mgr.get(workspaceUri); + assert.strictEqual(second, persistedEnv, 'the persisted env is retained on the retry'); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'the next get retried background discovery exactly once'); + assert.strictEqual((mgr as any)._initialized?.completed, true, 'a successful retry stays initialized'); + const names = (await mgr.getEnvironments('all')).map((e) => e.name).sort(); + assert.deepStrictEqual(names, ['base', 'wsenv'], 'the successful retry populated the collection'); + }); + + test('successful refresh emits no events when a same-path environment is semantically unchanged', async () => { + const sharedPath = Uri.file('/opt/miniconda3/envs/shared').fsPath; + refreshCondaEnvsStub.resolves([createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.9.0', id: 'shared-old' })]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves([createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.9.0', id: 'shared-new' })]); + await mgr.refresh(undefined); + + assert.deepStrictEqual(events, [], 'an unchanged same-path environment must not churn even when its id differs'); + const collection = (mgr as any).collection; + assert.strictEqual(collection.length, 1, 'the single same-path entry is retained'); + assert.strictEqual(collection[0].version, '3.9.0', 'the collection holds the resolved env'); + }); + + test('successful refresh emits exact remove then add when a same-path environment metadata changes', async () => { + const sharedPath = Uri.file('/opt/miniconda3/envs/shared').fsPath; + refreshCondaEnvsStub.resolves([createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.9.0', id: 'shared-old' })]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves([createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.10.0', id: 'shared-new' })]); + await mgr.refresh(undefined); + + assert.deepStrictEqual( + events.map((e) => `${e.kind}:${e.environment.name}:${e.environment.version}`), + ['remove:shared:3.9.0', 'add:shared:3.10.0'], + 'a changed same-path environment emits exact remove then add', + ); + const collection = (mgr as any).collection; + assert.strictEqual(collection.length, 1, 'the changed entry replaces the old one'); + assert.strictEqual(collection[0].version, '3.10.0', 'the collection reflects the updated metadata'); + }); + + test('successful refresh emits exact remove then add when a same-path environment group changes', async () => { + const sharedPath = Uri.file('/opt/miniconda3/envs/shared').fsPath; + refreshCondaEnvsStub.resolves([ + createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.9.0', id: 'shared-old', group: 'Named' }), + ]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves([ + createMockPythonEnvironment({ name: 'shared', envPath: sharedPath, version: '3.9.0', id: 'shared-new', group: 'Prefix' }), + ]); + await mgr.refresh(undefined); + + assert.deepStrictEqual( + events.map((e) => `${e.kind}:${e.environment.name}`), + ['remove:shared', 'add:shared'], + 'a changed group on the same path emits exact remove then add', + ); + const collection = (mgr as any).collection; + assert.strictEqual(collection.length, 1, 'the changed entry replaces the old one'); + assert.strictEqual(collection[0].group, 'Prefix', 'the collection reflects the updated group'); + }); + + test('successful refresh emits exact remove then add when a same-path environment activation changes', async () => { + const sharedPath = Uri.file('/opt/miniconda3/envs/shared').fsPath; + refreshCondaEnvsStub.resolves([ + createMockPythonEnvironment({ + name: 'shared', + envPath: sharedPath, + version: '3.9.0', + id: 'shared-old', + activation: [{ executable: 'conda', args: ['activate', 'shared'] }], + }), + ]); + const mgr = createManager(); + await mgr.initialize(); + + const events = collectEvents(mgr); + refreshCondaEnvsStub.resolves([ + createMockPythonEnvironment({ + name: 'shared', + envPath: sharedPath, + version: '3.9.0', + id: 'shared-new', + activation: [{ executable: 'conda', args: ['activate', 'shared', '--stack'] }], + }), + ]); + await mgr.refresh(undefined); + + assert.deepStrictEqual( + events.map((e) => `${e.kind}:${e.environment.name}`), + ['remove:shared', 'add:shared'], + 'a changed activation command on the same path emits exact remove then add', + ); + const collection = (mgr as any).collection; + assert.strictEqual(collection.length, 1, 'the changed entry replaces the old one'); + assert.deepStrictEqual( + collection[0].execInfo.activation, + [{ executable: 'conda', args: ['activate', 'shared', '--stack'] }], + 'the collection reflects the updated activation', + ); + }); + + function createManager(): CondaEnvManager { + const api = { + getPythonProjects: sinon.stub().returns([]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + return new CondaEnvManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, + ); + } + + function collectEvents(mgr: CondaEnvManager): any[] { + const events: any[] = []; + mgr.onDidChangeEnvironments((e) => events.push(...e)); + return events; + } + + const base = () => makeEnv('base', Uri.file('/opt/miniconda3').fsPath, '3.12.0'); + const envB = () => makeEnv('envB', Uri.file('/opt/miniconda3/envs/envB').fsPath, '3.11.0'); +}); diff --git a/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts b/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts new file mode 100644 index 000000000..547fc7173 --- /dev/null +++ b/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as persistentState from '../../../common/persistentState'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { refreshCondaEnvs } from '../../../managers/conda/condaUtils'; + +suite('condaUtils.refreshCondaEnvs - failure vs. successful contract', () => { + let nativeFinder: { refresh: sinon.SinonStub }; + let api: PythonEnvironmentApi; + let log: LogOutputChannel; + let manager: EnvironmentManager; + + setup(() => { + nativeFinder = { refresh: sinon.stub() }; + api = {} as PythonEnvironmentApi; + log = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } as unknown as LogOutputChannel; + manager = {} as EnvironmentManager; + + sinon.stub(logging, 'traceError'); + sinon.stub(logging, 'traceWarn'); + sinon.stub(logging, 'traceInfo'); + sinon.stub(logging, 'traceVerbose'); + + sinon.stub(persistentState, 'getWorkspacePersistentState').resolves({ + get: sinon.stub().resolves(undefined), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + } as any); + }); + + teardown(() => { + sinon.restore(); + }); + + test('returns undefined when the native finder rejects (discovery failure)', async () => { + nativeFinder.refresh.rejects(new Error('native finder boom')); + + const result = await refreshCondaEnvs( + true, + nativeFinder as unknown as NativePythonFinder, + api, + log, + manager, + ); + + assert.strictEqual(result, undefined, 'a rejected refresh must be reported as failure (undefined)'); + }); + + test('returns an empty array (not undefined) on a successful discovery with no conda envs', async () => { + nativeFinder.refresh.resolves([]); + + const result = await refreshCondaEnvs( + false, + nativeFinder as unknown as NativePythonFinder, + api, + log, + manager, + ); + + assert.ok(Array.isArray(result), 'a successful empty discovery must return an array, not undefined'); + assert.strictEqual(result!.length, 0, 'a successful empty discovery must return an empty array'); + }); + + test('returns undefined when the native finder produces a non-array result (malformed discovery)', async () => { + for (const bad of [null, undefined, { not: 'an array' }, 'oops']) { + nativeFinder.refresh.resolves(bad as any); + + const result = await refreshCondaEnvs( + true, + nativeFinder as unknown as NativePythonFinder, + api, + log, + manager, + ); + + assert.strictEqual(result, undefined, `a non-array finder result (${String(bad)}) must be reported as failure`); + } + }); +}); diff --git a/src/test/mocks/pythonEnvironment.ts b/src/test/mocks/pythonEnvironment.ts index 78562c4ed..4a0c4cbc1 100644 --- a/src/test/mocks/pythonEnvironment.ts +++ b/src/test/mocks/pythonEnvironment.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Uri } from 'vscode'; -import { PythonEnvironment } from '../../api'; +import { EnvironmentGroupInfo, PythonCommandRunConfiguration, PythonEnvironment } from '../../api'; import { PythonEnvironmentImpl } from '../../internal.api'; /** @@ -25,6 +25,10 @@ export interface MockPythonEnvironmentOptions { description?: string; /** Optional display name. Defaults to ` ()`. */ displayName?: string; + /** Optional group used to exercise reconciliation of consumer-visible metadata. */ + group?: string | EnvironmentGroupInfo; + /** Optional explicit `execInfo.activation` commands. */ + activation?: PythonCommandRunConfiguration[]; /** If true, includes an `activation` entry in `execInfo`. */ hasActivation?: boolean; } @@ -46,6 +50,8 @@ export function createMockPythonEnvironment(options: MockPythonEnvironmentOption id = `${name}-test`, description, displayName = `${name} (${version})`, + group, + activation, hasActivation = false, } = options; @@ -57,13 +63,16 @@ export function createMockPythonEnvironment(options: MockPythonEnvironmentOption displayPath: envPath, version, description, + group, environmentPath: Uri.file(envPath), sysPrefix, execInfo: { run: { executable: 'python' }, - ...(hasActivation && { - activation: [{ executable: envPath.replace('python', 'activate') }], - }), + ...(activation + ? { activation } + : hasActivation + ? { activation: [{ executable: envPath.replace('python', 'activate') }] } + : {}), }, }, );