Skip to content

Commit 7601808

Browse files
authored
Add ability to navigate to tests (#4289)
* Ability to navigate to tests * Add tests * Added telemetry * Change line width * Fix formatting * Custom symbol provider * Fixed linters * Fix tests
1 parent 9187479 commit 7601808

34 files changed

+1067
-32
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@
2727
"javascript.preferences.quoteStyle": "single",
2828
"typescriptHero.imports.stringQuoteStyle": "'",
2929
"prettier.tslintIntegration": false,
30-
"prettier.printWidth": 120,
30+
"prettier.printWidth": 180,
31+
"prettier.singleQuote": true
3132
}

pythonFiles/symbolProvider.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import ast
5+
import json
6+
import sys
7+
8+
9+
class Visitor(ast.NodeVisitor):
10+
def __init__(self):
11+
self.symbols = {"classes": [], "methods": [], "functions": []}
12+
13+
def visit_Module(self, node):
14+
self.visitChildren(node)
15+
16+
def visitChildren(self, node, namespace=""):
17+
for child in node.body:
18+
if isinstance(child, ast.FunctionDef):
19+
self.visitDef(child, namespace)
20+
if isinstance(child, ast.ClassDef):
21+
self.visitClassDef(child, namespace)
22+
try:
23+
if isinstance(child, ast.AsyncFunctionDef):
24+
self.visitDef(child, namespace)
25+
except Exception:
26+
pass
27+
28+
def visitDef(self, node, namespace=""):
29+
end_position = self.getEndPosition(node)
30+
symbol = "functions" if namespace == "" else "methods"
31+
self.symbols[symbol].append(self.getDataObject(node, namespace))
32+
33+
def visitClassDef(self, node, namespace=""):
34+
end_position = self.getEndPosition(node)
35+
self.symbols['classes'].append(self.getDataObject(node, namespace))
36+
37+
if len(namespace) > 0:
38+
namespace = "{0}::{1}".format(namespace, node.name)
39+
else:
40+
namespace = node.name
41+
self.visitChildren(node, namespace)
42+
43+
def getDataObject(self, node, namespace=""):
44+
end_position = self.getEndPosition(node)
45+
return {
46+
"namespace": namespace,
47+
"name": node.name,
48+
"range": {
49+
"start": {
50+
"line": node.lineno,
51+
"character": node.col_offset
52+
},
53+
"end": {
54+
"line": end_position[0],
55+
"character": end_position[1]
56+
}
57+
}
58+
}
59+
60+
def getEndPosition(self, node):
61+
if not hasattr(node, 'body') or len(node.body) == 0:
62+
return (node.lineno, node.col_offset)
63+
return self.getEndPosition(node.body[-1])
64+
65+
66+
def provide_symbols(source):
67+
"""Provides a list of all symbols in provided code.
68+
69+
The list comprises of 3-item tuples that contain the starting line number,
70+
ending line number and whether the statement is a single line.
71+
72+
"""
73+
tree = ast.parse(source)
74+
visitor = Visitor()
75+
visitor.visit(tree)
76+
sys.stdout.write(json.dumps(visitor.symbols))
77+
sys.stdout.flush()
78+
79+
80+
if __name__ == "__main__":
81+
if len(sys.argv) == 3:
82+
contents = sys.argv[2]
83+
else:
84+
with open(sys.argv[1], "r") as source:
85+
contents = source.read()
86+
87+
try:
88+
default_encoding = sys.getdefaultencoding()
89+
encoded_contents = contents.encode(default_encoding, 'surrogateescape')
90+
contents = encoded_contents.decode(default_encoding, 'replace')
91+
except (UnicodeError, LookupError):
92+
pass
93+
if isinstance(contents, bytes):
94+
contents = contents.decode('utf8')
95+
provide_symbols(contents)

