diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..381762c --- /dev/null +++ b/.vscode/settings.json @@ -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" + }, +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2037f38..5898a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index a0de764..cfc4c3f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/serverSide/src/vscode/dc/testingmanager/BaseManager.cls b/serverSide/src/vscode/dc/testingmanager/BaseManager.cls new file mode 100644 index 0000000..256e8a4 --- /dev/null +++ b/serverSide/src/vscode/dc/testingmanager/BaseManager.cls @@ -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) + } +} + +} diff --git a/serverSide/src/vscode/dc/testingmanager/CoverageManager.cls b/serverSide/src/vscode/dc/testingmanager/CoverageManager.cls new file mode 100644 index 0000000..5cf2f1b --- /dev/null +++ b/serverSide/src/vscode/dc/testingmanager/CoverageManager.cls @@ -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 +} + +} diff --git a/serverSide/src/vscode/dc/testingmanager/StandardManager.cls b/serverSide/src/vscode/dc/testingmanager/StandardManager.cls new file mode 100644 index 0000000..30e602a --- /dev/null +++ b/serverSide/src/vscode/dc/testingmanager/StandardManager.cls @@ -0,0 +1,4 @@ +Class vscode.dc.testingmanager.StandardManager Extends (BaseManager, %UnitTest.Manager) +{ + +} diff --git a/src/commonRunTestsHandler.ts b/src/commonRunTestsHandler.ts index 3e25e1e..4d98fa1 100644 --- a/src/commonRunTestsHandler.ts +++ b/src/commonRunTestsHandler.ts @@ -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, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) { logger.debug(`commonRunTestsHandler invoked by controller id=${controller.id}`); @@ -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; } @@ -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. @@ -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('multilineMethodArgs', false) ? 1 : 0; const runIndex = allTestRuns.push(run) - 1; runIndices.push(runIndex); @@ -268,9 +302,9 @@ 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() : []; }; @@ -278,6 +312,7 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage(fromTestItem) : []; }; } + const configuration = { type: "objectscript", request: "launch", diff --git a/src/debugTracker.ts b/src/debugTracker.ts index 4a73f26..d93dfc0 100644 --- a/src/debugTracker.ts +++ b/src/debugTracker.ts @@ -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: @@ -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) { @@ -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 }); + } } } } diff --git a/src/extension.ts b/src/extension.ts index d6fc081..c05308a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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)[] = []; diff --git a/src/ourFileCoverage.ts b/src/ourFileCoverage.ts index 1df5210..76b742f 100644 --- a/src/ourFileCoverage.ts +++ b/src/ourFileCoverage.ts @@ -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 { @@ -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 = new Map(); - if (vscode.workspace.getConfiguration('objectscript', this.uri).get('multilineMethodArgs', false)) { + if (vscode.workspace.getConfiguration('objectscript', this.uri).get('multilineMethodArgs', false)) { const response = await makeRESTRequest( "POST", serverSpec, @@ -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], }, ); diff --git a/src/utils.ts b/src/utils.ts index c4bd171..7abbf91 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,11 +4,6 @@ import { makeRESTRequest } from './makeRESTRequest'; import { IServerSpec } from '@intersystems-community/intersystems-servermanager'; import { osAPI } from './extension'; -const API_VERSION = 1; // Increment this whenever DDL of our util class changes -export const UTIL_CLASSNAME = `TestCoverage.UI.VSCodeUtilsV${API_VERSION}`; -export const SQL_FN_INT8BITSTRING = `fnVSCodeV${API_VERSION}Int8Bitstring`; -export const SQL_FN_RUNTESTPROXY = `fnVSCodeV${API_VERSION}RunTestProxy`; - export async function resolveServerSpecAndNamespace(uri: vscode.Uri): Promise<{ serverSpec: IServerSpec | undefined, namespace?: string }> { const server = await osAPI.asyncServerForUri(uri); if (!server) { @@ -49,92 +44,5 @@ export async function supportsCoverage(folder: vscode.WorkspaceFolder): Promise< if (response?.status !== 200) { return false; } - - // Does our util class already exist? - response = await makeRESTRequest( - "HEAD", - serverSpec, - { apiVersion: 1, namespace, path: `/doc/${UTIL_CLASSNAME}.cls` } - ); - if (response?.status === 200) { - return true; - } - - // No, so create it - return await createSQLUtilFunctions(serverSpec, namespace); -} - -async function createSQLUtilFunctions(serverSpec: IServerSpec, namespace: string): Promise { - logger.debug(`Creating our SQL Util functions class ${UTIL_CLASSNAME} for namespace: ${namespace}`); - - const functionsAsDDL =[ - // Convert an InterSystems native bitstring to an 8-bit character bitstring for manipulation in Typescript. - ` -CREATE FUNCTION ${SQL_FN_INT8BITSTRING}( - bitstring VARCHAR(32767) -) - FOR ${UTIL_CLASSNAME} - RETURNS VARCHAR(32767) - LANGUAGE OBJECTSCRIPT - { - New output,iMod8,char,weight,i,bitvalue - 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) - } - Quit output - } - `, - // Create a proxy classmethod invoking TestCoverage.Manager.RunTest method with the "CoverageDetail" parameter. - // Necessary because we run via the debugger so cannot directly pass by-reference the userparam array. - ` -CREATE FUNCTION ${SQL_FN_RUNTESTPROXY}( - testspec VARCHAR(32767), - qspec VARCHAR(32767), - coverageDetail INTEGER DEFAULT 1 -) - FOR ${UTIL_CLASSNAME} - RETURNS VARCHAR(32767) - LANGUAGE OBJECTSCRIPT - { - New userparam - Set userparam("CoverageDetail") = coverageDetail - Quit ##class(TestCoverage.Manager).RunTest( - testspec, - qspec, - .userparam - ) - } - `, - ]; - - for (const ddl of functionsAsDDL) { - const response = await makeRESTRequest( - "POST", - serverSpec, - { apiVersion: 1, namespace, path: "/action/query" }, - { query: ddl } - ); - if (!response || response.status !== 200 || response.data?.status?.errors?.length) { - vscode.window.showErrorMessage( - `Failed to create SQL Util functions in namespace ${namespace}: ${response?.data?.status?.summary || 'Unknown error'}`, - { modal: true } - ); - return false; - } - } return true; }