-
Notifications
You must be signed in to change notification settings - Fork 575
/
Copy pathtestUtil.ts
404 lines (355 loc) · 13.6 KB
/
testUtil.ts
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
401
402
403
404
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'assert'
import * as path from 'path'
import * as fs from 'fs-extra'
import * as vscode from 'vscode'
import * as fsextra from 'fs-extra'
import * as FakeTimers from '@sinonjs/fake-timers'
import * as pathutil from '../shared/utilities/pathUtils'
import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../shared/filesystemUtilities'
import globals from '../shared/extensionGlobals'
import { waitUntil } from '../shared/utilities/timeoutUtils'
import { MetricName, MetricShapes } from '../shared/telemetry/telemetry'
import { keys, selectFrom } from '../shared/utilities/tsUtils'
const testTempDirs: string[] = []
/**
* Writes the string form of `o` to `filepath` as UTF-8 text.
*
* Creates parent directories in `filepath`, if necessary.
*/
export function toFile(o: any, filepath: string) {
const text = o ? o.toString() : ''
const dir = path.dirname(filepath)
fsextra.mkdirpSync(dir)
fsextra.writeFileSync(filepath, text, 'utf8')
}
/**
* Gets the contents of `filepath` as UTF-8 encoded string.
*/
export function fromFile(filepath: string): string {
return fsextra.readFileSync(filepath, { encoding: 'utf8' })
}
/** Gets the full path to the Toolkit source root on this machine. */
export function getProjectDir(): string {
return path.join(__dirname, '../')
}
/** Instantiates a `WorkspaceFolder` object for use in tests. */
export function getWorkspaceFolder(dir: string): vscode.WorkspaceFolder {
const folder = {
uri: vscode.Uri.file(dir),
name: 'test-workspace-folder',
index: 0,
}
return folder
}
/**
* Creates a random, temporary workspace folder on the filesystem and returns a
* `WorkspaceFolder` object.
*
* @param name Optional name, defaults to "test-workspace-folder".
*/
export async function createTestWorkspaceFolder(name?: string): Promise<vscode.WorkspaceFolder> {
const tempFolder = await makeTemporaryToolkitFolder()
testTempDirs.push(tempFolder)
return {
uri: vscode.Uri.file(tempFolder),
name: name ?? 'test-workspace-folder',
index: 0,
}
}
export async function deleteTestTempDirs(): Promise<void> {
let failed = 0
for (const s of testTempDirs) {
if (!tryRemoveFolder(s)) {
failed += 1
}
}
if (failed > 0) {
console.error('deleteTestTempDirs: failed to delete %d/%d test temp dirs', failed, testTempDirs.length)
} else {
console.error('deleteTestTempDirs: deleted %d test temp dirs', testTempDirs.length)
}
}
/**
* Asserts that filepaths are equal, after normalizing for platform differences.
*/
export function assertEqualPaths(actual: string, expected: string, message?: string | Error) {
assert.strictEqual(pathutil.normalize(actual), pathutil.normalize(expected), message)
}
/**
* Asserts that UTF-8 contents of `file` are equal to `expected`.
*/
export function assertFileText(file: string, expected: string, message?: string | Error) {
const actualContents = fsextra.readFileSync(file, 'utf-8')
assert.strictEqual(actualContents, expected, message)
}
/** A lot of code will create a Promise, tick the clock, then wait for resolution. This function turns 3 lines into 1. */
export async function tickPromise<T>(promise: Promise<T>, clock: FakeTimers.InstalledClock, t: number): Promise<T> {
clock.tick(t)
return await promise
}
/**
* Creates an executable file (including any parent directories) with the given contents.
*/
export function createExecutableFile(filepath: string, contents: string): void {
fs.mkdirpSync(path.dirname(filepath))
if (process.platform === 'win32') {
fs.writeFileSync(filepath, `@echo OFF$\r\n${contents}\r\n`)
} else {
fs.writeFileSync(filepath, `#!/bin/sh\n${contents}`)
fs.chmodSync(filepath, 0o744)
}
}
/**
* Installs a fake clock, making sure to set a flag to clear real timers.
*
* Always uses the extension-scoped clock instead of the real one.
*
* **Implementations must use `globals.clock` to be correctly tested**
*/
export function installFakeClock(): FakeTimers.InstalledClock {
return FakeTimers.withGlobal(globals.clock).install({
shouldClearNativeTimers: true,
shouldAdvanceTime: false,
})
}
/**
* Gets all recorded metrics with the corresponding name.
*
* Unlike {@link assertTelemetry}, this function does not do any transformations to
* handle fields being converted into strings. It will also not return `passive` or `value`.
*/
export function getMetrics<K extends MetricName>(name: K): readonly Partial<MetricShapes[K]>[] {
const query = { metricName: name }
return globals.telemetry.logger.query(query) as unknown as Partial<MetricShapes[K]>[]
}
// Note that Typescript is unable to describe the set of all supertypes of a type.
// This should be typed something like `asserts actual is * extends T`
export function partialDeepCompare<T>(actual: unknown, expected: T, message?: string): asserts actual is T {
if (typeof actual !== 'object' || !actual || typeof expected !== 'object' || !expected) {
assert.deepStrictEqual(actual, expected, message)
}
const partial = selectFrom(actual, ...keys(expected as object))
assert.deepStrictEqual(partial, expected, message)
}
/**
* Finds the emitted telemetry metrics with the given `name`, then checks if the metadata fields
* match the expected values, in the order specified by `expected`. Comparisons are done using
* {@link partialDeepCompare}. **Only fields present in {@link expected} will be checked.**
*
* @param name Metric name
* @param expected Metric(s) shape(s) which are compared _in order_ to metrics matching `name`.
*/
export function assertTelemetry<K extends MetricName>(
name: K,
expected: MetricShapes[K] | MetricShapes[K][]
): void | never
export function assertTelemetry<K extends MetricName>(
name: K,
expected: MetricShapes[MetricName] | MetricShapes[MetricName][]
): void | never
export function assertTelemetry<K extends MetricName>(
name: K,
expected: MetricShapes[K] | MetricShapes[K][]
): void | never {
const expectedList = Array.isArray(expected) ? expected : [expected]
const query = { metricName: name }
const metadata = globals.telemetry.logger.query(query)
assert.ok(metadata.length > 0, `telemetry not found for metric name: "${name}"`)
for (let i = 0; i < expectedList.length; i++) {
const metric = expectedList[i]
const expectedCopy = { ...metric } as { -readonly [P in keyof MetricShapes[K]]: MetricShapes[K][P] }
const passive = expectedCopy?.passive
delete expectedCopy['passive']
Object.keys(expectedCopy).forEach(
k => ((expectedCopy as any)[k] = (expectedCopy as Record<string, any>)[k]?.toString())
)
const msg = `telemetry item ${i + 1} (of ${
expectedList.length
}) not found (in the expected order) for metric name: "${name}" `
partialDeepCompare(metadata[i], expectedCopy, msg)
// Check this explicitly because we deleted it above.
if (passive !== undefined) {
const metric = globals.telemetry.logger.queryFull(query)
assert.strictEqual(metric[0].Passive, passive)
}
}
}
/**
* Curried form of {@link assertTelemetry} for when you want partial application.
*/
export const assertTelemetryCurried =
<K extends MetricName>(name: K) =>
(expected: MetricShapes[K]) =>
assertTelemetry(name, expected)
/**
* Waits for _any_ active text editor to appear and have the desired contents.
* This is important since there may be delays between showing a new document and
* updates to the `activeTextEditor` field.
*
* Assumes that only a single document will be edited while polling. The contents of
* the document must match exactly to the text editor at some point, otherwise this
* function will timeout.
*/
export async function assertTextEditorContains(contents: string): Promise<void | never> {
const editor = await waitUntil(
async () => {
if (vscode.window.activeTextEditor?.document.getText() === contents) {
return vscode.window.activeTextEditor
}
},
{ interval: 5 }
)
if (!vscode.window.activeTextEditor) {
throw new Error('No active text editor found')
}
if (!editor) {
const actual = vscode.window.activeTextEditor.document
const documentName = actual.uri.toString(true)
const message = `Document "${documentName}" contained "${actual.getText()}", expected: "${contents}"`
assert.strictEqual(actual.getText(), contents, message)
}
}
/**
* Create and open an editor with provided fileText, fileName and options. If folder is not provided,
* will create a temp worksapce folder which will be automatically deleted in testing environment
* @param fileText The supplied text to fill this file with
* @param fileName The name of the file to save it as. Include the file extension here.
*
* @returns TextEditor that was just opened
*/
export async function openATextEditorWithText(
fileText: string,
fileName: string,
folder?: string,
options?: vscode.TextDocumentShowOptions
): Promise<vscode.TextEditor> {
const myWorkspaceFolder = folder ? folder : (await createTestWorkspaceFolder()).uri.fsPath
const filePath = path.join(myWorkspaceFolder, fileName)
toFile(fileText, filePath)
const textDocument = await vscode.workspace.openTextDocument(filePath)
return await vscode.window.showTextDocument(textDocument, options)
}
/**
* Waits for _any_ tab to appear and have the desired count
*/
export async function assertTabCount(size: number): Promise<void | never> {
const tabs = await waitUntil(
async () => {
const tabs = vscode.window.tabGroups.all
.map(tabGroup => tabGroup.tabs)
.reduce((acc, curVal) => acc.concat(curVal), [])
if (tabs.length === size) {
return tabs
}
},
{ interval: 5 }
)
if (!tabs) {
throw new Error('No desired tabs found')
}
}
/**
* Executes the "openEditors.closeAll" command and asserts that all visible
* editors were closed after waiting.
*/
export async function closeAllEditors(): Promise<void> {
// Derived by inspecting 'Keyboard Shortcuts' via command `>Preferences: Open Keyboard Shortcuts`
// Note: `workbench.action.closeAllEditors` is unreliable.
const closeAllCmd = 'openEditors.closeAll'
// Output channels are named with prefix "extension-output". https://github.com/microsoft/vscode/issues/148993#issuecomment-1167654358
// Maybe we can close these with a command?
const ignorePatterns = [/extension-output/, /tasks/]
const editors: vscode.TextEditor[] = []
const noVisibleEditor: boolean | undefined = await waitUntil(
async () => {
// Race: documents could appear after the call to closeAllEditors(), so retry.
await vscode.commands.executeCommand(closeAllCmd)
editors.length = 0
editors.push(
...vscode.window.visibleTextEditors.filter(
editor => !ignorePatterns.find(p => p.test(editor.document.fileName))
)
)
return editors.length === 0
},
{
timeout: 5000, // Arbitrary values. Should succeed except when VS Code is lagging heavily.
interval: 250,
truthy: true,
}
)
if (!noVisibleEditor) {
const editorNames = editors.map(editor => `\t${editor.document.fileName}`)
throw new Error(`Editors were still open after closeAllEditors():\n${editorNames.join('\n')}`)
}
}
export interface EventCapturer<T = unknown> extends vscode.Disposable {
/**
* All events captured after instrumentation
*/
readonly emits: readonly T[]
/**
* The most recently emitted event
*/
readonly last: T | undefined
/**
* Waits for the next event to be emitted
*/
next(timeout?: number): Promise<T>
}
/**
* Instruments an event for easier inspection.
*/
export function captureEvent<T>(event: vscode.Event<T>): EventCapturer<T> {
let disposed = false
let idx = 0
const emits: T[] = []
const listeners: vscode.Disposable[] = []
listeners.push(event(data => emits.push(data)))
return {
emits,
get last() {
return emits[emits.length - 1]
},
next: (timeout?: number) => {
if (disposed) {
throw new Error('Capturer has been disposed')
}
if (idx < emits.length) {
return Promise.resolve(emits[idx++])
}
return captureEventOnce(event, timeout)
},
dispose: () => {
disposed = true
vscode.Disposable.from(...listeners).dispose()
},
}
}
/**
* Captures the first value emitted by an event, optionally with a timeout
*/
export function captureEventOnce<T>(event: vscode.Event<T>, timeout?: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const stop = () => reject(new Error('Timed out waiting for event'))
event(data => resolve(data))
if (timeout !== undefined) {
setTimeout(stop, timeout)
}
})
}
/**
* Shuffle a list, Fisher-Yates Sorting Algorithm
*/
export function shuffleList<T>(list: T[]): T[] {
const shuffledList = [...list]
for (let i = shuffledList.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffledList[i], shuffledList[j]] = [shuffledList[j], shuffledList[i]]
}
return shuffledList
}