Skip to content

Commit 696e31a

Browse files
authored
feat - allow other extensions to register test runner (#1705)
1 parent afe9114 commit 696e31a

28 files changed

+808
-350
lines changed

package-lock.json

Lines changed: 135 additions & 129 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/projectExplorerCommands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { loadChildren, runTests, testController } from '../controller/testContro
88
import { loadJavaProjects, updateItemForDocument } from '../controller/utils';
99
import { IProgressReporter } from '../debugger.api';
1010
import { progressProvider } from '../extension';
11-
import { TestLevel } from '../types';
11+
import { TestLevel } from '../java-test-runner.api';
1212

1313
export async function runTestsFromJavaProjectExplorer(node: any, isDebug: boolean): Promise<void> {
1414
const testLevel: TestLevel = getTestLevel(node._nodeData);

src/commands/testDependenciesCommands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import * as fse from 'fs-extra';
99
import * as _ from 'lodash';
1010
import * as os from 'os';
1111
import { getJavaProjects, getProjectType } from '../controller/utils';
12-
import { IJavaTestItem, ProjectType, TestKind } from '../types';
12+
import { IJavaTestItem, ProjectType } from '../types';
1313
import { createWriteStream, WriteStream } from 'fs';
1414
import { URL } from 'url';
1515
import { ClientRequest, IncomingMessage } from 'http';
1616
import { sendError } from 'vscode-extension-telemetry-wrapper';
17+
import { TestKind } from '../java-test-runner.api';
1718

1819
export async function enableTests(testKind?: TestKind): Promise<void> {
1920
const project: IJavaTestItem | undefined = await getTargetProject();

src/controller/testController.ts

Lines changed: 179 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,25 @@
33

44
import * as _ from 'lodash';
55
import * as path from 'path';
6-
import { CancellationToken, DebugConfiguration, Disposable, FileCoverage, FileCoverageDetail, FileSystemWatcher, RelativePattern, TestController, TestItem, TestRun, TestRunProfileKind, TestRunRequest, tests, TestTag, Uri, window, workspace, WorkspaceFolder } from 'vscode';
6+
import { CancellationToken, DebugConfiguration, Disposable, FileCoverage, FileCoverageDetail, FileSystemWatcher, Location, MarkdownString, RelativePattern, TestController, TestItem, TestMessage, TestRun, TestRunProfileKind, TestRunRequest, tests, TestTag, Uri, window, workspace, WorkspaceFolder } from 'vscode';
77
import { instrumentOperation, sendError, sendInfo } from 'vscode-extension-telemetry-wrapper';
88
import { refreshExplorer } from '../commands/testExplorerCommands';
99
import { IProgressReporter } from '../debugger.api';
1010
import { progressProvider } from '../extension';
1111
import { testSourceProvider } from '../provider/testSourceProvider';
12-
import { IExecutionConfig } from '../runConfigs';
1312
import { BaseRunner } from '../runners/baseRunner/BaseRunner';
1413
import { JUnitRunner } from '../runners/junitRunner/JunitRunner';
1514
import { TestNGRunner } from '../runners/testngRunner/TestNGRunner';
16-
import { IJavaTestItem, IRunTestContext, TestKind, TestLevel } from '../types';
15+
import { IJavaTestItem } from '../types';
1716
import { loadRunConfig } from '../utils/configUtils';
1817
import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils';
1918
import { dataCache, ITestItemData } from './testItemDataCache';
20-
import { findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils';
19+
import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils';
2120
import { JavaTestCoverageProvider } from '../provider/JavaTestCoverageProvider';
21+
import { testRunnerService } from './testRunnerService';
22+
import { IRunTestContext, TestRunner, TestFinishEvent, TestItemStatusChangeEvent, TestKind, TestLevel, TestResultState, TestIdParts } from '../java-test-runner.api';
23+
import { processStackTraceLine } from '../runners/utils';
24+
import { parsePartsFromTestId } from '../utils/testItemUtils';
2225

2326
export let testController: TestController | undefined;
2427
export const watchers: Disposable[] = [];
@@ -43,6 +46,10 @@ export function createTestController(): void {
4346
startWatchingWorkspace();
4447
}
4548

49+
export function creatTestProfile(name: string, kind: TestRunProfileKind): void {
50+
testController?.createRunProfile(name, kind, runHandler, false, runnableTag);
51+
}
52+
4653
export const loadChildren: (item: TestItem, token?: CancellationToken) => any = instrumentOperation('java.test.explorer.loadChildren', async (_operationId: string, item: TestItem, token?: CancellationToken) => {
4754
if (!item) {
4855
await loadJavaProjects();
@@ -178,21 +185,48 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in
178185
try {
179186
await new Promise<void>(async (resolve: () => void): Promise<void> => {
180187
const token: CancellationToken = option.token ?? run.token;
188+
let disposables: Disposable[] = [];
181189
token.onCancellationRequested(() => {
182190
option.progressReporter?.done();
183191
run.end();
192+
disposables.forEach((d: Disposable) => d.dispose());
184193
return resolve();
185194
});
186195
enqueueTestMethods(testItems, run);
196+
// TODO: first group by project, then merge test methods.
187197
const queue: TestItem[][] = mergeTestMethods(testItems);
188198
for (const testsInQueue of queue) {
189199
if (testsInQueue.length === 0) {
190200
continue;
191201
}
192202
const testProjectMapping: Map<string, TestItem[]> = mapTestItemsByProject(testsInQueue);
193203
for (const [projectName, itemsPerProject] of testProjectMapping.entries()) {
204+
const workspaceFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(itemsPerProject[0].uri!);
205+
if (!workspaceFolder) {
206+
window.showErrorMessage(`Failed to get workspace folder from test item: ${itemsPerProject[0].label}.`);
207+
continue;
208+
}
209+
const testContext: IRunTestContext = {
210+
isDebug: option.isDebug,
211+
kind: TestKind.None,
212+
projectName,
213+
testItems: itemsPerProject,
214+
testRun: run,
215+
workspaceFolder,
216+
profile: request.profile,
217+
testConfig: await loadRunConfig(itemsPerProject, workspaceFolder),
218+
};
219+
const testRunner: TestRunner | undefined = testRunnerService.getRunner(request.profile?.label, request.profile?.kind);
220+
if (testRunner) {
221+
await executeWithTestRunner(option, testRunner, testContext, run, disposables);
222+
disposables.forEach((d: Disposable) => d.dispose());
223+
disposables = [];
224+
continue;
225+
}
194226
const testKindMapping: Map<TestKind, TestItem[]> = mapTestItemsByKind(itemsPerProject);
195227
for (const [kind, items] of testKindMapping.entries()) {
228+
testContext.kind = kind;
229+
testContext.testItems = items;
196230
if (option.progressReporter?.isCancelled()) {
197231
option.progressReporter = progressProvider?.createProgressReporter(option.isDebug ? 'Debug Tests' : 'Run Tests');
198232
}
@@ -208,33 +242,17 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in
208242
return resolve();
209243
});
210244
option.progressReporter?.report('Resolving launch configuration...');
211-
// TODO: improve the config experience
212-
const workspaceFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(items[0].uri!);
213-
if (!workspaceFolder) {
214-
window.showErrorMessage(`Failed to get workspace folder from test item: ${items[0].label}.`);
215-
continue;
216-
}
217-
const config: IExecutionConfig | undefined = await loadRunConfig(items, workspaceFolder);
218-
if (!config) {
245+
if (!testContext.testConfig) {
219246
continue;
220247
}
221-
const testContext: IRunTestContext = {
222-
isDebug: option.isDebug,
223-
kind,
224-
projectName,
225-
testItems: items,
226-
testRun: run,
227-
workspaceFolder,
228-
profile: request.profile,
229-
};
230248
const runner: BaseRunner | undefined = getRunnerByContext(testContext);
231249
if (!runner) {
232250
window.showErrorMessage(`Failed to get suitable runner for the test kind: ${testContext.kind}.`);
233251
continue;
234252
}
235253
try {
236254
await runner.setup();
237-
const resolvedConfiguration: DebugConfiguration = mergeConfigurations(option.launchConfiguration, config) ?? await resolveLaunchConfigurationForRunner(runner, testContext, config);
255+
const resolvedConfiguration: DebugConfiguration = mergeConfigurations(option.launchConfiguration, testContext.testConfig) ?? await resolveLaunchConfigurationForRunner(runner, testContext, testContext.testConfig);
238256
resolvedConfiguration.__progressId = option.progressReporter?.getId();
239257
delegatedToDebugger = true;
240258
trackTestFrameworkVersion(testContext.kind, resolvedConfiguration.classPaths, resolvedConfiguration.modulePaths);
@@ -258,6 +276,133 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in
258276
}
259277
});
260278

279+
async function executeWithTestRunner(option: IRunOption, testRunner: TestRunner, testContext: IRunTestContext, run: TestRun, disposables: Disposable[]) {
280+
option.progressReporter?.done();
281+
await new Promise<void>(async (resolve: () => void): Promise<void> => {
282+
disposables.push(testRunner.onDidChangeTestItemStatus((event: TestItemStatusChangeEvent) => {
283+
const parts: TestIdParts = parsePartsFromTestId(event.testId);
284+
let parentItem: TestItem;
285+
try {
286+
parentItem = findTestClass(parts);
287+
} catch (e) {
288+
sendError(e);
289+
window.showErrorMessage(e.message);
290+
return resolve();
291+
}
292+
let currentItem: TestItem | undefined;
293+
const invocations: string[] | undefined = parts.invocations;
294+
if (invocations?.length) {
295+
let i: number = 0;
296+
for (; i < invocations.length; i++) {
297+
currentItem = parentItem.children.get(`${parentItem.id}#${invocations[i]}`);
298+
if (!currentItem) {
299+
break;
300+
}
301+
parentItem = currentItem;
302+
}
303+
304+
if (i < invocations.length - 1) {
305+
window.showErrorMessage('Test not found:' + event.testId);
306+
sendError(new Error('Test not found:' + event.testId));
307+
return resolve();
308+
}
309+
310+
if (!currentItem) {
311+
currentItem = createTestItem({
312+
children: [],
313+
uri: parentItem.uri?.toString(),
314+
range: parentItem.range,
315+
jdtHandler: '',
316+
fullName: `${parentItem.id}#${invocations[invocations.length - 1]}`,
317+
label: event.displayName || invocations[invocations.length - 1],
318+
id: `${parentItem.id}#${invocations[invocations.length - 1]}`,
319+
projectName: testContext.projectName,
320+
testKind: TestKind.None,
321+
testLevel: TestLevel.Invocation,
322+
}, parentItem);
323+
}
324+
} else {
325+
currentItem = parentItem;
326+
}
327+
328+
if (event.displayName && getLabelWithoutCodicon(currentItem.label) !== event.displayName) {
329+
currentItem.description = event.displayName;
330+
}
331+
switch (event.state) {
332+
case TestResultState.Running:
333+
run.started(currentItem);
334+
break;
335+
case TestResultState.Passed:
336+
run.passed(currentItem);
337+
break;
338+
case TestResultState.Failed:
339+
case TestResultState.Errored:
340+
const testMessages: TestMessage[] = [];
341+
if (event.message) {
342+
const markdownTrace: MarkdownString = new MarkdownString();
343+
markdownTrace.supportHtml = true;
344+
markdownTrace.isTrusted = true;
345+
const testMessage: TestMessage = new TestMessage(markdownTrace);
346+
testMessages.push(testMessage);
347+
const lines: string[] = event.message.split(/\r?\n/);
348+
for (const line of lines) {
349+
const location: Location | undefined = processStackTraceLine(line, markdownTrace, currentItem, testContext.projectName);
350+
if (location) {
351+
testMessage.location = location;
352+
}
353+
}
354+
}
355+
run.failed(currentItem, testMessages);
356+
break;
357+
case TestResultState.Skipped:
358+
run.skipped(currentItem);
359+
break;
360+
default:
361+
break;
362+
}
363+
}));
364+
365+
disposables.push(testRunner.onDidFinishTestRun((_event: TestFinishEvent) => {
366+
return resolve();
367+
}));
368+
369+
testRunner.launch(testContext);
370+
});
371+
372+
function findTestClass(parts: TestIdParts): TestItem {
373+
const projectItem: TestItem | undefined = testController?.items.get(parts.project);
374+
if (!projectItem) {
375+
throw new Error('Failed to get the project test item.');
376+
}
377+
378+
if (parts.package === undefined) { // '' means default package
379+
throw new Error('package is undefined in the id parts.');
380+
}
381+
382+
const packageItem: TestItem | undefined = projectItem.children.get(`${projectItem.id}@${parts.package}`);
383+
if (!packageItem) {
384+
throw new Error('Failed to get the package test item.');
385+
}
386+
387+
if (!parts.class) {
388+
throw new Error('class is undefined in the id parts.');
389+
}
390+
391+
const classes: string[] = parts.class.split('$'); // handle nested classes
392+
let current: TestItem | undefined = packageItem.children.get(`${projectItem.id}@${classes[0]}`);
393+
if (!current) {
394+
throw new Error('Failed to get the class test item.');
395+
}
396+
for (let i: number = 1; i < classes.length; i++) {
397+
current = current.children.get(`${current.id}$${classes[i]}`);
398+
if (!current) {
399+
throw new Error('Failed to get the class test item.');
400+
}
401+
}
402+
return current;
403+
}
404+
}
405+
261406
function mergeConfigurations(launchConfiguration: DebugConfiguration | undefined, config: any): DebugConfiguration | undefined {
262407
if (!launchConfiguration) {
263408
return undefined;
@@ -586,6 +731,18 @@ function trackTestFrameworkVersion(testKind: TestKind, classpaths: string[], mod
586731
});
587732
}
588733

734+
function getLabelWithoutCodicon(name: string): string {
735+
if (name.includes('#')) {
736+
name = name.substring(name.indexOf('#') + 1);
737+
}
738+
739+
const result: RegExpMatchArray | null = name.match(/(?:\$\(.+\) )?(.*)/);
740+
if (result?.length === 2) {
741+
return result[1];
742+
}
743+
return name;
744+
}
745+
589746
interface IRunOption {
590747
isDebug: boolean;
591748
progressReporter?: IProgressReporter;

src/controller/testItemDataCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT license.
33

44
import { TestItem } from 'vscode';
5-
import { TestKind, TestLevel } from '../types';
5+
import { TestKind, TestLevel } from '../java-test-runner.api';
66

77
/**
88
* A map cache to save the metadata of the test item.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
import { TestRunProfileKind } from 'vscode';
5+
import { creatTestProfile } from './testController';
6+
import { TestRunner } from '../java-test-runner.api';
7+
8+
// TODO: this should be refactored. The test controller should be extended and hosting the registered runners.
9+
class TestRunnerService {
10+
11+
private registeredRunners: Map<string, TestRunner>;
12+
13+
constructor() {
14+
this.registeredRunners = new Map<string, TestRunner>();
15+
}
16+
17+
public registerTestRunner(name: string, kind: TestRunProfileKind, runner: TestRunner) {
18+
const key: string = `${name}:${kind}`;
19+
if (this.registeredRunners.has(key)) {
20+
throw new Error(`Runner ${key} has already been registered.`);
21+
}
22+
creatTestProfile(name, kind);
23+
this.registeredRunners.set(key, runner);
24+
}
25+
26+
public getRunner(name: string | undefined, kind: TestRunProfileKind | undefined): TestRunner | undefined {
27+
if (!name || !kind) {
28+
return undefined;
29+
}
30+
const key: string = `${name}:${kind}`;
31+
return this.registeredRunners.get(key);
32+
}
33+
}
34+
35+
export const testRunnerService: TestRunnerService = new TestRunnerService();

src/controller/utils.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { performance } from 'perf_hooks';
88
import { CancellationToken, commands, Range, TestItem, Uri, workspace, WorkspaceFolder } from 'vscode';
99
import { sendError } from 'vscode-extension-telemetry-wrapper';
1010
import { JavaTestRunnerDelegateCommands } from '../constants';
11-
import { IJavaTestItem, ProjectType, TestKind, TestLevel } from '../types';
11+
import { IJavaTestItem, ProjectType } from '../types';
1212
import { executeJavaLanguageServerCommand } from '../utils/commandUtils';
1313
import { getRequestDelay, lruCache, MovingAverage } from './debouncing';
1414
import { runnableTag, testController } from './testController';
1515
import { dataCache } from './testItemDataCache';
16+
import { TestKind, TestLevel } from '../java-test-runner.api';
1617

1718
/**
1819
* Load the Java projects, which are the root nodes of the test explorer
@@ -129,8 +130,8 @@ function updateTestItem(testItem: TestItem, metaInfo: IJavaTestItem): void {
129130

130131
/**
131132
* Create test item which will be shown in the test explorer
132-
* @param metaInfo The data from the server side of the test item
133-
* @param parent The parent node of the test item (if it has)
133+
* @param metaInfo The data from the server side of the test item.
134+
* @param parent The parent node of the test item (if it has).
134135
* @returns The created test item
135136
*/
136137
export function createTestItem(metaInfo: IJavaTestItem, parent?: TestItem): TestItem {
@@ -139,7 +140,7 @@ export function createTestItem(metaInfo: IJavaTestItem, parent?: TestItem): Test
139140
}
140141
const item: TestItem = testController.createTestItem(
141142
metaInfo.id,
142-
`${getCodiconLabel(metaInfo.testLevel)} ${metaInfo.label}`,
143+
`${getCodiconLabel(metaInfo.testLevel)} ${metaInfo.label}`.trim(),
143144
metaInfo.uri ? Uri.parse(metaInfo.uri) : undefined,
144145
);
145146
item.range = asRange(metaInfo.range);

0 commit comments

Comments
 (0)