-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommonRunTestsHandler.ts
More file actions
383 lines (345 loc) · 17.2 KB
/
commonRunTestsHandler.ts
File metadata and controls
383 lines (345 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import * as vscode from 'vscode';
import { IServerSpec } from "@intersystems-community/intersystems-servermanager";
import { allTestRuns, extensionId, osAPI, OurTestItem } from './extension';
import { relativeTestRoot } from './localTests';
import logger from './logger';
import { makeRESTRequest } from './makeRESTRequest';
import { OurFileCoverage } from './ourFileCoverage';
export async function commonRunTestsHandler(controller: vscode.TestController, resolveItemChildren: (item: vscode.TestItem) => Promise<void>, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) {
logger.debug(`commonRunTestsHandler invoked by controller id=${controller.id}`);
// For each authority (i.e. server:namespace) accumulate a map of the class-level Test nodes in the tree.
// We don't yet support running only some TestXXX methods in a testclass
const mapAuthorities = new Map<string, Map<string, OurTestItem>>();
const runIndices: number[] =[];
const queue: OurTestItem[] = [];
const coverageRequest = request.profile?.kind === vscode.TestRunProfileKind.Coverage;
// Loop through all included tests, or all known tests, and add them to our queue
if (request.include) {
request.include.forEach((test: OurTestItem) => {
if (!coverageRequest || test.supportsCoverage) {
queue.push(test);
}
});
} else {
// Run was launched from controller's root level
controller.items.forEach((test: OurTestItem) => {
if (!coverageRequest || test.supportsCoverage) {
queue.push(test);
}
});
}
if (coverageRequest && !queue.length) {
// No tests to run, but coverage requested
vscode.window.showErrorMessage("[Test Coverage Tool](https://openexchange.intersystems.com/package/Test-Coverage-Tool) not found.", );
return;
}
// Process every test that was queued. Recurse down to leaves (testmethods) and build a map of their parents (classes)
while (queue.length > 0 && !cancellation.isCancellationRequested) {
const test = queue.pop()!;
// Skip tests the user asked to exclude
if (request.exclude && request.exclude.filter((excludedTest) => excludedTest.id === test.id).length > 0) {
continue;
}
// 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 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") {
// Client-side editing, for which we will assume objectscript.conn names a server defined in `intersystems.servers`
const conn: any = vscode.workspace.getConfiguration("objectscript", test.uri).get("conn");
authority = (conn.server || "") + ":" + (conn.ns as string).toLowerCase();
const folder = vscode.workspace.getWorkspaceFolder(test.uri);
if (folder) {
key = key.slice(folder.uri.path.length + relativeTestRoot(folder).length + 1);
}
}
const mapTestClasses = mapAuthorities.get(authority) || new Map<string, OurTestItem>();
if (!mapTestClasses.has(key) && test.parent) {
// When leaf is a test its parent has a uri and is the class
// Otherwise the leaf is a class with no tests
mapTestClasses.set(key, test.parent.uri ? test.parent : test);
mapAuthorities.set(authority, mapTestClasses);
}
}
// Queue any children
test.children.forEach(test => queue.push(test));
}
// Cancelled while building our structures?
if (cancellation.isCancellationRequested) {
return;
}
if (mapAuthorities.size === 0) {
// Nothing included
vscode.window.showErrorMessage("Empty test run.", { modal: true });
return;
}
// Arrange for cancellation to stop the debugging sessions we start
cancellation.onCancellationRequested(() => {
runIndices.forEach((runIndex) => {
const session = allTestRuns[runIndex]?.debugSession;
if (session) {
vscode.debug.stopDebugging(session);
}
});
});
for await (const mapInstance of mapAuthorities) {
const run = controller.createTestRun(
request,
'Test Results',
true
);
let authority = mapInstance[0];
const mapTestClasses = mapInstance[1];
// enqueue everything up front so user sees immediately which tests will run
mapTestClasses.forEach((test) => {
let methodTarget = "";
if (request.include?.length === 1) {
const idParts = request.include[0].id.split(":");
if (idParts.length === 5) {
methodTarget = request.include[0].id;
}
}
test.children.forEach((methodTest) => {
if (methodTarget && methodTarget !== methodTest.id) {
// User specified a single test method to run, so skip all others
return;
}
run.enqueued(methodTest);
});
});
const firstClassTestItem = Array.from(mapTestClasses.values())[0];
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 = await osAPI.asyncServerForUri(oneUri);
const serverSpec: IServerSpec = {
username: server.username,
password: server.password,
name: server.serverName,
webServer: {
host: server.host,
port: server.port,
pathPrefix: server.pathPrefix,
scheme: server.scheme
}
};
const namespace: string = server.namespace.toUpperCase();
const responseCspapps = await makeRESTRequest(
"GET",
serverSpec,
{ apiVersion: 1, namespace: "%SYS", path: "/cspapps/%SYS" }
);
if (!responseCspapps?.data?.result?.content?.includes("/_vscode")) {
const reply = await vscode.window.showErrorMessage(`A '/_vscode' web application must be configured for the %SYS namespace of server '${serverSpec.name}'. The ${namespace} namespace also requires its ^UnitTestRoot global to point to the '${namespace}/UnitTestRoot' subfolder of that web application's path.`, { modal: true }, 'Use IPM Package', 'Follow Manual Instructions');
if (reply === 'Follow Manual Instructions') {
vscode.commands.executeCommand('vscode.open', 'https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO_serverflow#GVSCO_serverflow_folderspec');
} else if (reply === 'Use IPM Package') {
vscode.commands.executeCommand('vscode.open', 'https://openexchange.intersystems.com/package/vscode-per-namespace-settings');
}
run.end();
return;
}
const username: string = (serverSpec.username || 'UnknownUser').toLowerCase();
// When client-side mode is using 'objectscript.conn.docker-compose the first piece of 'authority' is blank,
if (authority.startsWith(":")) {
authority = folder?.name || "";
} else {
authority = authority.split(":")[0];
}
// Load our support classes if they are not already there and the correct version.
const thisExtension = vscode.extensions.getExtension(extensionId);
if (!thisExtension) {
// Never happens, but needed to satisfy typechecking below
return;
}
const extensionUri = thisExtension.extensionUri;
const supportClassesDir = extensionUri.with({ path: extensionUri.path + '/serverSide/src' + '/vscode/dc/testingmanager'});
const expectedVersion = thisExtension.packageJSON.version;
const expectedCount = (await vscode.workspace.fs.readDirectory(supportClassesDir)).length;
const response = await makeRESTRequest(
"POST",
serverSpec,
{ apiVersion: 1, namespace, path: "/action/query" },
{
query: `SELECT parent, _Default FROM %Dictionary.CompiledParameter WHERE Name='VERSION' AND parent %STARTSWITH 'vscode.dc.testingmanager.' AND _Default=?`,
parameters: [expectedVersion],
},
);
if (response?.status !== 200 || response?.data?.result?.content?.length !== expectedCount) {
const destinationDir = vscode.Uri.from({ scheme: 'isfs', authority: `${authority}:${namespace}`, path: '/vscode/dc/testingmanager'})
try {
await vscode.workspace.fs.copy(supportClassesDir, destinationDir, { overwrite: true });
} catch (error) {
await vscode.window.showErrorMessage(`Failed to copy support classes from ${supportClassesDir.path.slice(1)} to ${destinationDir.toString()}\n\n${JSON.stringify(error)}`, {modal: true});
}
}
// Form the ISFS target folder uri and delete any existing content
// Note that authority here is just server name, no :namespace
const testRoot = vscode.Uri.from({ scheme: 'isfs', authority, path: `/_vscode/${namespace}/UnitTestRoot/${username}`, query: "csp&ns=%SYS" });
try {
// Limitation of the Atelier API means this can only delete the files, not the folders
// but zombie folders shouldn't cause problems.
await vscode.workspace.fs.delete(testRoot, { recursive: true });
} catch (error) {
console.log(error);
}
// Map of uri strings checked for presence of a coverage.list file, recording the absolute path of those that were found
const mapCoverageLists = new Map<string, string>();
for await (const mapInstance of mapTestClasses) {
const key = mapInstance[0];
const pathParts = key.split('/');
pathParts.pop();
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 > 0) {
const currentPath = pathParts.join('/');
// Check for coverage.list file here
const coverageListUri = sourceBaseUri.with({ path: sourceBaseUri.path.concat(`${currentPath}/coverage.list`) });
if (mapCoverageLists.has(coverageListUri.toString())) {
// Already checked this uri path, and therefore all its ancestors
break;
}
try {
await vscode.workspace.fs.stat(coverageListUri);
mapCoverageLists.set(coverageListUri.toString(), testRoot.path.concat(currentPath));
} catch (error) {
if (error.code !== vscode.FileSystemError.FileNotFound().code) {
console.log(`Error checking for ${coverageListUri.toString()}:`, error);
}
mapCoverageLists.set(coverageListUri.toString(), '');
}
pathParts.pop();
}
}
// Copy all coverage.list files found into the corresponding place under testRoot
for await (const [uriString, path] of mapCoverageLists) {
if (path.length > 0) {
const coverageListUri = vscode.Uri.parse(uriString, true);
try {
await vscode.workspace.fs.copy(coverageListUri, testRoot.with({ path: `${path}/coverage.list` }));
} catch (error) {
console.log(`Error copying ${coverageListUri.path}:`, error);
}
}
}
// Next, copy the classes into the folder as a package hierarchy
for await (const mapInstance of mapTestClasses) {
const key = mapInstance[0];
const classTest = mapInstance[1];
const uri = classTest.uri;
const keyParts = key.split('/');
const clsFile = keyParts.pop() || '';
const directoryUri = testRoot.with({ path: testRoot.path.concat(keyParts.join('/') + '/') });
// This will always be true since every test added to the map above required a uri
if (uri) {
try {
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;
}
}
}
// Finally, run the tests using the debugger API
// but only stop at breakpoints etc if user chose "Debug Test" instead of "Run Test"
const isClientSideMode = controller.id === `${extensionId}-Local`;
const isDebug = request.profile?.kind === vscode.TestRunProfileKind.Debug;
const runQualifiers = !isClientSideMode ? "/noload/nodelete" : isDebug ? "/noload" : "";
const userParam = vscode.workspace.getConfiguration('objectscript', oneUri).get<boolean>('multilineMethodArgs', false) ? 1 : 0;
const runIndex = allTestRuns.push(run) - 1;
runIndices.push(runIndex);
// Compute the testspec argument for %UnitTest.Manager.RunTest() call.
// Typically it is a testsuite, the subfolder where we copied all the testclasses,
// but if only a single method of a single class is being tested we will also specify testcase and testmethod.
let testSpec = username;
if (request.include?.length === 1) {
const idParts = request.include[0].id.split(":");
if (idParts.length === 5) {
testSpec = `${username}\\${idParts[3].split(".").slice(0, -1).join("\\")}:${idParts[3]}:${idParts[4]}`;
}
}
let program = `##class(vscode.dc.testingmanager.StandardManager).RunTest("${testSpec}","${runQualifiers}",${userParam})`;
if (coverageRequest) {
program = `##class(vscode.dc.testingmanager.CoverageManager).RunTest("${testSpec}","${runQualifiers}",${userParam})`
request.profile.loadDetailedCoverage = async (_testRun, fileCoverage, _token) => {
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage() : [];
};
request.profile.loadDetailedCoverageForTest = async (_testRun, fileCoverage, fromTestItem, _token) => {
return fileCoverage instanceof OurFileCoverage ? fileCoverage.loadDetailedCoverage(fromTestItem) : [];
};
}
const configuration = {
type: "objectscript",
request: "launch",
name: `${controller.id.split("-").pop()}Tests:${serverSpec.name}:${namespace}:${username}`,
program,
// Extra properties needed by our DebugAdapterTracker
testingRunIndex: runIndex,
testingIdBase: firstClassTestItem.id.split(":", 3).join(":")
};
const sessionOptions: vscode.DebugSessionOptions = {
noDebug: !isDebug,
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
// and does this from current active document, so here we make sure there's a suitable one.
vscode.commands.executeCommand("vscode.open", oneUri, { preserveFocus: true });
// When debugging in client-side mode the classes must be loaded and compiled before the debug run happens, otherwise breakpoints don't bind
if (isClientSideMode && isDebug && !cancellation.isCancellationRequested) {
// Without the /debug option the classes are compiled without maps, preventing breakpoints from binding.
const preloadConfig = {
"type": "objectscript",
"request": "launch",
"name": 'LocalTests.Preload',
"program": `##class(%UnitTest.Manager).RunTest("${testSpec}","/nodisplay/load/debug/norun/nodelete")`,
};
// Prepare to detect when the preload completes
let sessionTerminated: () => void;
const listener = vscode.debug.onDidTerminateDebugSession((session) => {
if (session.name === 'LocalTests.Preload') {
sessionTerminated();
}
});
const sessionTerminatedPromise = new Promise<void>(resolve => sessionTerminated = resolve);
// Start the preload
if (!await vscode.debug.startDebugging(folder, preloadConfig, { noDebug: true, suppressDebugStatusbar: true })) {
listener.dispose();
await vscode.window.showErrorMessage(`Failed to preload client-side test classes for debugging`, { modal: true });
run.end();
allTestRuns[runIndex] = undefined;
return;
};
// Wait for it to complete
await sessionTerminatedPromise;
listener.dispose();
}
// Start the run unless already cancelled
if (cancellation.isCancellationRequested || !await vscode.debug.startDebugging(folder, configuration, sessionOptions)) {
if (!cancellation.isCancellationRequested) {
await vscode.window.showErrorMessage(`Failed to launch testing`, { modal: true });
}
run.end();
allTestRuns[runIndex] = undefined;
return;
}
}
}
}