Skip to content

Commit d6361e3

Browse files
Support per-testclass coverage tracking (#44)
Support per-class coverage tracking
2 parents 453dcdf + 967e878 commit d6361e3

14 files changed

+513
-385
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.0.1 (28-Jul-2025)
2+
* Activate the Covering Tests filter in the editor's Test Coverage Toolbar (#44)
3+
* Various bugfixes and improvements.
4+
15
## 2.0.0 (23-Jul-2025)
26
* Use [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) to present coverage information (#24)
37

README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,22 @@ _Server-side editing workspace_
2222

2323
When used alongside [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) this extension presents coverage inside VS Code:
2424

25-
![Code coverage example](images/README/Coverage-example.png)
25+
![Test coverage example](images/README/Coverage-example.png)
2626

27-
_Code coverage example showing coverage of Test Coverage Tool's own unit tests_
27+
_Coverage example showing coverage of Test Coverage Tool's own unit tests_
28+
29+
In the above screenshot the Test Coverage view has been dragged to the secondary sidebar.
30+
31+
32+
Displaying which code lines your tests cover aids the improvement of those tests. The greater the percentage of code lines covered by testing, the more likely your tests will detect regressions.
33+
34+
Below is an example from the [InterSystems Package Manager](https://github.com/intersystems/ipm) repository. Two test classes ran code in the %IPM.Repo.Definition class, but neither of them covered line 88 in the screenshot below:
35+
36+
![Tests missed part of a method](images/README/Coverage-missed-part-of-method.png)
37+
38+
_Tests failed to cover line 88_
39+
40+
The optional Test Coverage Toolbar at the top of the class's editor and the coverage decorations in the Explorer tree combine to promote good testing habits.
2841

2942
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.
3043

@@ -82,12 +95,13 @@ Hovering on a run's folder reveals an action button which launches %UnitTest's o
8295
8396
## Known Limitations
8497
85-
This extension is a preview and has some known limitations:
98+
This extension has some known quirks and limitations:
8699
87100
- The extension uses server-side REST support for debugging even when tests are not being debugged. Debug support is broken in InterSystems IRIS 2021.1.3, and maybe also in earlier 2021.1.x versions. Either upgrade to a later version or request an ad-hoc patch from InterSystems.
88-
- In client-side mode test-run results don't update the testing icons in the editor gutter or the Local Tests tree in Testing view. Workaround is to view them under the Recent History tree.
89-
- The extension has only been tested with InterSystems IRIS instances that use the English locale. Its technique for parsing the output from %UnitTest is likely to fail with other locales.
90-
- The `/autoload` feature of %UnitTest is not supported. This is only relevant to client-side mode.
101+
- Launching the IRIS debugger requires a document on the target server namespace to be open and active. This extension opens one automatically at the beginning of all types of test (Run, Debug and Run With Coverage).
102+
- The very first coverage-type run after installation may report that the Test Coverage Tool is missing. Use the Refresh Tests button on the Test Explorer view, then retry your coverage run.
103+
- The extension has only been tested with InterSystems IRIS instances that use the English locale. Its technique for parsing the output from %UnitTest may fail when used with other locales.
104+
- The `/autoload` feature of %UnitTest is not currently supported. This is only relevant to client-side mode.
91105
- The loading and deleting of unit test classes which occurs when using client-side mode will raise corresponding events on any source control class that the target namespace may have been configured to use.
92106
93107
## Feedback
404 KB
Loading

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"objectscript"
1616
],
1717
"engines": {
18-
"vscode": "^1.93.0"
18+
"vscode": "^1.96.0"
1919
},
2020
"icon": "images/logo.png",
2121
"categories": [
@@ -53,7 +53,7 @@
5353
"@types/glob": "^7.1.1",
5454
"@types/mocha": "^9.0.0",
5555
"@types/node": "^8.10.60",
56-
"@types/vscode": "^1.93.0",
56+
"@types/vscode": "^1.96.0",
5757
"@vscode/test-electron": "^2.3.8",
5858
"glob": "^7.1.6",
5959
"mocha": "^9.2.2",

src/commonRunTestsHandler.ts

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import { relativeTestRoot } from './localTests';
55
import logger from './logger';
66
import { makeRESTRequest } from './makeRESTRequest';
77
import { OurFileCoverage } from './ourFileCoverage';
8+
import { SQL_FN_RUNTESTPROXY, UTIL_CLASSNAME } from './utils';
89

910
export async function commonRunTestsHandler(controller: vscode.TestController, resolveItemChildren: (item: vscode.TestItem) => Promise<void>, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) {
1011
logger.debug(`commonRunTestsHandler invoked by controller id=${controller.id}`);
1112

12-
const isResolvedMap = new WeakMap<vscode.TestItem, boolean>();
13-
1413
// For each authority (i.e. server:namespace) accumulate a map of the class-level Test nodes in the tree.
1514
// We don't yet support running only some TestXXX methods in a testclass
16-
const mapAuthorities = new Map<string, Map<string, vscode.TestItem>>();
15+
const mapAuthorities = new Map<string, Map<string, OurTestItem>>();
1716
const runIndices: number[] =[];
1817
const queue: OurTestItem[] = [];
1918
const coverageRequest = request.profile?.kind === vscode.TestRunProfileKind.Coverage;
@@ -49,14 +48,14 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
4948
continue;
5049
}
5150

52-
// Resolve children if not already done
53-
if (test.canResolveChildren && !isResolvedMap.get(test)) {
51+
// Resolve children if not definitely already done
52+
if (test.canResolveChildren && test.children.size === 0) {
5453
await resolveItemChildren(test);
5554
}
5655

5756
// If a leaf item (a TestXXX method in a class) note its .cls file for copying.
58-
// Every leaf must have a uri.
59-
if (test.children.size === 0 && test.uri && test.parent) {
57+
// Every leaf should have a uri.
58+
if (test.children.size === 0 && test.uri) {
6059
let authority = test.uri.authority;
6160
let key = test.uri.path;
6261
if (test.uri.scheme === "file") {
@@ -69,9 +68,13 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
6968
}
7069
}
7170

72-
const mapTestClasses = mapAuthorities.get(authority) || new Map<string, vscode.TestItem>();
73-
mapTestClasses.set(key, test.parent);
74-
mapAuthorities.set(authority, mapTestClasses);
71+
const mapTestClasses = mapAuthorities.get(authority) || new Map<string, OurTestItem>();
72+
if (!mapTestClasses.has(key) && test.parent) {
73+
// When leaf is a test its parent has a uri and is the class
74+
// Otherwise the leaf is a class with no tests
75+
mapTestClasses.set(key, test.parent.uri ? test.parent : test);
76+
mapAuthorities.set(authority, mapTestClasses);
77+
}
7578
}
7679

7780
// Queue any children
@@ -108,15 +111,23 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
108111
);
109112
let authority = mapInstance[0];
110113
const mapTestClasses = mapInstance[1];
114+
115+
// enqueue everything up front so user sees immediately which tests will run
116+
mapTestClasses.forEach((test) => {
117+
test.children.forEach((methodTest) => {
118+
run.enqueued(methodTest);
119+
});
120+
});
121+
111122
const firstClassTestItem = Array.from(mapTestClasses.values())[0];
112-
const oneUri = firstClassTestItem.uri;
123+
const oneUri = firstClassTestItem.ourUri;
113124

114125
// This will always be true since every test added to the map above required a uri
115126
if (oneUri) {
116127

117128
// First, clear out the server-side folder for the classes whose testmethods will be run
118129
const folder = vscode.workspace.getWorkspaceFolder(oneUri);
119-
const server = osAPI.serverForUri(oneUri);
130+
const server = await osAPI.asyncServerForUri(oneUri);
120131
const serverSpec: IServerSpec = {
121132
username: server.username,
122133
password: server.password,
@@ -165,11 +176,16 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
165176
const key = mapInstance[0];
166177
const pathParts = key.split('/');
167178
pathParts.pop();
168-
const sourceBaseUri = mapInstance[1].uri?.with({ path: mapInstance[1].uri.path.split('/').slice(0, -pathParts.length).join('/') });
179+
const sourceBaseUri = mapInstance[1].ourUri?.with({ path: mapInstance[1].ourUri.path.split('/').slice(0, -pathParts.length).join('/') });
169180
if (!sourceBaseUri) {
170181
console.log(`No sourceBaseUri for key=${key}`);
171182
continue;
172183
}
184+
// isfs folders can't supply coverage.list files, so don't bother looking.
185+
// Instead the file has to be put in the /namespace/UnitTestRoot/ folder of the /_vscode webapp of the %SYS namespace.
186+
if (['isfs', 'isfs-readonly'].includes(sourceBaseUri.scheme)) {
187+
continue;
188+
}
173189
while (pathParts.length > 1) {
174190
const currentPath = pathParts.join('/');
175191
// Check for coverage.list file here
@@ -216,13 +232,9 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
216232
await vscode.workspace.fs.copy(uri, directoryUri.with({ path: directoryUri.path.concat(clsFile) }));
217233
} catch (error) {
218234
console.log(error);
235+
run.errored(classTest, new vscode.TestMessage(error instanceof Error ? error.message : String(error)));
219236
continue;
220237
}
221-
222-
// Unless the file copy failed, enqueue all the testitems that represent the TestXXX methods of the class
223-
classTest.children.forEach((methodTest) => {
224-
run.enqueued(methodTest);
225-
});
226238
}
227239
}
228240

@@ -240,31 +252,36 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
240252
let testSpec = username;
241253
if (request.include?.length === 1) {
242254
const idParts = request.include[0].id.split(":");
243-
if (idParts.length === 4) {
244-
testSpec = `${username}:${idParts[2]}:${idParts[3]}`;
255+
if (idParts.length === 5) {
256+
testSpec = `${username}:${idParts[3]}:${idParts[4]}`;
245257
}
246258
}
247259

248-
let managerClass = "%UnitTest.Manager";
260+
let program = `##class(%UnitTest.Manager).RunTest("${testSpec}","${runQualifiers}")`;
249261
if (coverageRequest) {
250-
managerClass = "TestCoverage.Manager";
251-
request.profile.loadDetailedCoverage = async (testRun, fileCoverage, token) => {
262+
program = `##class(${UTIL_CLASSNAME}).${SQL_FN_RUNTESTPROXY}("${testSpec}","${runQualifiers}",2)`;
263+
request.profile.loadDetailedCoverage = async (_testRun, fileCoverage, _token) => {
252264
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage() : [];
253265
};
266+
request.profile.loadDetailedCoverageForTest = async (_testRun, fileCoverage, fromTestItem, _token) => {
267+
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage(fromTestItem) : [];
268+
};
254269
}
255270
const configuration = {
256-
"type": "objectscript",
257-
"request": "launch",
258-
"name": `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
259-
"program": `##class(${managerClass}).RunTest("${testSpec}","${runQualifiers}")`,
271+
type: "objectscript",
272+
request: "launch",
273+
name: `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
274+
program,
260275

261276
// Extra properties needed by our DebugAdapterTracker
262-
"testingRunIndex": runIndex,
263-
"testingIdBase": firstClassTestItem.id.split(":", 2).join(":")
277+
testingRunIndex: runIndex,
278+
testingIdBase: firstClassTestItem.id.split(":", 3).join(":")
264279
};
265280
const sessionOptions: vscode.DebugSessionOptions = {
266281
noDebug: !isDebug,
267-
suppressDebugToolbar: request.profile?.kind !== vscode.TestRunProfileKind.Debug
282+
suppressDebugToolbar: request.profile?.kind !== vscode.TestRunProfileKind.Debug,
283+
suppressDebugView: request.profile?.kind !== vscode.TestRunProfileKind.Debug,
284+
testRun: run,
268285
};
269286

270287
// ObjectScript debugger's initializeRequest handler needs to identify target server and namespace

src/coverage.ts

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as vscode from 'vscode';
22
import { makeRESTRequest } from './makeRESTRequest';
33
import logger from './logger';
4-
import { OurTestRun } from './extension';
4+
import { OurTestRun, workspaceFolderTestClasses } from './extension';
55
import { serverSpecForUri } from './historyExplorer';
66
import { OurFileCoverage } from './ourFileCoverage';
77

@@ -23,48 +23,69 @@ export async function processCoverage(serverName: string, namespace: string, run
2323
}
2424

2525
export async function getFileCoverageResults(folderUri: vscode.Uri, namespace: string, coverageIndex: number): Promise<vscode.FileCoverage[]> {
26-
const serverSpec = serverSpecForUri(folderUri);
27-
const fileCoverageResults: vscode.FileCoverage[] = [];
26+
const serverSpec = await serverSpecForUri(folderUri);
2827
if (!serverSpec) {
2928
logger.error(`No server spec found for URI: ${folderUri.toString()}`);
30-
return fileCoverageResults;
29+
return [];
3130
}
3231
const exportSettings = vscode.workspace.getConfiguration('objectscript.export', folderUri);
3332
const response = await makeRESTRequest(
3433
"POST",
3534
serverSpec,
3635
{ apiVersion: 1, namespace, path: "/action/query" },
3736
{
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",
37+
query: "SELECT cu.Hash Hash, cu.Name Name, cu.Type, abcu.ExecutableLines, abcu.CoveredLines, ExecutableMethods, CoveredMethods, TestPath FROM TestCoverage_Data_Aggregate.ByCodeUnit abcu, TestCoverage_Data.CodeUnit cu, TestCoverage_Data.Coverage cov WHERE cov.CoveredLines IS NOT NULL AND abcu.CodeUnit = cu.Hash AND cov.Hash = cu.Hash AND abcu.Run = ? AND cov.Run = abcu.Run ORDER BY Hash",
3938
parameters: [coverageIndex],
4039
},
4140
);
41+
const mapFileCoverages: Map<string, OurFileCoverage> = new Map();
4242
if (response) {
4343
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}`;
44+
let fileCoverage = mapFileCoverages.get(element.Hash);
45+
if (!fileCoverage) {
46+
const fileType = element.Type.toLowerCase();
47+
let pathPrefix = ''
48+
if (folderUri.scheme === 'file') {
49+
pathPrefix = exportSettings.folder;
50+
if (pathPrefix && !pathPrefix.startsWith('/')) {
51+
pathPrefix = `/${pathPrefix}`;
52+
}
53+
if (exportSettings.addCategory) {
54+
// TODO handle rare(?) Object-format addCategory setting just like the ObjectScript extension implements in src/commands/export.ts
55+
pathPrefix += '/' + fileType;
56+
}
5057
}
51-
if (exportSettings.atelier) {
52-
pathPrefix += '/' + fileType;
58+
59+
// Respect exportSettings.map which the IPM project uses to export %IPM.Foo.cls into IPM/Foo.cls
60+
if (exportSettings.map) {
61+
for (const pattern of Object.keys(exportSettings.map)) {
62+
if (new RegExp(`^${pattern}$`).test(element.Name)) {
63+
element.Name = element.Name.replace(new RegExp(`^${pattern}$`), exportSettings.map[pattern]);
64+
break;
65+
}
66+
}
67+
}
68+
const fileUri = folderUri.with({ path: folderUri.path.concat(pathPrefix, `/${element.Name.replace(/\./g, '/')}.${fileType}`) });
69+
fileCoverage = new OurFileCoverage(
70+
coverageIndex,
71+
element.Hash,
72+
fileUri,
73+
new vscode.TestCoverageCount(element.CoveredLines, element.ExecutableLines),
74+
undefined,
75+
new vscode.TestCoverageCount(element.CoveredMethods, element.ExecutableMethods)
76+
);
77+
}
78+
const testPath: string = element.TestPath || 'all tests';
79+
if (testPath !== 'all tests') {
80+
//console.log(`Find TestItem matching test path ${testPath}`);
81+
const className = testPath.split(':')[1];
82+
const testItem = workspaceFolderTestClasses[vscode.workspace.getWorkspaceFolder(folderUri)?.index || 0].get(className);
83+
if (testItem) {
84+
fileCoverage.includesTests?.push(testItem);
5385
}
5486
}
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);
87+
mapFileCoverages.set(element.Hash, fileCoverage);
6788
});
6889
}
69-
return fileCoverageResults;
90+
return Array.from(mapFileCoverages.values());
7091
}

0 commit comments

Comments
 (0)