From faaf5e28f67b4364372c8ff6b2521c7a0ab4bd85 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:31:18 -0700 Subject: [PATCH 1/4] add copy test id menu item for pytest --- package.json | 18 ++++++++++++++++++ package.nls.json | 1 + src/client/common/application/commands.ts | 3 ++- src/client/common/constants.ts | 1 + src/client/testing/main.ts | 21 ++++++++++++++++++--- 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c9bed4e4b520..6b63ab2f4c95 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,11 @@ "category": "Python", "command": "python.createNewFile" }, + { + "category": "Python", + "command": "python.copyTestId", + "title": "%python.command.python.testing.copyTestId.title%" + }, { "category": "Python", "command": "python.analysis.restartLanguageServer", @@ -1231,6 +1236,13 @@ "command": "python.reportIssue" } ], + "testing/item/context": [ + { + "command": "python.copyTestId", + "group": "navigation", + "when": "resourceLangId == 'python'" + } + ], "commandPalette": [ { "category": "Python", @@ -1306,6 +1318,12 @@ "title": "%python.command.python.execSelectionInTerminal.title%", "when": "!virtualWorkspace && shellExecutionSupported && editorLangId == python" }, + { + "category": "Python", + "command": "python.copyTestId", + "title": "%python.command.python.testing.copyTestId.title%", + "when": "false" + }, { "category": "Python", "command": "python.execInREPL", diff --git a/package.nls.json b/package.nls.json index 00c96c09b19a..37a9ce435f2f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,7 @@ "python.command.python.analysis.restartLanguageServer.title": "Restart Language Server", "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", + "python.command.python.testing.copyTestId.title": "Copy Test Id", "python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.", "python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project", "python.menu.createNewFile.title": "Python File", diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 98ea2669d773..402025ee38db 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -3,7 +3,7 @@ 'use strict'; -import { CancellationToken, Position, TextDocument, Uri } from 'vscode'; +import { CancellationToken, Position, TestItem, TextDocument, Uri } from 'vscode'; import { Commands as LSCommands } from '../../activation/commands'; import { Channel, Commands, CommandSource } from '../constants'; import { CreateEnvironmentOptions } from '../../pythonEnvironments/creation/proposed.createEnvApis'; @@ -50,6 +50,7 @@ export type AllCommands = keyof ICommandNameArgumentTypeMapping; * Used to provide strong typing for command & args. */ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgumentTypeMapping { + [Commands.CopyTestId]: [TestItem]; [Commands.Create_Environment]: [CreateEnvironmentOptions]; ['vscode.openWith']: [Uri, string]; ['workbench.action.quickOpen']: [string]; diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 4a8962e86b58..15fd037a3d9f 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -39,6 +39,7 @@ export namespace Commands { export const CreateNewFile = 'python.createNewFile'; export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter'; export const Create_Environment = 'python.createEnvironment'; + export const CopyTestId = 'python.copyTestId'; export const Create_Environment_Button = 'python.createEnvironment-button'; export const Create_Environment_Check = 'python.createEnvironmentCheck'; export const Create_Terminal = 'python.createTerminal'; diff --git a/src/client/testing/main.ts b/src/client/testing/main.ts index c2675ed4a72b..93034a3f7858 100644 --- a/src/client/testing/main.ts +++ b/src/client/testing/main.ts @@ -1,7 +1,17 @@ 'use strict'; import { inject, injectable } from 'inversify'; -import { ConfigurationChangeEvent, Disposable, Uri, tests, TestResultState, WorkspaceFolder, Command } from 'vscode'; +import { + ConfigurationChangeEvent, + Disposable, + Uri, + tests, + TestResultState, + WorkspaceFolder, + Command, + TestItem, +} from 'vscode'; +import { env } from 'vscode'; import { IApplicationShell, ICommandManager, IContextKeyManager, IWorkspaceService } from '../common/application/types'; import * as constants from '../common/constants'; import '../common/extensions'; @@ -20,7 +30,7 @@ import { DelayedTrigger, IDelayedTrigger } from '../common/utils/delayTrigger'; import { ExtensionContextKey } from '../common/application/contextKeys'; import { checkForFailedTests, updateTestResultMap } from './testController/common/testItemUtilities'; import { Testing } from '../common/utils/localize'; -import { traceVerbose } from '../logging'; +import { traceLog, traceVerbose } from '../logging'; @injectable() export class TestingService implements ITestingService { @@ -158,7 +168,6 @@ export class UnitTestManagementService implements IExtensionActivationService { private registerCommands(): void { const commandManager = this.serviceContainer.get(ICommandManager); - this.disposableRegistry.push( commandManager.registerCommand( constants.Commands.Tests_Configure, @@ -195,6 +204,12 @@ export class UnitTestManagementService implements IExtensionActivationService { }, }; }), + commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => { + if (testItem && typeof testItem.id === 'string') { + await env.clipboard.writeText(testItem.id); + traceLog('Testing: Copied test id to clipboard, id: ' + testItem.id); + } + }), ); } From 687920f527ce6d7ce1809515f817d9d184588db0 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:35:58 -0700 Subject: [PATCH 2/4] updates to support unittest copy --- package.json | 2 +- src/client/testing/main.ts | 8 ++--- src/client/testing/utils.ts | 49 +++++++++++++++++++++++++++ src/test/testing/utils.unit.test.ts | 51 +++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 src/client/testing/utils.ts create mode 100644 src/test/testing/utils.unit.test.ts diff --git a/package.json b/package.json index 6b63ab2f4c95..047894514956 100644 --- a/package.json +++ b/package.json @@ -1240,7 +1240,7 @@ { "command": "python.copyTestId", "group": "navigation", - "when": "resourceLangId == 'python'" + "when": "controllerId == 'python-tests'" } ], "commandPalette": [ diff --git a/src/client/testing/main.ts b/src/client/testing/main.ts index 93034a3f7858..f6b77fb57e81 100644 --- a/src/client/testing/main.ts +++ b/src/client/testing/main.ts @@ -30,7 +30,8 @@ import { DelayedTrigger, IDelayedTrigger } from '../common/utils/delayTrigger'; import { ExtensionContextKey } from '../common/application/contextKeys'; import { checkForFailedTests, updateTestResultMap } from './testController/common/testItemUtilities'; import { Testing } from '../common/utils/localize'; -import { traceLog, traceVerbose } from '../logging'; +import { traceVerbose } from '../logging'; +import { writeTestIdToClipboard } from './utils'; @injectable() export class TestingService implements ITestingService { @@ -205,10 +206,7 @@ export class UnitTestManagementService implements IExtensionActivationService { }; }), commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => { - if (testItem && typeof testItem.id === 'string') { - await env.clipboard.writeText(testItem.id); - traceLog('Testing: Copied test id to clipboard, id: ' + testItem.id); - } + writeTestIdToClipboard(testItem); }), ); } diff --git a/src/client/testing/utils.ts b/src/client/testing/utils.ts new file mode 100644 index 000000000000..c1027d4a8dc1 --- /dev/null +++ b/src/client/testing/utils.ts @@ -0,0 +1,49 @@ +import { TestItem, env } from 'vscode'; +import { traceLog } from '../logging'; + +export async function writeTestIdToClipboard(testItem: TestItem): Promise { + if (testItem && typeof testItem.id === 'string') { + if (testItem.id.includes('\\') && testItem.id.indexOf('::') === -1) { + // Convert the id to a module.class.method format as this is a unittest + const moduleClassMethod = idToModuleClassMethod(testItem.id); + if (moduleClassMethod) { + await env.clipboard.writeText(moduleClassMethod); + traceLog('Testing: Copied test id to clipboard, id: ' + moduleClassMethod); + return; + } + } + // Otherwise use the id as is for pytest + await clipboardWriteText(testItem.id); + traceLog('Testing: Copied test id to clipboard, id: ' + testItem.id); + } +} + +export function idToModuleClassMethod(id: string): string | undefined { + // Split by backslash + const parts = id.split('\\'); + if (parts.length === 1) { + // Only one part, likely a parent folder or file + return parts[0]; + } + if (parts.length === 2) { + // Two parts: filePath and className + const [filePath, className] = parts.slice(-2); + const fileName = filePath.split(/[\\/]/).pop(); + if (!fileName) { + return undefined; + } + const module = fileName.replace(/\.py$/, ''); + return `${module}.${className}`; + } + // Three or more parts: filePath, className, methodName + const [filePath, className, methodName] = parts.slice(-3); + const fileName = filePath.split(/[\\/]/).pop(); + if (!fileName) { + return undefined; + } + const module = fileName.replace(/\.py$/, ''); + return `${module}.${className}.${methodName}`; +} +export function clipboardWriteText(text: string): Thenable { + return env.clipboard.writeText(text); +} diff --git a/src/test/testing/utils.unit.test.ts b/src/test/testing/utils.unit.test.ts new file mode 100644 index 000000000000..0a0c42a9e1b6 --- /dev/null +++ b/src/test/testing/utils.unit.test.ts @@ -0,0 +1,51 @@ +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as utils from '../../client/testing/utils'; +import sinon from 'sinon'; +use(chaiAsPromised.default); + +describe('idToModuleClassMethod', () => { + it('returns the only part if there is one', () => { + expect(utils.idToModuleClassMethod('foo')).to.equal('foo'); + }); + it('returns module.class for two parts', () => { + expect(utils.idToModuleClassMethod('a/b/c.py\\MyClass')).to.equal('c.MyClass'); + }); + it('returns module.class.method for three parts', () => { + expect(utils.idToModuleClassMethod('a/b/c.py\\MyClass\\my_method')).to.equal('c.MyClass.my_method'); + }); + it('returns undefined if fileName is missing', () => { + expect(utils.idToModuleClassMethod('\\MyClass')).to.be.undefined; + }); +}); + +describe('writeTestIdToClipboard', () => { + let clipboardStub: sinon.SinonStub; + + afterEach(() => { + sinon.restore(); + }); + + it('writes module.class.method for unittest id', async () => { + clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); + const { writeTestIdToClipboard } = utils; + const testItem = { id: 'a/b/c.py\\MyClass\\my_method' }; + await writeTestIdToClipboard(testItem as any); + sinon.assert.calledOnceWithExactly(clipboardStub, 'c.MyClass.my_method'); + }); + + it('writes id as is for pytest id', async () => { + clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); + const { writeTestIdToClipboard } = utils; + const testItem = { id: 'tests/test_foo.py::TestClass::test_method' }; + await writeTestIdToClipboard(testItem as any); + sinon.assert.calledOnceWithExactly(clipboardStub, 'tests/test_foo.py::TestClass::test_method'); + }); + + it('does nothing if testItem is undefined', async () => { + clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); + const { writeTestIdToClipboard } = utils; + await writeTestIdToClipboard(undefined as any); + sinon.assert.notCalled(clipboardStub); + }); +}); From 2b4e35eab00dea98acc3d41beda80abc16fa5110 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:59:34 -0700 Subject: [PATCH 3/4] remove import --- src/client/testing/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/testing/main.ts b/src/client/testing/main.ts index f6b77fb57e81..1941ce5e57c2 100644 --- a/src/client/testing/main.ts +++ b/src/client/testing/main.ts @@ -11,7 +11,6 @@ import { Command, TestItem, } from 'vscode'; -import { env } from 'vscode'; import { IApplicationShell, ICommandManager, IContextKeyManager, IWorkspaceService } from '../common/application/types'; import * as constants from '../common/constants'; import '../common/extensions'; From e6e47ec6ecdcbc25af2dedfc9f131d595733117a Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:07:30 -0700 Subject: [PATCH 4/4] update testing arch --- src/test/testing/utils.unit.test.ts | 70 ++++++++++++++--------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/test/testing/utils.unit.test.ts b/src/test/testing/utils.unit.test.ts index 0a0c42a9e1b6..8efa0cee0e65 100644 --- a/src/test/testing/utils.unit.test.ts +++ b/src/test/testing/utils.unit.test.ts @@ -4,48 +4,48 @@ import * as utils from '../../client/testing/utils'; import sinon from 'sinon'; use(chaiAsPromised.default); -describe('idToModuleClassMethod', () => { - it('returns the only part if there is one', () => { +function test_idToModuleClassMethod() { + try { expect(utils.idToModuleClassMethod('foo')).to.equal('foo'); - }); - it('returns module.class for two parts', () => { - expect(utils.idToModuleClassMethod('a/b/c.py\\MyClass')).to.equal('c.MyClass'); - }); - it('returns module.class.method for three parts', () => { - expect(utils.idToModuleClassMethod('a/b/c.py\\MyClass\\my_method')).to.equal('c.MyClass.my_method'); - }); - it('returns undefined if fileName is missing', () => { + expect(utils.idToModuleClassMethod('a/b/c.pyMyClass')).to.equal('c.MyClass'); + expect(utils.idToModuleClassMethod('a/b/c.pyMyClassmy_method')).to.equal('c.MyClass.my_method'); expect(utils.idToModuleClassMethod('\\MyClass')).to.be.undefined; - }); -}); + console.log('test_idToModuleClassMethod passed'); + } catch (e) { + console.error('test_idToModuleClassMethod failed:', e); + } +} -describe('writeTestIdToClipboard', () => { - let clipboardStub: sinon.SinonStub; - - afterEach(() => { - sinon.restore(); - }); - - it('writes module.class.method for unittest id', async () => { - clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); - const { writeTestIdToClipboard } = utils; - const testItem = { id: 'a/b/c.py\\MyClass\\my_method' }; +async function test_writeTestIdToClipboard() { + let clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); + const { writeTestIdToClipboard } = utils; + try { + // unittest id + const testItem = { id: 'a/b/c.pyMyClass\\my_method' }; await writeTestIdToClipboard(testItem as any); sinon.assert.calledOnceWithExactly(clipboardStub, 'c.MyClass.my_method'); - }); + clipboardStub.resetHistory(); - it('writes id as is for pytest id', async () => { - clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); - const { writeTestIdToClipboard } = utils; - const testItem = { id: 'tests/test_foo.py::TestClass::test_method' }; - await writeTestIdToClipboard(testItem as any); + // pytest id + const testItem2 = { id: 'tests/test_foo.py::TestClass::test_method' }; + await writeTestIdToClipboard(testItem2 as any); sinon.assert.calledOnceWithExactly(clipboardStub, 'tests/test_foo.py::TestClass::test_method'); - }); + clipboardStub.resetHistory(); - it('does nothing if testItem is undefined', async () => { - clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves(); - const { writeTestIdToClipboard } = utils; + // undefined await writeTestIdToClipboard(undefined as any); sinon.assert.notCalled(clipboardStub); - }); -}); + + console.log('test_writeTestIdToClipboard passed'); + } catch (e) { + console.error('test_writeTestIdToClipboard failed:', e); + } finally { + sinon.restore(); + } +} + +// Run tests +(async () => { + test_idToModuleClassMethod(); + await test_writeTestIdToClipboard(); +})();