Skip to content

Commit 54ec092

Browse files
Merge pull request #43 from gjsjohnmurray/do-24
Show test coverage data generated by Test Coverage Tool package
2 parents 09f9ecb + 0f66d8c commit 54ec092

14 files changed

+487
-65
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 2.0.0 (23-Jul-2025)
2+
* Use [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) to present coverage information (#24)
3+
14
## 0.2.3 (13-Nov-2024)
25
* Add manual refresh to test trees, and automatic refresh when client setting changes (#20)
36
* Fix overprompting during authentication.

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# InterSystems Testing Manager
22

3-
This preview extension uses VS Code's [Testing API](https://code.visualstudio.com/api/extension-guides/testing) to discover, run and debug unit test classes built with the [%UnitTest testing framework](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=TUNT_WhatIsPercentUnitTest) of the InterSystems IRIS platforms, plus Caché-based predecessors supporting the `/api/atelier` REST service.
3+
> **New in Version 2.0 - Test Coverage**
4+
>
5+
> The v2.0 release has been entered into the [InterSystems Developer Tools Contest 2025](https://openexchange.intersystems.com/contest/42). Please support it with your vote between 28th July and 3rd August.
6+
7+
This extension uses VS Code's [Testing API](https://code.visualstudio.com/api/extension-guides/testing) to discover, run and debug unit test classes built with the [%UnitTest testing framework](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=TUNT_WhatIsPercentUnitTest) of the InterSystems IRIS platforms, plus Caché-based predecessors supporting the `/api/atelier` REST service.
48

59
It augments the ObjectScript, InterSystems Language Server and Server Manager extensions, which are elements of the [InterSystems ObjectScript Extension Pack](https://marketplace.visualstudio.com/items?itemName=intersystems-community.objectscript-pack).
610

@@ -16,6 +20,12 @@ _Client-side editing workspace_
1620

1721
_Server-side editing workspace_
1822

23+
When used alongside [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) this extension presents coverage inside VS Code:
24+
25+
![Code coverage example](images/README/Coverage-example.png)
26+
27+
_Code coverage example showing coverage of Test Coverage Tool's own unit tests_
28+
1929
In order to support topologies in which client-side-managed test classes have to be run in the namespace of a remote server, this extension uses the `/_vscode` web application on the test-running server, no matter whether local or remote.
2030

2131
## Server Preparations
@@ -34,6 +44,8 @@ In order to support topologies in which client-side-managed test classes have to
3444
```
3545
> If you previously used the `%UnitTest` framework in a namespace, be aware that you are probably replacing an existing value. Consider taking a note of that in case you need to revert.
3646
47+
3. If you want to gather and display test coverage data, set up [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) in the namespace(s) where your tests will execute.
48+
3749
## Workspace Preparations
3850
3951
For a workspace using client-side editing, test classes are by default sought in `.cls` files under the `internal/testing/unit_tests` subfolder, using the conventional layout of one additional subfolder per package-name element. If your test classes are located elsewhere, use the `intersystems.testingManager.client.relativeTestRoot` setting to point there.
@@ -50,7 +62,7 @@ A subfolder is shown for each root folder of your workspace, which may be a mult
5062
5163
At the level of an individual test class the final expansion shows a leaf for each `TestXXX` method.
5264
53-
Hovering over any level of a tests tree will reveal action buttons that run all the tests from this level down. The 'Run' button does so without stopping at any breakpoints, in contrast to the 'Debug' button. At class or method level a 'Go to Test' button opens the class code and positions the cursor appropriately. At higher levels this button navigates to Explorer View.
65+
Hovering over any level of a tests tree will reveal action buttons that run all the tests from this level down. The 'Run Test' button does so without stopping at any breakpoints, in contrast to the 'Debug Test' button. At class or method level a 'Go to Test' button opens the class code and positions the cursor appropriately. At higher levels this button navigates to Explorer View.
5466
5567
When a test class is open in an editor tab it displays icons in the gutter at the top of the class and at the start of each test method. These show the outcome of the most recent run, if any, and can be clicked to perform testing operations.
5668
@@ -59,6 +71,9 @@ The `...` menu of the Testing panel in Test Explorer includes several useful com
5971
## Debugging Tests
6072
After opening a test class, click in the gutter to set a VS Code breakpoint in the normal manner. Then launch the test-run with the Debug option on the context menu of the testing icons in the gutter.
6173
74+
## Obtaining Test Coverage Information
75+
Use the 'Run with Coverage' option to submit your tests to [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool). When the run finishes the 'TEST COVERAGE' view will appear, usually below the 'TEST EXPLORER'. Use this to discover what proportion of executable code lines were covered by the most recent coverage run. Open sources to see color markers on line numbers showing covered (green) and not covered (red) lines. Learn more in the [VS Code documentation](https://code.visualstudio.com/docs/debugtest/testing#_test-coverage).
76+
6277
## Recent Testing History
6378
6479
The %UnitTest framework persists results of runs in server-side tables. The 'Recent History' root folder lets you explore the most recent ten sets of results for each server and namespace the workspace uses.

images/README/Coverage-example.png

547 KB
Loading

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "testingmanager",
33
"displayName": "InterSystems Testing Manager",
4-
"version": "0.2.4-SNAPSHOT",
4+
"version": "2.0.0-SNAPSHOT",
55
"preview": true,
66
"publisher": "intersystems-community",
77
"description": "Manage testing on InterSystems servers.",

src/commonRunTestsHandler.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as vscode from 'vscode';
22
import { IServerSpec } from "@intersystems-community/intersystems-servermanager";
3-
import { allTestRuns, extensionId, osAPI } from './extension';
3+
import { allTestRuns, extensionId, osAPI, OurTestItem } from './extension';
44
import { relativeTestRoot } from './localTests';
55
import logger from './logger';
66
import { makeRESTRequest } from './makeRESTRequest';
7+
import { OurFileCoverage } from './ourFileCoverage';
78

89
export async function commonRunTestsHandler(controller: vscode.TestController, resolveItemChildren: (item: vscode.TestItem) => Promise<void>, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) {
910
logger.debug(`commonRunTestsHandler invoked by controller id=${controller.id}`);
@@ -14,14 +15,29 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
1415
// We don't yet support running only some TestXXX methods in a testclass
1516
const mapAuthorities = new Map<string, Map<string, vscode.TestItem>>();
1617
const runIndices: number[] =[];
17-
const queue: vscode.TestItem[] = [];
18+
const queue: OurTestItem[] = [];
19+
const coverageRequest = request.profile?.kind === vscode.TestRunProfileKind.Coverage;
1820

1921
// Loop through all included tests, or all known tests, and add them to our queue
2022
if (request.include) {
21-
request.include.forEach(test => queue.push(test));
23+
request.include.forEach((test: OurTestItem) => {
24+
if (!coverageRequest || test.supportsCoverage) {
25+
queue.push(test);
26+
}
27+
});
2228
} else {
2329
// Run was launched from controller's root level
24-
controller.items.forEach(test => queue.push(test));
30+
controller.items.forEach((test: OurTestItem) => {
31+
if (!coverageRequest || test.supportsCoverage) {
32+
queue.push(test);
33+
}
34+
});
35+
}
36+
37+
if (coverageRequest && !queue.length) {
38+
// No tests to run, but coverage requested
39+
vscode.window.showErrorMessage("[Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) not found.", );
40+
return;
2541
}
2642

2743
// Process every test that was queued. Recurse down to leaves (testmethods) and build a map of their parents (classes)
@@ -69,7 +85,7 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
6985

7086
if (mapAuthorities.size === 0) {
7187
// Nothing included
72-
vscode.window.showWarningMessage(`Empty test run`);
88+
vscode.window.showErrorMessage("Empty test run.", { modal: true });
7389
return;
7490
}
7591

@@ -143,6 +159,49 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
143159
console.log(error);
144160
}
145161

162+
// Map of uri strings checked for presence of a coverage.list file, recording the relative path of those that were found
163+
const mapCoverageLists = new Map<string, string>();
164+
for await (const mapInstance of mapTestClasses) {
165+
const key = mapInstance[0];
166+
const pathParts = key.split('/');
167+
pathParts.pop();
168+
const sourceBaseUri = mapInstance[1].uri?.with({ path: mapInstance[1].uri.path.split('/').slice(0, -pathParts.length).join('/') });
169+
if (!sourceBaseUri) {
170+
console.log(`No sourceBaseUri for key=${key}`);
171+
continue;
172+
}
173+
while (pathParts.length > 1) {
174+
const currentPath = pathParts.join('/');
175+
// Check for coverage.list file here
176+
const coverageListUri = sourceBaseUri.with({ path: sourceBaseUri.path.concat(`${currentPath}/coverage.list`) });
177+
if (mapCoverageLists.has(coverageListUri.toString())) {
178+
// Already checked this uri path, and therefore all its ancestors
179+
break;
180+
}
181+
try {
182+
await vscode.workspace.fs.stat(coverageListUri);
183+
mapCoverageLists.set(coverageListUri.toString(), currentPath);
184+
} catch (error) {
185+
if (error.code !== vscode.FileSystemError.FileNotFound().code) {
186+
console.log(`Error checking for ${coverageListUri.toString()}:`, error);
187+
}
188+
mapCoverageLists.set(coverageListUri.toString(), '');
189+
}
190+
pathParts.pop();
191+
}
192+
}
193+
// Copy all coverage.list files found into the corresponding place under testRoot
194+
for await (const [uriString, path] of mapCoverageLists) {
195+
if (path.length > 0) {
196+
const coverageListUri = vscode.Uri.parse(uriString, true);
197+
try {
198+
await vscode.workspace.fs.copy(coverageListUri, testRoot.with({ path: testRoot.path.concat(`${path}/coverage.list`) }));
199+
} catch (error) {
200+
console.log(`Error copying ${coverageListUri.path}:`, error);
201+
}
202+
}
203+
}
204+
146205
// Next, copy the classes into the folder as a package hierarchy
147206
for await (const mapInstance of mapTestClasses) {
148207
const key = mapInstance[0];
@@ -186,11 +245,18 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
186245
}
187246
}
188247

248+
let managerClass = "%UnitTest.Manager";
249+
if (coverageRequest) {
250+
managerClass = "TestCoverage.Manager";
251+
request.profile.loadDetailedCoverage = async (testRun, fileCoverage, token) => {
252+
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage() : [];
253+
};
254+
}
189255
const configuration = {
190256
"type": "objectscript",
191257
"request": "launch",
192258
"name": `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
193-
"program": `##class(%UnitTest.Manager).RunTest("${testSpec}","${runQualifiers}")`,
259+
"program": `##class(${managerClass}).RunTest("${testSpec}","${runQualifiers}")`,
194260

195261
// Extra properties needed by our DebugAdapterTracker
196262
"testingRunIndex": runIndex,

src/coverage.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as vscode from 'vscode';
2+
import { makeRESTRequest } from './makeRESTRequest';
3+
import logger from './logger';
4+
import { OurTestRun } from './extension';
5+
import { serverSpecForUri } from './historyExplorer';
6+
import { OurFileCoverage } from './ourFileCoverage';
7+
8+
export async function processCoverage(serverName: string, namespace: string, run: OurTestRun): Promise<void> {
9+
const uri = run.debugSession?.workspaceFolder?.uri;
10+
const coverageIndex = run.debugSession?.configuration.coverageIndex;
11+
logger.debug(`processCoverage: serverName=${serverName}, namespace=${namespace}, uri=${uri?.toString()}, coverageIndex=${coverageIndex}`);
12+
if (uri) {
13+
const fileCoverageResults = await getFileCoverageResults(uri, namespace, coverageIndex || 0);
14+
if (fileCoverageResults.length > 0) {
15+
logger.debug(`Coverage results for run ${coverageIndex}: ${JSON.stringify(fileCoverageResults)}`);
16+
fileCoverageResults.forEach(fileCoverage => {
17+
run.addCoverage(fileCoverage);
18+
})
19+
} else {
20+
logger.debug(`No coverage results found for run ${coverageIndex}`);
21+
}
22+
}
23+
}
24+
25+
export async function getFileCoverageResults(folderUri: vscode.Uri, namespace: string, coverageIndex: number): Promise<vscode.FileCoverage[]> {
26+
const serverSpec = serverSpecForUri(folderUri);
27+
const fileCoverageResults: vscode.FileCoverage[] = [];
28+
if (!serverSpec) {
29+
logger.error(`No server spec found for URI: ${folderUri.toString()}`);
30+
return fileCoverageResults;
31+
}
32+
const exportSettings = vscode.workspace.getConfiguration('objectscript.export', folderUri);
33+
const response = await makeRESTRequest(
34+
"POST",
35+
serverSpec,
36+
{ apiVersion: 1, namespace, path: "/action/query" },
37+
{
38+
query: "SELECT cu.Hash, cu.Name Name, cu.Type, abcu.ExecutableLines, CoveredLines, ExecutableMethods, CoveredMethods, RtnLine FROM TestCoverage_Data_Aggregate.ByCodeUnit abcu, TestCoverage_Data.CodeUnit cu WHERE abcu.CodeUnit = cu.Hash AND Run = ? ORDER BY Name",
39+
parameters: [coverageIndex],
40+
},
41+
);
42+
if (response) {
43+
response?.data?.result?.content?.forEach(element => {
44+
const fileType = element.Type.toLowerCase();
45+
let pathPrefix = ''
46+
if (folderUri.scheme === 'file') {
47+
pathPrefix = exportSettings.folder;
48+
if (pathPrefix && !pathPrefix.startsWith('/')) {
49+
pathPrefix = `/${pathPrefix}`;
50+
}
51+
if (exportSettings.atelier) {
52+
pathPrefix += '/' + fileType;
53+
}
54+
}
55+
const fileUri = folderUri.with({ path: folderUri.path.concat(pathPrefix, `/${element.Name.replace(/\./g, '/')}.${fileType}`) });
56+
logger.debug(`getFileCoverageResults element: ${JSON.stringify(element)}`);
57+
logger.debug(`getFileCoverageResults fileUri: ${fileUri.toString()}`);
58+
const fileCoverage = new OurFileCoverage(
59+
coverageIndex,
60+
element.Hash,
61+
fileUri,
62+
new vscode.TestCoverageCount(element.CoveredLines, element.ExecutableLines),
63+
undefined,
64+
new vscode.TestCoverageCount(element.CoveredMethods, element.ExecutableMethods)
65+
);
66+
fileCoverageResults.push(fileCoverage);
67+
});
68+
}
69+
return fileCoverageResults;
70+
}

src/debugTracker.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import * as vscode from 'vscode';
2-
import { allTestRuns, loadedTestController, localTestController, TestRun } from './extension';
2+
import { allTestRuns, loadedTestController, localTestController, OurTestRun } from './extension';
33
import { refreshHistoryRootItem } from './historyExplorer';
4+
import { processCoverage } from './coverage';
45

56
export class DebugTracker implements vscode.DebugAdapterTracker {
67

78
private session: vscode.DebugSession;
89
private serverName: string;
910
private namespace: string;
1011
private testController: vscode.TestController
11-
private run?: TestRun;
12+
private run?: OurTestRun;
1213
private testingIdBase: string;
1314
private className?: string;
1415
private testMethodName?: string;
@@ -56,6 +57,14 @@ export class DebugTracker implements vscode.DebugAdapterTracker {
5657
}
5758
const line: string = (message.body.output as string).replace(/\n/, '');
5859
this.run.appendOutput(line + '\r\n');
60+
61+
const coverageMatch = line.match(/^(?:http|https):\/\/.*\/TestCoverage\.UI\.AggregateResultViewer\.cls\?Index=(\d+)/);
62+
if (coverageMatch && this.run.debugSession) {
63+
const coverageIndex = Number(coverageMatch[1]);
64+
this.run.debugSession.configuration.coverageIndex = coverageIndex;
65+
console.log(`Coverage index set to ${coverageIndex}`);
66+
}
67+
5968
if (this.className === undefined) {
6069
const classBegin = line.match(/^ ([%\dA-Za-z][\dA-Za-z0-9\.]*) begins \.\.\./);
6170
if (classBegin) {
@@ -139,10 +148,13 @@ export class DebugTracker implements vscode.DebugAdapterTracker {
139148
//console.log(`**Starting session ${this.session.name}, run.name = ${this.run?.name}`);
140149
}
141150

142-
onWillStopSession(): void {
143-
//console.log(`**Stopping session ${this.session.name}`);
151+
async onWillStopSession(): Promise<void> {
152+
console.log(`**Stopping session ${this.session.name}`);
144153
if (this.run) {
154+
await processCoverage(this.serverName, this.namespace, this.run);
155+
//console.log(`**processCoverage done`);
145156
this.run.end();
157+
//console.log(`**run.end() done`);
146158
refreshHistoryRootItem(this.serverName, this.namespace);
147159
}
148160

src/extension.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ export let historyBrowserController: vscode.TestController;
1414
export let osAPI: any;
1515
export let smAPI: serverManager.ServerManagerAPI | undefined;
1616

17-
export interface TestRun extends vscode.TestRun {
17+
export interface OurTestRun extends vscode.TestRun {
1818
debugSession?: vscode.DebugSession
1919
}
20-
export const allTestRuns: (TestRun | undefined)[] = [];
20+
21+
export interface OurTestItem extends vscode.TestItem {
22+
supportsCoverage?: boolean
23+
}
24+
25+
export const allTestRuns: (OurTestRun | undefined)[] = [];
2126

2227
async function getServerManagerAPI(): Promise<serverManager.ServerManagerAPI | undefined> {
2328
const targetExtension = vscode.extensions.getExtension("intersystems-community.servermanager");

0 commit comments

Comments
 (0)