diff --git a/package-lock.json b/package-lock.json index 78b11f05..1f8324f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@types/mocha": "^7.0.2", "@types/node": "^14.18.0", "@types/semver": "7.5.4", - "@types/vscode": "1.75.0", + "@types/vscode": "1.83.0", "@types/ws": "8.5.4", "@types/xmldom": "^0.1.29", "@typescript-eslint/eslint-plugin": "^4.32.0", @@ -55,7 +55,7 @@ "webpack-cli": "^4.5.0" }, "engines": { - "vscode": "^1.75.0" + "vscode": "^1.83.0" } }, "node_modules/@babel/code-frame": { @@ -442,9 +442,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.75.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.75.0.tgz", - "integrity": "sha512-SAr0PoOhJS6FUq5LjNr8C/StBKALZwDVm3+U4pjF/3iYkt3GioJOPV/oB1Sf1l7lROe4TgrMyL5N1yaEgTWycw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.83.0.tgz", + "integrity": "sha512-3mUtHqLAVz9hegut9au4xehuBrzRE3UJiQMpoEHkNl6XHliihO7eATx2BMHs0odsmmrwjJrlixx/Pte6M3ygDQ==", "dev": true }, "node_modules/@types/ws": { @@ -5656,9 +5656,9 @@ "dev": true }, "@types/vscode": { - "version": "1.75.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.75.0.tgz", - "integrity": "sha512-SAr0PoOhJS6FUq5LjNr8C/StBKALZwDVm3+U4pjF/3iYkt3GioJOPV/oB1Sf1l7lROe4TgrMyL5N1yaEgTWycw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.83.0.tgz", + "integrity": "sha512-3mUtHqLAVz9hegut9au4xehuBrzRE3UJiQMpoEHkNl6XHliihO7eATx2BMHs0odsmmrwjJrlixx/Pte6M3ygDQ==", "dev": true }, "@types/ws": { diff --git a/package.json b/package.json index b33556b1..6ece37b0 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ } ], "engines": { - "vscode": "^1.75.0" + "vscode": "^1.83.0" }, "enabledApiProposals": [ "fileSearchProvider", @@ -1401,7 +1401,7 @@ "objectscript.compileFlags": { "type": "string", "default": "cuk", - "markdownDescription": "Compilation flags. Common compilation flags are ***b*** (compile dependent classes), ***k*** (keep generated source code) and ***u*** (skip related up-to-date documents). For descriptions of all available flags and qualifiers, click [here](https://docs.intersystems.com/irislatest/csp/docbook/Doc.View.cls?KEY=RCOS_vsystem#RCOS_vsystem_flags_qualifiers)." + "markdownDescription": "Compilation flags. Common compilation flags are ***b*** (compile dependent classes), ***k*** (keep generated source code) and ***u*** (skip related up-to-date documents). For descriptions of all available flags and qualifiers, click [here](https://docs.intersystems.com/irislatest/csp/docbook/Doc.View.cls?KEY=RCOS_vsystem_flags_qualifiers)." }, "objectscript.overwriteServerChanges": { "type": "boolean", @@ -1526,6 +1526,43 @@ "description": "Controls whether a prompt to enable VS Code proposed APIs is shown when a server-side workspace folder is opened.", "type": "boolean", "default": true + }, + "objectscript.unitTest.relativeTestRoots": { + "description": "Paths to where client-side test classes are stored. Relative to the workspace folder root.", + "type": "array", + "default": [], + "scope": "resource", + "items": { + "type": "string", + "pattern": "^([\\p{L}\\d_. -]+([\\/\\\\][\\p{L}\\d_. -]*))?$", + "patternErrorMessage": "Each folder name can only contain letters, digits, space, hyphen ('-'), period ('.'), or underscore ('_'), and the full path must neither begin nor end with a slash." + } + }, + "objectscript.unitTest.autoload.folder": { + "markdownDescription": "When running client-side test classes, automatically load the contents of sub-directories with this name. See the [%UnitTest /autoload qualifier documentation](https://docs.intersystems.com/irislatest/csp/documatic/%25CSP.Documatic.cls?LIBRARY=%25SYS&CLASSNAME=%25UnitTest.Manager#RunTest) for details.", + "type": "string", + "default": "_autoload", + "scope": "resource", + "pattern": "^[\\p{L}\\d_. -]*$", + "patternErrorMessage": "Folder name can only contain letters, digits, space, hyphen ('-'), period ('.'), or underscore ('_')." + }, + "objectscript.unitTest.autoload.xml": { + "description": "Controls whether the autoload feature loads XML files.", + "type": "boolean", + "default": true, + "scope": "resource" + }, + "objectscript.unitTest.autoload.udl": { + "description": "Controls whether the autoload feature loads UDL files (cls, mac, int, inc).", + "type": "boolean", + "default": true, + "scope": "resource" + }, + "objectscript.unitTest.showOutput": { + "description": "Controls whether unit test console output is shown.", + "type": "boolean", + "default": true, + "scope": "resource" } } }, @@ -1686,7 +1723,7 @@ "test": "node ./out/test/runTest.js", "lint": "eslint src/**", "lint-fix": "eslint --fix src/**", - "download-api": "dts dev 1.75.0", + "download-api": "dts dev 1.83.0", "postinstall": "npm run download-api" }, "devDependencies": { @@ -1695,7 +1732,7 @@ "@types/mocha": "^7.0.2", "@types/node": "^14.18.0", "@types/semver": "7.5.4", - "@types/vscode": "1.75.0", + "@types/vscode": "1.83.0", "@types/ws": "8.5.4", "@types/xmldom": "^0.1.29", "@typescript-eslint/eslint-plugin": "^4.32.0", diff --git a/src/api/atelier.d.ts b/src/api/atelier.d.ts index 81161463..0f95544f 100644 --- a/src/api/atelier.d.ts +++ b/src/api/atelier.d.ts @@ -105,4 +105,12 @@ interface AsyncSearchRequest { console: false; } -export type AsyncRequest = AsyncCompileRequest | AsyncSearchRequest; +interface AsyncUnitTestRequest { + request: "unittest"; + tests: { class: string; methods?: string[] }[]; + load?: { file: string; content: string[] }[]; + console?: boolean; + debug?: boolean; +} + +export type AsyncRequest = AsyncCompileRequest | AsyncSearchRequest | AsyncUnitTestRequest; diff --git a/src/api/index.ts b/src/api/index.ts index 451f2b2c..7c74982a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -631,13 +631,13 @@ export class AtelierAPI { } // v1+ - public queueAsync(request: Atelier.AsyncRequest): Promise { - return this.request(1, "POST", `${this.ns}/work`, request); + public queueAsync(request: Atelier.AsyncRequest, noOutput = false): Promise { + return this.request(1, "POST", `${this.ns}/work`, request, undefined, undefined, { noOutput }); } // v1+ - public pollAsync(id: string): Promise { - return this.request(1, "GET", `${this.ns}/work/${id}`); + public pollAsync(id: string, noOutput = false): Promise { + return this.request(1, "GET", `${this.ns}/work/${id}`, undefined, undefined, { noOutput }); } // v1+ diff --git a/src/commands/addServerNamespaceToWorkspace.ts b/src/commands/addServerNamespaceToWorkspace.ts index 9e7463eb..527c1134 100644 --- a/src/commands/addServerNamespaceToWorkspace.ts +++ b/src/commands/addServerNamespaceToWorkspace.ts @@ -7,6 +7,7 @@ import { FILESYSTEM_SCHEMA, FILESYSTEM_READONLY_SCHEMA, filesystemSchemas, + smExtensionId, } from "../extension"; import { cspAppsForUri, outputChannel } from "../utils"; import { pickProject } from "./project"; @@ -164,7 +165,7 @@ export async function addServerNamespaceToWorkspace(resource?: vscode.Uri): Prom } export async function getServerManagerApi(): Promise { - const targetExtension = vscode.extensions.getExtension("intersystems-community.servermanager"); + const targetExtension = vscode.extensions.getExtension(smExtensionId); if (!targetExtension) { return undefined; } diff --git a/src/commands/connectFolderToServerNamespace.ts b/src/commands/connectFolderToServerNamespace.ts index a6f2539a..ab5f6334 100644 --- a/src/commands/connectFolderToServerNamespace.ts +++ b/src/commands/connectFolderToServerNamespace.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import { AtelierAPI } from "../api"; -import { panel, resolveConnectionSpec, getResolvedConnectionSpec } from "../extension"; +import { panel, resolveConnectionSpec, getResolvedConnectionSpec, smExtensionId } from "../extension"; interface ConnSettings { server: string; @@ -89,7 +89,7 @@ export async function connectFolderToServerNamespace(): Promise { } async function getServerManagerApi(): Promise { - const targetExtension = vscode.extensions.getExtension("intersystems-community.servermanager"); + const targetExtension = vscode.extensions.getExtension(smExtensionId); if (!targetExtension) { return undefined; } diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts new file mode 100644 index 00000000..e1c089bc --- /dev/null +++ b/src/commands/unitTest.ts @@ -0,0 +1,1130 @@ +import * as vscode from "vscode"; +import * as Atelier from "../api/atelier"; +import { extensionId, filesystemSchemas, lsExtensionId } from "../extension"; +import { getFileText, methodOffsetToLine, outputChannel, stripClassMemberNameQuotes, uriIsParentOf } from "../utils"; +import { fileSpecFromURI } from "../utils/FileProviderUtil"; +import { AtelierAPI } from "../api"; +import { DocumentContentProvider } from "../providers/DocumentContentProvider"; + +enum TestStatus { + Failed = 0, + Passed, + Skipped, +} + +interface TestAssertLocation { + offset?: number; + document: string; + label?: string; + namespace?: string; +} + +/** The result of a finished test */ +interface TestResult { + /** The name of the class */ + class: string; + /** The status of the test */ + status: TestStatus; + /** How long the test took to run, in milliseconds */ + duration: number; + /** The name of the method without the "Test" prefix */ + method?: string; + /** + * An array of failures. The location will only be + * defined if `method` is defined. + * Will be empty if `status` is not `0` (failed). + */ + failures: { message: string; location?: TestAssertLocation }[]; + /** + * The text of the error that terminated + * execution of this test. + * Will be `undefined` if `status` is not `0` (failed). + */ + error?: string; +} + +/** A cache of all test classes in a test root */ +const classesForRoot: WeakMap> = new WeakMap(); + +/** The separator between the class URI string and method name in the method's `TestItem` id */ +const methodIdSeparator = "\\\\\\"; + +const textDecoder = new TextDecoder(); + +/** Write the string represenation of `error` to `outputChannel` and show it */ +function outputErrorAsString(error: any): void { + if (error && error.errorText && error.errorText !== "") { + outputChannel.appendLine(error.errorText); + } else { + outputChannel.appendLine( + typeof error == "string" ? error : error instanceof Error ? error.message : JSON.stringify(error) + ); + } + outputChannel.show(true); +} + +/** Find the root `TestItem` for `uri` */ +function rootItemForItem(testController: vscode.TestController, uri: vscode.Uri): vscode.TestItem | undefined { + let rootItem: vscode.TestItem; + for (const [, i] of testController.items) { + if (uriIsParentOf(i.uri, uri)) { + rootItem = i; + break; + } + } + return rootItem; +} + +/** Compute `TestItem`s for `Test*` methods in `parent` */ +async function addTestItemsForClass(testController: vscode.TestController, parent: vscode.TestItem): Promise { + // Get the symbols for the parent class + const parentSymbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + parent.uri + ); + if (parentSymbols?.length == 1 && parentSymbols[0].kind == vscode.SymbolKind.Class) { + const rootItem = rootItemForItem(testController, parent.uri); + if (rootItem) { + // Add this class to our cache + // Need to do this here because we need the + // DocumentSymbols to accurately determine the class + const classes = classesForRoot.get(rootItem); + classes.set(parentSymbols[0].name, parent); + classesForRoot.set(rootItem, classes); + } + parent.range = parentSymbols[0].range; + // Add an item for each Test* method defined in this class + parentSymbols[0].children.forEach((clsMember) => { + const memberName = stripClassMemberNameQuotes(clsMember.name); + if (clsMember.detail == "Method" && memberName.startsWith("Test")) { + const displayName = memberName.slice(4); + const newItem = testController.createTestItem( + `${parent.id}${methodIdSeparator}${displayName}`, + displayName, + parent.uri + ); + newItem.range = clsMember.range; + // Always show non-inherited methods at the top + newItem.sortText = `##${displayName}`; + parent.children.add(newItem); + } + }); + if (filesystemSchemas.includes(parent.uri.scheme)) { + // Query the server to find inherited Test* methods + const api = new AtelierAPI(parent.uri); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(parent.uri).name; + const methodsMap: Map = new Map(); + const inheritedMethods: { Name: string; Origin: string }[] = await api + .actionQuery( + "SELECT Name, Origin FROM %Dictionary.CompiledMethod WHERE " + + "parent->ID = ? AND Origin != parent->ID AND Name %STARTSWITH 'Test' " + + "AND ClassMethod = 0 AND ClientMethod = 0 ORDER BY Name", + [parentSymbols[0].name] + ) + .then((data) => data.result.content) + .catch(() => []); + inheritedMethods.forEach((method) => { + const methodsArr = methodsMap.get(method.Origin) ?? []; + methodsArr.push(method.Name); + methodsMap.set(method.Origin, methodsArr); + }); + for (const [origin, originMethods] of methodsMap) { + const uri = DocumentContentProvider.getUri(`${origin}.cls`, workspaceFolder); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + uri + ); + // Add an item for each Test* method defined in this class + if (symbols?.length == 1 && symbols[0].kind == vscode.SymbolKind.Class) { + originMethods.forEach((originMethod) => { + const symbol = symbols[0].children.find( + (clsMember) => clsMember.detail == "Method" && stripClassMemberNameQuotes(clsMember.name) == originMethod + ); + if (symbol) { + const displayName = stripClassMemberNameQuotes(symbol.name).slice(4); + const newItem = testController.createTestItem( + `${parent.id}${methodIdSeparator}${displayName}`, + displayName, + parent.uri + ); + newItem.range = symbol.range; + parent.children.add(newItem); + } + }); + } + } + } + } +} + +/** Get the array of `objectscript.unitTest.relativeTestRoots` for workspace folder `uri`. */ +function relativeTestRootsForUri(uri: vscode.Uri): string[] { + let roots: string[] = vscode.workspace.getConfiguration("objectscript.unitTest", uri).get("relativeTestRoots"); + roots = roots.map((r) => r.replaceAll("\\", "/")); // VS Code URIs always use / as a separator + if (roots.length > 1) { + // Filter out any duplicate roots, or roots that are a subdirectory of another root + roots = roots.filter((root, idx) => !roots.some((r, i) => i != idx && (root.startsWith(`${r}/`) || root == r))); + } + return roots; +} + +/** Compute root `TestItem`s for `folder`. Returns `[]` if `folder` can't contain tests. */ +function createRootItemsForWorkspaceFolder( + testController: vscode.TestController, + folder: vscode.WorkspaceFolder +): vscode.TestItem[] { + let newItems: vscode.TestItem[] = []; + if ([...filesystemSchemas, "file"].includes(folder.uri.scheme)) { + const api = new AtelierAPI(folder.uri); + // Must have an active server connection to a non-%SYS namespace and Atelier API version 8 or above + const errorMsg = + !api.active || api.ns == "" + ? "Server connection is inactive" + : api.ns == "%SYS" + ? "Connected to the %SYS namespace" + : api.config.apiVersion < 8 + ? "Must be connected to InterSystems IRIS version 2023.3 or above" + : folder.uri.scheme != "file" && ["", "1"].includes(new URLSearchParams(folder.uri.query).get("csp")) + ? "Web application folder" + : undefined; + let itemUris: vscode.Uri[]; + if (folder.uri.scheme == "file") { + const roots = relativeTestRootsForUri(folder.uri); + const baseUri = folder.uri.with({ path: `${folder.uri.path}${!folder.uri.path.endsWith("/") ? "/" : ""}` }); + itemUris = roots.map((root) => baseUri.with({ path: `${baseUri.path}${root}` })); + } else { + itemUris = [folder.uri]; + } + newItems = itemUris.map((uri) => { + const newItem = testController.createTestItem(uri.toString(), folder.name, uri); + if (uri.scheme == "file") { + // Add the root as the description + newItem.description = uri.path.slice(folder.uri.path.length + (!folder.uri.path.endsWith("/") ? 1 : 0)); + newItem.sortText = newItem.label + newItem.description; + } + if (errorMsg != undefined) { + // Show the user why we can't run tests from this folder + newItem.canResolveChildren = false; + newItem.error = errorMsg; + } else { + newItem.canResolveChildren = true; + } + return newItem; + }); + } + return newItems; +} + +/** Get the `TestItem` for class `uri`. If `create` is true, create intermediate `TestItem`s. */ +async function getTestItemForClass( + testController: vscode.TestController, + uri: vscode.Uri, + create = false +): Promise { + let item: vscode.TestItem; + const rootItem = rootItemForItem(testController, uri); + if (rootItem) { + // Walk the directory path until we reach a dead end or the TestItem for this class + let docPath = uri.path.slice(rootItem.uri.path.length); + docPath = docPath.startsWith("/") ? docPath.slice(1) : docPath; + const docPathParts = docPath.split("/"); + item = rootItem; + for (const part of docPathParts) { + const currUri = item.uri.with({ path: `${item.uri.path}${!item.uri.path.endsWith("/") ? "/" : ""}${part}` }); + let currItem = item.children.get(currUri.toString()); + if (!currItem && create) { + // We're allowed to create non-existent directory TestItems as we walk the path + await testController.resolveHandler(item); + currItem = item.children.get(currUri.toString()); + } + item = currItem; + if (!item) { + break; + } + } + } + return item; +} + +/** Create a "root" item for all workspace folders that have an active server connection and MAY have tests in them. */ +function replaceRootTestItems(testController: vscode.TestController): void { + testController.items.forEach((i) => classesForRoot.delete(i)); + const rootItems: vscode.TestItem[] = []; + vscode.workspace.workspaceFolders?.forEach((folder) => { + const newItems = createRootItemsForWorkspaceFolder(testController, folder); + rootItems.push(...newItems); + }); + rootItems.forEach((i) => classesForRoot.set(i, new Map())); + testController.items.replace(rootItems); +} + +/** Create a `Promise` that resolves to a query result containing an array of children for `item`. */ +function childrenForServerSideFolderItem( + item: vscode.TestItem +): Promise>> { + let query: string; + let parameters: string[]; + let folder = !item.uri.path.endsWith("/") ? item.uri.path + "/" : item.uri.path; + folder = folder.startsWith("/") ? folder.slice(1) : folder; + if (folder == "/") { + // Treat this the same as an empty folder + folder = ""; + } + folder = folder.replace(/\//g, "."); + const folderLen = String(folder.length + 1); // Need the + 1 because SUBSTR is 1 indexed + const params = new URLSearchParams(item.uri.query); + const api = new AtelierAPI(item.uri); + if (params.has("project")) { + query = + "SELECT DISTINCT CASE " + + "WHEN $LENGTH(SUBSTR(Name,?),'.') > 1 THEN $PIECE(SUBSTR(Name,?),'.') " + + "ELSE SUBSTR(Name,?)||'.cls' END Name " + + "FROM %Studio.Project_ProjectItemsList(?) " + + "WHERE Type = 'CLS' AND Name %STARTSWITH ? AND " + + "Name IN (SELECT Name FROM %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@'))"; + parameters = [folderLen, folderLen, folderLen, params.get("project"), folder]; + } else { + query = + "SELECT DISTINCT CASE " + + "WHEN $LENGTH(SUBSTR(Name,?),'.') > 2 THEN $PIECE(SUBSTR(Name,?),'.') " + + "ELSE SUBSTR(Name,?) END Name " + + "FROM %Library.RoutineMgr_StudioOpenDialog(?,?,?,?,?,?,?,?,?,?) " + + "WHERE Name %STARTSWITH ? AND " + + "Name IN (SELECT Name||'.cls' FROM %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@'))"; + parameters = [ + folderLen, + folderLen, + folderLen, + fileSpecFromURI(item.uri), + "1", + "1", + params.has("system") && params.get("system").length ? params.get("system") : "0", + "1", + "0", + params.has("generated") && params.get("generated").length ? params.get("generated") : "0", + "", + "0", + params.has("mapped") && params.get("mapped") == "0" ? "0" : "1", + folder, + ]; + } + return api.actionQuery(query, parameters); +} + +/** Create a child `TestItem` of `item` with label `child`. */ +function addChildItem(testController: vscode.TestController, item: vscode.TestItem, child: string): void { + const newUri = item.uri.with({ + path: `${item.uri.path}${!item.uri.path.endsWith("/") ? "/" : ""}${child}`, + }); + if (!item.children.get(newUri.toString())) { + // Only add the item if it doesn't already exist + const newItem = testController.createTestItem(newUri.toString(), child, newUri); + newItem.canResolveChildren = true; + item.children.add(newItem); + } +} + +/** Determine the class name of `item` in `root` */ +function classNameForItem(item: vscode.TestItem, root: vscode.TestItem): string | undefined { + let cls: string; + const classes = classesForRoot.get(root); + if (classes) { + for (const element of classes) { + if (element[1].id == item.id) { + cls = element[0]; + break; + } + } + } + return cls; +} + +/** Render `line` as beautified markdown */ +function markdownifyLine(line: string, bullet = false): string { + const idx = line.indexOf(":") + 1; + return `${bullet ? "- " : ""}${ + idx + ? `**${line.slice(0, idx)}**${line + .slice(idx) + // Need to HTML encode so rest of line is treated as raw text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """)}` + : line + }`; +} + +/** If `uri` is a test class without a `TestItem`, compute its `TestItem`, filling in intermediate `TestItem`s along the way */ +async function addItemForClassUri(testController: vscode.TestController, uri: vscode.Uri): Promise { + if (uri.path.toLowerCase().endsWith(".cls")) { + const item = await getTestItemForClass(testController, uri, true); + if (item && item.canResolveChildren && !item.children.size) { + // Resolve the methods + testController.resolveHandler(item); + } + } +} + +/** The `runHandler` function for the `TestRunProfile`s. */ +async function runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken, + testController: vscode.TestController, + debug = false +): Promise { + const action = debug ? "debug" : "run"; + let root: vscode.TestItem; + const asyncRequest: Atelier.AsyncUnitTestRequest = { + request: "unittest", + tests: [], + debug, + }; + const clsItemsRun: vscode.TestItem[] = []; + + try { + // Determine the test root for this run + let roots: vscode.TestItem[]; + if (request.include?.length) { + roots = [...new Set(request.include.map((i) => rootItemForItem(testController, i.uri)))]; + } else { + // Run was launched from controller's root level + // Ignore any roots that have errors + roots = []; + testController.items.forEach((i) => i.error == undefined && roots.push(i)); + } + if (roots.length > 1) { + // Can't run tests from multiple roots, so ask the user to pick one + const picked = await vscode.window.showQuickPick( + roots.map((i) => { + return { + label: i.label, + detail: i.uri.toString(true), + item: i, + }; + }), + { + matchOnDetail: true, + ignoreFocusOut: true, + title: `Cannot ${action} tests from multiple roots at once`, + placeHolder: `Please select a root to ${action} tests from`, + } + ); + if (picked) { + root = picked.item; + } + } else if (roots.length == 1) { + root = roots[0]; + } + if (!root) { + // Need a root to continue + return; + } + + // Add the initial items to the queue to process + const queue: vscode.TestItem[] = []; + if (request.include?.length) { + request.include.forEach((i) => { + if (uriIsParentOf(root.uri, i.uri)) { + queue.push(i); + } + }); + } else { + queue.push(root); + } + + // Get the autoload configuration for the root + const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", root.uri); + const autoloadFolder: string = autoload.get("folder"); + const autoloadXml: boolean = autoload.get("xml"); + const autoloadUdl: boolean = autoload.get("udl"); + const autoloadEnabled: boolean = autoloadFolder != "" && (autoloadXml || autoloadUdl) && root.uri.scheme == "file"; + const autoloadProcessed: string[] = []; + + // Process every test that was queued + // Recurse down to leaves (methods) and build a map of their parents (classes) + while (queue.length > 0 && !token.isCancellationRequested) { + const test = queue.pop(); + + // Skip tests the user asked to exclude + if (request.exclude?.length && request.exclude.some((excludedTest) => excludedTest.id === test.id)) { + continue; + } + + if (autoloadEnabled) { + // Process any autoload folders needed by this item + const basePath = root.uri.path.endsWith("/") ? root.uri.path.slice(0, -1) : root.uri.path; + const directories = ["", ...test.uri.path.slice(basePath.length + 1).split("/")]; + if (directories[directories.length - 1].toLowerCase().endsWith(".cls")) { + // Remove the class name + directories.pop(); + } + let testPath = ""; + do { + const currentDir = directories.shift(); + testPath = currentDir != "" ? `${testPath}/${currentDir}` : ""; + if (!autoloadProcessed.includes(testPath)) { + // Look for XML or UDL files in the autoload folder + const files = await vscode.workspace.findFiles( + new vscode.RelativePattern( + test.uri.with({ path: `${basePath}${testPath}/${autoloadFolder}` }), + `**/*.{${autoloadXml ? "xml,XML" : ""}${autoloadXml && autoloadUdl ? "," : ""}${ + autoloadUdl ? "cls,CLS,mac,MAC,int,INT,inc,INC" : "" + }}` + ) + ); + if (files.length) { + if (asyncRequest.load == undefined) asyncRequest.load = []; + for (const file of files) { + // Add this file to the list to load + asyncRequest.load.push({ + file: file.fsPath, + content: textDecoder.decode(await vscode.workspace.fs.readFile(file)).split(/\r?\n/), + }); + } + } + autoloadProcessed.push(testPath); + } + } while (directories.length); + } + + // Resolve children if not already done + if (test.canResolveChildren && !test.children.size) { + await testController.resolveHandler(test); + } + + if (test.uri.path.toLowerCase().endsWith(".cls")) { + if (test.id.includes(methodIdSeparator)) { + // This is a method item + // Will only reach this code if this item is in request.include + + // Look up the name of this class + const cls = classNameForItem(test.parent, root); + if (cls) { + // Check if there's a test object for the parent class already + const clsObjIdx = asyncRequest.tests.findIndex((t) => t.class == cls); + if (clsObjIdx > -1) { + // Modify the existing test object if required + const clsObj = asyncRequest.tests[clsObjIdx]; + if (clsObj.methods && !clsObj.methods.includes(test.label)) { + clsObj.methods.push(test.label); + asyncRequest.tests[clsObjIdx] = clsObj; + } + } else { + // Create a new test object + asyncRequest.tests.push({ + class: cls, + methods: [test.label], + }); + if (test.parent.uri.scheme == "file") { + // Add this class to the list to load + asyncRequest.load.push({ + file: test.parent.uri.fsPath, + content: textDecoder.decode(await vscode.workspace.fs.readFile(test.parent.uri)).split(/\r?\n/), + }); + } + clsItemsRun.push(test.parent); + } + } + } else { + // This is a class item + + // Look up the name of this class + const cls = classNameForItem(test, root); + if (cls && test.children.size) { + // It doesn't make sense to run a class with no "Test" methods + // Create the test object + const clsObj: { class: string; methods?: string[] } = { class: cls }; + if (request.exclude?.length) { + // Determine the methods to run + clsObj.methods = []; + test.children.forEach((i) => { + if (!request.exclude.some((excludedTest) => excludedTest.id === i.id)) { + clsObj.methods.push(i.label); + } + }); + if (clsObj.methods.length == 0) { + // No methods to run, so don't add the test object + continue; + } + if (clsObj.methods.length == test.children.size) { + // A test object with no methods means "run all methods" + delete clsObj.methods; + } + } + if (test.uri.scheme == "file") { + // Add this class to the list to load + asyncRequest.load.push({ + file: test.uri.fsPath, + content: textDecoder.decode(await vscode.workspace.fs.readFile(test.uri)).split(/\r?\n/), + }); + } + asyncRequest.tests.push(clsObj); + clsItemsRun.push(test); + } + } + } else { + // Queue any children + test.children.forEach((i) => queue.push(i)); + } + } + + if (token.isCancellationRequested) { + return; + } + } catch (error) { + outputErrorAsString(error); + vscode.window.showErrorMessage( + `Error determining tests to ${action}. Check 'ObjectScript' output channel for details.`, + "Dismiss" + ); + return; + } + + if (!asyncRequest.tests.length) { + vscode.window.showInformationMessage(`No tests to ${action}.`, "Dismiss"); + return; + } + + // Ignore console output at the user's request + asyncRequest.console = vscode.workspace.getConfiguration("objectscript.unitTest", root.uri).get("showOutput"); + + // Send the queue request + const api = new AtelierAPI(root.uri); + const queueResp: Atelier.Response = await api.queueAsync(asyncRequest, true).catch((error) => { + outputErrorAsString(error); + vscode.window.showErrorMessage( + `Error creating job to ${action} tests. Check 'ObjectScript' output channel for details.`, + "Dismiss" + ); + return undefined; + }); + if (!queueResp) return; + + // Request was successfully queued, so get the ID + const id: string = queueResp.result.location; + if (token.isCancellationRequested) { + // The user cancelled the request, so cancel it on the server + await api.verifiedCancel(id, false); + return; + } + + // Start the TestRun + const testRun = testController.createTestRun(request, undefined, true); + + try { + // "Start" all of the test classes and methods that we're running + clsItemsRun.forEach((c) => { + testRun.started(c); + c.children.forEach((m) => testRun.started(m)); + }); + + // Create a map of all TestItems that we know the status of + const knownStatuses: WeakMap = new WeakMap(); + + // Keep track of if/when the debug session was started + let startedDebugging = false; + + // Get the map of class items for this root + const classes = classesForRoot.get(root); + + // Keep track of the item that the current console output is from + let currentOutputItem: vscode.TestItem | undefined; + + // The workspace folder that we're running tests in + const workspaceFolder = vscode.workspace.getWorkspaceFolder(root.uri); + + // A map of all documents that we've computed symbols for + const documentSymbols: Map = new Map(); + + // A map of all documents that we've fetched the text of + const filesText: Map = new Map(); + + // Poll until the tests have finished running or are cancelled by the user + const processUnitTestResults = async (): Promise> => { + const pollResp = await api.pollAsync(id, true); + if (pollResp.console.length) { + // Log console output + for (const consoleLine of pollResp.console) { + const indent = consoleLine.search(/\S/); + if (indent == 4) { + if (consoleLine.endsWith("...")) { + // This is the beginning of a class + currentOutputItem = classes.get(consoleLine.trim().split(" ")[0]); + } else { + // This is the end of a class + if (currentOutputItem != undefined && currentOutputItem.id.includes(methodIdSeparator)) { + currentOutputItem = currentOutputItem.parent; + } + } + } else if (indent == 6 && consoleLine.endsWith("...")) { + // This is the beginning of a method + if (currentOutputItem != undefined) { + if (currentOutputItem.id.includes(methodIdSeparator)) { + currentOutputItem = currentOutputItem.parent.children.get( + `${currentOutputItem.parent.id}${methodIdSeparator}${consoleLine.trim().slice(4).split("(")[0]}` + ); + } else { + currentOutputItem = currentOutputItem.children.get( + `${currentOutputItem.id}${methodIdSeparator}${consoleLine.trim().slice(4).split("(")[0]}` + ); + } + } + } else if (indent == 2 && currentOutputItem != undefined) { + // This is the end of all test classes + currentOutputItem = undefined; + } + if (currentOutputItem != undefined) { + testRun.appendOutput( + `${consoleLine}\r\n`, + new vscode.Location(currentOutputItem.uri, currentOutputItem.range), + currentOutputItem + ); + } else { + testRun.appendOutput(`${consoleLine}\r\n`); + } + } + } + if (testRun.token.isCancellationRequested) { + // The user cancelled the request, so cancel it on the server + return api.verifiedCancel(id, false); + } + + if (Array.isArray(pollResp.result)) { + // Process results + for (const testResult of pollResp.result) { + const clsItem = classes.get(testResult.class); + if (clsItem) { + if (testResult.method) { + // This is a method's result + const methodItem = clsItem.children.get(`${clsItem.id}${methodIdSeparator}${testResult.method}`); + if (methodItem) { + knownStatuses.set(methodItem, testResult.status); + switch (testResult.status) { + case TestStatus.Failed: { + const messages: vscode.TestMessage[] = []; + if (testResult.error) { + // Make the error the first message + messages.push( + new vscode.TestMessage(new vscode.MarkdownString(markdownifyLine(testResult.error))) + ); + } + if (testResult.failures.length) { + // Add a TestMessage for each failed assert with the correct location, if provided + for (const failure of testResult.failures) { + const message = new vscode.TestMessage( + new vscode.MarkdownString(markdownifyLine(failure.message)) + ); + if (failure.location) { + if (failure.location.document.toLowerCase().endsWith(".cls")) { + let locationUri: vscode.Uri; + if (classes.has(failure.location.document.slice(0, -4))) { + // This is one of the known test classes + locationUri = classes.get(failure.location.document.slice(0, -4)).uri; + } else { + // This is some other class. There's a chance that + // the class won't exist after the tests are run + // but we still want to provide the location + // because it will often be useful to the user. + locationUri = DocumentContentProvider.getUri( + failure.location.document, + workspaceFolder.name, + failure.location.namespace + ); + } + if (locationUri) { + if (!documentSymbols.has(locationUri.toString())) { + const newSymbols = await vscode.commands + .executeCommand( + "vscode.executeDocumentSymbolProvider", + locationUri + ) + .then( + (r) => r[0]?.children, + () => undefined + ); + if (newSymbols != undefined) documentSymbols.set(locationUri.toString(), newSymbols); + } + const locationSymbols = documentSymbols.get(locationUri.toString()); + if (locationSymbols != undefined) { + // Get the text of the class + if (!filesText.has(locationUri.toString())) { + const newFileText = await getFileText(locationUri).catch(() => undefined); + if (newFileText != undefined) filesText.set(locationUri.toString(), newFileText); + } + const fileText = filesText.get(locationUri.toString()); + if (fileText != undefined) { + // Find the line in the text + const locationLine = methodOffsetToLine( + locationSymbols, + fileText, + failure.location.label, + failure.location.offset + ); + if (locationLine != undefined) { + // We found the line, so add a location to the message + message.location = new vscode.Location( + locationUri, + // locationLine is one-indexed but Range is zero-indexed + new vscode.Range(locationLine - 1, 0, locationLine, 0) + ); + } + } + } + } + } else if (failure.location.label == undefined) { + // This location doesn't contain a label, so if we can + // resolve a URI for the document then report the location. + // There's a chance that the generated URI will be for a + // document that won't exist after the tests are run + // (for example, an autoloaded document that's in an + // XML file) but we still want to provide the location + // because it will often be useful to the user. + const locationUri = DocumentContentProvider.getUri( + failure.location.document, + workspaceFolder.name, + failure.location.namespace + ); + if (locationUri) { + message.location = new vscode.Location( + locationUri, + new vscode.Range(failure.location.offset ?? 0, 0, (failure.location.offset ?? 0) + 1, 0) + ); + } + } else { + // This location isn't in a class and + // requires resolving a label to a line. + // We can try to resolve it but the document might + // get cleaned up when the tests finish running. + } + } + messages.push(message); + } + } + testRun.failed(methodItem, messages, testResult.duration); + break; + } + case TestStatus.Passed: + testRun.passed(methodItem, testResult.duration); + break; + default: + testRun.skipped(methodItem); + } + } + } else { + // This is a class's result + // Report any methods that don't have statuses yet as "skipped" + clsItem.children.forEach((methodItem) => { + if (!knownStatuses.has(methodItem)) { + knownStatuses.set(methodItem, TestStatus.Skipped); + testRun.skipped(methodItem); + } + }); + // Report this class's status + switch (testResult.status) { + case TestStatus.Failed: { + const messages: vscode.TestMessage[] = []; + if (testResult.error) { + // Make the error the first message + messages.push(new vscode.TestMessage(new vscode.MarkdownString(markdownifyLine(testResult.error)))); + } + if (testResult.failures.length) { + // Add a TestMessage showing the failures as a bulleted list + messages.push( + new vscode.TestMessage( + new vscode.MarkdownString( + `There are failed test methods:\n${testResult.failures + .map((failure) => markdownifyLine(failure.message, true)) + .join("\n")}` + ) + ) + ); + } + testRun.failed(clsItem, messages, testResult.duration); + break; + } + case TestStatus.Passed: + testRun.passed(clsItem, testResult.duration); + break; + default: { + // Only report a class as skipped if all of its methods are skipped + let allSkipped = true; + for (const [, methodItem] of clsItem.children) { + if (knownStatuses.get(methodItem) == TestStatus.Passed) { + allSkipped = false; + break; + } + } + if (allSkipped) { + testRun.skipped(clsItem); + } else { + testRun.passed(clsItem, testResult.duration); + } + } + } + } + } + } + } else if (debug && queueResp.result.content?.debugId && pollResp.result?.content?.debugReady) { + // Make sure the activeTextEditor's document is in the same workspace folder as the test + // root so the debugger connects to the correct server and runs in the correct namespace + const rootWsFolderIdx = vscode.workspace.getWorkspaceFolder(root.uri)?.index; + if ( + !vscode.window.activeTextEditor?.document.uri || + vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)?.index != rootWsFolderIdx + ) { + // Make an existing editor active if one is in the correct workspace folder + let shown = false; + for (const editor of vscode.window.visibleTextEditors) { + if (vscode.workspace.getWorkspaceFolder(editor.document.uri)?.index == rootWsFolderIdx) { + await vscode.window.showTextDocument(editor.document); + shown = true; + break; + } + } + if (!shown) { + // Show the first test class. Ugly but necessary. + await vscode.window.showTextDocument(classesForRoot.get(root).get(asyncRequest.tests[0].class)?.uri); + } + } + // Start the debugging session + startedDebugging = await vscode.debug.startDebugging(undefined, { + type: "objectscript", + request: "attach", + name: "Unit tests", + cspDebugId: queueResp.result.content.debugId, + isUnitTest: true, + }); + } + + if (pollResp.retryafter) { + // Poll again + await new Promise((resolve) => { + // Poll less often when debugging because the tests + // will be executing much slower due to user interaction + setTimeout(resolve, startedDebugging ? 250 : 50); + }); + if (testRun.token.isCancellationRequested) { + // The user cancelled the request, so cancel it on the server + return api.verifiedCancel(id, false); + } + return processUnitTestResults(); + } + return pollResp; + }; + await processUnitTestResults(); + } catch (error) { + outputErrorAsString(error); + vscode.window.showErrorMessage( + `Error ${action}${debug ? "g" : "n"}ing tests. Check 'ObjectScript' output channel for details.`, + "Dismiss" + ); + } + testRun.end(); +} + +/** The `configureHandler` function for the `TestRunProfile`s. */ +function configureHandler(): void { + // Open the settings UI and focus on the "objectscript.unitTest" settings + vscode.commands.executeCommand( + "workbench.action.openSettings", + "@ext:intersystems-community.vscode-objectscript unitTest" + ); +} + +/** Set up the `TestController` and all of its dependencies. */ +export function setUpTestController(): vscode.Disposable[] { + // Create and set up the test controller + const testController = vscode.tests.createTestController(extensionId, "ObjectScript"); + testController.resolveHandler = async (item?: vscode.TestItem) => { + if (!item) return; // Can't resolve "undefined" + item.busy = true; + try { + if (item.uri.path.toLowerCase().endsWith(".cls")) { + // Compute items for the Test* methods in this class + await addTestItemsForClass(testController, item); + } else { + if (item.uri.scheme == "file") { + // Read the local directory for non-autoload subdirectories and classes + const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", item.uri); + const autoloadFolder: string = autoload.get("folder"); + const autoloadEnabled: boolean = autoloadFolder != "" && (autoload.get("xml") || autoload.get("udl")); + (await vscode.workspace.fs.readDirectory(item.uri)).forEach((element) => { + if ( + (element[1] == vscode.FileType.Directory && + !element[0].startsWith("_") && // %UnitTest.Manager skips subfolders that start with _ + (!autoloadEnabled || (autoloadEnabled && element[0] != autoloadFolder))) || + (element[1] == vscode.FileType.File && element[0].toLowerCase().endsWith(".cls")) + ) { + // This element is a non-autoload directory or a .cls file + addChildItem(testController, item, element[0]); + } + }); + } else { + // Query the server for subpackages and classes + (await childrenForServerSideFolderItem(item).then((data) => data.result.content)).forEach((child) => + addChildItem(testController, item, child.Name) + ); + } + } + } catch (error) { + outputErrorAsString(error); + item.error = new vscode.MarkdownString( + "Error fetching children. Check `ObjectScript` output channel for details." + ); + } + item.busy = false; + }; + testController.refreshHandler = () => { + replaceRootTestItems(testController); + }; + // Create the run and debug profiles + const runProfile = testController.createRunProfile( + "ObjectScript Run", + vscode.TestRunProfileKind.Run, + (r, t) => runHandler(r, t, testController), + true + ); + const debugProfile = testController.createRunProfile( + "ObjectScript Debug", + vscode.TestRunProfileKind.Debug, + (r, t) => runHandler(r, t, testController, true), + true + ); + runProfile.configureHandler = configureHandler; + debugProfile.configureHandler = configureHandler; + // Create the initial root items + replaceRootTestItems(testController); + + const openClass = vscode.workspace.textDocuments.find((d) => d.languageId == "objectscript-class"); + if (openClass) { + // Create TestItems for any test classes that are open at activation. + // Must be done after this extension activates because the resolve + // handler executes the DocumentSymbol command and therefore needs + // this extension (or the Language Server) active to respond to it. + // Will only wait a second for the extension(s) to be active. + const languageServer = vscode.extensions.getExtension(lsExtensionId); + const waitForResponse = (iter: number): Thenable => + iter > 20 + ? Promise.resolve([]) + : vscode.commands + .executeCommand("vscode.executeDocumentSymbolProvider", openClass.uri) + .then((r) => + r == undefined + ? new Promise((resolve) => setTimeout(resolve, 50)).then(() => waitForResponse(iter + 1)) + : r + ); + Promise.allSettled([ + vscode.extensions.getExtension(extensionId).activate(), + languageServer && !languageServer.isActive + ? Promise.allSettled([languageServer.activate(), waitForResponse(1)]) + : Promise.resolve(), + ]).then(() => + vscode.workspace.textDocuments.forEach((document) => addItemForClassUri(testController, document.uri)) + ); + } + + // Register disposables + return [ + testController, + runProfile, + debugProfile, + // Register event handlers + vscode.workspace.onDidChangeWorkspaceFolders((e) => { + // Update root items if needed + e.removed.forEach((wf) => { + testController.items.forEach((i) => { + if (uriIsParentOf(wf.uri, i.uri)) { + // Remove this TestItem + classesForRoot.delete(i); + testController.items.delete(i.id); + } + }); + }); + e.added.forEach((wf) => { + const newItems = createRootItemsForWorkspaceFolder(testController, wf); + newItems.forEach((i) => { + testController.items.add(i); + classesForRoot.set(i, new Map()); + }); + }); + }), + vscode.workspace.onDidChangeConfiguration((e) => { + // Determine the root items that need to be replaced, if any + const replace: vscode.TestItem[] = []; + testController.items.forEach((item) => { + if ( + (item.uri.scheme == "file" && e.affectsConfiguration("objectscript.unitTest", item.uri)) || + e.affectsConfiguration("objectscript.conn", item.uri) || + e.affectsConfiguration("intersystems.servers", item.uri) + ) { + replace.push(item); + } + }); + // Replace the affected root items + replace.forEach((item) => { + classesForRoot.delete(item); + testController.items.delete(item.id); + const folder = vscode.workspace.getWorkspaceFolder(item.uri); + if (folder) { + const newItems = createRootItemsForWorkspaceFolder(testController, folder); + newItems.forEach((i) => { + testController.items.add(i); + classesForRoot.set(i, new Map()); + if (replace.some((r) => r.id == i.id)) { + testController.invalidateTestResults(i); + } + }); + } + }); + // Re-compute TestItems for any open test classes + vscode.workspace.textDocuments.forEach((document) => addItemForClassUri(testController, document.uri)); + }), + vscode.workspace.onDidOpenTextDocument((document) => addItemForClassUri(testController, document.uri)), + vscode.workspace.onDidChangeTextDocument(async (e) => { + // If this is a test class, re-compute its TestItems + if (e.document.languageId == "objectscript-class") { + // Don't pass create flag because if it existed it would + // have been created already by the onDidOpen handler + const item = await getTestItemForClass(testController, e.document.uri); + if (item) { + testController.invalidateTestResults(item); + if (item.canResolveChildren && !item.children.size) { + // Resolve the methods + testController.resolveHandler(item); + } + } + } + }), + vscode.workspace.onDidDeleteFiles((e) => + e.files.forEach(async (uri) => { + // If a TestItem was deleted, remove it from the controller + if (uri.path.toLowerCase().endsWith(".cls")) { + const item = await getTestItemForClass(testController, uri); + if (item) { + const rootItem = rootItemForItem(testController, uri); + if (rootItem) { + // Remove from our cache of classes + const classes = classesForRoot.get(rootItem); + if (classes) { + let cls: string; + for (const element of classes) { + if (element[1].id == item.id) { + cls = element[0]; + break; + } + } + if (cls) { + classes.delete(cls); + classesForRoot.set(rootItem, classes); + } + } + } + item.parent.children.delete(uri.toString()); + } + } + }) + ), + vscode.workspace.onDidCreateFiles((e) => e.files.forEach((uri) => addItemForClassUri(testController, uri))), + ]; +} diff --git a/src/debug/debugSession.ts b/src/debug/debugSession.ts index 01f7a691..fc5d5f1f 100644 --- a/src/debug/debugSession.ts +++ b/src/debug/debugSession.ts @@ -1,5 +1,11 @@ import vscode = require("vscode"); -import { currentFile, currentFileFromContent } from "../utils"; +import { + currentFile, + currentFileFromContent, + getFileText, + methodOffsetToLine, + stripClassMemberNameQuotes, +} from "../utils"; import { InitializedEvent, LoggingDebugSession, @@ -17,7 +23,7 @@ import { DebugProtocol } from "@vscode/debugprotocol"; import WebSocket = require("ws"); import { AtelierAPI } from "../api"; import * as xdebug from "./xdebugConnection"; -import { documentContentProvider, OBJECTSCRIPT_FILE_SCHEMA, schemas } from "../extension"; +import { lsExtensionId, schemas } from "../extension"; import { DocumentContentProvider } from "../providers/DocumentContentProvider"; import { formatPropertyValue } from "./utils"; @@ -33,20 +39,8 @@ interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments { cspDebugId?: string; /** Automatically stop target after connect. If not specified, target does not stop. */ stopOnEntry?: boolean; -} - -/** Get the text of file `uri`. Works for all file systems and the `objectscript` `DocumentContentProvider`. */ -async function getFileText(uri: vscode.Uri): Promise { - if (uri.scheme == OBJECTSCRIPT_FILE_SCHEMA) { - return await documentContentProvider.provideTextDocumentContent(uri, new vscode.CancellationTokenSource().token); - } else { - return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); - } -} - -/** Strip quotes from method `name` if present */ -function stripMethodNameQuotes(name: string): string { - return name.charAt(0) == '"' && name.charAt(name.length - 1) == '"' ? name.slice(1, -1).replaceAll('""', '"') : name; + /** True if this request is for a unit test debug session. Only passed when `cspDebugId` is set. */ + isUnitTest?: boolean; } /** converts a uri from VS Code to a server-side XDebug file URI with respect to source root settings */ @@ -103,6 +97,12 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { /** The condition used for the watchpoint that allows us to detach from a CSPDEBUG session after the page has been loaded. */ private readonly _cspWatchpointCondition = `(($DATA(allowed)=1)&&(allowed=1)&&($ZNAME="%SYS.cspServer")&&(%response.Timeout'="")&&($CLASSNAME()="%CSP.Session"))`; + /** If this is a unit test session */ + private _isUnitTest = false; + + /** The condition used for the watchpoint that allows us to detach from a CSPDEBUG session after the unit tests have finished running. */ + private readonly _unitTestWatchpointCondition = `(($ZNAME?1"%Api.Atelier.v".E)&&($CLASSNAME()?1"%Api.Atelier.v".E))`; + /** If we're stopped at a breakpoint. */ private _break = false; @@ -243,9 +243,18 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { const debugTarget = args.cspDebugId != undefined ? `CSPDEBUG:${args.cspDebugId}` : `PID:${args.processId}`; await this._connection.sendFeatureSetCommand("debug_target", debugTarget); if (args.cspDebugId != undefined) { - // Set a watchpoint so the target breaks after the REST response is sent - await this._connection.sendBreakpointSetCommand(new xdebug.Watchpoint("ok", this._cspWatchpointCondition)); - this._isCsp = true; + if (args.isUnitTest) { + // Set a watchpoint so the target breaks after the unit tests have finished + await this._connection.sendBreakpointSetCommand( + new xdebug.Watchpoint("QQQZZZDebugWatchpointTriggerVar", this._unitTestWatchpointCondition) + ); + this._isUnitTest = true; + } else { + // Set a watchpoint so the target breaks after the REST response is sent + await this._connection.sendBreakpointSetCommand(new xdebug.Watchpoint("ok", this._cspWatchpointCondition)); + this._isCsp = true; + } + this._stopOnEntry = false; this.sendResponse(response); } else { this._isCsp = false; @@ -325,7 +334,7 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { const uri = schemas.includes(scheme) ? vscode.Uri.parse(filePath) : vscode.Uri.file(filePath); const fileUri = await convertClientPathToDebugger(uri, this._namespace); const [, fileName] = fileUri.match(/\|([^|]+)$/); - const languageServer: boolean = vscode.extensions.getExtension("intersystems.language-server")?.isActive ?? false; + const languageServer: boolean = vscode.extensions.getExtension(lsExtensionId)?.isActive ?? false; const currentList = await this._connection.sendBreakpointListCommand(); currentList.breakpoints @@ -366,7 +375,7 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { ) { // This breakpoint is in a method const currentdoc = (await getFileText(uri)).split(/\r?\n/); - const methodName = stripMethodNameQuotes(currentSymbol.name); + const methodName = stripClassMemberNameQuotes(currentSymbol.name); if (languageServer) { // selectionRange.start.line is the method definition line for ( @@ -573,8 +582,13 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { args: DebugProtocol.StackTraceArguments ): Promise { const stack = await this._connection.sendStackGetCommand(); - const languageServer: boolean = vscode.extensions.getExtension("intersystems.language-server")?.isActive ?? false; + // Is set to true if we're at the CSP or unit test ending watchpoint. + // We need to do this so VS Code doesn't try to open the source of + // a stack frame before the debug session terminates. That should + // only happen if the server has source code for %SYS.cspServer.mac/int + // or %Api.Atelier.v.cls/.int where X >= 8. + let noStack = false; const stackFrames = await Promise.all( stack.stack.map(async (stackFrame: xdebug.StackFrame, index): Promise => { const [, namespace, name] = decodeURI(stackFrame.fileUri).match(/^dbgp:\/\/\|([^|]+)\|(.*)$/); @@ -610,37 +624,8 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { fileUri ) )[0].children; - // Find the DocumentSymbol for this method - let currentSymbol: vscode.DocumentSymbol; - for (const symbol of symbols) { - if ( - stripMethodNameQuotes(symbol.name) === stackFrame.method && - symbol.detail.toLowerCase().includes("method") - ) { - currentSymbol = symbol; - break; - } - } - if (currentSymbol !== undefined) { - const fileTextLines = fileText.split(/\r?\n/); - if (languageServer) { - for ( - let methodlinenum = currentSymbol.selectionRange.start.line; - methodlinenum <= currentSymbol.range.end.line; - methodlinenum++ - ) { - // Find the offset of this breakpoint in the method - const methodlinetext: string = fileTextLines[methodlinenum].trim(); - if (methodlinetext.endsWith("{")) { - // This is the last line of the method definition, so count from here - line = methodlinenum + stackFrame.methodOffset + 1; - break; - } - } - } else { - line = currentSymbol.selectionRange.start.line + stackFrame.methodOffset; - } - } + const newLine = methodOffsetToLine(symbols, fileText, stackFrame.method, stackFrame.methodOffset); + if (newLine != undefined) line = newLine; } if ( this._isCsp && @@ -654,6 +639,17 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { // Stop the debugging session const xdebugResponse = await this._connection.sendDetachCommand(); await this._checkStatus(xdebugResponse); + noStack = true; + } + } + if (this._isUnitTest && this._break && source.name.startsWith("%Api.Atelier.v") && index == 0) { + // Check if we're at our special watchpoint + const { result } = await this._connection.sendEvalCommand(this._unitTestWatchpointCondition); + if (result.type == "int" && result.value == "1") { + // Stop the debugging session + const xdebugResponse = await this._connection.sendDetachCommand(); + await this._checkStatus(xdebugResponse); + noStack = true; } } this._stackFrames.set(stackFrameId, stackFrame); @@ -671,9 +667,11 @@ export class ObjectScriptDebugSession extends LoggingDebugSession { ); this._break = false; - response.body = { - stackFrames, - }; + if (!noStack) { + response.body = { + stackFrames, + }; + } this.sendResponse(response); } diff --git a/src/extension.ts b/src/extension.ts index 8de01603..5797e246 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,6 @@ export const extensionId = "intersystems-community.vscode-objectscript"; +export const lsExtensionId = "intersystems.language-server"; +export const smExtensionId = "intersystems-community.servermanager"; import vscode = require("vscode"); import * as semver from "semver"; @@ -128,6 +130,7 @@ import { RESTDebugPanel } from "./commands/restDebugPanel"; import { modifyWsFolder } from "./commands/addServerNamespaceToWorkspace"; import { WebSocketTerminalProfileProvider, launchWebSocketTerminal } from "./commands/webSocketTerminal"; import { getCSPToken } from "./utils/getCSPToken"; +import { setUpTestController } from "./commands/unitTest"; const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; const extensionVersion = packageJson.version; @@ -464,8 +467,7 @@ function setConnectionState(configName: string, active: boolean) { // Promise to return the API of the servermanager async function serverManager(): Promise { - const extId = "intersystems-community.servermanager"; - let extension = vscode.extensions.getExtension(extId); + let extension = vscode.extensions.getExtension(smExtensionId); const ignore = config("ignoreInstallServerManager") || vscode.workspace.getConfiguration("intersystems.servers").get("/ignore", false); @@ -474,14 +476,14 @@ async function serverManager(): Promise { return; } try { - await vscode.commands.executeCommand("extension.open", extId); + await vscode.commands.executeCommand("extension.open", smExtensionId); } catch (ex) { // Such command do not exists, suppose we are under Theia, it's not possible to install this extension this way return; } await vscode.window .showInformationMessage( - `The [InterSystems Server Manager extension](https://marketplace.visualstudio.com/items?itemName=${extId}) is recommended to help you [define connections and store passwords securely](https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO_config#GVSCO_config_addserver) in your keychain.`, + `The [InterSystems Server Manager extension](https://marketplace.visualstudio.com/items?itemName=${smExtensionId}) is recommended to help you [define connections and store passwords securely](https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO_config#GVSCO_config_addserver) in your keychain.`, "Install", "Later", "Never" @@ -490,8 +492,8 @@ async function serverManager(): Promise { switch (action) { case "Install": await vscode.commands.executeCommand("workbench.extensions.search", `@tag:"intersystems"`).then(null, null); - await vscode.commands.executeCommand("workbench.extensions.installExtension", extId); - extension = vscode.extensions.getExtension(extId); + await vscode.commands.executeCommand("workbench.extensions.installExtension", smExtensionId); + extension = vscode.extensions.getExtension(smExtensionId); break; case "Never": config().update("ignoreInstallServerManager", true, vscode.ConfigurationTarget.Global); @@ -510,22 +512,21 @@ async function serverManager(): Promise { } function languageServer(install = true): vscode.Extension { - const extId = "intersystems.language-server"; - let extension = vscode.extensions.getExtension(extId); + let extension = vscode.extensions.getExtension(lsExtensionId); async function languageServerInstall() { if (config("ignoreInstallLanguageServer")) { return; } try { - await vscode.commands.executeCommand("extension.open", extId); + await vscode.commands.executeCommand("extension.open", lsExtensionId); } catch (ex) { // Such command do not exists, suppose we are under Theia, it's not possible to install this extension this way return; } await vscode.window .showInformationMessage( - `Install the [InterSystems Language Server extension](https://marketplace.visualstudio.com/items?itemName=${extId}) for best handling of ObjectScript code.`, + `Install the [InterSystems Language Server extension](https://marketplace.visualstudio.com/items?itemName=${lsExtensionId}) for best handling of ObjectScript code.`, "Install", "Later" ) @@ -533,8 +534,8 @@ function languageServer(install = true): vscode.Extension { switch (action) { case "Install": await vscode.commands.executeCommand("workbench.extensions.search", `@tag:"intersystems"`).then(null, null); - await vscode.commands.executeCommand("workbench.extensions.installExtension", extId); - extension = vscode.extensions.getExtension(extId); + await vscode.commands.executeCommand("workbench.extensions.installExtension", lsExtensionId); + extension = vscode.extensions.getExtension(lsExtensionId); break; case "Later": default: @@ -1345,6 +1346,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { } } ), + ...setUpTestController(), /* Anything we use from the VS Code proposed API */ ...proposed diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 858f28c9..e162ec0b 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -4,12 +4,12 @@ import { before } from "mocha"; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import { window, extensions } from "vscode"; -import { extensionId } from "../../extension"; +import { extensionId, smExtensionId } from "../../extension"; suite("Extension Test Suite", () => { suiteSetup(async function () { // make sure extension is activated - const serverManager = extensions.getExtension("intersystems-community.servermanager"); + const serverManager = extensions.getExtension(smExtensionId); await serverManager?.activate(); const ext = extensions.getExtension(extensionId); await ext?.activate(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 310da046..7bb87b73 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,17 @@ import path = require("path"); import { exec } from "child_process"; import * as vscode from "vscode"; -import { config, schemas, workspaceState, terminals, extensionContext, cspApps } from "../extension"; +import { + config, + schemas, + workspaceState, + terminals, + extensionContext, + cspApps, + lsExtensionId, + OBJECTSCRIPT_FILE_SCHEMA, + documentContentProvider, +} from "../extension"; import { getCategory } from "../commands/export"; import { isCSPFile } from "../providers/FileSystemProvider/FileSystemProvider"; import { AtelierAPI } from "../api"; @@ -622,6 +632,73 @@ export async function isClassDeployed(cls: string, api: AtelierAPI): Promise { + if (uri.scheme == OBJECTSCRIPT_FILE_SCHEMA) { + return await documentContentProvider.provideTextDocumentContent(uri, new vscode.CancellationTokenSource().token); + } else { + return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + } +} + +/** Determine the exact line of `method` and `offset` within a class. If the line could be determined, it is returned one-indexed. */ +export function methodOffsetToLine( + members: vscode.DocumentSymbol[], + fileText: string, + method: string, + offset = 0 +): number | undefined { + let line: number; + const languageServer: boolean = vscode.extensions.getExtension(lsExtensionId)?.isActive ?? false; + // Find the DocumentSymbol for this method + let currentSymbol: vscode.DocumentSymbol; + for (const symbol of members) { + if (stripClassMemberNameQuotes(symbol.name) === method && symbol.detail.toLowerCase().includes("method")) { + currentSymbol = symbol; + break; + } + } + if (currentSymbol !== undefined) { + const fileTextLines = fileText.split(/\r?\n/); + if (languageServer) { + for ( + let methodlinenum = currentSymbol.selectionRange.start.line; + methodlinenum <= currentSymbol.range.end.line; + methodlinenum++ + ) { + // Find the offset of this breakpoint in the method + const methodlinetext: string = fileTextLines[methodlinenum].trim(); + if (methodlinetext.endsWith("{")) { + // This is the last line of the method definition, so count from here + line = methodlinenum + offset + 1; + break; + } + } + } else { + line = currentSymbol.selectionRange.start.line + offset; + } + } + return line; +} + // --------------------------------------------------------------------- // Source: https://github.com/amsterdamharu/lib/blob/master/src/index.js