Skip to content

Support per-class coverage tracking #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.0.1 (28-Jul-2025)
* Activate the Covering Tests filter in the editor's Test Coverage Toolbar (#44)
* Various bugfixes and improvements.

## 2.0.0 (23-Jul-2025)
* Use [Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) to present coverage information (#24)

Expand Down
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ _Server-side editing workspace_

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

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

_Code coverage example showing coverage of Test Coverage Tool's own unit tests_
_Coverage example showing coverage of Test Coverage Tool's own unit tests_

In the above screenshot the Test Coverage view has been dragged to the secondary sidebar.


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.

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:

![Tests missed part of a method](images/README/Coverage-missed-part-of-method.png)

_Tests failed to cover line 88_

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.

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.

Expand Down Expand Up @@ -82,12 +95,13 @@ Hovering on a run's folder reveals an action button which launches %UnitTest's o

## Known Limitations

This extension is a preview and has some known limitations:
This extension has some known quirks and limitations:

- 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.
- 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.
- 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.
- The `/autoload` feature of %UnitTest is not supported. This is only relevant to client-side mode.
- 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).
- 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.
- 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.
- The `/autoload` feature of %UnitTest is not currently supported. This is only relevant to client-side mode.
- 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.

## Feedback
Expand Down
Binary file added images/README/Coverage-missed-part-of-method.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"objectscript"
],
"engines": {
"vscode": "^1.93.0"
"vscode": "^1.96.0"
},
"icon": "images/logo.png",
"categories": [
Expand Down Expand Up @@ -53,7 +53,7 @@
"@types/glob": "^7.1.1",
"@types/mocha": "^9.0.0",
"@types/node": "^8.10.60",
"@types/vscode": "^1.93.0",
"@types/vscode": "^1.96.0",
"@vscode/test-electron": "^2.3.8",
"glob": "^7.1.6",
"mocha": "^9.2.2",
Expand Down
77 changes: 47 additions & 30 deletions src/commonRunTestsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { relativeTestRoot } from './localTests';
import logger from './logger';
import { makeRESTRequest } from './makeRESTRequest';
import { OurFileCoverage } from './ourFileCoverage';
import { SQL_FN_RUNTESTPROXY, UTIL_CLASSNAME } from './utils';

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

const isResolvedMap = new WeakMap<vscode.TestItem, boolean>();

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

// Resolve children if not already done
if (test.canResolveChildren && !isResolvedMap.get(test)) {
// Resolve children if not definitely already done
if (test.canResolveChildren && test.children.size === 0) {
await resolveItemChildren(test);
}

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

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

// Queue any children
Expand Down Expand Up @@ -108,15 +111,23 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
);
let authority = mapInstance[0];
const mapTestClasses = mapInstance[1];

// enqueue everything up front so user sees immediately which tests will run
mapTestClasses.forEach((test) => {
test.children.forEach((methodTest) => {
run.enqueued(methodTest);
});
});

const firstClassTestItem = Array.from(mapTestClasses.values())[0];
const oneUri = firstClassTestItem.uri;
const oneUri = firstClassTestItem.ourUri;

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

// First, clear out the server-side folder for the classes whose testmethods will be run
const folder = vscode.workspace.getWorkspaceFolder(oneUri);
const server = osAPI.serverForUri(oneUri);
const server = await osAPI.asyncServerForUri(oneUri);
const serverSpec: IServerSpec = {
username: server.username,
password: server.password,
Expand Down Expand Up @@ -165,11 +176,16 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
const key = mapInstance[0];
const pathParts = key.split('/');
pathParts.pop();
const sourceBaseUri = mapInstance[1].uri?.with({ path: mapInstance[1].uri.path.split('/').slice(0, -pathParts.length).join('/') });
const sourceBaseUri = mapInstance[1].ourUri?.with({ path: mapInstance[1].ourUri.path.split('/').slice(0, -pathParts.length).join('/') });
if (!sourceBaseUri) {
console.log(`No sourceBaseUri for key=${key}`);
continue;
}
// isfs folders can't supply coverage.list files, so don't bother looking.
// Instead the file has to be put in the /namespace/UnitTestRoot/ folder of the /_vscode webapp of the %SYS namespace.
if (['isfs', 'isfs-readonly'].includes(sourceBaseUri.scheme)) {
continue;
}
while (pathParts.length > 1) {
const currentPath = pathParts.join('/');
// Check for coverage.list file here
Expand Down Expand Up @@ -216,13 +232,9 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
await vscode.workspace.fs.copy(uri, directoryUri.with({ path: directoryUri.path.concat(clsFile) }));
} catch (error) {
console.log(error);
run.errored(classTest, new vscode.TestMessage(error instanceof Error ? error.message : String(error)));
continue;
}

// Unless the file copy failed, enqueue all the testitems that represent the TestXXX methods of the class
classTest.children.forEach((methodTest) => {
run.enqueued(methodTest);
});
}
}