src/client/activation/jedi.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@ export class JediExtensionActivator implements ILanguageServerActivator {
107107

108108
const testManagementService = this.serviceManager.get<IUnitTestManagementService>(IUnitTestManagementService);
109109
testManagementService
110-
.activate()
111-
.then(() => testManagementService.activateCodeLenses(symbolProvider))
110+
.activate(symbolProvider)
112111
.catch(ex => this.serviceManager.get<ILogger>(ILogger).logError('Failed to activate Unit Tests', ex));
113112
}
114113

src/client/activation/languageServer/languageServer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export class LanguageServer implements ILanguageServer {
9090
if (!this.languageClient) {
9191
throw new Error('languageClient not initialized');
9292
}
93-
await this.testManager.activate();
94-
await this.testManager.activateCodeLenses(new LanguageServerSymbolProvider(this.languageClient!));
93+
await this.testManager.activate(new LanguageServerSymbolProvider(this.languageClient!));
9594
}
9695
}

src/client/common/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export namespace Commands {
4141
export const Enable_Linter = 'python.enableLinting';
4242
export const Run_Linter = 'python.runLinting';
4343
export const Enable_SourceMap_Support = 'python.enableSourceMapSupport';
44+
export const navigateToTestFunction = 'navigateToTestFunction';
45+
export const navigateToTestSuite = 'navigateToTestSuite';
46+
export const navigateToTestFile = 'navigateToTestFile';
4447
}
4548
export namespace Octicons {
4649
export const Test_Pass = '$(check)';

src/client/common/types.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
'use strict';
44

55
import { Socket } from 'net';
6-
import { ConfigurationTarget, DiagnosticSeverity, Disposable, Event, Extension, ExtensionContext, OutputChannel, Uri, WorkspaceEdit } from 'vscode';
6+
import { ConfigurationTarget, DiagnosticSeverity, Disposable, DocumentSymbolProvider, Event, Extension, ExtensionContext, OutputChannel, Uri, WorkspaceEdit } from 'vscode';
77
import { EnvironmentVariables } from './variables/types';
88
export const IOutputChannel = Symbol('IOutputChannel');
9-
export interface IOutputChannel extends OutputChannel { }
9+
export interface IOutputChannel extends OutputChannel {}
1010
export const IDocumentSymbolProvider = Symbol('IDocumentSymbolProvider');
11+
export interface IDocumentSymbolProvider extends DocumentSymbolProvider {}
1112
export const IsWindows = Symbol('IS_WINDOWS');
1213
export const IDisposableRegistry = Symbol('IDiposableRegistry');
1314
export type IDisposableRegistry = { push(disposable: Disposable): void };
@@ -293,7 +294,7 @@ export interface IDataScienceSettings {
293294
showCellInputCode: boolean;
294295
collapseCellInputCodeByDefault: boolean;
295296
maxOutputSize: number;
296-
sendSelectionToInteractiveWindow : boolean;
297+
sendSelectionToInteractiveWindow: boolean;
297298
markdownRegularExpression: string;
298299
codeRegularExpression: string;
299300
}
@@ -313,7 +314,7 @@ export interface ISocketServer extends Disposable {
313314
}
314315

315316
export const IExtensionContext = Symbol('ExtensionContext');
316-
export interface IExtensionContext extends ExtensionContext { }
317+
export interface IExtensionContext extends ExtensionContext {}
317318

318319
export const IExtensions = Symbol('IExtensions');
319320
export interface IExtensions {

src/client/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import { EditorLoadTelemetry } from './telemetry/types';
102102
import { registerTypes as commonRegisterTerminalTypes } from './terminals/serviceRegistry';
103103
import { ICodeExecutionManager, ITerminalAutoActivation } from './terminals/types';
104104
import { TEST_OUTPUT_CHANNEL } from './unittests/common/constants';
105+
import { ITestCodeNavigatorCommandHandler } from './unittests/navigation/types';
105106
import { registerTypes as unitTestsRegisterTypes } from './unittests/serviceRegistry';
106107

107108
durations.codeLoadingTime = stopWatch.elapsedTime;
@@ -294,6 +295,7 @@ function initializeServices(context: ExtensionContext, serviceManager: ServiceMa
294295
serviceContainer.get<InterpreterLocatorProgressHandler>(InterpreterLocatorProgressHandler).register();
295296
serviceContainer.get<IInterpreterLocatorProgressService>(IInterpreterLocatorProgressService).register();
296297
serviceContainer.get<IApplicationDiagnostics>(IApplicationDiagnostics).register();
298+
serviceContainer.get<ITestCodeNavigatorCommandHandler>(ITestCodeNavigatorCommandHandler).register();
297299
}
298300

299301
// tslint:disable-next-line:no-any

src/client/telemetry/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export enum EventName {
4040
UNITTEST_RUN = 'UNITTEST.RUN',
4141
UNITTEST_DISCOVER = 'UNITTEST.DISCOVER',
4242
UNITTEST_VIEW_OUTPUT = 'UNITTEST.VIEW_OUTPUT',
43+
UNITTEST_NAVIGATE_TEST_FILE = 'UNITTEST.NAVIGATE.TEST_FILE',
44+
UNITTEST_NAVIGATE_TEST_FUNCTION = 'UNITTEST.NAVIGATE.TEST_FUNCTION',
45+
UNITTEST_NAVIGATE_TEST_SUITE = 'UNITTEST.NAVIGATE.TEST_SUITE',
4346
PYTHON_LANGUAGE_SERVER_ANALYSISTIME = 'PYTHON_LANGUAGE_SERVER.ANALYSIS_TIME',
4447
PYTHON_LANGUAGE_SERVER_ENABLED = 'PYTHON_LANGUAGE_SERVER.ENABLED',
4548
PYTHON_LANGUAGE_SERVER_EXTRACTED = 'PYTHON_LANGUAGE_SERVER.EXTRACTED',

src/client/telemetry/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,7 @@ interface IEventNamePropertyMapping {
324324
[Telemetry.StartJupyter]: never | undefined;
325325
[Telemetry.SubmitCellThroughInput]: never | undefined;
326326
[Telemetry.Undo]: never | undefined;
327+
[EventName.UNITTEST_NAVIGATE_TEST_FILE]: never | undefined;
328+
[EventName.UNITTEST_NAVIGATE_TEST_FUNCTION]: never | undefined;
329+
[EventName.UNITTEST_NAVIGATE_TEST_SUITE]: never | undefined;
327330
}

src/client/unittests/codeLenses/main.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import { PYTHON } from '../../common/constants';
33
import { ITestCollectionStorageService } from '../common/types';
44
import { TestFileCodeLensProvider } from './testFiles';
55

6-
export function activateCodeLenses(onDidChange: vscode.EventEmitter<void>,
7-
symboldProvider: vscode.DocumentSymbolProvider, testCollectionStorage: ITestCollectionStorageService): vscode.Disposable {
8-
6+
export function activateCodeLenses(
7+
onDidChange: vscode.EventEmitter<void>,
8+
symbolProvider: vscode.DocumentSymbolProvider,
9+
testCollectionStorage: ITestCollectionStorageService
10+
): vscode.Disposable {
911
const disposables: vscode.Disposable[] = [];
10-
const codeLensProvider = new TestFileCodeLensProvider(onDidChange, symboldProvider, testCollectionStorage);
12+
const codeLensProvider = new TestFileCodeLensProvider(onDidChange, symbolProvider, testCollectionStorage);
1113
disposables.push(vscode.languages.registerCodeLensProvider(PYTHON, codeLensProvider));
1214

1315
return {
14-
dispose: () => { disposables.forEach(d => d.dispose()); }
16+
dispose: () => {
17+
disposables.forEach(d => d.dispose());
18+
}
1519
};
1620
}

src/client/unittests/common/services/storageService.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { inject, injectable } from 'inversify';
22
import { Disposable, Uri, workspace } from 'vscode';
33
import { IDisposableRegistry } from '../../../common/types';
4-
import { ITestCollectionStorageService, Tests } from './../types';
4+
import { FlattenedTestFunction, FlattenedTestSuite, ITestCollectionStorageService, TestFunction, Tests, TestSuite } from './../types';
55

66
@injectable()
77
export class TestCollectionStorageService implements ITestCollectionStorageService {
@@ -17,6 +17,20 @@ export class TestCollectionStorageService implements ITestCollectionStorageServi
1717
const workspaceFolder = this.getWorkspaceFolderPath(wkspace) || '';
1818
this.testsIndexedByWorkspaceUri.set(workspaceFolder, tests);
1919
}
20+
public findFlattendTestFunction(resource: Uri, func: TestFunction): FlattenedTestFunction | undefined {
21+
const tests = this.getTests(resource);
22+
if (!tests) {
23+
return;
24+
}
25+
return tests.testFunctions.find(f => f.testFunction === func);
26+
}
27+
public findFlattendTestSuite(resource: Uri, suite: TestSuite): FlattenedTestSuite | undefined {
28+
const tests = this.getTests(resource);
29+
if (!tests) {
30+
return;
31+
}
32+
return tests.testSuites.find(f => f.testSuite === suite);
33+
}
2034
public dispose() {
2135
this.testsIndexedByWorkspaceUri.clear();
2236
}

src/client/unittests/common/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export const ITestCollectionStorageService = Symbol('ITestCollectionStorageServi
183183
export interface ITestCollectionStorageService extends Disposable {
184184
getTests(wkspace: Uri): Tests | undefined;
185185
storeTests(wkspace: Uri, tests: Tests | null | undefined): void;
186+
findFlattendTestFunction(resource: Uri, func: TestFunction): FlattenedTestFunction | undefined;
187+
findFlattendTestSuite(resource: Uri, func: TestSuite): FlattenedTestSuite | undefined;
186188
}
187189

188190
export const ITestResultsService = Symbol('ITestResultsService');

src/client/unittests/display/picker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,9 @@ function onItemSelected(cmdSource: CommandSource, wkspace: Uri, selection: TestI
237237
break;
238238
}
239239
case Type.RunMethod: {
240-
cmd = constants.Commands.Tests_Run;
240+
cmd = constants.Commands.navigateToTestFunction;
241241
// tslint:disable-next-line:prefer-type-cast no-object-literal-type-assertion
242-
args.push({ testFunction: [selection.fn!.testFunction] } as TestsToRun);
242+
args.push(selection.fn!.testFunction);
243243
break;
244244
}
245245
case Type.DebugMethod: {

src/client/unittests/main.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ConfigurationChangeEvent, Disposable, OutputChannel, TextDocument, Uri
77
import * as vscode from 'vscode';
88
import { ICommandManager, IDocumentManager, IWorkspaceService } from '../common/application/types';
99
import * as constants from '../common/constants';
10+
import '../common/extensions';
1011
import { IConfigurationService, IDisposableRegistry, ILogger, IOutputChannel } from '../common/types';
1112
import { IServiceContainer } from '../ioc/types';
1213
import { EventName } from '../telemetry/constants';
@@ -42,17 +43,14 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di
4243
this.workspaceTestManagerService.dispose();
4344
}
4445
}
45-
public async activate(): Promise<void> {
46+
public async activate(symboldProvider: vscode.DocumentSymbolProvider): Promise<void> {
4647
this.workspaceTestManagerService = this.serviceContainer.get<IWorkspaceTestManagerService>(IWorkspaceTestManagerService);
4748

4849
this.registerHandlers();
4950
this.registerCommands();
5051
this.autoDiscoverTests()
5152
.catch(ex => this.serviceContainer.get<ILogger>(ILogger).logError('Failed to auto discover tests upon activation', ex));
52-
}
53-
public async activateCodeLenses(symboldProvider: vscode.DocumentSymbolProvider): Promise<void> {
54-
const testCollectionStorage = this.serviceContainer.get<ITestCollectionStorageService>(ITestCollectionStorageService);
55-
this.disposableRegistry.push(activateCodeLenses(this.onDidChange, symboldProvider, testCollectionStorage));
53+
await this.registerSymbolProvider(symboldProvider);
5654
}
5755
public async getTestManager(displayTestNotConfiguredMessage: boolean, resource?: Uri): Promise<ITestManager | undefined | void> {
5856
let wkspace: Uri | undefined;
@@ -280,6 +278,10 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di
280278
this.testResultDisplay.displayProgressStatus(promise, debug);
281279
await promise;
282280
}
281+
private async registerSymbolProvider(symboldProvider: vscode.DocumentSymbolProvider): Promise<void> {
282+
const testCollectionStorage = this.serviceContainer.get<ITestCollectionStorageService>(ITestCollectionStorageService);
283+
this.disposableRegistry.push(activateCodeLenses(this.onDidChange, symboldProvider, testCollectionStorage));
284+
}
283285
private registerCommands(): void {
284286
const disposablesRegistry = this.serviceContainer.get<Disposable[]>(IDisposableRegistry);
285287
const commandManager = this.serviceContainer.get<ICommandManager>(ICommandManager);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable, named } from 'inversify';
7+
import { ICommandManager } from '../../common/application/types';
8+
import { Commands } from '../../common/constants';
9+
import { IDisposable, IDisposableRegistry } from '../../common/types';
10+
import { ITestCodeNavigator, ITestCodeNavigatorCommandHandler, NavigableItemType } from './types';
11+
12+
@injectable()
13+
export class TestCodeNavigatorCommandHandler implements ITestCodeNavigatorCommandHandler {
14+
private disposables: IDisposable[] = [];
15+
constructor(
16+
@inject(ICommandManager) private readonly commandManager: ICommandManager,
17+
@inject(ITestCodeNavigator) @named(NavigableItemType.testFile) private readonly testFileNavigator: ITestCodeNavigator,
18+
@inject(ITestCodeNavigator) @named(NavigableItemType.testFunction) private readonly testFunctionNavigator: ITestCodeNavigator,
19+
@inject(ITestCodeNavigator) @named(NavigableItemType.testSuite) private readonly testSuiteNavigator: ITestCodeNavigator,
20+
@inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry
21+
) {
22+
disposableRegistry.push(this);
23+
}
24+
public dispose() {
25+
this.disposables.forEach(item => item.dispose());
26+
}
27+
public register(): void {
28+
if (this.disposables.length > 0) {
29+
return;
30+
}
31+
let disposable = this.commandManager.registerCommand(Commands.navigateToTestFile, this.testFileNavigator.navigateTo, this.testFileNavigator);
32+
this.disposables.push(disposable);
33+
disposable = this.commandManager.registerCommand(Commands.navigateToTestFunction, this.testFunctionNavigator.navigateTo, this.testFunctionNavigator);
34+
this.disposables.push(disposable);
35+
disposable = this.commandManager.registerCommand(Commands.navigateToTestSuite, this.testSuiteNavigator.navigateTo, this.testSuiteNavigator);
36+
this.disposables.push(disposable);
37+
}
38+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable } from 'inversify';
7+
import { Uri } from 'vscode';
8+
import { swallowExceptions } from '../../common/utils/decorators';
9+
import { captureTelemetry } from '../../telemetry';
10+
import { EventName } from '../../telemetry/constants';
11+
import { TestFile } from '../common/types';
12+
import { ITestCodeNavigator, ITestNavigatorHelper } from './types';
13+
14+
@injectable()
15+
export class TestFileCodeNavigator implements ITestCodeNavigator {
16+
constructor(@inject(ITestNavigatorHelper) private readonly helper: ITestNavigatorHelper) {}
17+
@swallowExceptions('Navigate to test file')
18+
@captureTelemetry(EventName.UNITTEST_NAVIGATE_TEST_FILE, undefined, true)
19+
public async navigateTo(_: Uri, item: TestFile): Promise<void> {
20+
await this.helper.openFile(Uri.file(item.fullPath));
21+
}
22+
}

0 commit comments

Comments
 (0)