diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cccace..886d98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index ae86408..a0de764 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/images/README/Coverage-missed-part-of-method.png b/images/README/Coverage-missed-part-of-method.png new file mode 100644 index 0000000..5f02ab6 Binary files /dev/null and b/images/README/Coverage-missed-part-of-method.png differ diff --git a/package-lock.json b/package-lock.json index 296aabd..95ce66f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "testingmanager", - "version": "2.0.0-SNAPSHOT", + "version": "2.0.1-SNAPSHOT", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "testingmanager", - "version": "2.0.0-SNAPSHOT", + "version": "2.0.1-SNAPSHOT", "license": "MIT", "dependencies": { "axios": "^0.24", @@ -19,7 +19,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", @@ -28,7 +28,7 @@ "typescript": "^5.5.4" }, "engines": { - "vscode": "^1.93.0" + "vscode": "^1.96.0" } }, "node_modules/@babel/code-frame": { @@ -209,9 +209,9 @@ "peer": true }, "node_modules/@types/vscode": { - "version": "1.95.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.95.0.tgz", - "integrity": "sha512-0LBD8TEiNbet3NvWsmn59zLzOFu/txSlGxnv5yAFHCrhG9WvAnR3IvfHzMOs2aeWqgvNjq9pO99IUw8d3n+unw==", + "version": "1.102.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz", + "integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==", "dev": true, "license": "MIT" }, @@ -3163,9 +3163,9 @@ "peer": true }, "@types/vscode": { - "version": "1.95.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.95.0.tgz", - "integrity": "sha512-0LBD8TEiNbet3NvWsmn59zLzOFu/txSlGxnv5yAFHCrhG9WvAnR3IvfHzMOs2aeWqgvNjq9pO99IUw8d3n+unw==", + "version": "1.102.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz", + "integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==", "dev": true }, "@ungap/promise-all-settled": { diff --git a/package.json b/package.json index 97fa1a2..a151630 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "objectscript" ], "engines": { - "vscode": "^1.93.0" + "vscode": "^1.96.0" }, "icon": "images/logo.png", "categories": [ @@ -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", diff --git a/src/commonRunTestsHandler.ts b/src/commonRunTestsHandler.ts index fdf6542..f7c30b0 100644 --- a/src/commonRunTestsHandler.ts +++ b/src/commonRunTestsHandler.ts @@ -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, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) { logger.debug(`commonRunTestsHandler invoked by controller id=${controller.id}`); - const isResolvedMap = new WeakMap(); - // 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>(); + const mapAuthorities = new Map>(); const runIndices: number[] =[]; const queue: OurTestItem[] = []; const coverageRequest = request.profile?.kind === vscode.TestRunProfileKind.Coverage; @@ -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") { @@ -69,9 +68,13 @@ export async function commonRunTestsHandler(controller: vscode.TestController, r } } - const mapTestClasses = mapAuthorities.get(authority) || new Map(); - mapTestClasses.set(key, test.parent); - mapAuthorities.set(authority, mapTestClasses); + const mapTestClasses = mapAuthorities.get(authority) || new Map(); + 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 @@ -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, @@ -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 @@ -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); - }); } } @@ -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 diff --git a/src/coverage.ts b/src/coverage.ts index c923a15..22cbb20 100644 --- a/src/coverage.ts +++ b/src/coverage.ts @@ -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'; @@ -23,11 +23,10 @@ export async function processCoverage(serverName: string, namespace: string, run } export async function getFileCoverageResults(folderUri: vscode.Uri, namespace: string, coverageIndex: number): Promise { - 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( @@ -35,36 +34,58 @@ export async function getFileCoverageResults(folderUri: vscode.Uri, namespace: s 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 = 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()); } diff --git a/src/debugTracker.ts b/src/debugTracker.ts index e4e7bef..1b90707 100644 --- a/src/debugTracker.ts +++ b/src/debugTracker.ts @@ -17,6 +17,7 @@ export class DebugTracker implements vscode.DebugAdapterTracker { private methodTestMap: Map; private methodTest?: vscode.TestItem; private failureMessages: vscode.TestMessage[] = []; + private skippedMessages: vscode.TestMessage[] = []; constructor(session: vscode.DebugSession) { this.session = session; @@ -62,7 +63,7 @@ export class DebugTracker implements vscode.DebugAdapterTracker { if (coverageMatch && this.run.debugSession) { const coverageIndex = Number(coverageMatch[1]); this.run.debugSession.configuration.coverageIndex = coverageIndex; - console.log(`Coverage index set to ${coverageIndex}`); + //console.log(`Coverage index set to ${coverageIndex}`); } if (this.className === undefined) { @@ -99,6 +100,11 @@ export class DebugTracker implements vscode.DebugAdapterTracker { this.run.passed(this.methodTest, this.testDuration) break; + case 'skipped': + // Pending https://github.com/microsoft/vscode/issues/133198 we can't do anything with this.skippedMessages + this.run.skipped(this.methodTest); + break; + case 'failed': this.run.failed(this.methodTest, this.failureMessages.length > 0 ? this.failureMessages : { message: 'Failed with no messages' }, this.testDuration); break; @@ -111,6 +117,7 @@ export class DebugTracker implements vscode.DebugAdapterTracker { this.testDuration = undefined; this.methodTest = undefined; this.failureMessages = []; + this.skippedMessages = []; return; } } @@ -129,13 +136,21 @@ export class DebugTracker implements vscode.DebugAdapterTracker { //console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName ${macroName}, outcome 'failed', message=${message}`); this.failureMessages.push({ message: message }); } else { - const logMessageMatch = line.match(/^ LogMessage:(.*)$/); - if (logMessageMatch) { - const message = logMessageMatch[1]; - //console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName LogMessage, message=${message}`); - const duration = message.match(/^Duration of execution: (\d*\.\d+) sec.$/); - if (duration) { - this.testDuration = + duration[1] * 1000; + const assertSkippedMatch = line.match(/^ (Test\w+):(.*) \(skipped\)$/); + 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 }); + } else { + const logMessageMatch = line.match(/^ LogMessage:(.*)$/); + if (logMessageMatch) { + const message = logMessageMatch[1]; + //console.log(`Class ${this.className}, Test-method ${this.testMethodName}, macroName LogMessage, message=${message}`); + const duration = message.match(/^Duration of execution: (\d*\.\d+) sec.$/); + if (duration) { + this.testDuration = + duration[1] * 1000; + } } } } diff --git a/src/extension.ts b/src/extension.ts index 5acd996..d6fc081 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode"; import * as serverManager from "@intersystems-community/intersystems-servermanager"; import { setupHistoryExplorerController } from "./historyExplorer"; import { setupServerTestsController } from "./serverTests"; -import { setupLocalTestsController } from "./localTests"; +import { replaceLocalRootItems, setupLocalTestsController } from "./localTests"; import { DebugTrackerFactory } from "./debugTrackerFactory"; export const extensionId = "intersystems-community.testingmanager"; @@ -19,11 +19,15 @@ export interface OurTestRun extends vscode.TestRun { } export interface OurTestItem extends vscode.TestItem { + ourUri?: vscode.Uri; supportsCoverage?: boolean } export const allTestRuns: (OurTestRun | undefined)[] = []; +// Array indexed by workspaceFolder index, each element holding a map from classname ('P1.P2.C') to TestItem +export let workspaceFolderTestClasses: Map[] = []; + async function getServerManagerAPI(): Promise { const targetExtension = vscode.extensions.getExtension("intersystems-community.servermanager"); if (!targetExtension) { @@ -62,6 +66,22 @@ export async function activate(context: vscode.ExtensionContext) { smAPI = await getServerManagerAPI(); // TODO notify user if either of these returned undefined (extensionDependencies setting should prevent that, but better to be safe) + + const initWorkspaceFolderTestClasses = () => { + workspaceFolderTestClasses = []; + vscode.workspace.workspaceFolders?.forEach(() => { + // Initialize the map for this workspace folder's test class items + workspaceFolderTestClasses.push(new Map()); + }); + } + initWorkspaceFolderTestClasses(); + context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { + initWorkspaceFolderTestClasses(); + await replaceLocalRootItems(localTestController); + })); + + + // Other parts of this extension will use the test controllers we create here localTestController = vscode.tests.createTestController(`${extensionId}-Local`, '$(folder-library) Local Tests'); context.subscriptions.push(localTestController); diff --git a/src/historyExplorer.ts b/src/historyExplorer.ts index 8f89f9b..d171c0e 100644 --- a/src/historyExplorer.ts +++ b/src/historyExplorer.ts @@ -3,296 +3,255 @@ import { IServerSpec } from "@intersystems-community/intersystems-servermanager" import { historyBrowserController, osAPI, OurTestItem, smAPI } from './extension'; import logger from './logger'; import { makeRESTRequest } from './makeRESTRequest'; +import { supportsCoverage } from './utils'; interface IResult { - status: number; - errorDescription?: string; - duration: number; + status: number; + errorDescription?: string; + duration: number; } const resultMap = new WeakMap(); export async function setupHistoryExplorerController() { - logger.debug('setupHistoryExplorerController invoked'); + logger.debug('setupHistoryExplorerController invoked'); - historyBrowserController.resolveHandler = async (item) => { - if (item) { - const idParts = item.id.split(':'); - if (idParts.length === 2) { - await addTestInstances(item, historyBrowserController); - } - else if (idParts.length === 3) { - await addTestSuites(item, historyBrowserController); - } - else if (idParts.length === 4) { - await addTestCases(item, historyBrowserController); - } - else if (idParts.length === 5) { - await addTestMethods(item, historyBrowserController); - } - else if (idParts.length === 6) { - await addTestAsserts(item, historyBrowserController); - } - } - else { - // Root items - replaceRootItems(historyBrowserController); - } + historyBrowserController.resolveHandler = async (item) => { + if (item) { + const idParts = item.id.split(':'); + if (idParts.length === 3) { + addTestInstances(item, historyBrowserController); + } + else if (idParts.length === 4) { + addTestSuites(item, historyBrowserController); + } + else if (idParts.length === 5) { + addTestCases(item, historyBrowserController); + } + else if (idParts.length === 6) { + addTestMethods(item, historyBrowserController); + } + else if (idParts.length === 7) { + addTestAsserts(item, historyBrowserController); + } } - - // Add a manual Refresh button - historyBrowserController.refreshHandler = (token?: vscode.CancellationToken) => { - historyBrowserController.items.replace([historyBrowserController.createTestItem('-', 'loading...')]); - replaceRootItems(historyBrowserController); + else { + // Root items + replaceRootItems(historyBrowserController); } + } + + // Add a manual Refresh button + historyBrowserController.refreshHandler = async (token?: vscode.CancellationToken) => { + historyBrowserController.items.replace([historyBrowserController.createTestItem('-', 'loading...')]); + replaceRootItems(historyBrowserController); + } } -export function serverSpecForUri(uri: vscode.Uri): IServerSpec | undefined { - const server = osAPI.serverForUri(uri); - if (server) { - return { - username: server.username, - password: server.password, - name: server.serverName, - webServer: { - host: server.host, - port: server.port, - pathPrefix: server.pathPrefix, - scheme: server.scheme - } - }; - } - return undefined; +export async function serverSpecForUri(uri: vscode.Uri): Promise { + const server = await osAPI.asyncServerForUri(uri); + if (server) { + return { + username: server.username, + password: server.password, + name: server.serverName, + webServer: { + host: server.host, + port: server.port, + pathPrefix: server.pathPrefix, + scheme: server.scheme + } + }; + } + return undefined; } export async function serverSpec(item: vscode.TestItem): Promise { - const serverName = item.id.split(':')[0]; - if (serverName) { - if (!smAPI) { - return undefined; - } - return await smAPI.getServerSpec(serverName); - } - else if (item.uri){ - return serverSpecForUri(item.uri); - } - else { - logger.error(`serverSpec: No serverName or URI for item ${item.id}`); - return undefined; + const serverName = item.id.split(':')[1]; + if (serverName) { + if (!smAPI) { + return undefined; } + return smAPI.getServerSpec(serverName); + } + else if (item.uri) { + return serverSpecForUri(item.uri); + } + else { + logger.error(`serverSpec: No serverName or URI for item ${item.id}`); + return undefined; + } } async function addTestInstances(item: OurTestItem, controller: vscode.TestController) { - item.busy = true; - const spec = await serverSpec(item); - const namespace = item.id.split(':')[1]; - if (spec) { - const response = await makeRESTRequest( - "POST", - spec, - { apiVersion: 1, namespace, path: "/action/query" }, - { query: "SELECT TOP 10 InstanceIndex, DateTime, Duration FROM %UnitTest_Result.TestInstance ORDER BY DateTime DESC" }, + item.busy = true; + const spec = await serverSpec(item); + const namespace = item.id.split(':')[2]; + if (spec) { + const response = await makeRESTRequest( + "POST", + spec, + { apiVersion: 1, namespace, path: "/action/query" }, + { query: "SELECT TOP 10 InstanceIndex, DateTime, Duration FROM %UnitTest_Result.TestInstance ORDER BY DateTime DESC" }, + ); + if (response) { + const portalUri = vscode.Uri.from({ + scheme: spec.webServer.scheme || "http", + authority: `${spec.webServer.host}:${spec.webServer.port}`, + path: `${spec.webServer.pathPrefix || ""}/csp/sys/%UnitTest.Portal.Indices.cls`, + }); + response?.data?.result?.content?.forEach(element => { + const child: OurTestItem = controller.createTestItem( + `${item.id}:${element.InstanceIndex}`, + `${element.DateTime}`, + portalUri.with({ query: `Index=${element.InstanceIndex}&$NAMESPACE=${namespace}` }) ); - if (response) { - const portalUri = vscode.Uri.from({ - scheme: spec.webServer.scheme || "http", - authority: `${spec.webServer.host}:${spec.webServer.port}`, - path: `${spec.webServer.pathPrefix || ""}/csp/sys/%UnitTest.Portal.Indices.cls`, - }); - response?.data?.result?.content?.forEach(element => { - const child: OurTestItem = controller.createTestItem( - `${item.id}:${element.InstanceIndex}`, - `${element.DateTime}`, - portalUri.with({ query: `Index=${element.InstanceIndex}&$NAMESPACE=${namespace}` }) - ); - child.sortText = (1e12 - element.InstanceIndex).toString().padStart(12, "0"); - child.description = `run ${element.InstanceIndex}`; - child.canResolveChildren = true; - child.supportsCoverage = item.supportsCoverage; - item.children.add(child); - }); - } + child.sortText = (1e12 - element.InstanceIndex).toString().padStart(12, "0"); + child.description = `run ${element.InstanceIndex}`; + child.canResolveChildren = true; + child.supportsCoverage = item.supportsCoverage; + item.children.add(child); + }); } - item.busy = false; + } + item.busy = false; } async function addTestSuites(item: OurTestItem, controller: vscode.TestController) { - const spec = await serverSpec(item); - const parts = item.id.split(':'); - const namespace = parts[1]; - const instanceIndex = parts[2]; - if (spec) { - const response = await makeRESTRequest( - "POST", - spec, - { apiVersion: 1, namespace, path: "/action/query" }, - { - query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestSuite WHERE TestInstance = ?", - parameters: [instanceIndex] - }, - ); - if (response) { - const run = controller.createTestRun(new vscode.TestRunRequest(), `Item '${item.label}' history`, false); - response?.data?.result?.content?.forEach(element => { - const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Name}`); - child.canResolveChildren = true; - child.supportsCoverage = item.supportsCoverage; - item.children.add(child); - if (element.Status) { - run.passed(child, element.Duration * 1000); - } - else { - run.failed(child, new vscode.TestMessage(element.ErrorDescription), element.Duration * 1000); - } - }); - run.end(); - } + const spec = await serverSpec(item); + const parts = item.id.split(':'); + const namespace = parts[2]; + const instanceIndex = parts[3]; + if (spec) { + const response = await makeRESTRequest( + "POST", + spec, + { apiVersion: 1, namespace, path: "/action/query" }, + { + query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestSuite WHERE TestInstance = ?", + parameters: [instanceIndex] + }, + ); + if (response) { + response?.data?.result?.content?.forEach(element => { + const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Name}`, item.uri); + child.description = element.Status.toString(); + child.canResolveChildren = true; + child.supportsCoverage = item.supportsCoverage; + item.children.add(child); + }); } + } } async function addTestCases(item: OurTestItem, controller: vscode.TestController) { - const spec = await serverSpec(item); - const parts = item.id.split(':'); - const namespace = parts[1]; - const testSuite = parts[3]; - if (spec) { - const response = await makeRESTRequest( - "POST", - spec, - { apiVersion: 1, namespace, path: "/action/query" }, - { - query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestCase WHERE TestSuite = ?", - parameters: [testSuite] - }, - ); - if (response) { - const run = controller.createTestRun(new vscode.TestRunRequest(), `Item '${item.label}' history`, false); - response?.data?.result?.content?.forEach(element => { - const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Name.split('.').pop()}`); - child.canResolveChildren = true; - child.supportsCoverage = item.supportsCoverage; - item.children.add(child); - if (element.Status) { - run.passed(child, element.Duration * 1000); - } - else { - run.failed(child, new vscode.TestMessage(element.ErrorDescription), element.Duration * 1000); - } - }); - run.end(); - } + const spec = await serverSpec(item); + const parts = item.id.split(':'); + const namespace = parts[2]; + const testSuite = parts[4]; + if (spec) { + const response = await makeRESTRequest( + "POST", + spec, + { apiVersion: 1, namespace, path: "/action/query" }, + { + query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestCase WHERE TestSuite = ?", + parameters: [testSuite] + }, + ); + if (response) { + response?.data?.result?.content?.forEach(element => { + const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Name.split('.').pop()}`, item.uri); + child.description = element.Status.toString(); + child.canResolveChildren = true; + child.supportsCoverage = item.supportsCoverage; + item.children.add(child); + }); } + } } async function addTestMethods(item: OurTestItem, controller: vscode.TestController) { - const spec = await serverSpec(item); - const parts = item.id.split(':'); - const namespace = parts[1]; - const testCase = parts[4]; - if (spec) { - const response = await makeRESTRequest( - "POST", - spec, - { apiVersion: 1, namespace, path: "/action/query" }, - { - query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestMethod WHERE TestCase = ?", - parameters: [testCase] - }, - ); - if (response) { - const run = controller.createTestRun(new vscode.TestRunRequest(), `Item '${item.label}' history`, false); - response?.data?.result?.content?.forEach(element => { - const methodName: string = element.Name; - // We drop the first 4 characters of the method name because they should always be "Test" - const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${methodName.slice(4)}`); - child.canResolveChildren = true; - child.supportsCoverage = item.supportsCoverage; - item.children.add(child); + const spec = await serverSpec(item); + const parts = item.id.split(':'); + const namespace = parts[2]; + const testCase = parts[5]; + if (spec) { + const response = await makeRESTRequest( + "POST", + spec, + { apiVersion: 1, namespace, path: "/action/query" }, + { + query: "SELECT ID, Name, Duration, Status, ErrorDescription FROM %UnitTest_Result.TestMethod WHERE TestCase = ?", + parameters: [testCase] + }, + ); + if (response) { + response?.data?.result?.content?.forEach(element => { + const methodName: string = element.Name; + // We drop the first 4 characters of the method name because they should always be "Test" + const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${methodName.slice(4)}`, item.uri); + child.description = element.Status.toString(); + child.canResolveChildren = true; + child.supportsCoverage = item.supportsCoverage; + item.children.add(child); - // Remember result fields so they can be reinstated when the descendant Asserts are 'run' - resultMap.set(child, { status: element.Status, errorDescription: element.ErrorDescription, duration: element.Duration }); - if (element.Status) { - run.passed(child, element.Duration * 1000); - } - else { - run.failed(child, new vscode.TestMessage(element.ErrorDescription), element.Duration * 1000); - } - }); - run.end(); - } + // Remember result fields so they can be reinstated when the descendant Asserts are 'run' + resultMap.set(child, { status: element.Status, errorDescription: element.ErrorDescription, duration: element.Duration }); + }); } + } } async function addTestAsserts(item: OurTestItem, controller: vscode.TestController) { - const spec = await serverSpec(item); - const parts = item.id.split(':'); - const namespace = parts[1]; - const testMethod = parts[5]; - if (spec) { - const response = await makeRESTRequest( - "POST", - spec, - { apiVersion: 1, namespace, path: "/action/query" }, - { - query: "SELECT ID, Counter, COUNT(Counter %FOREACH(TestMethod)) AS MaxCounter, Action, Status, Description FROM %UnitTest_Result.TestAssert WHERE TestMethod = ?", - parameters: [testMethod] - }, - ); - if (response) { - const run = controller.createTestRun(new vscode.TestRunRequest(), `Item '${item.label}' history`, false); - - // Prevent this level's duration from being blanked out because of children's (absent) durations - const itemResult = resultMap.get(item); - if (itemResult) { - if (itemResult.status) { - run.passed(item, itemResult.duration * 1000); - } - else { - run.failed(item, new vscode.TestMessage(itemResult.errorDescription || "(No error description)"), itemResult.duration * 1000); - } - } - - response?.data?.result?.content?.forEach(element => { - const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Action}`); - child.sortText = `${element.Counter.toString().padStart(element.MaxCounter.toString().length, "0")}`; - child.description = element.Description; - child.canResolveChildren = false; - child.supportsCoverage = item.supportsCoverage; - item.children.add(child); - if (element.Status) { - run.passed(child); - } - else { - run.failed(child, new vscode.TestMessage(element.Description)); - } - }); - - run.end(); - } + const spec = await serverSpec(item); + const parts = item.id.split(':'); + const namespace = parts[2]; + const testMethod = parts[6]; + if (spec) { + const response = await makeRESTRequest( + "POST", + spec, + { apiVersion: 1, namespace, path: "/action/query" }, + { + query: "SELECT ID, Counter, COUNT(Counter %FOREACH(TestMethod)) AS MaxCounter, Action, Status, Description FROM %UnitTest_Result.TestAssert WHERE TestMethod = ?", + parameters: [testMethod] + }, + ); + if (response) { + response?.data?.result?.content?.forEach(element => { + const child: OurTestItem = controller.createTestItem(`${item.id}:${element.ID}`, `${element.Action}`, item.uri); + child.sortText = `${element.Counter.toString().padStart(element.MaxCounter.toString().length, "0")}`; + child.description = `${element.Status} ${element.Description}`; + child.canResolveChildren = false; + child.supportsCoverage = item.supportsCoverage; + item.children.add(child); + }); } + } } /* Replace a test controller's root items with one item for each server:NAMESPACE this workspace uses. If `schemes` array is passed, a folder must use one of the named schemes in order to qualify. */ -export function replaceRootItems(controller: vscode.TestController, schemes?: string[]) { - const rootItems: vscode.TestItem[] = []; - const rootMap = new Map(); - vscode.workspace.workspaceFolders?.forEach(folder => { - if (!schemes || schemes.includes(folder.uri.scheme)) { - const server = osAPI.serverForUri(folder.uri); - if (server.namespace) { - const key = server.serverName + ":" + server.namespace.toUpperCase(); - if (!rootMap.has(key)) { - const item: OurTestItem = controller.createTestItem(key, key, folder.uri); - item.canResolveChildren = true; - item.supportsCoverage = false; - rootMap.set(key, item); - } +export async function replaceRootItems(controller: vscode.TestController, schemes?: string[]) { + //const rootItems: vscode.TestItem[] = []; + const rootMap = new Map(); + vscode.workspace.workspaceFolders?.forEach(async (folder) => { + if (!schemes || schemes.includes(folder.uri.scheme)) { + const server = osAPI.serverForUri(folder.uri); + if (server.namespace) { + const key = folder.index + ":" + server.serverName + ":" + server.namespace.toUpperCase(); + if (!rootMap.has(key)) { + const item: OurTestItem = controller.createTestItem(key, server.serverName + ":" + server.namespace.toUpperCase(), folder.uri); + item.canResolveChildren = true; + item.supportsCoverage = await supportsCoverage(folder); + rootMap.set(key, item); } } - }); - rootMap.forEach(item => rootItems.push(item)); - controller.items.replace(rootItems); + }; + //rootMap.forEach(item => rootItems.push(item)); + controller.items.replace(Array.from(rootMap.values())); + }); } export function refreshHistoryRootItem(serverName: string, namespace: string) { diff --git a/src/localTests.ts b/src/localTests.ts index 6e2d9dc..24dd712 100644 --- a/src/localTests.ts +++ b/src/localTests.ts @@ -1,35 +1,54 @@ import * as vscode from 'vscode'; import { commonRunTestsHandler } from './commonRunTestsHandler'; -import { localTestController, OurTestItem } from './extension'; +import { localTestController, OurTestItem, workspaceFolderTestClasses } from './extension'; import logger from './logger'; import { resolveServerSpecAndNamespace, supportsCoverage } from './utils'; -const isResolvedMap = new WeakMap(); - async function resolveItemChildren(item: OurTestItem) { if (item) { - isResolvedMap.set(item, true); - const itemUri = item.uri; + const itemUri = item.ourUri; if (itemUri) { + const folderIndex = item.id.split(':')[0]; //vscode.workspace.getWorkspaceFolder(itemUri)?.index || 0; item.busy = true; try { const contents = await vscode.workspace.fs.readDirectory(itemUri); contents.filter((entry) => entry[1] === vscode.FileType.Directory).forEach((entry) => { const name = entry[0]; - const child: OurTestItem = localTestController.createTestItem(`${item.id}${name}.`, name, itemUri.with({path: `${itemUri.path}/${name}`})); + const childId = `${item.id}${name}.`; + if (item.children.get(childId)) { + return; + } + const child: OurTestItem = localTestController.createTestItem(childId, name); + child.ourUri = itemUri.with({path: `${itemUri.path}/${name}`}); child.canResolveChildren = true; - child.supportsCoverage = item.supportsCoverage; + child.supportsCoverage = item.supportsCoverage; item.children.add(child); }); contents.filter((entry) => entry[1] === vscode.FileType.File).forEach((entry) => { const name = entry[0]; if (name.endsWith('.cls')) { - const child: OurTestItem = localTestController.createTestItem(`${item.id}${name.slice(0, name.length - 4)}`, name, itemUri.with({path: `${itemUri.path}/${name}`})); + const childId = `${item.id}${name.slice(0, name.length - 4)}`; + if (item.children.get(childId)) { + return; + } + const child: OurTestItem = localTestController.createTestItem(childId, name, itemUri.with({path: `${itemUri.path}/${name}`})); + child.ourUri = child.uri; child.canResolveChildren = true; child.supportsCoverage = item.supportsCoverage; item.children.add(child); + const fullClassName = child.id.split(':')[3]; + if (!child.parent) { + console.log(`*** BUG - child (id=${child.id}) has no parent after item.children.add(child) where item.id=${item.id}`); + } + //console.log(`workspaceFolderTestClasses.length=${workspaceFolderTestClasses.length}, index=${folderIndex}`); + workspaceFolderTestClasses[folderIndex].set(fullClassName, child); } }); + if (item.children.size === 0) { + // If no children, this is a class with no tests + item.canResolveChildren = false; + item.supportsCoverage = false; + } } catch (error) { if (error.code !== vscode.FileSystemError.FileNotADirectory().code) { throw error; @@ -41,15 +60,23 @@ async function resolveItemChildren(item: OurTestItem) { for (let index = 0; index < lines.length; index++) { const lineText = lines[index]; if (lineText.startsWith('Class ')) { - if (!lineText.includes('%UnitTest.TestCase')) { - break; - } + // Removed this check because some test classes do not subclass %UnitTest.TestCase directly + // and it would be tricky to check for this client-side, before classes are loaded into a server. + // See https://github.com/intersystems-community/intersystems-testingmanager/issues/27 + // if (!lineText.includes('%UnitTest.TestCase')) { + // break; + // } item.range = new vscode.Range(new vscode.Position(index, 0), new vscode.Position(index + 1, 0)) } const match = lineText.match(/^Method Test(.+)\(/); if (match) { const testName = match[1]; - const child: OurTestItem = localTestController.createTestItem(`${item.id}:Test${testName}`, testName, itemUri); + const childId = `${item.id}:Test${testName}`; + if (item.children.get(childId)) { + continue; + } + const child: OurTestItem = localTestController.createTestItem(childId, testName, itemUri); + child.ourUri = child.uri; child.range = new vscode.Range(new vscode.Position(index, 0), new vscode.Position(index + 1, 0)) child.canResolveChildren = false; child.supportsCoverage = item.supportsCoverage; @@ -116,17 +143,19 @@ export function relativeTestRoot(folder: vscode.WorkspaceFolder): string { /* Replace root items with one item for each file-type workspace root for which a named server can be identified */ -async function replaceLocalRootItems(controller: vscode.TestController) { +export async function replaceLocalRootItems(controller: vscode.TestController) { const rootItems: vscode.TestItem[] = []; const rootMap = new Map(); for await (const folder of vscode.workspace.workspaceFolders || []) { if (folder.uri.scheme === 'file') { + workspaceFolderTestClasses[folder.index].clear(); const { serverSpec, namespace } = await resolveServerSpecAndNamespace(folder.uri); if (serverSpec && namespace) { - const key = serverSpec.name + ":" + namespace + ":"; + const key = folder.index.toString() + ":" + serverSpec.name + ":" + namespace + ":"; if (!rootMap.has(key)) { const relativeRoot = relativeTestRoot(folder); - const item: OurTestItem = controller.createTestItem(key, folder.name, folder.uri.with({path: `${folder.uri.path}/${relativeRoot}`})); + const item: OurTestItem = controller.createTestItem(key, folder.name); + item.ourUri = folder.uri.with({path: `${folder.uri.path}/${relativeRoot}`}); item.description = relativeRoot; item.canResolveChildren = true; item.supportsCoverage = await supportsCoverage(folder); diff --git a/src/ourFileCoverage.ts b/src/ourFileCoverage.ts index 37119f0..2b77f1c 100644 --- a/src/ourFileCoverage.ts +++ b/src/ourFileCoverage.ts @@ -3,22 +3,23 @@ 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 { public readonly codeUnit: string; private coverageIndex: number; - constructor(coverageIndex: number, codeUnit: string, uri: vscode.Uri, statementCoverage: vscode.TestCoverageCount, branchCoverage?: vscode.TestCoverageCount, declarationCoverage?: vscode.TestCoverageCount) { - super(uri, statementCoverage, branchCoverage, declarationCoverage); + constructor(coverageIndex: number, codeUnit: string, uri: vscode.Uri, statementCoverage: vscode.TestCoverageCount, branchCoverage?: vscode.TestCoverageCount, declarationCoverage?: vscode.TestCoverageCount, includesTests?: vscode.TestItem[]) { + super(uri, statementCoverage, branchCoverage, declarationCoverage, includesTests); this.coverageIndex = coverageIndex; this.codeUnit = codeUnit; } - async loadDetailedCoverage(): Promise { + async loadDetailedCoverage(fromTestItem?: vscode.TestItem): Promise { logger.debug(`loadDetailedCoverage invoked for ${this.codeUnit} (${this.uri.toString()})`); const detailedCoverage: vscode.FileCoverageDetail[] = []; - const server = osAPI.serverForUri(this.uri); + const server = await osAPI.asyncServerForUri(this.uri); const serverSpec: IServerSpec = { username: server.username, password: server.password, @@ -56,13 +57,21 @@ export class OurFileCoverage extends vscode.FileCoverage { }); } + let testPath = 'all tests'; + if (fromTestItem && serverSpec.username) { + // If a specific test item is provided, use its ID to determine the test path we want data from + const dottedClassname = fromTestItem.id.split(':')[3]; + testPath = serverSpec.username.toLowerCase() + '\\' + dottedClassname.split('.').slice(0,-1).join('\\') + ':' + dottedClassname; + } + + // Get the coverage results response = await makeRESTRequest( "POST", serverSpec, { apiVersion: 1, namespace, path: "/action/query" }, { - query: "SELECT TestCoverage_UI.fnVSCodeInt8Bitstring(cu.ExecutableLines) i8bsExecutableLines, TestCoverage_UI.fnVSCodeInt8Bitstring(cov.CoveredLines) i8bsCoveredLines FROM TestCoverage_Data.CodeUnit cu, TestCoverage_Data.Coverage cov WHERE cu.Hash = cov.Hash AND Run = ? AND cu.Hash = ? AND TestPath = 'all tests'", - parameters: [this.coverageIndex, this.codeUnit], + 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 = ?`, + parameters: [this.coverageIndex, this.codeUnit, testPath], }, ); if (response) { diff --git a/src/serverTests.ts b/src/serverTests.ts index 8e10fa5..21dad3e 100644 --- a/src/serverTests.ts +++ b/src/serverTests.ts @@ -1,18 +1,19 @@ import * as vscode from 'vscode'; -import { loadedTestController, OurTestItem } from './extension'; +import { loadedTestController, OurTestItem, workspaceFolderTestClasses } from './extension'; import { replaceRootItems, serverSpec } from './historyExplorer'; import logger from './logger'; import { makeRESTRequest } from './makeRESTRequest'; import { commonRunTestsHandler } from './commonRunTestsHandler'; async function resolveItemChildren(item?: OurTestItem) { - if (item) { + if (item && item.uri) { item.busy = true; + const folderIndex = vscode.workspace.getWorkspaceFolder(item.uri)?.index || 0; const spec = await serverSpec(item); const parts = item.id.split(':'); - const namespace = parts[1]; + const namespace = parts[2]; if (spec) { - if (parts.length === 2) { + if (parts.length === 3) { // Find all TestCase classes const response = await makeRESTRequest( "POST", @@ -54,6 +55,9 @@ async function resolveItemChildren(item?: OurTestItem) { } if (tiClass.children.size > 0) { item.children.add(tiClass); + const fullClassName = tiClass.id.split(':')[3]; + //console.log(`workspaceFolderTestClasses.length=${workspaceFolderTestClasses.length}, index=${folderIndex}`); + workspaceFolderTestClasses[folderIndex].set(fullClassName, tiClass); } } } diff --git a/src/utils.ts b/src/utils.ts index 9104cd7..c4bd171 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,11 @@ 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) { @@ -44,61 +49,92 @@ 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/TestCoverage.UI.VSCodeUtils.cls" } + { 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 SQL Util functions for namespace: ${namespace}`); + logger.debug(`Creating our SQL Util functions class ${UTIL_CLASSNAME} for namespace: ${namespace}`); - const functionDDL = ` -CREATE FUNCTION fnVSCodeInt8Bitstring( + 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 TestCoverage.UI.VSCodeUtils + 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 + 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 + 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) + If iMod8 > -1 { + Set output = output_$Char(char) } - QUIT output + Quit output } - `; - const response = await makeRESTRequest( - "POST", - serverSpec, - { apiVersion: 1, namespace, path: "/action/query" }, - { - query: functionDDL + `, + // 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; } - ); - if (!response || response.status !== 200 || response.data?.status?.errors?.length) { - vscode.window.showErrorMessage(`Failed to create SQL Util function(s) in namespace ${namespace}: ${response?.data?.status?.summary || 'Unknown error'}`, { modal: true }); - return false; } return true; }