Expand All @@ -240,31 +252,36 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
let testSpec = username;
if (request.include?.length === 1) {
const idParts = request.include[0].id.split(":");
if (idParts.length === 4) {
testSpec = `${username}:${idParts[2]}:${idParts[3]}`;
if (idParts.length === 5) {
testSpec = `${username}:${idParts[3]}:${idParts[4]}`;
}
}

let managerClass = "%UnitTest.Manager";
let program = `##class(%UnitTest.Manager).RunTest("${testSpec}","${runQualifiers}")`;
if (coverageRequest) {
managerClass = "TestCoverage.Manager";
request.profile.loadDetailedCoverage = async (testRun, fileCoverage, token) => {
program = `##class(${UTIL_CLASSNAME}).${SQL_FN_RUNTESTPROXY}("${testSpec}","${runQualifiers}",2)`;
request.profile.loadDetailedCoverage = async (_testRun, fileCoverage, _token) => {
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage() : [];
};
request.profile.loadDetailedCoverageForTest = async (_testRun, fileCoverage, fromTestItem, _token) => {
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage(fromTestItem) : [];
};
}
const configuration = {
"type": "objectscript",
"request": "launch",
"name": `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
"program": `##class(${managerClass}).RunTest("${testSpec}","${runQualifiers}")`,
type: "objectscript",
request: "launch",
name: `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
program,

// Extra properties needed by our DebugAdapterTracker
"testingRunIndex": runIndex,
"testingIdBase": firstClassTestItem.id.split(":", 2).join(":")
testingRunIndex: runIndex,
testingIdBase: firstClassTestItem.id.split(":", 3).join(":")
};
const sessionOptions: vscode.DebugSessionOptions = {
noDebug: !isDebug,
suppressDebugToolbar: request.profile?.kind !== vscode.TestRunProfileKind.Debug
suppressDebugToolbar: request.profile?.kind !== vscode.TestRunProfileKind.Debug,
suppressDebugView: request.profile?.kind !== vscode.TestRunProfileKind.Debug,
testRun: run,
};

// ObjectScript debugger's initializeRequest handler needs to identify target server and namespace
Expand Down
73 changes: 47 additions & 26 deletions src/coverage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { makeRESTRequest } from './makeRESTRequest';
import logger from './logger';
import { OurTestRun } from './extension';
import { OurTestRun, workspaceFolderTestClasses } from './extension';
import { serverSpecForUri } from './historyExplorer';
import { OurFileCoverage } from './ourFileCoverage';

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

export async function getFileCoverageResults(folderUri: vscode.Uri, namespace: string, coverageIndex: number): Promise<vscode.FileCoverage[]> {
const serverSpec = serverSpecForUri(folderUri);
const fileCoverageResults: vscode.FileCoverage[] = [];
const serverSpec = await serverSpecForUri(folderUri);
if (!serverSpec) {
logger.error(`No server spec found for URI: ${folderUri.toString()}`);
return fileCoverageResults;
return [];
}
const exportSettings = vscode.workspace.getConfiguration('objectscript.export', folderUri);
const response = await makeRESTRequest(
"POST",
serverSpec,
{ apiVersion: 1, namespace, path: "/action/query" },
{
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",
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",
parameters: [coverageIndex],
},
);
const mapFileCoverages: Map<string, OurFileCoverage> = new Map();
if (response) {
response?.data?.result?.content?.forEach(element => {
const fileType = element.Type.toLowerCase();
let pathPrefix = ''
if (folderUri.scheme === 'file') {
pathPrefix = exportSettings.folder;
if (pathPrefix && !pathPrefix.startsWith('/')) {
pathPrefix = `/${pathPrefix}`;
let fileCoverage = mapFileCoverages.get(element.Hash);
if (!fileCoverage) {
const fileType = element.Type.toLowerCase();
let pathPrefix = ''
if (folderUri.scheme === 'file') {
pathPrefix = exportSettings.folder;
if (pathPrefix && !pathPrefix.startsWith('/')) {
pathPrefix = `/${pathPrefix}`;
}
if (exportSettings.addCategory) {
// TODO handle rare(?) Object-format addCategory setting just like the ObjectScript extension implements in src/commands/export.ts
pathPrefix += '/' + fileType;
}
}
if (exportSettings.atelier) {
pathPrefix += '/' + fileType;

// Respect exportSettings.map which the IPM project uses to export %IPM.Foo.cls into IPM/Foo.cls
if (exportSettings.map) {
for (const pattern of Object.keys(exportSettings.map)) {
if (new RegExp(`^${pattern}$`).test(element.Name)) {
element.Name = element.Name.replace(new RegExp(`^${pattern}$`), exportSettings.map[pattern]);
break;
}
}
}
const fileUri = folderUri.with({ path: folderUri.path.concat(pathPrefix, `/${element.Name.replace(/\./g, '/')}.${fileType}`) });
fileCoverage = new OurFileCoverage(
coverageIndex,
element.Hash,
fileUri,
new vscode.TestCoverageCount(element.CoveredLines, element.ExecutableLines),
undefined,
new vscode.TestCoverageCount(element.CoveredMethods, element.ExecutableMethods)
);
}
const testPath: string = element.TestPath || 'all tests';
if (testPath !== 'all tests') {
//console.log(`Find TestItem matching test path ${testPath}`);
const className = testPath.split(':')[1];
const testItem = workspaceFolderTestClasses[vscode.workspace.getWorkspaceFolder(folderUri)?.index || 0].get(className);
if (testItem) {
fileCoverage.includesTests?.push(testItem);
}
}
const fileUri = folderUri.with({ path: folderUri.path.concat(pathPrefix, `/${element.Name.replace(/\./g, '/')}.${fileType}`) });
logger.debug(`getFileCoverageResults element: ${JSON.stringify(element)}`);
logger.debug(`getFileCoverageResults fileUri: ${fileUri.toString()}`);
const fileCoverage = new OurFileCoverage(
coverageIndex,
element.Hash,
fileUri,
new vscode.TestCoverageCount(element.CoveredLines, element.ExecutableLines),
undefined,
new vscode.TestCoverageCount(element.CoveredMethods, element.ExecutableMethods)
);
fileCoverageResults.push(fileCoverage);
mapFileCoverages.set(element.Hash, fileCoverage);
});
}
return fileCoverageResults;
return Array.from(mapFileCoverages.values());
}
Loading
Loading