From 7d38442b3c83aca9096e567e7f2df49b71d352ef Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 08:43:30 -0800 Subject: [PATCH 1/3] feat: add command to reveal environment in manager view --- package.json | 15 ++ package.nls.json | 1 + src/extension.ts | 4 + src/features/envCommands.ts | 14 ++ src/features/views/envManagersView.ts | 58 ++++- src/test/features/envCommands.unit.test.ts | 52 ++++- .../views/envManagersView.unit.test.ts | 220 ++++++++++++++++++ 7 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 src/test/features/views/envManagersView.unit.test.ts diff --git a/package.json b/package.json index d423d204..cbf5f502 100644 --- a/package.json +++ b/package.json @@ -313,6 +313,12 @@ "category": "Python Envs", "icon": "$(folder-opened)" }, + { + "command": "python-envs.revealEnvInManagerView", + "title": "%python-envs.revealEnvInManagerView.title%", + "category": "Python Envs", + "icon": "$(eye)" + }, { "command": "python-envs.runPetInTerminal", "title": "%python-envs.runPetInTerminal.title%", @@ -423,6 +429,10 @@ "command": "python-envs.revealProjectInExplorer", "when": "false" }, + { + "command": "python-envs.revealEnvInManagerView", + "when": "false" + }, { "command": "python-envs.createNewProjectFromTemplate", "when": "config.python.useEnvironmentsExtension != false" @@ -531,6 +541,11 @@ "command": "python-envs.uninstallPackage", "group": "inline", "when": "view == python-projects && viewItem == python-package" + }, + { + "command": "python-envs.revealEnvInManagerView", + "group": "inline", + "when": "view == python-projects && viewItem == python-env" } ], "view/title": [ diff --git a/package.nls.json b/package.nls.json index 876385bf..7b5b568c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -42,6 +42,7 @@ "python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal", "python-envs.uninstallPackage.title": "Uninstall Package", "python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer", + "python-envs.revealEnvInManagerView.title": "Reveal in Environment Managers View", "python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...", "python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv." } diff --git a/src/extension.ts b/src/extension.ts index 124b3fae..460fb0d4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,6 +35,7 @@ import { refreshPackagesCommand, removeEnvironmentCommand, removePythonProject, + revealEnvInManagerView, revealProjectInExplorer, runAsTaskCommand, runInDedicatedTerminalCommand, @@ -312,6 +313,9 @@ export async function activate(context: ExtensionContext): Promise { await revealProjectInExplorer(item); }), + commands.registerCommand('python-envs.revealEnvInManagerView', async (item) => { + await revealEnvInManagerView(item, managerView); + }), commands.registerCommand('python-envs.terminal.activate', async () => { const terminal = activeTerminal(); if (terminal) { diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index f74727bb..255ad0de 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -52,6 +52,7 @@ import { import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; import { TerminalManager } from './terminal/terminalManager'; +import { EnvManagerView } from './views/envManagersView'; import { EnvManagerTreeItem, EnvTreeItemKind, @@ -751,3 +752,16 @@ export async function revealProjectInExplorer(item: unknown): Promise { traceVerbose(`Invalid context for reveal project in explorer: ${item}`); } } + +/** + * Focuses the Environment Managers view and reveals the given project environment. + */ +export async function revealEnvInManagerView(item: unknown, managerView: EnvManagerView): Promise { + if (item instanceof ProjectEnvironment) { + await commands.executeCommand('env-managers.focus'); + await managerView.reveal(item.environment); + return; + } + + traceVerbose(`Invalid context for reveal environment in manager view: ${item}`); +} diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 5cc11dd8..f5328fd9 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -34,10 +34,14 @@ export class EnvManagerView implements TreeDataProvider, Disposable >(); private revealMap = new Map(); private managerViews = new Map(); + private groupViews = new Map(); private selected: Map = new Map(); private disposables: Disposable[] = []; - public constructor(public providers: EnvironmentManagers, private stateManager: ITemporaryStateManager) { + public constructor( + public providers: EnvironmentManagers, + private stateManager: ITemporaryStateManager, + ) { this.treeView = window.createTreeView('env-managers', { treeDataProvider: this, }); @@ -46,6 +50,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable new Disposable(() => { this.revealMap.clear(); this.managerViews.clear(); + this.groupViews.clear(); this.selected.clear(); }), this.treeView, @@ -107,6 +112,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable if (!element) { const views: EnvTreeItem[] = []; this.managerViews.clear(); + this.groupViews.clear(); this.providers.managers.forEach((m) => { const view = new EnvManagerTreeItem(m); views.push(view); @@ -137,7 +143,10 @@ export class EnvManagerView implements TreeDataProvider, Disposable }); groupObjects.forEach((group) => { - views.push(new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group)); + const groupView = new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group); + const groupName = typeof group === 'string' ? group : group.name; + this.groupViews.set(`${manager.id}:${groupName}`, groupView); + views.push(groupView); }); if (views.length === 0) { @@ -202,12 +211,47 @@ export class EnvManagerView implements TreeDataProvider, Disposable return element.parent; } - reveal(environment?: PythonEnvironment) { - const view = environment ? this.revealMap.get(environment.envId.id) : undefined; + /** + * Reveals and focuses on the given environment in the Environment Managers view. + * + * @param environment - The Python environment to reveal + */ + async reveal(environment?: PythonEnvironment): Promise { + if (!environment) { + return; + } + + const manager = this.providers.getEnvironmentManager(environment); + if (!manager) { + return; + } + + if (!this.managerViews.has(manager.id)) { + await this.getChildren(undefined); + } + + const managerView = this.managerViews.get(manager.id); + if (!managerView) { + return; + } + + const groupName = typeof environment.group === 'string' ? environment.group : environment.group?.name; + if (groupName) { + if (!this.groupViews.has(`${manager.id}:${groupName}`)) { + await this.getChildren(managerView); + } + + const groupView = this.groupViews.get(`${manager.id}:${groupName}`); + if (groupView) { + await this.getChildren(groupView); + } + } else { + await this.getChildren(managerView); + } + + const view = this.revealMap.get(environment.envId.id); if (view && this.treeView.visible) { - setImmediate(async () => { - await this.treeView.reveal(view); - }); + await this.treeView.reveal(view, { expand: false, focus: true, select: true }); } } diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index e7ee3ce7..ae7aac87 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,11 +1,13 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Uri } from 'vscode'; +import { commands, Uri } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import { createAnyEnvironmentCommand } from '../../features/envCommands'; +import { createAnyEnvironmentCommand, revealEnvInManagerView } from '../../features/envCommands'; +import { EnvManagerView } from '../../features/views/envManagersView'; +import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api'; import { setupNonThenable } from '../mocks/helper'; @@ -175,3 +177,49 @@ suite('Create Any Environment Command Tests', () => { em.verifyAll(); }); }); + +suite('Reveal Env In Manager View Command Tests', () => { + let managerView: typeMoq.IMock; + let executeCommandStub: sinon.SinonStub; + + setup(() => { + managerView = typeMoq.Mock.ofType(); + setupNonThenable(managerView); + executeCommandStub = sinon.stub(commands, 'executeCommand'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Focuses env-managers view and reveals environment when given a ProjectEnvironment', async () => { + // Mock + const project: PythonProject = { + uri: Uri.file('/test/project'), + name: 'test-project', + }; + const projectItem = new ProjectItem(project); + + const environment: PythonEnvironment = { + envId: { id: 'test-env-id', managerId: 'test-manager' }, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + version: '3.10.0', + environmentPath: Uri.file('/path/to/env'), + execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } }, + sysPrefix: '/path/to/env', + }; + const projectEnv = new ProjectEnvironment(projectItem, environment); + + executeCommandStub.resolves(); + managerView.setup((m) => m.reveal(environment)).returns(() => Promise.resolve()); + + // Run + await revealEnvInManagerView(projectEnv, managerView.object); + + // Assert + assert.ok(executeCommandStub.calledOnceWith('env-managers.focus'), 'Should focus the env-managers view'); + managerView.verify((m) => m.reveal(environment), typeMoq.Times.once()); + }); +}); diff --git a/src/test/features/views/envManagersView.unit.test.ts b/src/test/features/views/envManagersView.unit.test.ts new file mode 100644 index 00000000..a4819189 --- /dev/null +++ b/src/test/features/views/envManagersView.unit.test.ts @@ -0,0 +1,220 @@ +import * as sinon from 'sinon'; +import * as typeMoq from 'typemoq'; +import { EventEmitter, TreeView, Uri, window } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import { EnvManagerView } from '../../../features/views/envManagersView'; +import { ITemporaryStateManager } from '../../../features/views/temporaryStateManager'; +import { EnvTreeItem } from '../../../features/views/treeViewItems'; +import { + DidChangeEnvironmentManagerEventArgs, + DidChangePackageManagerEventArgs, + EnvironmentManagers, + InternalDidChangeEnvironmentsEventArgs, + InternalDidChangePackagesEventArgs, + InternalEnvironmentManager, +} from '../../../internal.api'; +import { setupNonThenable } from '../../mocks/helper'; + +suite('EnvManagerView.reveal Tests', () => { + let envManagers: typeMoq.IMock; + let stateManager: typeMoq.IMock; + let manager: typeMoq.IMock; + let treeView: typeMoq.IMock>; + let createTreeViewStub: sinon.SinonStub; + + // Event emitters for EnvironmentManagers + let onDidChangeEnvironmentsEmitter: EventEmitter; + let onDidChangeEnvironmentManagerEmitter: EventEmitter; + let onDidChangePackagesEmitter: EventEmitter; + let onDidChangePackageManagerEmitter: EventEmitter; + let onDidChangeStateEmitter: EventEmitter<{ itemId: string; stateKey: string }>; + + setup(() => { + // Create event emitters + onDidChangeEnvironmentsEmitter = new EventEmitter(); + onDidChangeEnvironmentManagerEmitter = new EventEmitter(); + onDidChangePackagesEmitter = new EventEmitter(); + onDidChangePackageManagerEmitter = new EventEmitter(); + onDidChangeStateEmitter = new EventEmitter(); + + // Mock manager + manager = typeMoq.Mock.ofType(); + manager.setup((m) => m.id).returns(() => 'test-manager'); + manager.setup((m) => m.displayName).returns(() => 'Test Manager'); + setupNonThenable(manager); + + // Mock environment managers + envManagers = typeMoq.Mock.ofType(); + envManagers.setup((e) => e.managers).returns(() => [manager.object]); + envManagers.setup((e) => e.onDidChangeEnvironments).returns(() => onDidChangeEnvironmentsEmitter.event); + envManagers + .setup((e) => e.onDidChangeEnvironmentManager) + .returns(() => onDidChangeEnvironmentManagerEmitter.event); + envManagers.setup((e) => e.onDidChangePackages).returns(() => onDidChangePackagesEmitter.event); + envManagers.setup((e) => e.onDidChangePackageManager).returns(() => onDidChangePackageManagerEmitter.event); + setupNonThenable(envManagers); + + // Mock state manager + stateManager = typeMoq.Mock.ofType(); + stateManager.setup((s) => s.onDidChangeState).returns(() => onDidChangeStateEmitter.event); + setupNonThenable(stateManager); + + // Mock tree view + treeView = typeMoq.Mock.ofType>(); + treeView.setup((t) => t.visible).returns(() => true); + setupNonThenable(treeView); + + // Stub window.createTreeView + createTreeViewStub = sinon.stub(window, 'createTreeView').returns(treeView.object); + }); + + teardown(() => { + sinon.restore(); + onDidChangeEnvironmentsEmitter.dispose(); + onDidChangeEnvironmentManagerEmitter.dispose(); + onDidChangePackagesEmitter.dispose(); + onDidChangePackageManagerEmitter.dispose(); + onDidChangeStateEmitter.dispose(); + }); + + test('Reveals environment without group by expanding manager', async () => { + // Mock + const environment: PythonEnvironment = { + envId: { id: 'env-id', managerId: 'test-manager' }, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + version: '3.10.0', + environmentPath: Uri.file('/path/to/env'), + execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } }, + sysPrefix: '/path/to/env', + }; + + envManagers.setup((e) => e.getEnvironmentManager(environment)).returns(() => manager.object); + manager.setup((m) => m.getEnvironments('all')).returns(() => Promise.resolve([environment])); + + treeView + .setup((t) => + t.reveal(typeMoq.It.isAny(), typeMoq.It.isObjectWith({ expand: false, focus: true, select: true })), + ) + .returns(() => Promise.resolve()) + .verifiable(typeMoq.Times.once()); + + const view = new EnvManagerView(envManagers.object, stateManager.object); + + // Run + await view.reveal(environment); + + // Assert + treeView.verifyAll(); + + view.dispose(); + }); + + test('Reveals environment with string group by expanding manager and group', async () => { + // Mock + const environment: PythonEnvironment = { + envId: { id: 'env-id', managerId: 'test-manager' }, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + version: '3.10.0', + environmentPath: Uri.file('/path/to/env'), + execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } }, + sysPrefix: '/path/to/env', + group: 'TestGroup', + }; + + envManagers.setup((e) => e.getEnvironmentManager(environment)).returns(() => manager.object); + manager.setup((m) => m.getEnvironments('all')).returns(() => Promise.resolve([environment])); + + treeView + .setup((t) => + t.reveal(typeMoq.It.isAny(), typeMoq.It.isObjectWith({ expand: false, focus: true, select: true })), + ) + .returns(() => Promise.resolve()) + .verifiable(typeMoq.Times.once()); + + const view = new EnvManagerView(envManagers.object, stateManager.object); + + // Run + await view.reveal(environment); + + // Assert + treeView.verifyAll(); + + view.dispose(); + }); + + test('Reveals environment with EnvironmentGroupInfo group', async () => { + // Mock + const environment: PythonEnvironment = { + envId: { id: 'env-id', managerId: 'test-manager' }, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + version: '3.10.0', + environmentPath: Uri.file('/path/to/env'), + execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } }, + sysPrefix: '/path/to/env', + group: { name: 'GroupName', description: 'Group Description' }, + }; + + envManagers.setup((e) => e.getEnvironmentManager(environment)).returns(() => manager.object); + manager.setup((m) => m.getEnvironments('all')).returns(() => Promise.resolve([environment])); + + treeView + .setup((t) => + t.reveal(typeMoq.It.isAny(), typeMoq.It.isObjectWith({ expand: false, focus: true, select: true })), + ) + .returns(() => Promise.resolve()) + .verifiable(typeMoq.Times.once()); + + const view = new EnvManagerView(envManagers.object, stateManager.object); + + // Run + await view.reveal(environment); + + // Assert + treeView.verifyAll(); + + view.dispose(); + }); + + test('Does not call treeView.reveal when tree view is not visible', async () => { + // Mock - tree view not visible + treeView.reset(); + treeView.setup((t) => t.visible).returns(() => false); + setupNonThenable(treeView); + + const environment: PythonEnvironment = { + envId: { id: 'env-id', managerId: 'test-manager' }, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + version: '3.10.0', + environmentPath: Uri.file('/path/to/env'), + execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } }, + sysPrefix: '/path/to/env', + }; + + envManagers.setup((e) => e.getEnvironmentManager(environment)).returns(() => manager.object); + manager.setup((m) => m.getEnvironments('all')).returns(() => Promise.resolve([environment])); + + // Re-stub createTreeView to return the updated mock + createTreeViewStub.restore(); + createTreeViewStub = sinon.stub(window, 'createTreeView').returns(treeView.object); + + const view = new EnvManagerView(envManagers.object, stateManager.object); + + treeView.setup((t) => t.reveal(typeMoq.It.isAny(), typeMoq.It.isAny())).verifiable(typeMoq.Times.never()); + + // Run + await view.reveal(environment); + + // Assert - reveal should not be called when not visible + treeView.verifyAll(); + + view.dispose(); + }); +}); From 1efdddbc29bb82e24472ec8788b8f3976c55ef2b Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 08:51:01 -0800 Subject: [PATCH 2/3] wrapper function for vscode for testing --- src/common/window.apis.ts | 6 ++++++ src/features/envCommands.ts | 18 +++++------------- src/features/views/envManagersView.ts | 5 +++-- src/test/features/envCommands.unit.test.ts | 5 +++-- .../views/envManagersView.unit.test.ts | 7 ++++--- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index 26af79d5..89326a82 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -27,6 +27,8 @@ import { TerminalShellExecutionStartEvent, TerminalShellIntegrationChangeEvent, TextEditor, + TreeView, + TreeViewOptions, Uri, window, WindowState, @@ -377,3 +379,7 @@ export function onDidChangeWindowState( ): Disposable { return window.onDidChangeWindowState(listener, thisArgs, disposables); } + +export function createTreeView(viewId: string, options: TreeViewOptions): TreeView { + return window.createTreeView(viewId, options); +} diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 255ad0de..2e774602 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -1,15 +1,6 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { - commands, - ProgressLocation, - QuickInputButtons, - TaskExecution, - TaskRevealKind, - Terminal, - Uri, - workspace, -} from 'vscode'; +import { ProgressLocation, QuickInputButtons, TaskExecution, TaskRevealKind, Terminal, Uri, workspace } from 'vscode'; import { CreateEnvironmentOptions, PythonEnvironment, @@ -28,6 +19,7 @@ import { } from '../internal.api'; import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; +import { executeCommand } from '../common/command.api'; import { clipboardWriteText } from '../common/env.apis'; import {} from '../common/errors/utils'; import { Pickers } from '../common/localize'; @@ -470,7 +462,7 @@ export async function addPythonProjectCommand( 'Open Folder', ); if (r === 'Open Folder') { - await commands.executeCommand('vscode.openFolder'); + await executeCommand('vscode.openFolder'); return; } } @@ -747,7 +739,7 @@ export async function copyPathToClipboard(item: unknown): Promise { export async function revealProjectInExplorer(item: unknown): Promise { if (item instanceof ProjectItem) { const projectUri = item.project.uri; - await commands.executeCommand('revealInExplorer', projectUri); + await executeCommand('revealInExplorer', projectUri); } else { traceVerbose(`Invalid context for reveal project in explorer: ${item}`); } @@ -758,7 +750,7 @@ export async function revealProjectInExplorer(item: unknown): Promise { */ export async function revealEnvInManagerView(item: unknown, managerView: EnvManagerView): Promise { if (item instanceof ProjectEnvironment) { - await commands.executeCommand('env-managers.focus'); + await executeCommand('env-managers.focus'); await managerView.reveal(item.environment); return; } diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index f5328fd9..06becc9b 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -1,7 +1,8 @@ -import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView, window } from 'vscode'; +import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView } from 'vscode'; import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api'; import { ProjectViews } from '../../common/localize'; import { createSimpleDebounce } from '../../common/utils/debounce'; +import { createTreeView } from '../../common/window.apis'; import { DidChangeEnvironmentManagerEventArgs, DidChangePackageManagerEventArgs, @@ -42,7 +43,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable public providers: EnvironmentManagers, private stateManager: ITemporaryStateManager, ) { - this.treeView = window.createTreeView('env-managers', { + this.treeView = createTreeView('env-managers', { treeDataProvider: this, }); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index ae7aac87..e972d3b4 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,8 +1,9 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { commands, Uri } from 'vscode'; +import { Uri } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; +import * as commandApi from '../../common/command.api'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; import { createAnyEnvironmentCommand, revealEnvInManagerView } from '../../features/envCommands'; @@ -185,7 +186,7 @@ suite('Reveal Env In Manager View Command Tests', () => { setup(() => { managerView = typeMoq.Mock.ofType(); setupNonThenable(managerView); - executeCommandStub = sinon.stub(commands, 'executeCommand'); + executeCommandStub = sinon.stub(commandApi, 'executeCommand'); }); teardown(() => { diff --git a/src/test/features/views/envManagersView.unit.test.ts b/src/test/features/views/envManagersView.unit.test.ts index a4819189..57ecd74f 100644 --- a/src/test/features/views/envManagersView.unit.test.ts +++ b/src/test/features/views/envManagersView.unit.test.ts @@ -1,7 +1,8 @@ import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { EventEmitter, TreeView, Uri, window } from 'vscode'; +import { EventEmitter, TreeView, Uri } from 'vscode'; import { PythonEnvironment } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; import { EnvManagerView } from '../../../features/views/envManagersView'; import { ITemporaryStateManager } from '../../../features/views/temporaryStateManager'; import { EnvTreeItem } from '../../../features/views/treeViewItems'; @@ -65,7 +66,7 @@ suite('EnvManagerView.reveal Tests', () => { setupNonThenable(treeView); // Stub window.createTreeView - createTreeViewStub = sinon.stub(window, 'createTreeView').returns(treeView.object); + createTreeViewStub = sinon.stub(windowApis, 'createTreeView').returns(treeView.object); }); teardown(() => { @@ -203,7 +204,7 @@ suite('EnvManagerView.reveal Tests', () => { // Re-stub createTreeView to return the updated mock createTreeViewStub.restore(); - createTreeViewStub = sinon.stub(window, 'createTreeView').returns(treeView.object); + createTreeViewStub = sinon.stub(windowApis, 'createTreeView').returns(treeView.object); const view = new EnvManagerView(envManagers.object, stateManager.object); From 9b3a69b89a8cff0a21abe7d071471ac499c386a8 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 08:53:45 -0800 Subject: [PATCH 3/3] learning --- .../testing-workflow.instructions.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 60a41505..8dd6067b 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -37,17 +37,20 @@ This guide covers the full testing lifecycle: These mistakes have occurred REPEATEDLY. Check this list BEFORE writing any test code: -| Mistake | Fix | -| ---------------------------------------------- | ------------------------------------------------------------------ | -| Hardcoded POSIX paths like `'/test/workspace'` | Use `'.'` for relative paths, `Uri.file(x).fsPath` for comparisons | -| Stubbing `workspace.getConfiguration` directly | Stub the wrapper `workspaceApis.getConfiguration` instead | -| Stubbing `workspace.workspaceFolders` property | Stub wrapper function `workspaceApis.getWorkspaceFolders()` | -| Comparing `fsPath` to raw string | Compare `fsPath` to `Uri.file(expected).fsPath` | +| Mistake | Fix | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Hardcoded POSIX paths like `'/test/workspace'` | Use `'.'` for relative paths, `Uri.file(x).fsPath` for comparisons | +| Stubbing `workspace.getConfiguration` directly | Stub the wrapper `workspaceApis.getConfiguration` instead | +| Stubbing `workspace.workspaceFolders` property | Stub wrapper function `workspaceApis.getWorkspaceFolders()` | +| Comparing `fsPath` to raw string | Compare `fsPath` to `Uri.file(expected).fsPath` | +| Stubbing `commands.executeCommand` directly | First update production code to use `executeCommand` from `command.api.ts`, then stub that | +| Stubbing `window.createTreeView` directly | First update production code to use `createTreeView` from `window.apis.ts`, then stub that | **Pre-flight checklist before completing test work:** - [ ] All paths use `Uri.file().fsPath` (no hardcoded `/path/to/x`) - [ ] All VS Code API stubs use wrapper modules, not `vscode.*` directly +- [ ] Production code uses wrappers for any VS Code API that tests need to stub (check `src/common/*.apis.ts`) - [ ] Tests pass on both Windows and POSIX ## Test Types @@ -597,4 +600,5 @@ envConfig.inspect - Use `sinon.useFakeTimers()` with `clock.tickAsync()` instead of `await new Promise(resolve => setTimeout(resolve, ms))` for debounce/timeout handling - eliminates flakiness and speeds up tests significantly (1) - Always compile tests (`npm run compile-tests`) before running them after adding new test cases - test counts will be wrong if running against stale compiled output (1) - Never create "documentation tests" that just `assert.ok(true)` — if mocking limitations prevent testing, either test a different layer that IS mockable, or skip the test entirely with a clear explanation (1) -- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3) +- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (4) +- **Before writing tests**, check if the function under test calls VS Code APIs directly (e.g., `commands.executeCommand`, `window.createTreeView`, `workspace.getConfiguration`). If so, FIRST update the production code to use wrapper functions from `src/common/*.apis.ts` (create the wrapper if it doesn't exist), THEN write tests that stub those wrappers. This prevents CI failures where sinon cannot stub the vscode namespace (4)