Skip to content

Commit 804eebe

Browse files
edvilmeCopilot
andcommitted
fix: use case-insensitive manager ID matching to prevent false install prompts
The managerReady module used a case-sensitive startsWith scan against allExtensions() to check if an extension was installed. If there was any casing mismatch between the extension ID in settings and the ID returned by VS Code, the check would incorrectly report the extension as missing and prompt the user to install it. Additionally, the deferred maps used raw manager IDs as keys, so a registration event with different casing than the settings-provided ID would fail to resolve the waiting deferred. Changes: - Replace allExtensions().some() with getExtension() which is case-insensitive in VS Code's API - Move getExtension() call inside setImmediate to avoid stale state during startup - Normalize all map keys and checked set to lowercase - Add _resetManagerReadyForTesting() helper for test isolation - Add 5 regression tests covering case-insensitive matching Fixes #1465 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c5a1430 commit 804eebe

2 files changed

Lines changed: 201 additions & 21 deletions

File tree

src/features/common/managerReady.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Disposable, l10n, Uri } from 'vscode';
2-
import { allExtensions, getExtension } from '../../common/extension.apis';
2+
import { getExtension } from '../../common/extension.apis';
33
import { WorkbenchStrings } from '../../common/localize';
44
import { traceError, traceInfo, traceWarn } from '../../common/logging';
55
import { EventNames } from '../../common/telemetry/constants';
@@ -81,38 +81,40 @@ class ManagerReadyImpl implements ManagerReady {
8181
) {
8282
this.disposables.push(
8383
em.onDidChangeEnvironmentManager((e) => {
84-
if (this.envManagers.has(e.manager.id)) {
85-
this.envManagers.get(e.manager.id)?.resolve();
84+
const key = e.manager.id.toLowerCase();
85+
if (this.envManagers.has(key)) {
86+
this.envManagers.get(key)?.resolve();
8687
} else {
8788
const deferred = createDeferred<void>();
88-
this.envManagers.set(e.manager.id, deferred);
89+
this.envManagers.set(key, deferred);
8990
deferred.resolve();
9091
}
9192
}),
9293
em.onDidChangePackageManager((e) => {
93-
if (this.pkgManagers.has(e.manager.id)) {
94-
this.pkgManagers.get(e.manager.id)?.resolve();
94+
const key = e.manager.id.toLowerCase();
95+
if (this.pkgManagers.has(key)) {
96+
this.pkgManagers.get(key)?.resolve();
9597
} else {
9698
const deferred = createDeferred<void>();
97-
this.pkgManagers.set(e.manager.id, deferred);
99+
this.pkgManagers.set(key, deferred);
98100
deferred.resolve();
99101
}
100102
}),
101103
);
102104
}
103105

104106
private checkExtension(managerId: string) {
105-
const installed = allExtensions().some((ext) => managerId.startsWith(`${ext.id}:`));
106-
if (this.checked.has(managerId)) {
107+
const normalizedId = managerId.toLowerCase();
108+
if (this.checked.has(normalizedId)) {
107109
return;
108110
}
109-
this.checked.add(managerId);
111+
this.checked.add(normalizedId);
110112
const extId = getExtensionId(managerId);
111113
if (extId) {
112114
setImmediate(async () => {
113-
if (installed) {
114-
const ext = getExtension(extId);
115-
if (ext && !ext.isActive) {
115+
const ext = getExtension(extId);
116+
if (ext) {
117+
if (!ext.isActive) {
116118
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
117119
try {
118120
await ext.activate();
@@ -122,7 +124,10 @@ class ManagerReadyImpl implements ManagerReady {
122124
}
123125
}
124126
} else {
125-
traceError(`Extension for manager ${managerId} is not installed.`);
127+
traceError(
128+
`Extension for manager ${managerId} is not installed. ` +
129+
`Looked up extId="${extId}" via getExtension().`,
130+
);
126131
const result = await showErrorMessage(
127132
l10n.t(`Do you want to install extension {0} to enable {1} support.`, extId, managerId),
128133
WorkbenchStrings.installExtension,
@@ -160,11 +165,12 @@ class ManagerReadyImpl implements ManagerReady {
160165
}
161166

162167
private _waitForEnvManager(managerId: string): Promise<void> {
163-
if (this.envManagers.has(managerId)) {
164-
return this.envManagers.get(managerId)!.promise;
168+
const key = managerId.toLowerCase();
169+
if (this.envManagers.has(key)) {
170+
return this.envManagers.get(key)!.promise;
165171
}
166172
const deferred = createDeferred<void>();
167-
this.envManagers.set(managerId, deferred);
173+
this.envManagers.set(key, deferred);
168174
return withManagerTimeout(deferred, managerId, 'environment');
169175
}
170176

@@ -209,11 +215,12 @@ class ManagerReadyImpl implements ManagerReady {
209215
}
210216

211217
private _waitForPkgManager(managerId: string): Promise<void> {
212-
if (this.pkgManagers.has(managerId)) {
213-
return this.pkgManagers.get(managerId)!.promise;
218+
const key = managerId.toLowerCase();
219+
if (this.pkgManagers.has(key)) {
220+
return this.pkgManagers.get(key)!.promise;
214221
}
215222
const deferred = createDeferred<void>();
216-
this.pkgManagers.set(managerId, deferred);
223+
this.pkgManagers.set(key, deferred);
217224
return withManagerTimeout(deferred, managerId, 'package');
218225
}
219226

@@ -251,6 +258,14 @@ export function createManagerReady(em: EnvironmentManagers, pm: PythonProjectMan
251258
}
252259
}
253260

261+
/**
262+
* Reset the module-level singleton so `createManagerReady` can be called again.
263+
* Only for use in tests.
264+
*/
265+
export function _resetManagerReadyForTesting(): void {
266+
_deferred = createDeferred<ManagerReady>();
267+
}
268+
254269
export async function waitForEnvManager(uris?: Uri[]): Promise<void> {
255270
const mr = await _deferred.promise;
256271
return mr.waitForEnvManager(uris);

src/test/features/common/managerReady.unit.test.ts

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
11
import assert from 'assert';
22
import * as sinon from 'sinon';
3+
import { Disposable, EventEmitter } from 'vscode';
4+
import * as extensionApis from '../../../common/extension.apis';
35
import * as logging from '../../../common/logging';
46
import { EventNames } from '../../../common/telemetry/constants';
57
import * as telemetrySender from '../../../common/telemetry/sender';
68
import { createDeferred } from '../../../common/utils/deferred';
7-
import { MANAGER_READY_TIMEOUT_MS, withManagerTimeout } from '../../../features/common/managerReady';
9+
import * as windowApis from '../../../common/window.apis';
10+
import {
11+
MANAGER_READY_TIMEOUT_MS,
12+
_resetManagerReadyForTesting,
13+
createManagerReady,
14+
waitForEnvManagerId,
15+
waitForPkgManagerId,
16+
withManagerTimeout,
17+
} from '../../../features/common/managerReady';
18+
import * as settingHelpers from '../../../features/settings/settingHelpers';
19+
import {
20+
DidChangeEnvironmentManagerEventArgs,
21+
DidChangePackageManagerEventArgs,
22+
EnvironmentManagers,
23+
InternalEnvironmentManager,
24+
InternalPackageManager,
25+
PythonProjectManager,
26+
} from '../../../internal.api';
827

928
suite('withManagerTimeout', () => {
1029
let clock: sinon.SinonFakeTimers;
@@ -104,3 +123,149 @@ suite('withManagerTimeout', () => {
104123
assert.strictEqual(properties.managerKind, 'package');
105124
});
106125
});
126+
127+
suite('ManagerReady - case-insensitive ID matching', () => {
128+
let envManagerEmitter: EventEmitter<DidChangeEnvironmentManagerEventArgs>;
129+
let pkgManagerEmitter: EventEmitter<DidChangePackageManagerEventArgs>;
130+
let clock: sinon.SinonFakeTimers;
131+
let disposables: Disposable[];
132+
133+
setup(() => {
134+
clock = sinon.useFakeTimers();
135+
disposables = [];
136+
137+
_resetManagerReadyForTesting();
138+
139+
envManagerEmitter = new EventEmitter<DidChangeEnvironmentManagerEventArgs>();
140+
pkgManagerEmitter = new EventEmitter<DidChangePackageManagerEventArgs>();
141+
142+
// Stub logging and telemetry to keep test output clean
143+
sinon.stub(logging, 'traceWarn');
144+
sinon.stub(logging, 'traceError');
145+
sinon.stub(logging, 'traceInfo');
146+
sinon.stub(telemetrySender, 'sendTelemetryEvent');
147+
sinon.stub(windowApis, 'showErrorMessage');
148+
sinon.stub(extensionApis, 'getExtension').returns({
149+
id: 'ms-python.python',
150+
isActive: true,
151+
} as unknown as ReturnType<typeof extensionApis.getExtension>);
152+
sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').returns('ms-python.python:venv');
153+
sinon.stub(settingHelpers, 'getDefaultPkgManagerSetting').returns('ms-python.python:pip');
154+
155+
const mockEm = {
156+
onDidChangeEnvironmentManager: envManagerEmitter.event,
157+
onDidChangePackageManager: pkgManagerEmitter.event,
158+
} as unknown as EnvironmentManagers;
159+
160+
const mockPm = {
161+
getProjects: () => [],
162+
} as unknown as PythonProjectManager;
163+
164+
createManagerReady(mockEm, mockPm, disposables);
165+
});
166+
167+
teardown(() => {
168+
clock.restore();
169+
disposables.forEach((d) => d.dispose());
170+
envManagerEmitter.dispose();
171+
pkgManagerEmitter.dispose();
172+
sinon.restore();
173+
_resetManagerReadyForTesting();
174+
});
175+
176+
test('env manager registered with different casing resolves the wait', async () => {
177+
// Settings say "ms-python.python:venv" (lowercase), but the registered
178+
// manager might have a different-cased extension ID
179+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
180+
181+
// Simulate manager registration with mixed-case ID
182+
envManagerEmitter.fire({
183+
kind: 'registered',
184+
manager: { id: 'Ms-Python.Python:venv' } as unknown as InternalEnvironmentManager,
185+
});
186+
187+
await clock.tickAsync(0);
188+
await waitPromise;
189+
190+
// If we get here, the deferred resolved — no timeout needed
191+
});
192+
193+
test('pkg manager registered with different casing resolves the wait', async () => {
194+
const waitPromise = waitForPkgManagerId(['ms-python.python:pip']);
195+
196+
pkgManagerEmitter.fire({
197+
kind: 'registered',
198+
manager: { id: 'Ms-Python.Python:pip' } as unknown as InternalPackageManager,
199+
});
200+
201+
await clock.tickAsync(0);
202+
await waitPromise;
203+
});
204+
205+
test('manager registered before wait is called still resolves (case-insensitive)', async () => {
206+
// Manager registers first with mixed case
207+
envManagerEmitter.fire({
208+
kind: 'registered',
209+
manager: { id: 'MS-PYTHON.PYTHON:venv' } as unknown as InternalEnvironmentManager,
210+
});
211+
212+
await clock.tickAsync(0);
213+
214+
// Then we wait for the lowercase version from settings — should resolve immediately
215+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
216+
await clock.tickAsync(0);
217+
await waitPromise;
218+
});
219+
220+
test('checkExtension uses getExtension (case-insensitive) not allExtensions scan', async () => {
221+
const getExtensionStub = extensionApis.getExtension as sinon.SinonStub;
222+
getExtensionStub.withArgs('ms-python.python').returns({
223+
id: 'ms-python.python',
224+
isActive: true,
225+
});
226+
227+
// Trigger checkExtension via waitForEnvManagerId
228+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
229+
230+
// Resolve the manager to avoid timeout
231+
envManagerEmitter.fire({
232+
kind: 'registered',
233+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
234+
});
235+
236+
await clock.tickAsync(0);
237+
await waitPromise;
238+
239+
// getExtension should have been called (it's case-insensitive in VS Code)
240+
assert.ok(getExtensionStub.called, 'getExtension should be used for extension lookup');
241+
242+
// showErrorMessage should NOT have been called since the extension is found
243+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
244+
assert.ok(!showErrorStub.called, 'should not prompt to install when extension is found via getExtension');
245+
});
246+
247+
test('checkExtension deduplicates mixed-case manager IDs', async () => {
248+
const getExtensionStub = extensionApis.getExtension as sinon.SinonStub;
249+
getExtensionStub.returns({ id: 'ms-python.python', isActive: true });
250+
251+
// Wait for the same manager ID in different casings
252+
const wait1 = waitForEnvManagerId(['ms-python.python:venv']);
253+
const wait2 = waitForEnvManagerId(['Ms-Python.Python:venv']);
254+
255+
envManagerEmitter.fire({
256+
kind: 'registered',
257+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
258+
});
259+
260+
await clock.tickAsync(0);
261+
await Promise.all([wait1, wait2]);
262+
263+
// Run any pending setImmediate callbacks
264+
clock.tick(0);
265+
await clock.tickAsync(0);
266+
267+
// getExtension should only be called once (deduplicated by normalized key)
268+
const callsForPython = getExtensionStub.getCalls().filter((c: sinon.SinonSpyCall) => c.args[0] === 'ms-python.python');
269+
assert.strictEqual(callsForPython.length, 1, 'checkExtension should deduplicate case-insensitive manager IDs');
270+
});
271+
});

0 commit comments

Comments
 (0)