-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathcontroller.ts
More file actions
400 lines (349 loc) · 13.2 KB
/
controller.ts
File metadata and controls
400 lines (349 loc) · 13.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
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
import { createHash } from "crypto";
import { promises as fs, statSync } from "fs";
import * as path from "path";
import picomatch from "picomatch";
import * as vscode from "vscode";
import { coverageContext } from "./coverage";
import { DisposableStore, MutableDisposable } from "./disposable";
import { ExtensionConfig } from "./extension-config";
import { last } from "./iterable";
import { ICreateOpts, ItemType, getContainingItemsForFile, testMetadata } from "./metadata";
import { IParsedNode, parseSource } from "./parsing";
import { RunHandler, TestRunner } from "./runner";
import { ISourceMapMaintainer, SourceMapStore } from "./source-map-store";
import {
fileMightHaveTests,
type TestFunctionSpecifierConfig,
} from "./test-function-specifier-config";
const diagnosticCollection = vscode.languages.createDiagnosticCollection("nodejs-testing-dupes");
function jsExtensions(extensions: string[]) {
let jsExtensions = "";
if (extensions == null || extensions.length == 0) {
throw "this case never occurs";
} else if (extensions.length == 1) {
jsExtensions = `.${extensions[0]}`;
} else {
jsExtensions = `.{${extensions.join(",")}}`;
}
return jsExtensions;
}
/** @see https://nodejs.org/api/test.html#test-runner-execution-model */
function defaultFilePatterns(extensions: string[]) {
return [
`**/{test,test-*,*.test,*-test,*_test}${jsExtensions(extensions)}`,
`**/test/**/*${jsExtensions(extensions)}`,
];
}
const forceForwardSlashes = (p: string) => p.replace(/\\/g, "/");
export class Controller {
private readonly disposable = new DisposableStore();
private readonly watcher = this.disposable.add(new MutableDisposable());
private readonly didChangeEmitter = new vscode.EventEmitter<void>();
/**
* Include patterns for workspace folders. `findFiles` doesn't do proper brace
* expansion yet, so this is an array
*/
private readonly findPatterns: vscode.RelativePattern[];
/** Pattern to check included files */
private readonly includeTest: picomatch.Matcher;
/** Promise that resolves when workspace files have been scanned */
private initialFileScan?: Promise<void>;
/** Mapping of the top-level tests found in each compiled */
private readonly testsInFiles = new Map<
/* uri */ string,
{
hash: number;
sourceMap: ISourceMapMaintainer;
items: Map<string, vscode.TestItem>;
}
>();
/** Change emitter used for testing, to pick up when the file watcher detects a change */
public readonly onDidChange = this.didChangeEmitter.event;
/** Handler for a normal test run */
public readonly runHandler: RunHandler;
/** Handler for a test debug run */
public readonly debugHandler: RunHandler;
/** Handler for a test coverage run */
public readonly coverageHandler: RunHandler;
constructor(
public readonly ctrl: vscode.TestController,
private readonly wf: vscode.WorkspaceFolder,
private readonly smStore: SourceMapStore,
runner: TestRunner,
include: string[],
exclude: string[],
extensionConfigs: ExtensionConfig[],
/**
* The configuration which defines which functions should be treated as tests
*/
private readonly testSpecifiers: TestFunctionSpecifierConfig[],
) {
this.disposable.add(ctrl);
this.disposable.add(runner);
const extensions = extensionConfigs.flatMap((x) => x.extensions);
const relativeInclude = include
.map((i) => (path.isAbsolute(i) ? path.relative(wf.uri.fsPath, i) : i))
.filter((i) => !path.isAbsolute(i) && !i.startsWith(".."));
this.findPatterns = relativeInclude.map((p) => {
const pattern = path.posix.join(forceForwardSlashes(p), `**/*${jsExtensions(extensions)}`);
return new vscode.RelativePattern(wf, pattern);
});
this.includeTest = picomatch(
relativeInclude.flatMap((i) =>
extensionConfigs
.flatMap((x): string[] => x.filePatterns || defaultFilePatterns(x.extensions))
.map((tp) => `${forceForwardSlashes(path.resolve(wf.uri.fsPath, i))}/${tp}`),
),
{
ignore: exclude.map((e) => {
e = forceForwardSlashes(path.resolve(wf.uri.fsPath, e));
// if the exclude is e.g. a directory, make it a glob pattern.
try {
if (!e.includes("*") && statSync(e).isDirectory()) {
return `${e}/**/*.*`;
}
} catch {
// ignored
}
return e;
}),
cwd: wf.uri.fsPath,
posixSlashes: true,
},
);
ctrl.resolveHandler = this.resolveHandler();
this.runHandler = runner.makeHandler(wf, ctrl, false, false);
this.debugHandler = runner.makeHandler(wf, ctrl, true, false);
this.coverageHandler = runner.makeHandler(wf, ctrl, false, true);
ctrl.refreshHandler = () => this.scanFiles();
ctrl.createRunProfile("Run", vscode.TestRunProfileKind.Run, this.runHandler, true);
ctrl.createRunProfile("Debug", vscode.TestRunProfileKind.Debug, this.debugHandler, true);
const coverageProfile = ctrl.createRunProfile(
"Coverage",
vscode.TestRunProfileKind.Coverage,
this.coverageHandler,
true,
);
coverageProfile.loadDetailedCoverage = coverageContext.loadDetailedCoverage;
}
public dispose() {
this.disposable.dispose();
}
private resolveHandler() {
return async (test?: vscode.TestItem) => {
if (!test) {
if (this.watcher.value) {
await this.initialFileScan; // will have been set when the watcher was created
} else {
await this.startWatchingWorkspace();
}
}
};
}
public syncFile(uri: vscode.Uri, contents?: () => string) {
if (this.includeTest(uri.fsPath)) {
this._syncFile(uri, contents?.());
}
}
/**
* Re-check this file for tests, and add them to the UI.
* Assumes that the URI has already passed `this.includeTest`
*
* @param uri the URI of the file in question to reparse and check for tests
* @param contents the file contents of uri - to be used as an optimization
*/
private async _syncFile(uri: vscode.Uri, contents?: string) {
contents ??= await fs.readFile(uri.fsPath, "utf8");
// If this file definitely doesn't have any tests, we can skip any expensive processing on it
if (!fileMightHaveTests(this.testSpecifiers, contents)) {
this.deleteFileTests(uri.toString());
return;
}
// avoid re-parsing if the contents are the same (e.g. if a file is edited
// and then saved.)
const previous = this.testsInFiles.get(uri.toString());
const hash = createHash("sha256").update(contents).digest().readInt32BE(0);
if (hash === previous?.hash) {
return;
}
const tree = parseSource(contents, this.wf.uri.path, uri.path, this.testSpecifiers);
if (!tree.length) {
this.deleteFileTests(uri.toString());
return;
}
const smMaintainer = previous?.sourceMap ?? this.smStore.maintain(uri);
const sourceMap = await smMaintainer.refresh(contents);
const add = (
parent: vscode.TestItem,
node: IParsedNode,
id: string,
start: vscode.Location,
end: vscode.Location,
): vscode.TestItem => {
let item = parent.children.get(id);
if (!item) {
item = this.ctrl.createTestItem(id, node.name, start.uri);
testMetadata.set(item, { type: ItemType.Test });
parent.children.add(item);
}
item.range = new vscode.Range(start.range.start, end.range.end);
const seen = new Map<string, vscode.TestItem>();
const level2Dupes = new Map<string, vscode.TestItem>();
for (const [, sibling] of parent.children) {
if (sibling.id == item.id || sibling.label !== node.name) {
continue;
}
for (const [, cousin] of sibling.children) {
level2Dupes.set(cousin.label, cousin);
}
}
for (const child of node.children) {
const existing = seen.get(child.name) || level2Dupes.get(child.name);
const start = sourceMap.originalPositionFor(
child.location.start.line,
child.location.start.column,
);
const end = sourceMap.originalPositionFor(
child.location.end.line,
child.location.end.column,
);
if (existing) {
addDuplicateDiagnostic(start, existing);
continue;
}
seen.set(child.name, add(item, child, child.name, start, end));
}
for (const [id] of item.children) {
if (!seen.has(id)) {
item.children.delete(id);
}
}
return item;
};
// We assume that all tests inside a top-level describe/test are from the same
// source file. This is probably a good assumption. Likewise we assume that a single
// a single describe/test is not split between different files.
const newTestsInFile = new Map<string, vscode.TestItem>();
let nId: number = 0;
for (const node of tree) {
const start = sourceMap.originalPositionFor(
node.location.start.line,
node.location.start.column,
);
const end = sourceMap.originalPositionFor(node.location.end.line, node.location.end.column);
const file = last(this.getContainingItemsForFile(start.uri, { compiledFile: uri }))!.item!;
diagnosticCollection.delete(start.uri);
if (newTestsInFile.has(node.name) && ["describe", "suite"].includes(node.fn)) {
const id = `${node.name}#${nId++}`;
newTestsInFile.set(id, add(file, node, id, start, end));
}
else {
nId = 0;
newTestsInFile.set(node.name, add(file, node, node.name, start, end));
}
}
if (previous) {
for (const [id, test] of previous.items) {
if (!newTestsInFile.has(id)) {
(test.parent?.children ?? this.ctrl.items).delete(id);
}
}
}
this.testsInFiles.set(uri.toString(), { items: newTestsInFile, hash, sourceMap: smMaintainer });
this.didChangeEmitter.fire();
}
private deleteFileTests(uriStr: string) {
const previous = this.testsInFiles.get(uriStr);
if (!previous) {
return;
}
this.testsInFiles.delete(uriStr);
for (const [id, item] of previous.items) {
diagnosticCollection.delete(item.uri!);
const itemsIt = this.getContainingItemsForFile(item.uri!);
// keep 'deleteFrom' as the node to remove if there are no nested children
let deleteFrom: { items: vscode.TestItemCollection; id: string } | undefined;
let last: vscode.TestItemCollection | undefined;
for (const { children, item } of itemsIt) {
if (item && children.size === 1) {
deleteFrom ??= { items: last || this.ctrl.items, id: item.id };
} else {
deleteFrom = undefined;
}
last = children;
}
if (!last!.get(id)) {
break;
}
if (deleteFrom) {
deleteFrom.items.delete(deleteFrom.id);
} else {
last!.delete(id);
}
}
this.didChangeEmitter.fire();
}
public async startWatchingWorkspace() {
// we need to watch for *every* change due to https://github.com/microsoft/vscode/issues/60813
const watcher = (this.watcher.value = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(this.wf, `**/*`),
));
watcher.onDidCreate((uri) => this.syncFile(uri));
watcher.onDidChange((uri) => this.syncFile(uri));
watcher.onDidDelete((uri) => {
const prefix = uri.toString();
for (const key of this.testsInFiles.keys()) {
if (key === prefix || (key[prefix.length] === "/" && key.startsWith(prefix))) {
this.deleteFileTests(key);
}
}
});
const promise = (this.initialFileScan = this.scanFiles());
await promise;
}
private async scanFiles() {
if (!this.watcher.value) {
// starting the watcher will call this again
return this.startWatchingWorkspace();
}
const toRemove = new Set(this.testsInFiles.keys());
const todo = this.findPatterns.map(async (pattern) => {
const todoInner = [];
for (const file of await vscode.workspace.findFiles(pattern)) {
if (this.includeTest(file.fsPath)) {
todoInner.push(this._syncFile(file));
toRemove.delete(file.toString());
}
}
await Promise.all(todoInner);
});
await Promise.all(todo);
for (const uriStr of toRemove) {
this.deleteFileTests(uriStr);
}
if (this.testsInFiles.size === 0) {
this.watcher.dispose(); // stop watching if there are no tests discovered
}
}
/** Gets the test collection for a file of the given URI, descending from the root. */
private getContainingItemsForFile(uri: vscode.Uri, createOpts?: ICreateOpts) {
return getContainingItemsForFile(this.wf, this.ctrl, uri, createOpts);
}
}
const addDuplicateDiagnostic = (location: vscode.Location, existing: vscode.TestItem) => {
const diagnostic = new vscode.Diagnostic(
location.range,
"Duplicate tests cannot be run individually and will not be reported correctly by the test framework. Please rename them.",
vscode.DiagnosticSeverity.Warning,
);
diagnostic.relatedInformation = [
new vscode.DiagnosticRelatedInformation(
new vscode.Location(existing.uri!, existing.range!),
"First declared here",
),
];
diagnosticCollection.set(
location.uri,
diagnosticCollection.get(location.uri)?.concat([diagnostic]) || [diagnostic],
);
};