VSCodium / VS Code extension which lets you run OCaml PPX Expect and PPX Inline Test with the (native) Test Explorer UI.
1/*
2 * SPDX-License-Identifier: MIT
3 * Copyright (C) 2023 Roland Csaszar
4 *
5 * Project: vscode-ocaml-expect-inline
6 * File: list_tests.ts
7 * Date: 28.Feb.2023
8 *
9 * ==============================================================================
10 * Everything to add tests to the Test Explorer tree from the output of an
11 * inline runner list of tests.
12 */
13import * as c from "./constants";
14import * as h from "./extension_helpers";
15import * as io from "./osInteraction";
16import * as p from "./parsing";
17import * as vscode from "vscode";
18
19/**
20 * Add all tests of all workspaces to the Test Explorer.
21 * @param env The extension's environment.
22 * @returns The list of `TestItems` that have been deleted from the Test
23 * Explorer tree.
24 */
25export async function addTests(
26 env: h.Env,
27 roots: readonly vscode.WorkspaceFolder[]
28) {
29 env.outChannel.appendLine("Adding new tests ...");
30
31 const promises = [];
32 for (const root of roots) {
33 env.outChannel.appendLine(`Processing workspace ${root.name}`);
34 promises.push(addWorkspaceTests(env, root));
35 }
36
37 const toDeleteArray = await Promise.allSettled(promises);
38
39 env.outChannel.appendLine("Finished adding new tests.");
40
41 // eslint-disable-next-line arrow-body-style
42 return toDeleteArray.flatMap((e) => {
43 return e.status === "fulfilled" ? e.value : [];
44 });
45}
46
47/**
48 * Add all tests of a single workspace `root` to the Test Explorer.
49 * @param env Everything needed to add these tests.
50 * @param root The workspace to add the tests from.
51 * @returns The list of `TestItems` that have been deleted from the Test
52 * Explorer tree.
53 */
54// eslint-disable-next-line max-statements
55async function addWorkspaceTests(env: h.Env, root: vscode.WorkspaceFolder) {
56 await setOpamEnv(env, root);
57
58 // eslint-disable-next-line @typescript-eslint/no-extra-parens
59 if (!(await h.isDuneWorking(root, env))) {
60 vscode.window.showErrorMessage(
61 `Error: Dune command '${c.getCfgDunePath(
62 env.config
63 )}' is not working in ${
64 root.name
65 }.\nSee the 'Output' window view of 'Expect and Inline Tests' for details.`
66 );
67 return [];
68 }
69
70 const workspaceItem = getWorkspaceItem();
71 env.controller.items.add(workspaceItem);
72 const toDelete: vscode.TestItem[] = [];
73 // eslint-disable-next-line @typescript-eslint/no-extra-parens
74 toDelete.push(...(await addPPXTests(env, root, workspaceItem)));
75
76 return toDelete;
77
78 /**
79 * Return the `TestItem` of the current workspace if it does exist or create
80 * it.
81 * @returns The `TestItem` of the current workspace if it does exist or
82 * create it.
83 */
84 function getWorkspaceItem() {
85 const item = env.controller.items.get(root.name);
86 if (item) {
87 return item;
88 }
89
90 return env.controller.createTestItem(
91 root.name,
92 c.workspaceLabel(root.name),
93 root.uri
94 );
95 }
96}
97
98/**
99 * Run `opam env`, parse its output and set the environment accordingly.
100 * @param env The extension's environment.
101 * @param root The working directory for `opam`.
102 */
103async function setOpamEnv(env: h.Env, root: vscode.WorkspaceFolder) {
104 const opamEnv = await io.opamEnv(root);
105 for (const oEnv of opamEnv) {
106 process.env[oEnv.name] = oEnv.value;
107 env.outChannel.appendLine(
108 `Workspace ${root.name}: adding env: ${oEnv.name} ${oEnv.value}`
109 );
110 }
111}
112
113/**
114 * Add all inline and expect PPX test of the workspace `root`.
115 * @param env Everything needed to add these tests.
116 * @param root The workspace to add the tests to and from.
117 * @param workspaceItem The parent of the test tree in the Test Explorer view.
118 * @returns The list of `TestItems` that have been deleted from the Test
119 * Explorer tree.
120 */
121async function addPPXTests(
122 env: h.Env,
123 root: vscode.WorkspaceFolder,
124 workspaceItem: vscode.TestItem
125) {
126 env.outChannel.appendLine(
127 `Workspace ${root.name}: searching for inline test runners ...`
128 );
129
130 const inlineRunnerPaths = await io.findFilesRelative(root, c.runnerExeGlob);
131 const justBuildPaths = sanitizeRunnerPaths(env, inlineRunnerPaths);
132 if (!justBuildPaths.length) {
133 env.outChannel.appendLine(
134 `Workspace ${root.name}: no inline test runners found`
135 );
136 }
137 for (const runner of justBuildPaths) {
138 env.outChannel.appendLine(`Found inline runner ${runner}`);
139 }
140
141 return generateTestList(env, {
142 runnerPaths: justBuildPaths,
143 root,
144 workspaceItem,
145 });
146}
147
148/**
149 * Remove all test runners to exclude and test runner executables in `.sandbox`.
150 * @param env The extension's environment.
151 * @param inlineRunnerPaths The list of inline test runner paths to check.
152 * @returns The list of test runners without the ones to exclude.
153 */
154function sanitizeRunnerPaths(env: h.Env, inlineRunnerPaths: string[]) {
155 const justBuildPaths: string[] = [];
156 const excludePaths = c.getCfgExcludeRunners(env.config);
157
158 // eslint-disable-next-line no-labels
159 outer: for (const incPath of inlineRunnerPaths) {
160 if (incPath.includes(c.sandboxDir)) {
161 // eslint-disable-next-line no-continue
162 continue;
163 }
164 for (const excPath of excludePaths) {
165 if (incPath.endsWith(excPath)) {
166 // eslint-disable-next-line no-labels, no-continue
167 continue outer;
168 }
169 }
170
171 justBuildPaths.push(incPath);
172 }
173 return justBuildPaths;
174}
175
176/**
177 * Generate the tree of tests in the Test Explorer from the list of tests of the
178 * test runners.
179 * @param env The environment to generate the tree of tests.
180 * @param data The data needed to generate the test tree.
181 * @returns The list of `TestItems` that have been deleted from the Test
182 * Explorer tree.
183 */
184// eslint-disable-next-line max-statements
185async function generateTestList(
186 env: h.Env,
187 data: {
188 runnerPaths: string[];
189 root: vscode.WorkspaceFolder;
190 workspaceItem: vscode.TestItem;
191 }
192) {
193 const groups: { name: string; tests: p.TestType[] }[] = [];
194 for (const rPath of data.runnerPaths) {
195 env.outChannel.appendLine(`Starting runner ${rPath}`);
196 // eslint-disable-next-line no-await-in-loop
197 const out = await io.runRunnerListDune({
198 token: undefined,
199 root: data.root,
200 duneCmd: c.getCfgDunePath(env.config),
201 runner: rPath,
202 });
203 env.outChannel.appendLine(
204 `Finished run: ${rPath}\nList of tests:\n${out.stdout}\nStderr: ${
205 out.stderr
206 }\nError: ${out.error ? out.error : ""}`
207 );
208
209 if (out.stdout) {
210 groups.push(
211 // eslint-disable-next-line @typescript-eslint/no-extra-parens, no-await-in-loop
212 ...(await parseTestListOutput(env, {
213 root: data.root,
214 workspaceItem: data.workspaceItem,
215 listOutput: out.stdout,
216 rPath,
217 }))
218 );
219 }
220 }
221 const toDelete = deleteNonExistingGroups(data.workspaceItem, groups);
222 for (const del of toDelete) {
223 env.outChannel.appendLine(`Deleting ${del.label} ID: ${del.id}`);
224 }
225 return toDelete;
226}
227
228/**
229 * Parse the output of the test list and add the test items to the test tree.
230 * @param env The environment needed to add the tests.
231 * @param data The data needed to add the test item to the tree.
232 * @returns The list of tests parsed from the output.
233 */
234// eslint-disable-next-line max-statements
235async function parseTestListOutput(
236 env: h.Env,
237 data: {
238 root: vscode.WorkspaceFolder;
239 workspaceItem: vscode.TestItem;
240 listOutput: string;
241 rPath: string;
242 }
243) {
244 const groups = p.parseTestList(data.listOutput);
245
246 for (const group of groups) {
247 // eslint-disable-next-line no-await-in-loop
248 const sourcePath = await io.findSourceOfTest(data.root, group.name);
249 const groupItem = getTestItem({
250 controller: env.controller,
251 parent: data.workspaceItem,
252 id: group.name,
253 label: group.name,
254 uri: sourcePath,
255 });
256 for (const t of group.tests) {
257 addTestItem(env, {
258 t,
259 sourcePath,
260 groupItem,
261 root: data.root,
262 runnerPath: data.rPath,
263 });
264 }
265 }
266 return groups;
267}
268
269/**
270 * Check the given list of groups if there are any nodes in the tree, that have
271 * been deleted and delete these groups.
272 * That is, the group is not in the list of groups but is a children of the
273 * suite node.
274 * @param workspaceItem The workspace node the test belong to.
275 * @param groups The list of groups to check.
276 * @returns The list of `TestItems` that have been deleted from the Test
277 * Explorer tree.
278 */
279function deleteNonExistingGroups(
280 workspaceItem: vscode.TestItem,
281 groups: { name: string; tests: p.TestType[] }[]
282) {
283 const workspaceGroup: vscode.TestItem[] = [];
284 workspaceItem.children.forEach((e) => workspaceGroup.push(e));
285 const toDelete = workspaceGroup.filter(
286 (e) => !groups.find((v) => v.name === e.id)
287 );
288
289 toDelete.forEach((e) => {
290 e.children.forEach((ch) => {
291 workspaceItem.children.delete(ch.id);
292 });
293 workspaceItem.children.delete(e.id);
294 });
295
296 return toDelete;
297}
298
299/**
300 * Add or update a single test item to the Test Explorer tree.
301 * @param env The environment needed to add the test.
302 * @param data The data needed to add the test item to the tree.
303 */
304export function addTestItem(
305 env: h.Env,
306 data: {
307 t: p.TestType;
308 sourcePath: vscode.Uri;
309 groupItem: vscode.TestItem;
310 root: vscode.WorkspaceFolder;
311 runnerPath: string;
312 }
313) {
314 const testItem = getTestItem({
315 controller: env.controller,
316 parent: data.groupItem,
317 label: data.t.name,
318 id: `${data.t.line}`,
319 uri: data.sourcePath,
320 line: data.t.line,
321 colStart: data.t.startCol,
322 colEnd: data.t.endCol,
323 });
324
325 if (!env.testData.get(testItem)) {
326 env.testData.set(testItem, {
327 root: data.root,
328 runner: data.runnerPath,
329 library: p.getLibrary(data.runnerPath),
330 file: data.sourcePath.path,
331 });
332 }
333}
334
335/**
336 * Return the `TestItem` with id `data.id`.
337 * If it does already exist, just update its name. If it does not yet exist,
338 * create a new `TestItem` and return that.
339 * @param data The needed data.
340 * @returns The existing or created `TestItem`.
341 */
342function getTestItem(data: {
343 controller: vscode.TestController;
344 parent: vscode.TestItem;
345 id: string;
346 label: string;
347 line?: number;
348 colStart?: number;
349 colEnd?: number;
350 uri?: vscode.Uri;
351}) {
352 let item = data.parent.children.get(data.id);
353 if (item) {
354 item.label = data.label;
355 item.range = h.toRange(
356 data.line ? data.line - 1 : 0,
357 data.colStart,
358 data.colEnd
359 );
360 return item;
361 }
362
363 item = data.controller.createTestItem(data.id, data.label, data.uri);
364 if (data.line) {
365 item.range = h.toRange(
366 data.line ? data.line - 1 : 0,
367 data.colStart,
368 data.colEnd
369 );
370 }
371
372 data.parent.children.add(item);
373
374 return item;
375}
376
377/**
378 * Return a list of tests to run.
379 *
380 * Either all tests of the `controller` are run or only the ones specified in
381 * `request`.
382 * @param request The request which may hold a list of tests (`TestItem`s) to
383 * run.
384 * @param controller Holding all existing `TestItem`s.
385 * @param toDelete The list of deleted `TestItems`, don't add these to the tests
386 * to run.
387 * @returns The list of tests to run.
388 */
389export function testList(
390 request: vscode.TestRunRequest,
391 controller: vscode.TestController,
392 toDelete: vscode.TestItem[]
393) {
394 const tests: vscode.TestItem[] = [];
395
396 if (request.include) {
397 request.include.forEach((t) => tests.push(...testAndChilds(t)));
398 } else {
399 controller.items.forEach((t) => tests.push(...testAndChilds(t)));
400 }
401
402 return tests.filter((t) => !request.exclude?.includes(t));
403
404 /**
405 * Return a list of a test and its children, if it has any.
406 * @param t The test to check for children.
407 * @returns A list of a test and its children.
408 */
409 function testAndChilds(t: vscode.TestItem) {
410 const testNChilds: vscode.TestItem[] = [];
411 if (t.children?.size > 0) {
412 t.children.forEach((el) => testNChilds.push(...testAndChilds(el)));
413 } else if (!toDelete.find((e) => e === t)) {
414 testNChilds.push(t);
415 }
416
417 return testNChilds;
418 }
419}