From c3b00113e6adbb6030af2d94106f280cecd53e38 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Sat, 22 Aug 2026 19:08:10 -0700 Subject: [PATCH 1/2] fix: preserve conda environments on transient discovery failure refreshCondaEnvs now returns undefined when the native finder throws/rejects or returns a non-array/malformed value, distinct from an authoritative successful empty array. CondaEnvManager guards its initialize, refresh, and background-get discovery paths so a transient failure preserves the known-good collection and emits no removals, while a successful empty result still removes stale environments normally. Persisted global/workspace selections are restored via loadEnvMap on the failure path; loadEnvMap returns the environments it appended so only those are announced, it re-checks membership by exact path after resolution so overlapping failed refreshes cannot double-append, and the failure path revalidates each appended environment against the current collection before announcing so a concurrent successful refresh that replaced the collection cannot produce a stale add. The successful discovery paths announce only their authoritative refresh results plus the environments their own loadEnvMap invocation appended, so an environment appended and announced by a concurrent failed refresh is not announced a second time. A successful refresh reconciles its add/remove events by normalized path, and treats a same-path environment as continuous only when its observable metadata is unchanged, so a path present before and after the refresh is neither removed nor re-added when nothing consumers observe changed, preventing add/remove/add churn and a transient disappearance when a delayed successful refresh captures a path a prior failed refresh already announced, while a same-path environment whose observable metadata changed still emits an exact remove of the old followed by an add of the new. When initialization''s own discovery fails, it resolves current waiters without throwing and then clears the initialization state only if that failed attempt still owns it, so the next ordinary get retries discovery and a later successful attempt stays initialized. A background initialization triggered by the fast path likewise propagates a failed transient discovery as a rejected outcome so the initialization state is reset rather than marked permanently complete, letting the next get or initialize retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/managers/conda/condaEnvManager.ts | 127 +++- src/managers/conda/condaUtils.ts | 12 +- ...EnvManager.resultPreservation.unit.test.ts | 577 ++++++++++++++++++ .../condaUtils.refreshCondaEnvs.unit.test.ts | 83 +++ 4 files changed, 773 insertions(+), 26 deletions(-) create mode 100644 src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts create mode 100644 src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 39495a416..2b1a52541 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -95,12 +95,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 +125,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 +180,10 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + deferred.resolve(); + if (discoveryFailed && this._initialized === deferred) { + this._initialized = undefined; + } } } @@ -314,14 +324,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 || !this.isEquivalentEnvironment(env, current); + }) + .map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })), + ...resolvedEnvs + .filter((env) => { + const previous = discardedByPath.get(normalizePath(env.environmentPath.fsPath)); + return !previous || !this.isEquivalentEnvironment(previous, env); + }) + .map((env) => ({ kind: EnvironmentChangeKind.add, environment: env })), ]; this._onDidChangeEnvironments.fire(args); @@ -342,15 +375,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 +525,30 @@ 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 isEquivalentEnvironment(a: PythonEnvironment, b: PythonEnvironment): boolean { + return ( + a.name === b.name && + a.displayName === b.displayName && + a.version === b.version && + a.description === b.description && + a.sysPrefix === b.sysPrefix && + a.error === b.error && + a.execInfo.run.executable === b.execInfo.run.executable + ); + } + + private async loadEnvMap(): Promise { + const appended: PythonEnvironment[] = []; this.globalEnv = undefined; this.fsPathToEnv.clear(); @@ -498,11 +560,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 +613,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 +643,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } } + return appended; } private fromEnvMap(uri: Uri): PythonEnvironment | undefined { @@ -612,6 +688,11 @@ 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; 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..c85d8e04c --- /dev/null +++ b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts @@ -0,0 +1,577 @@ +/* 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'); + }); + + 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`); + } + }); +}); From 8e2fa53790f4d7d7b755a8c1f67fdbfa81364698 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Sun, 23 Aug 2026 11:44:47 -0700 Subject: [PATCH 2/2] fix: compare all consumer-visible fields in conda same-path reconciliation The same-path continuity check used by a successful conda refresh only compared a subset of fields (name, displayName, version, description, sysPrefix, error, run.executable), so a same-path environment whose other consumer-visible metadata changed (shortDisplayName, displayPath, tooltip, iconPath, group, or any execInfo activation/deactivation command or shell map) updated the collection without emitting a remove/add, leaving consumers stale. Reconciliation now compares every public PythonEnvironmentInfo field except environmentPath (already matched by normalized path) and the manager-generated random envId.id, using typed structural equality for arrays, Maps, and Uri/MarkdownString/ThemeIcon values. Truly equivalent same-path resolutions (differing only by the random id) still suppress churn, preserving the reverse-race guarantee, while any observable metadata change emits an exact remove of the old followed by an add of the new. The empty-payload fire on a no-op refresh is retained to stay consistent with the sibling venv/poetry/pyenv managers, which all fire unconditionally. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/managers/conda/condaEnvManager.ts | 185 ++++++++++++++++-- ...EnvManager.resultPreservation.unit.test.ts | 64 ++++++ src/test/mocks/pythonEnvironment.ts | 17 +- 3 files changed, 248 insertions(+), 18 deletions(-) diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 2b1a52541..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, @@ -346,13 +349,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { ...discard .filter((env) => { const current = resolvedByPath.get(normalizePath(env.environmentPath.fsPath)); - return !current || !this.isEquivalentEnvironment(env, current); + 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 || !this.isEquivalentEnvironment(previous, env); + return !previous || !isEquivalentEnvironment(previous, env); }) .map((env) => ({ kind: EnvironmentChangeKind.add, environment: env })), ]; @@ -535,18 +538,6 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } - private isEquivalentEnvironment(a: PythonEnvironment, b: PythonEnvironment): boolean { - return ( - a.name === b.name && - a.displayName === b.displayName && - a.version === b.version && - a.description === b.description && - a.sysPrefix === b.sysPrefix && - a.error === b.error && - a.execInfo.run.executable === b.execInfo.run.executable - ); - } - private async loadEnvMap(): Promise { const appended: PythonEnvironment[] = []; this.globalEnv = undefined; @@ -699,3 +690,169 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { }); } } + +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/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts index c85d8e04c..ee3ab02d3 100644 --- a/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts @@ -554,6 +554,70 @@ suite('CondaEnvManager - result preservation on discovery failure', () => { 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([]), 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') }] } + : {}), }, }, );