Skip to content

Commit 7607548

Browse files
authored
support copy testing path (#25257)
fixes #20047
1 parent bad502a commit 7607548

File tree

7 files changed

+136
-3
lines changed

7 files changed

+136
-3
lines changed

package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@
272272
"category": "Python",
273273
"command": "python.createNewFile"
274274
},
275+
{
276+
"category": "Python",
277+
"command": "python.copyTestId",
278+
"title": "%python.command.python.testing.copyTestId.title%"
279+
},
275280
{
276281
"category": "Python",
277282
"command": "python.analysis.restartLanguageServer",
@@ -1231,6 +1236,13 @@
12311236
"command": "python.reportIssue"
12321237
}
12331238
],
1239+
"testing/item/context": [
1240+
{
1241+
"command": "python.copyTestId",
1242+
"group": "navigation",
1243+
"when": "controllerId == 'python-tests'"
1244+
}
1245+
],
12341246
"commandPalette": [
12351247
{
12361248
"category": "Python",
@@ -1306,6 +1318,12 @@
13061318
"title": "%python.command.python.execSelectionInTerminal.title%",
13071319
"when": "!virtualWorkspace && shellExecutionSupported && editorLangId == python"
13081320
},
1321+
{
1322+
"category": "Python",
1323+
"command": "python.copyTestId",
1324+
"title": "%python.command.python.testing.copyTestId.title%",
1325+
"when": "false"
1326+
},
13091327
{
13101328
"category": "Python",
13111329
"command": "python.execInREPL",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"python.command.python.analysis.restartLanguageServer.title": "Restart Language Server",
2828
"python.command.python.launchTensorBoard.title": "Launch TensorBoard",
2929
"python.command.python.refreshTensorBoard.title": "Refresh TensorBoard",
30+
"python.command.python.testing.copyTestId.title": "Copy Test Id",
3031
"python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.",
3132
"python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project",
3233
"python.menu.createNewFile.title": "Python File",

src/client/common/application/commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
'use strict';
55

6-
import { CancellationToken, Position, TextDocument, Uri } from 'vscode';
6+
import { CancellationToken, Position, TestItem, TextDocument, Uri } from 'vscode';
77
import { Commands as LSCommands } from '../../activation/commands';
88
import { Channel, Commands, CommandSource } from '../constants';
99
import { CreateEnvironmentOptions } from '../../pythonEnvironments/creation/proposed.createEnvApis';
@@ -50,6 +50,7 @@ export type AllCommands = keyof ICommandNameArgumentTypeMapping;
5050
* Used to provide strong typing for command & args.
5151
*/
5252
export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgumentTypeMapping {
53+
[Commands.CopyTestId]: [TestItem];
5354
[Commands.Create_Environment]: [CreateEnvironmentOptions];
5455
['vscode.openWith']: [Uri, string];
5556
['workbench.action.quickOpen']: [string];

src/client/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export namespace Commands {
3939
export const CreateNewFile = 'python.createNewFile';
4040
export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter';
4141
export const Create_Environment = 'python.createEnvironment';
42+
export const CopyTestId = 'python.copyTestId';
4243
export const Create_Environment_Button = 'python.createEnvironment-button';
4344
export const Create_Environment_Check = 'python.createEnvironmentCheck';
4445
export const Create_Terminal = 'python.createTerminal';

src/client/testing/main.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
'use strict';
22

33
import { inject, injectable } from 'inversify';
4-
import { ConfigurationChangeEvent, Disposable, Uri, tests, TestResultState, WorkspaceFolder, Command } from 'vscode';
4+
import {
5+
ConfigurationChangeEvent,
6+
Disposable,
7+
Uri,
8+
tests,
9+
TestResultState,
10+
WorkspaceFolder,
11+
Command,
12+
TestItem,
13+
} from 'vscode';
514
import { IApplicationShell, ICommandManager, IContextKeyManager, IWorkspaceService } from '../common/application/types';
615
import * as constants from '../common/constants';
716
import '../common/extensions';
@@ -21,6 +30,7 @@ import { ExtensionContextKey } from '../common/application/contextKeys';
2130
import { checkForFailedTests, updateTestResultMap } from './testController/common/testItemUtilities';
2231
import { Testing } from '../common/utils/localize';
2332
import { traceVerbose } from '../logging';
33+
import { writeTestIdToClipboard } from './utils';
2434

2535
@injectable()
2636
export class TestingService implements ITestingService {
@@ -158,7 +168,6 @@ export class UnitTestManagementService implements IExtensionActivationService {
158168

159169
private registerCommands(): void {
160170
const commandManager = this.serviceContainer.get<ICommandManager>(ICommandManager);
161-
162171
this.disposableRegistry.push(
163172
commandManager.registerCommand(
164173
constants.Commands.Tests_Configure,
@@ -195,6 +204,9 @@ export class UnitTestManagementService implements IExtensionActivationService {
195204
},
196205
};
197206
}),
207+
commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => {
208+
writeTestIdToClipboard(testItem);
209+
}),
198210
);
199211
}
200212

src/client/testing/utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { TestItem, env } from 'vscode';
2+
import { traceLog } from '../logging';
3+
4+
export async function writeTestIdToClipboard(testItem: TestItem): Promise<void> {
5+
if (testItem && typeof testItem.id === 'string') {
6+
if (testItem.id.includes('\\') && testItem.id.indexOf('::') === -1) {
7+
// Convert the id to a module.class.method format as this is a unittest
8+
const moduleClassMethod = idToModuleClassMethod(testItem.id);
9+
if (moduleClassMethod) {
10+
await env.clipboard.writeText(moduleClassMethod);
11+
traceLog('Testing: Copied test id to clipboard, id: ' + moduleClassMethod);
12+
return;
13+
}
14+
}
15+
// Otherwise use the id as is for pytest
16+
await clipboardWriteText(testItem.id);
17+
traceLog('Testing: Copied test id to clipboard, id: ' + testItem.id);
18+
}
19+
}
20+
21+
export function idToModuleClassMethod(id: string): string | undefined {
22+
// Split by backslash
23+
const parts = id.split('\\');
24+
if (parts.length === 1) {
25+
// Only one part, likely a parent folder or file
26+
return parts[0];
27+
}
28+
if (parts.length === 2) {
29+
// Two parts: filePath and className
30+
const [filePath, className] = parts.slice(-2);
31+
const fileName = filePath.split(/[\\/]/).pop();
32+
if (!fileName) {
33+
return undefined;
34+
}
35+
const module = fileName.replace(/\.py$/, '');
36+
return `${module}.${className}`;
37+
}
38+
// Three or more parts: filePath, className, methodName
39+
const [filePath, className, methodName] = parts.slice(-3);
40+
const fileName = filePath.split(/[\\/]/).pop();
41+
if (!fileName) {
42+
return undefined;
43+
}
44+
const module = fileName.replace(/\.py$/, '');
45+
return `${module}.${className}.${methodName}`;
46+
}
47+
export function clipboardWriteText(text: string): Thenable<void> {
48+
return env.clipboard.writeText(text);
49+
}

src/test/testing/utils.unit.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, use } from 'chai';
2+
import * as chaiAsPromised from 'chai-as-promised';
3+
import * as utils from '../../client/testing/utils';
4+
import sinon from 'sinon';
5+
use(chaiAsPromised.default);
6+
7+
function test_idToModuleClassMethod() {
8+
try {
9+
expect(utils.idToModuleClassMethod('foo')).to.equal('foo');
10+
expect(utils.idToModuleClassMethod('a/b/c.pyMyClass')).to.equal('c.MyClass');
11+
expect(utils.idToModuleClassMethod('a/b/c.pyMyClassmy_method')).to.equal('c.MyClass.my_method');
12+
expect(utils.idToModuleClassMethod('\\MyClass')).to.be.undefined;
13+
console.log('test_idToModuleClassMethod passed');
14+
} catch (e) {
15+
console.error('test_idToModuleClassMethod failed:', e);
16+
}
17+
}
18+
19+
async function test_writeTestIdToClipboard() {
20+
let clipboardStub = sinon.stub(utils, 'clipboardWriteText').resolves();
21+
const { writeTestIdToClipboard } = utils;
22+
try {
23+
// unittest id
24+
const testItem = { id: 'a/b/c.pyMyClass\\my_method' };
25+
await writeTestIdToClipboard(testItem as any);
26+
sinon.assert.calledOnceWithExactly(clipboardStub, 'c.MyClass.my_method');
27+
clipboardStub.resetHistory();
28+
29+
// pytest id
30+
const testItem2 = { id: 'tests/test_foo.py::TestClass::test_method' };
31+
await writeTestIdToClipboard(testItem2 as any);
32+
sinon.assert.calledOnceWithExactly(clipboardStub, 'tests/test_foo.py::TestClass::test_method');
33+
clipboardStub.resetHistory();
34+
35+
// undefined
36+
await writeTestIdToClipboard(undefined as any);
37+
sinon.assert.notCalled(clipboardStub);
38+
39+
console.log('test_writeTestIdToClipboard passed');
40+
} catch (e) {
41+
console.error('test_writeTestIdToClipboard failed:', e);
42+
} finally {
43+
sinon.restore();
44+
}
45+
}
46+
47+
// Run tests
48+
(async () => {
49+
test_idToModuleClassMethod();
50+
await test_writeTestIdToClipboard();
51+
})();

0 commit comments

Comments
 (0)