Skip to content
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
20 changes: 20 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"objectscript.export": {
"folder": "serverSide\\src",
"addCategory": false,
"map": {},
"atelier": true,
"generated": false,
"filter": "",
"exactFilter": "",
"category": "*",
"maxConcurrentConnections": 0,
"mapped": false
},
"objectscript.conn": {
"active": true,
"server": "docker-52774",
"ns": "USER",
"username": "_SYSTEM"
},
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.0.3 (dd-Mmm-yyyy)
* Imporove positioning of assertion failure markers (#50)
* Remove need to define helper SQL functions via DDL.
* Promote use of `vscode-per-namespace-settings` package for webapp setup.
* Update DC contest text in README.

## 2.0.2 (30-Jul-2025)
* Fix coverage marking when `"objectscript.multilineMethodArgs": true` (#46)
* Improve method range highlighting accessed from coverage tree (#48)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **New in Version 2.0 - Test Coverage**
>
> 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.
> The v2.0 release was awarded first place in the [InterSystems Developer Tools Contest 2025](https://openexchange.intersystems.com/contest/42) in both voting categories, Experts and Community Members.

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.

Expand Down
66 changes: 66 additions & 0 deletions serverSide/src/vscode/dc/testingmanager/BaseManager.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/// Helper superclass for the testing managers we use.
/// Must collate ahead of its subclasses, so it is already available on the server
/// when they are copied there by a directory copy.
Class vscode.dc.testingmanager.BaseManager [ Abstract ]
{

/// Keep this in sync with the version property in package.json
Parameter VERSION As STRING = "2.0.3";

Property tmMethodMap As %String [ MultiDimensional, Private ];

Method tmMapOneFile(file As %String) [ Private ]
{
Kill ..tmMethodMap(file)
Set tFlags=+..UserParam # 2 * 16 // Bit 0 f UserParam indicates we must get udl-multiline format (since Atelier API v4)
Set tSC=##class(%Atelier.v1.Utils.TextServices).GetTextAsArray(file,tFlags,.tTextArray)
//TODO: use the text array to create a tag-to-linenumber map
For lineNumber=1:1:+$Get(tTextArray(0)) {
Set line=$Get(tTextArray(lineNumber))
Set keyword=$Piece(line," ",1)
If keyword'="Method",keyword'="ClassMethod" Continue
Set tag=$Piece($Piece(line," ",2),"(",1)
// Take account of multi-line method format
While ("{"'[$Get(tTextArray(lineNumber+1))) {
Set lineNumber=lineNumber+1
}
Set ..tmMethodMap(file,tag)=lineNumber
}
// Note linecount as an indicator we've indexed this file, even if we found no methods
Set ..tmMethodMap(file)=+$Get(tTextArray(0))
}

/// Copied from %UnitTest.Manager and enhanced to append location information
/// to some log messages.
Method LogAssert(success, action, description, extra, location)
{
Set testsuite=i%TheStack(i%TheStack,"suite")
Set testcase=i%TheStack(i%TheStack,"case")
Set testmethod=i%TheStack(i%TheStack,"method")
If testmethod="" Quit
Do LogAssert^%SYS.UNITTEST(..OriginNS,..ResultId,testsuite,testcase,testmethod,success,action,description,$GET(location))

// Convert location to +offset^file if it is a type of assertion outcome we want to display inline
If success'=1,$Get(location)'="" {
Set file=$Piece(location,"^",2)
Set tagOffset=$Piece(location,"^",1)
Set offset=$Piece(tagOffset,"+",2)
Set tag=$Piece(tagOffset,"+",1)
If (tag'="") {
// Create a tag-to-linenumber map for file if we don't have one already
If '$Data(..tmMethodMap(file)) Do ..tmMapOneFile(file)
// Use it to compute what to add to the offset to get an absolute line number
Set tagLineNumber=$Get(..tmMethodMap(file,tag))
Set location="+"_(offset+tagLineNumber)_"^"_file
}
}
Set line=action_":"_description_" ("_..GetTestState(success)_")"
if success'=1 Set line = line_"@"_location
If 'success,..Display["error" {
Do ..PrintErrorLine(line,.extra)
} Else {
Do ..PrintLine(line,4)
}
}

}
34 changes: 34 additions & 0 deletions serverSide/src/vscode/dc/testingmanager/CoverageManager.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Class vscode.dc.testingmanager.CoverageManager Extends (BaseManager, TestCoverage.Manager)
{

ClassMethod RunTest(ByRef testspec As %String, qspec As %String, ByRef userparam) As %Status
{
Set userparam("CoverageDetail")=2
Return ##super(testspec, qspec, .userparam)
}

/// SQL function to convert an IRIS bitstring to an Int8 bitstring for ease of handling in Typescript
/// Example usage: SELECT vscode_dc_testingmanager_CoverageManager.tmInt8Bitstring(cu.ExecutableLines) i8bsExecutableLines
ClassMethod tmInt8Bitstring(bitstring As %String) As %String [ SqlProc ]
{
Set output = "", iMod8=-1, char=0, weight=1
For i=1:1:$BitCount(bitstring) {
Set bitvalue = $Bit(bitstring, i)
Set iMod8 = (i-1)#8
If bitvalue {
Set char = char+weight
}
Set weight = weight*2
If iMod8 = 7 {
Set output = output_$Char(char)
Set char = 0, weight = 1
Set iMod8 = -1
}
}
If iMod8 > -1 {
Set output = output_$Char(char)
}
Return output
}

}
4 changes: 4 additions & 0 deletions serverSide/src/vscode/dc/testingmanager/StandardManager.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Class vscode.dc.testingmanager.StandardManager Extends (BaseManager, %UnitTest.Manager)
{

}
47 changes: 41 additions & 6 deletions src/commonRunTestsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ 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}`);
Expand Down Expand Up @@ -158,10 +157,13 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
);

if (!responseCspapps?.data?.result?.content?.includes("/_vscode")) {
const reply = await vscode.window.showErrorMessage(`A '/_vscode' web application must be configured for the %SYS namespace of server '${serverSpec.name}'. The ${namespace} namespace also requires its ^UnitTestRoot global to point to the '${namespace}/UnitTestRoot' subfolder of that web application's path.`, { modal: true }, 'Instructions');
if (reply === 'Instructions') {
const reply = await vscode.window.showErrorMessage(`A '/_vscode' web application must be configured for the %SYS namespace of server '${serverSpec.name}'. The ${namespace} namespace also requires its ^UnitTestRoot global to point to the '${namespace}/UnitTestRoot' subfolder of that web application's path.`, { modal: true }, 'Use IPM Package', 'Follow Manual Instructions');
if (reply === 'Follow Manual Instructions') {
vscode.commands.executeCommand('vscode.open', 'https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO_serverflow#GVSCO_serverflow_folderspec');
} else if (reply === 'Use IPM Package') {
vscode.commands.executeCommand('vscode.open', 'https://openexchange.intersystems.com/package/vscode-per-namespace-settings');
}
run.end();
return;
}

Expand All @@ -170,9 +172,40 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
// When client-side mode is using 'objectscript.conn.docker-compose the first piece of 'authority' is blank,
if (authority.startsWith(":")) {
authority = folder?.name || "";
} else {
authority = authority.split(":")[0];
}

// Load our support classes if they are not already there and the correct version.
const thisExtension = vscode.extensions.getExtension(extensionId);
if (!thisExtension) {
// Never happens, but needed to satisfy typechecking below
return;
}
const extensionUri = thisExtension.extensionUri;
const supportClassesDir = extensionUri.with({ path: extensionUri.path + '/serverSide/src' + '/vscode/dc/testingmanager'});
const expectedVersion = thisExtension.packageJSON.version;
const expectedCount = (await vscode.workspace.fs.readDirectory(supportClassesDir)).length;
const response = await makeRESTRequest(
"POST",
serverSpec,
{ apiVersion: 1, namespace, path: "/action/query" },
{
query: `SELECT parent, _Default FROM %Dictionary.CompiledParameter WHERE Name='VERSION' AND parent %STARTSWITH 'vscode.dc.testingmanager.' AND _Default=?`,
parameters: [expectedVersion],
},
);
if (response?.status !== 200 || response?.data?.result?.content?.length !== expectedCount) {
const destinationDir = vscode.Uri.from({ scheme: 'isfs', authority: `${authority}:${namespace}`, path: '/vscode/dc/testingmanager'})
try {
await vscode.workspace.fs.copy(supportClassesDir, destinationDir, { overwrite: true });
} catch (error) {
await vscode.window.showErrorMessage(`Failed to copy support classes from ${supportClassesDir.path.slice(1)} to ${destinationDir.toString()}\n\n${JSON.stringify(error)}`, {modal: true});
}
}

// No longer rely on ISFS redirection of /.vscode because since ObjectScript v3.0 it no longer works for client-only workspaces.
const testRoot = vscode.Uri.from({ scheme: 'isfs', authority: authority.split(":")[0], path: `/_vscode/${namespace}/UnitTestRoot/${username}`, query: "csp&ns=%SYS" });
const testRoot = vscode.Uri.from({ scheme: 'isfs', authority, path: `/_vscode/${namespace}/UnitTestRoot/${username}`, query: "csp&ns=%SYS" });
try {
// Limitation of the Atelier API means this can only delete the files, not the folders
// but zombie folders shouldn't cause problems.
Expand Down Expand Up @@ -254,6 +287,7 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
const isClientSideMode = controller.id === `${extensionId}-Local`;
const isDebug = request.profile?.kind === vscode.TestRunProfileKind.Debug;
const runQualifiers = !isClientSideMode ? "/noload/nodelete" : isDebug ? "/noload" : "";
const userParam = vscode.workspace.getConfiguration('objectscript', oneUri).get<boolean>('multilineMethodArgs', false) ? 1 : 0;
const runIndex = allTestRuns.push(run) - 1;
runIndices.push(runIndex);

Expand All @@ -268,16 +302,17 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r
}
}

let program = `##class(%UnitTest.Manager).RunTest("${testSpec}","${runQualifiers}")`;
let program = `##class(vscode.dc.testingmanager.StandardManager).RunTest("${testSpec}","${runQualifiers}",${userParam})`;
if (coverageRequest) {
program = `##class(${UTIL_CLASSNAME}).${SQL_FN_RUNTESTPROXY}("${testSpec}","${runQualifiers}",2)`;
program = `##class(vscode.dc.testingmanager.CoverageManager).RunTest("${testSpec}","${runQualifiers}",${userParam})`
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",
Expand Down
33 changes: 25 additions & 8 deletions src/debugTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ export class DebugTracker implements vscode.DebugAdapterTracker {
break;

case 'failed':
this.run.failed(this.methodTest, this.failureMessages.length > 0 ? this.failureMessages : { message: 'Failed with no messages' }, this.testDuration);
if (this.failureMessages.length > 0) {
this.run.failed(this.methodTest, this.failureMessages, this.testDuration);
} else {
this.run.failed(this.methodTest, { message: 'Failed with no messages' }, this.testDuration);
}
break;

default:
Expand All @@ -129,19 +133,25 @@ export class DebugTracker implements vscode.DebugAdapterTracker {
//const message = assertPassedMatch[2];
//console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName ${macroName}, outcome 'passed', message=${message}`);
} else {
const assertFailedMatch = line.match(/^(Assert\w+):(.*) \(failed\) <<====/);
const assertFailedMatch = line.match(/^(Assert\w+):(.*) \(failed\)@\+(\d+)\^(.*) <<====/);
if (assertFailedMatch) {
//const macroName = assertFailedMatch[1];
const failedMessage = assertFailedMatch[2];
//console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName ${macroName}, outcome 'failed', message=${message}`);
this.failureMessages.push({ message: failedMessage });
const message = assertFailedMatch[2];
const offset = Number(assertFailedMatch[3]);
const location = this.methodTest?.uri && this.methodTest.range
? new vscode.Location(this.methodTest.uri, new vscode.Position(offset, 0))
: undefined;
this.failureMessages.push({ message, location });
} else {
const assertSkippedMatch = line.match(/^ (Test\w+):(.*) \(skipped\)$/);
const assertSkippedMatch = line.match(/^ (Test\w+):(.*) \(skipped\)@\+(\d+)\^(.*)$/);
if (assertSkippedMatch) {
//const macroName = assertSkippedMatch[1];
const message = assertSkippedMatch[2];
//console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName ${macroName}, outcome 'skipped', message=${message}`);
this.skippedMessages.push({ message: message });
const offset = Number(assertSkippedMatch[3]);
const location = this.methodTest?.uri && this.methodTest.range
? new vscode.Location(this.methodTest.uri, new vscode.Position(offset, 0))
: undefined;
this.skippedMessages.push({ message, location });
} else {
const logMessageMatch = line.match(/^ LogMessage:(.*)$/);
if (logMessageMatch) {
Expand All @@ -151,6 +161,13 @@ export class DebugTracker implements vscode.DebugAdapterTracker {
if (duration) {
this.testDuration = + duration[1] * 1000;
}
} else {
const logStateStatusMatch = line.match(/^LogStateStatus:(.*)$/);
if (logStateStatusMatch) {
const message = logStateStatusMatch[1];
//console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName LogStateStatus, message=${message}`);
this.failureMessages.push({ message });
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ export let osAPI: any;
export let smAPI: serverManager.ServerManagerAPI | undefined;

export interface OurTestRun extends vscode.TestRun {
debugSession?: vscode.DebugSession
debugSession?: vscode.DebugSession;
}

export interface OurTestItem extends vscode.TestItem {
ourUri?: vscode.Uri;
supportsCoverage?: boolean
supportsCoverage?: boolean;
}

export const allTestRuns: (OurTestRun | undefined)[] = [];
Expand Down
5 changes: 2 additions & 3 deletions src/ourFileCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import logger from './logger';
import { IServerSpec } from '@intersystems-community/intersystems-servermanager';
import { makeRESTRequest } from './makeRESTRequest';
import { osAPI } from './extension';
import { SQL_FN_INT8BITSTRING } from './utils';

export class OurFileCoverage extends vscode.FileCoverage {

Expand Down Expand Up @@ -37,7 +36,7 @@ export class OurFileCoverage extends vscode.FileCoverage {

// When ObjectScript extension spreads method arguments over multiple lines, we need to compute offsets
const mapOffsets: Map<string, number> = new Map();
if (vscode.workspace.getConfiguration('objectscript', this.uri).get('multilineMethodArgs', false)) {
if (vscode.workspace.getConfiguration('objectscript', this.uri).get<boolean>('multilineMethodArgs', false)) {
const response = await makeRESTRequest(
"POST",
serverSpec,
Expand Down Expand Up @@ -89,7 +88,7 @@ export class OurFileCoverage extends vscode.FileCoverage {
serverSpec,
{ apiVersion: 1, namespace, path: "/action/query" },
{
query: `SELECT TestCoverage_UI.${SQL_FN_INT8BITSTRING}(cu.ExecutableLines) i8bsExecutableLines, TestCoverage_UI.${SQL_FN_INT8BITSTRING}(cov.CoveredLines) i8bsCoveredLines FROM TestCoverage_Data.CodeUnit cu, TestCoverage_Data.Coverage cov WHERE cu.Hash = cov.Hash AND Run = ? AND cu.Hash = ? AND TestPath = ?`,
query: `SELECT vscode_dc_testingmanager.CoverageManager_tmInt8Bitstring(cu.ExecutableLines) i8bsExecutableLines, vscode_dc_testingmanager.CoverageManager_tmInt8Bitstring(cov.CoveredLines) i8bsCoveredLines FROM TestCoverage_Data.CodeUnit cu, TestCoverage_Data.Coverage cov WHERE cu.Hash = cov.Hash AND Run = ? AND cu.Hash = ? AND TestPath = ?`,
parameters: [this.coverageIndex, this.codeUnit, testPath],
},
);
Expand Down
Loading
Loading