diff --git a/.gitignore b/.gitignore index 32f514ad07a..0601e762dff 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ vscode.db /cli/target /cli/openssl product.overrides.json +*.snap.actual diff --git a/build/hygiene.js b/build/hygiene.js index b8881081b2e..2c01b1f4d75 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -150,10 +150,12 @@ function hygiene(some, linting = true) { } const productJsonFilter = filter('product.json', { restore: true }); + const snapshotFilter = filter(['**', '!**/*.snap', '!**/*.snap.actual']); const unicodeFilterStream = filter(unicodeFilter, { restore: true }); const result = input .pipe(filter((f) => !f.stat.isDirectory())) + .pipe(snapshotFilter) .pipe(productJsonFilter) .pipe(process.env['BUILD_SOURCEVERSION'] ? es.through() : productJson) .pipe(productJsonFilter.restore) diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index 308e03bfcd7..f06dcf7acbc 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -47,7 +47,7 @@ export namespace Iterable { return false; } - export function find(iterable: Iterable, predicate: (t: T) => t is R): T | undefined; + export function find(iterable: Iterable, predicate: (t: T) => t is R): R | undefined; export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined; export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined { for (const element of iterable) { diff --git a/src/vs/base/test/common/snapshot.ts b/src/vs/base/test/common/snapshot.ts new file mode 100644 index 00000000000..f1104cf65dc --- /dev/null +++ b/src/vs/base/test/common/snapshot.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Lazy } from 'vs/base/common/lazy'; +import { FileAccess } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; + +declare const __readFileInTests: (path: string) => Promise; +declare const __writeFileInTests: (path: string, contents: string) => Promise; +declare const __readDirInTests: (path: string) => Promise; +declare const __unlinkInTests: (path: string) => Promise; +declare const __mkdirPInTests: (path: string) => Promise; + +// setup on import so assertSnapshot has the current context without explicit passing +let context: Lazy | undefined; +const sanitizeName = (name: string) => name.replace(/[^a-z0-9_-]/gi, '_'); +const normalizeCrlf = (str: string) => str.replace(/\r\n/g, '\n'); + +export interface ISnapshotOptions { + /** Name for snapshot file, rather than an incremented number */ + name?: string; + /** Extension name of the snapshot file, defaults to `.snap` */ + extension?: string; +} + +/** + * This is exported only for tests against the snapshotting itself! Use + * {@link assertSnapshot} as a consumer! + */ +export class SnapshotContext { + private nextIndex = 0; + private readonly namePrefix: string; + private readonly snapshotsDir: URI; + private readonly usedNames = new Set(); + + constructor(private readonly test: Mocha.Test | undefined) { + if (!test) { + throw new Error('assertSnapshot can only be used in a test'); + } + + if (!test.file) { + throw new Error('currentTest.file is not set, please open an issue with the test you\'re trying to run'); + } + + const src = FileAccess.asFileUri(''); + const parts = test.file.split(/[/\\]/g); + + this.namePrefix = sanitizeName(test.fullTitle()) + '_'; + this.snapshotsDir = URI.joinPath(src, ...[...parts.slice(0, -1), '__snapshots__']); + } + + public async assert(value: any, options?: ISnapshotOptions) { + const originalStack = new Error().stack!; // save to make the stack nicer on failure + const nameOrIndex = (options?.name ? sanitizeName(options.name) : this.nextIndex++); + const fileName = this.namePrefix + nameOrIndex + '.' + (options?.extension || 'snap'); + this.usedNames.add(fileName); + + const fpath = URI.joinPath(this.snapshotsDir, fileName).fsPath; + const actual = formatValue(value); + let expected: string; + try { + expected = await __readFileInTests(fpath); + } catch { + console.info(`Creating new snapshot in: ${fpath}`); + await __mkdirPInTests(this.snapshotsDir.fsPath); + await __writeFileInTests(fpath, actual); + return; + } + + if (normalizeCrlf(expected) !== normalizeCrlf(actual)) { + await __writeFileInTests(fpath + '.actual', actual); + const err: any = new Error(`Snapshot #${nameOrIndex} does not match expected output`); + err.expected = expected; + err.actual = actual; + err.snapshotPath = fpath; + err.stack = (err.stack as string) + .split('\n') + // remove all frames from the async stack and keep the original caller's frame + .slice(0, 1) + .concat(originalStack.split('\n').slice(3)) + .join('\n'); + throw err; + } + } + + public async removeOldSnapshots() { + const contents = await __readDirInTests(this.snapshotsDir.fsPath); + const toDelete = contents.filter(f => f.startsWith(this.namePrefix) && !this.usedNames.has(f)); + if (toDelete.length) { + console.info(`Deleting ${toDelete.length} old snapshots for ${this.test?.fullTitle()}`); + } + + await Promise.all(toDelete.map(f => __unlinkInTests(URI.joinPath(this.snapshotsDir, f).fsPath))); + } +} + +const debugDescriptionSymbol = Symbol.for('debug.description'); + +function formatValue(value: unknown, level = 0, seen: unknown[] = []): string { + switch (typeof value) { + case 'bigint': + case 'boolean': + case 'number': + case 'symbol': + case 'undefined': + return String(value); + case 'string': + return level === 0 ? value : JSON.stringify(value); + case 'function': + return `[Function ${value.name}]`; + case 'object': { + if (value === null) { + return 'null'; + } + if (value instanceof RegExp) { + return String(value); + } + if (seen.includes(value)) { + return '[Circular]'; + } + if (debugDescriptionSymbol in value && typeof (value as any)[debugDescriptionSymbol] === 'function') { + return (value as any)[debugDescriptionSymbol](); + } + const oi = ' '.repeat(level); + const ci = ' '.repeat(level + 1); + if (Array.isArray(value)) { + const children = value.map(v => formatValue(v, level + 1, [...seen, value])); + const multiline = children.some(c => c.includes('\n')) || children.join(', ').length > 80; + return multiline ? `[\n${ci}${children.join(`,\n${ci}`)}\n${oi}]` : `[ ${children.join(', ')} ]`; + } + + let entries; + let prefix = ''; + if (value instanceof Map) { + prefix = 'Map '; + entries = [...value.entries()]; + } else if (value instanceof Set) { + prefix = 'Set '; + entries = [...value.entries()]; + } else { + entries = Object.entries(value); + } + + const lines = entries.map(([k, v]) => `${k}: ${formatValue(v, level + 1, [...seen, value])}`); + return prefix + (lines.length > 1 + ? `{\n${ci}${lines.join(`,\n${ci}`)}\n${oi}}` + : `{ ${lines.join(',\n')} }`); + } + default: + throw new Error(`Unknown type ${value}`); + } +} + +setup(function () { + const currentTest = this.currentTest; + context = new Lazy(() => new SnapshotContext(currentTest)); +}); +teardown(async function () { + if (this.currentTest?.state === 'passed') { + await context?.rawValue?.removeOldSnapshots(); + } + context = undefined; +}); + +/** + * Implements a snapshot testing utility. ⚠️ This is async! ⚠️ + * + * The first time a snapshot test is run, it'll record the value it's called + * with as the expected value. Subsequent runs will fail if the value differs, + * but the snapshot can be regenerated by hand or using the Selfhost Test + * Provider Extension which'll offer to update it. + * + * The snapshot will be associated with the currently running test and stored + * in a `__snapshots__` directory next to the test file, which is expected to + * be the first `.test.js` file in the callstack. + */ +export function assertSnapshot(value: any, options?: ISnapshotOptions): Promise { + if (!context) { + throw new Error('assertSnapshot can only be used in a test'); + } + + return context.value.assert(value, options); +} diff --git a/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_0.snap b/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_0.snap new file mode 100644 index 00000000000..53d99c7989b --- /dev/null +++ b/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_0.snap @@ -0,0 +1,8 @@ +hello_world__0.snap: + { cool: true } +hello_world__1.snap: + { nifty: true } +hello_world__fourthTest.snap: + { customName: 2 } +hello_world__thirdTest.txt: + { customName: 1 } diff --git a/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_1.snap b/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_1.snap new file mode 100644 index 00000000000..b7106c217c2 --- /dev/null +++ b/src/vs/base/test/node/__snapshots__/snapshot_cleans_up_old_snapshots_1.snap @@ -0,0 +1,4 @@ +hello_world__0.snap: + { cool: true } +hello_world__thirdTest.snap: + { customName: 1 } diff --git a/src/vs/base/test/node/__snapshots__/snapshot_creates_a_snapshot_0.snap b/src/vs/base/test/node/__snapshots__/snapshot_creates_a_snapshot_0.snap new file mode 100644 index 00000000000..4c53158fc13 --- /dev/null +++ b/src/vs/base/test/node/__snapshots__/snapshot_creates_a_snapshot_0.snap @@ -0,0 +1,2 @@ +hello_world__0.snap: + { cool: true } diff --git a/src/vs/base/test/node/__snapshots__/snapshot_formats_object_nicely_0.snap b/src/vs/base/test/node/__snapshots__/snapshot_formats_object_nicely_0.snap new file mode 100644 index 00000000000..d5e7745d232 --- /dev/null +++ b/src/vs/base/test/node/__snapshots__/snapshot_formats_object_nicely_0.snap @@ -0,0 +1,35 @@ +[ + 1, + true, + undefined, + null, + 123, + Symbol(heyo), + "hello", + { hello: "world" }, + { a: [Circular] }, + Map { + hello: 1, + goodbye: 2 + }, + Set { + 1: 1, + 2: 2, + 3: 3 + }, + [Function helloWorld], + /hello/g, + [ + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string", + "long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string" + ], + Range [1 -> 5] +] \ No newline at end of file diff --git a/src/vs/base/test/node/snapshot.test.ts b/src/vs/base/test/node/snapshot.test.ts new file mode 100644 index 00000000000..0f36e39a91d --- /dev/null +++ b/src/vs/base/test/node/snapshot.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { tmpdir } from 'os'; +import { getRandomTestPath } from 'vs/base/test/node/testUtils'; +import { Promises } from 'vs/base/node/pfs'; +import { SnapshotContext, assertSnapshot } from 'vs/base/test/common/snapshot'; +import { URI } from 'vs/base/common/uri'; +import path = require('path'); +import { assertThrowsAsync } from 'vs/base/test/common/utils'; + +// tests for snapshot are in Node so that we can use native FS operations to +// set up and validate things. +// +// Uses snapshots for testing snapshots. It's snapception! + +suite('snapshot', () => { + let testDir: string; + + setup(function () { + testDir = getRandomTestPath(tmpdir(), 'vsctests', 'snapshot'); + return Promises.mkdir(testDir, { recursive: true }); + }); + + teardown(function () { + return Promises.rm(testDir); + }); + + const makeContext = (test: Partial | undefined) => { + const ctx = new SnapshotContext(test as Mocha.Test); + (ctx as any as { snapshotsDir: URI }).snapshotsDir = URI.file(testDir); + return ctx; + }; + + const snapshotFileTree = async () => { + let str = ''; + + const printDir = async (dir: string, indent: number) => { + const children = await Promises.readdir(dir); + for (const child of children) { + const p = path.join(dir, child); + if ((await Promises.stat(p)).isFile()) { + const content = await Promises.readFile(p, 'utf-8'); + str += `${' '.repeat(indent)}${child}:\n`; + for (const line of content.split('\n')) { + str += `${' '.repeat(indent + 2)}${line}\n`; + } + } else { + str += `${' '.repeat(indent)}${child}/\n`; + await printDir(p, indent + 2); + } + } + }; + + await printDir(testDir, 0); + await assertSnapshot(str); + }; + + test('creates a snapshot', async () => { + const ctx = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + await ctx.assert({ cool: true }); + await snapshotFileTree(); + }); + + test('validates a snapshot', async () => { + const ctx1 = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + await ctx1.assert({ cool: true }); + + const ctx2 = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + // should pass: + await ctx2.assert({ cool: true }); + + const ctx3 = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + // should fail: + await assertThrowsAsync(() => ctx3.assert({ cool: false })); + }); + + test('cleans up old snapshots', async () => { + const ctx1 = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + await ctx1.assert({ cool: true }); + await ctx1.assert({ nifty: true }); + await ctx1.assert({ customName: 1 }, { name: 'thirdTest', extension: 'txt' }); + await ctx1.assert({ customName: 2 }, { name: 'fourthTest' }); + + await snapshotFileTree(); + + const ctx2 = makeContext({ + file: 'foo/bar', + fullTitle: () => 'hello world!' + }); + + await ctx2.assert({ cool: true }); + await ctx2.assert({ customName: 1 }, { name: 'thirdTest' }); + await ctx2.removeOldSnapshots(); + + await snapshotFileTree(); + }); + + test('formats object nicely', async () => { + const circular: any = {}; + circular.a = circular; + + await assertSnapshot([ + 1, + true, + undefined, + null, + 123n, + Symbol('heyo'), + 'hello', + { hello: 'world' }, + circular, + new Map([['hello', 1], ['goodbye', 2]]), + new Set([1, 2, 3]), + function helloWorld() { }, + /hello/g, + new Array(10).fill('long string'.repeat(10)), + { [Symbol.for('debug.description')]() { return `Range [1 -> 5]`; } }, + ]); + }); +}); diff --git a/src/vs/editor/contrib/bracketMatching/test/browser/bracketMatching.test.ts b/src/vs/editor/contrib/bracketMatching/test/browser/bracketMatching.test.ts index c16f4d7e9af..289fe8aa96a 100644 --- a/src/vs/editor/contrib/bracketMatching/test/browser/bracketMatching.test.ts +++ b/src/vs/editor/contrib/bracketMatching/test/browser/bracketMatching.test.ts @@ -12,9 +12,9 @@ import { instantiateTextModel } from 'vs/editor/test/common/testTextModel'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ILanguageService } from 'vs/editor/common/languages/language'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('bracket matching', () => { - let disposables: DisposableStore; let instantiationService: TestInstantiationService; let languageConfigurationService: ILanguageConfigurationService; @@ -31,6 +31,8 @@ suite('bracket matching', () => { disposables.dispose(); }); + ensureNoDisposablesAreLeakedInTestSuite(); + function createTextModelWithBrackets(text: string) { const languageId = 'bracketMode'; disposables.add(languageService.registerLanguage({ id: languageId })); diff --git a/src/vs/editor/contrib/suggest/test/browser/suggest.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggest.test.ts index 1ff62f2cb35..d4df28ea332 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggest.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggest.test.ts @@ -12,10 +12,10 @@ import { CompletionItemKind, CompletionItemProvider } from 'vs/editor/common/lan import { CompletionOptions, provideSuggestionItems, SnippetSortOrder } from 'vs/editor/contrib/suggest/browser/suggest'; import { createTextModel } from 'vs/editor/test/common/testTextModel'; import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('Suggest', function () { - let model: TextModel; let registration: IDisposable; let registry: LanguageFeatureRegistry; @@ -54,37 +54,43 @@ suite('Suggest', function () { model.dispose(); }); + ensureNoDisposablesAreLeakedInTestSuite(); + test('sort - snippet inline', async function () { - const { items } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Inline)); + const { items, disposable } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Inline)); assert.strictEqual(items.length, 3); assert.strictEqual(items[0].completion.label, 'aaa'); assert.strictEqual(items[1].completion.label, 'fff'); assert.strictEqual(items[2].completion.label, 'zzz'); + disposable.dispose(); }); test('sort - snippet top', async function () { - const { items } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Top)); + const { items, disposable } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Top)); assert.strictEqual(items.length, 3); assert.strictEqual(items[0].completion.label, 'aaa'); assert.strictEqual(items[1].completion.label, 'zzz'); assert.strictEqual(items[2].completion.label, 'fff'); + disposable.dispose(); }); test('sort - snippet bottom', async function () { - const { items } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Bottom)); + const { items, disposable } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Bottom)); assert.strictEqual(items.length, 3); assert.strictEqual(items[0].completion.label, 'fff'); assert.strictEqual(items[1].completion.label, 'aaa'); assert.strictEqual(items[2].completion.label, 'zzz'); + disposable.dispose(); }); test('sort - snippet none', async function () { - const { items } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(undefined, new Set().add(CompletionItemKind.Snippet))); + const { items, disposable } = await provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(undefined, new Set().add(CompletionItemKind.Snippet))); assert.strictEqual(items.length, 1); assert.strictEqual(items[0].completion.label, 'fff'); + disposable.dispose(); }); - test('only from', function () { + test('only from', function (callback) { const foo: any = { triggerCharacters: [], @@ -102,11 +108,13 @@ suite('Suggest', function () { }; const registration = registry.register({ pattern: 'bar/path', scheme: 'foo' }, foo); - provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(undefined, undefined, new Set().add(foo))).then(({ items }) => { + provideSuggestionItems(registry, model, new Position(1, 1), new CompletionOptions(undefined, undefined, new Set().add(foo))).then(({ items, disposable }) => { registration.dispose(); assert.strictEqual(items.length, 1); assert.ok(items[0].provider === foo); + disposable.dispose(); + callback(); }); }); @@ -142,7 +150,7 @@ suite('Suggest', function () { }; const registration = registry.register({ pattern: 'bar/path', scheme: 'foo' }, foo); - const { items } = await provideSuggestionItems(registry, model, new Position(0, 0), new CompletionOptions(undefined, undefined, new Set().add(foo))); + const { items, disposable } = await provideSuggestionItems(registry, model, new Position(0, 0), new CompletionOptions(undefined, undefined, new Set().add(foo))); registration.dispose(); assert.strictEqual(items.length, 2); @@ -152,5 +160,6 @@ suite('Suggest', function () { assert.strictEqual(a.isInvalid, false); assert.strictEqual(b.completion.label, 'two'); assert.strictEqual(b.isInvalid, true); + disposable.dispose(); }); }); diff --git a/src/vs/editor/test/common/model/bracketPairColorizer/brackets.test.ts b/src/vs/editor/test/common/model/bracketPairColorizer/brackets.test.ts index abf6200a8fa..2a21907778d 100644 --- a/src/vs/editor/test/common/model/bracketPairColorizer/brackets.test.ts +++ b/src/vs/editor/test/common/model/bracketPairColorizer/brackets.test.ts @@ -5,12 +5,15 @@ import * as assert from 'assert'; import { DisposableStore } from 'vs/base/common/lifecycle'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { LanguageAgnosticBracketTokens } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/brackets'; import { SmallImmutableSet, DenseKeyProvider } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/smallImmutableSet'; import { Token, TokenKind } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/tokenizer'; import { TestLanguageConfigurationService } from 'vs/editor/test/common/modes/testLanguageConfigurationService'; suite('Bracket Pair Colorizer - Brackets', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + test('Basic', () => { const languageId = 'testMode1'; const denseKeyProvider = new DenseKeyProvider(); @@ -24,7 +27,7 @@ suite('Bracket Pair Colorizer - Brackets', () => { }; const disposableStore = new DisposableStore(); - const languageConfigService = new TestLanguageConfigurationService(); + const languageConfigService = disposableStore.add(new TestLanguageConfigurationService()); disposableStore.add(languageConfigService.register(languageId, { brackets: [ ['{', '}'], ['[', ']'], ['(', ')'], diff --git a/src/vs/workbench/api/common/extensionHostMain.ts b/src/vs/workbench/api/common/extensionHostMain.ts index a06c4e93d8f..40f1cdaf146 100644 --- a/src/vs/workbench/api/common/extensionHostMain.ts +++ b/src/vs/workbench/api/common/extensionHostMain.ts @@ -97,8 +97,8 @@ export abstract class ErrorHandler { return _prepareStackTrace; }, set(v) { - if (v === prepareStackTraceAndFindExtension || v[_wasWrapped]) { - _prepareStackTrace = v; + if (v === prepareStackTraceAndFindExtension || !v || v[_wasWrapped]) { + _prepareStackTrace = v || prepareStackTraceAndFindExtension; return; } diff --git a/src/vs/workbench/api/test/common/extensionHostMain.test.ts b/src/vs/workbench/api/test/common/extensionHostMain.test.ts index 52b49352d81..e201637150c 100644 --- a/src/vs/workbench/api/test/common/extensionHostMain.test.ts +++ b/src/vs/workbench/api/test/common/extensionHostMain.test.ts @@ -63,6 +63,7 @@ suite('ExtensionHostMain#ErrorHandler - Wrapping prepareStackTrace can cause slo }] ); + const originalPrepareStackTrace = Error.prepareStackTrace; const insta = new InstantiationService(collection, false); let existingErrorHandler: (e: any) => void; @@ -81,6 +82,10 @@ suite('ExtensionHostMain#ErrorHandler - Wrapping prepareStackTrace can cause slo findSubstrCount = 0; }); + teardown(() => { + Error.prepareStackTrace = originalPrepareStackTrace; + }); + test('basics', function () { const err = new Error('test1'); diff --git a/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts b/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts index a32952de8ca..d411e9a4c1f 100644 --- a/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts +++ b/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts @@ -7,7 +7,6 @@ import * as assert from 'assert'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IReader, transaction } from 'vs/base/common/observable'; import { isDefined } from 'vs/base/common/types'; -import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { Range } from 'vs/editor/common/core/range'; import { linesDiffComputers } from 'vs/editor/common/diff/linesDiffComputers'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; @@ -20,7 +19,8 @@ import { MergeEditorModel } from 'vs/workbench/contrib/mergeEditor/browser/model import { MergeEditorTelemetry } from 'vs/workbench/contrib/mergeEditor/browser/telemetry'; suite('merge editor model', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + // todo: renable when failing case is found https://github.com/microsoft/vscode/pull/190444#issuecomment-1678151428 + // ensureNoDisposablesAreLeakedInTestSuite(); test('prepend line', async () => { await testMergeModel( diff --git a/src/vs/workbench/test/browser/codeeditor.test.ts b/src/vs/workbench/test/browser/codeeditor.test.ts index 75f27d69dca..6bea2f87b2e 100644 --- a/src/vs/workbench/test/browser/codeeditor.test.ts +++ b/src/vs/workbench/test/browser/codeeditor.test.ts @@ -25,6 +25,7 @@ import { createTextModel } from 'vs/editor/test/common/testTextModel'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; import { DisposableStore } from 'vs/base/common/lifecycle'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('Editor - Range decorations', () => { @@ -43,13 +44,13 @@ suite('Editor - Range decorations', () => { instantiationService.stub(ILanguageService, LanguageService); instantiationService.stub(IModelService, stubModelService(instantiationService)); text = 'LINE1' + '\n' + 'LINE2' + '\n' + 'LINE3' + '\n' + 'LINE4' + '\r\n' + 'LINE5'; - model = aModel(URI.file('some_file')); - codeEditor = createTestCodeEditor(model); + model = disposables.add(aModel(URI.file('some_file'))); + codeEditor = disposables.add(createTestCodeEditor(model)); instantiationService.stub(IEditorService, 'activeEditor', { get resource() { return codeEditor.getModel()!.uri; } }); instantiationService.stub(IEditorService, 'activeTextEditorControl', codeEditor); - testObject = instantiationService.createInstance(RangeHighlightDecorations); + testObject = disposables.add(instantiationService.createInstance(RangeHighlightDecorations)); }); teardown(() => { @@ -58,6 +59,8 @@ suite('Editor - Range decorations', () => { disposables.dispose(); }); + ensureNoDisposablesAreLeakedInTestSuite(); + test('highlight range for the resource if it is an active editor', function () { const range: IRange = new Range(1, 1, 1, 1); testObject.highlightRange({ resource: model.uri, range }); diff --git a/src/vs/workbench/test/common/utils.ts b/src/vs/workbench/test/common/utils.ts index 498c5c499fe..f4a175f315a 100644 --- a/src/vs/workbench/test/common/utils.ts +++ b/src/vs/workbench/test/common/utils.ts @@ -5,7 +5,6 @@ import * as assert from 'assert'; import { LanguagesRegistry } from 'vs/editor/common/services/languagesRegistry'; -// import { LanguageService } from 'vs/editor/common/services/languageServiceImpl'; /** * This function is called before test running and also again at the end of test running diff --git a/test/unit/browser/index.js b/test/unit/browser/index.js index 668227d2fa5..5d19c5e5205 100644 --- a/test/unit/browser/index.js +++ b/test/unit/browser/index.js @@ -14,6 +14,7 @@ const createStatsCollector = require('../../../node_modules/mocha/lib/stats-coll const MochaJUnitReporter = require('mocha-junit-reporter'); const url = require('url'); const minimatch = require('minimatch'); +const fs = require('fs'); const playwright = require('@playwright/test'); const { applyReporter } = require('../reporter'); @@ -141,9 +142,18 @@ async function runTestsInBrowser(testModules, browserType) { } const emitter = new events.EventEmitter(); - await page.exposeFunction('mocha_report', (type, data1, data2) => { - emitter.emit(type, data1, data2); - }); + + await Promise.all([ + page.exposeFunction('mocha_report', (type, data1, data2) => { + emitter.emit(type, data1, data2); + }), + // Test file operations that are common across platforms. Used for test infra, namely snapshot tests + page.exposeFunction('__readFileInTests', (path) => fs.promises.readFile(path, 'utf-8')), + page.exposeFunction('__writeFileInTests', (path, contents) => fs.promises.writeFile(path, contents)), + page.exposeFunction('__readDirInTests', (path) => fs.promises.readdir(path)), + page.exposeFunction('__unlinkInTests', (path) => fs.promises.unlink(path)), + page.exposeFunction('__mkdirPInTests', (path) => fs.promises.mkdir(path, { recursive: true })), + ]); await page.goto(target.href); diff --git a/test/unit/browser/renderer.html b/test/unit/browser/renderer.html index 540dd2a82aa..45786072900 100644 --- a/test/unit/browser/renderer.html +++ b/test/unit/browser/renderer.html @@ -116,15 +116,22 @@ runner.on('pending', test => window.mocha_report('pending', serializeRunnable(test))); }; + + async function loadModules(modules) { + for (const file of modules) { + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, globalThis, file, mocha); + const m = await new Promise((resolve, reject) => require([file], resolve, err => { + console.log("BAD " + file + JSON.stringify(err, undefined, '\t')); + resolve({}); + })); + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_REQUIRE, m, file, mocha); + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_POST_REQUIRE, globalThis, file, mocha); + } + } + window.loadAndRun = async function loadAndRun({ modules, grep }, manual = false) { // load - await Promise.all(modules.map(module => new Promise((resolve, reject) => { - require([module], resolve, err => { - console.log("BAD " + module + JSON.stringify(err, undefined, '\t')); - // console.log(module); - resolve({}); - }); - }))); + await loadModules(modules); // await new Promise((resolve, reject) => { // require(modules, resolve, err => { // console.log(err); diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index a91725887a5..21ef972f7c4 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -5,8 +5,9 @@ /*eslint-env mocha*/ +const fs = require('fs'); + (function () { - const fs = require('fs'); const originals = {}; let logging = false; let withStacks = false; @@ -79,6 +80,15 @@ globalThis._VSCODE_NODE_MODULES = new Proxy(Object.create(null), { get: (_target globalThis._VSCODE_PRODUCT_JSON = (require.__$__nodeRequire ?? require)('../../../product.json'); globalThis._VSCODE_PACKAGE_JSON = (require.__$__nodeRequire ?? require)('../../../package.json'); +// Test file operations that are common across platforms. Used for test infra, namely snapshot tests +Object.assign(globalThis, { + __readFileInTests: path => fs.promises.readFile(path, 'utf-8'), + __writeFileInTests: (path, contents) => fs.promises.writeFile(path, contents), + __readDirInTests: path => fs.promises.readdir(path), + __unlinkInTests: path => fs.promises.unlink(path), + __mkdirPInTests: path => fs.promises.mkdir(path, { recursive: true }), +}); + const _tests_glob = '**/test/**/*.test.js'; let loader; let _out; @@ -121,6 +131,15 @@ function loadWorkbenchTestingUtilsModule() { }); } +async function loadModules(modules) { + for (const file of modules) { + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, globalThis, file, mocha); + const m = await new Promise((resolve, reject) => loader.require([file], resolve, reject)); + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_REQUIRE, m, file, mocha); + mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_POST_REQUIRE, globalThis, file, mocha); + } +} + function loadTestModules(opts) { if (opts.run) { @@ -130,9 +149,7 @@ function loadTestModules(opts) { file = file.replace(/\.ts$/, '.js'); return path.relative(_out, file).replace(/\.js$/, ''); }); - return new Promise((resolve, reject) => { - loader.require(modules, resolve, reject); - }); + return loadModules(modules); } const pattern = opts.runGlob || _tests_glob; @@ -146,11 +163,7 @@ function loadTestModules(opts) { const modules = files.map(file => file.replace(/\.js$/, '')); resolve(modules); }); - }).then(modules => { - return new Promise((resolve, reject) => { - loader.require(modules, resolve, reject); - }); - }); + }).then(loadModules); } function loadTests(opts) { @@ -239,6 +252,7 @@ function serializeError(err) { return { message: err.message, stack: err.stack, + snapshotPath: err.snapshotPath, actual: safeStringify({ value: err.actual }), expected: safeStringify({ value: err.expected }), uncaught: err.uncaught, diff --git a/test/unit/fullJsonStreamReporter.js b/test/unit/fullJsonStreamReporter.js index 0f3ff897c8e..07b2315a004 100644 --- a/test/unit/fullJsonStreamReporter.js +++ b/test/unit/fullJsonStreamReporter.js @@ -37,6 +37,7 @@ module.exports = class FullJsonStreamReporter extends BaseRunner { test.expected = err.expected; test.actualJSON = err.actualJSON; test.expectedJSON = err.expectedJSON; + test.snapshotPath = err.snapshotPath; test.err = err.message; test.stack = err.stack || null; writeEvent(['fail', test]); diff --git a/test/unit/node/index.js b/test/unit/node/index.js index 757df426336..b6f326a27d8 100644 --- a/test/unit/node/index.js +++ b/test/unit/node/index.js @@ -9,7 +9,7 @@ process.env.MOCHA_COLORS = '1'; // Force colors (note that this must come before any mocha imports) const assert = require('assert'); -const mocha = require('mocha'); +const Mocha = require('mocha'); const path = require('path'); const fs = require('fs'); const glob = require('glob'); @@ -64,6 +64,14 @@ function main() { globalThis._VSCODE_PRODUCT_JSON = require(`${REPO_ROOT}/product.json`); globalThis._VSCODE_PACKAGE_JSON = require(`${REPO_ROOT}/package.json`); + // Test file operations that are common across platforms. Used for test infra, namely snapshot tests + Object.assign(globalThis, { + __readFileInTests: (/** @type {string} */ path) => fs.promises.readFile(path, 'utf-8'), + __writeFileInTests: (/** @type {string} */ path, /** @type {BufferEncoding} */ contents) => fs.promises.writeFile(path, contents), + __readDirInTests: (/** @type {string} */ path) => fs.promises.readdir(path), + __unlinkInTests: (/** @type {string} */ path) => fs.promises.unlink(path), + __mkdirPInTests: (/** @type {string} */ path) => fs.promises.mkdir(path, { recursive: true }), + }); process.on('uncaughtException', function (e) { console.error(e.stack || e); @@ -127,7 +135,24 @@ function main() { return write.apply(process.stderr, args); }; - /** @type { (callback:(err:any)=>void)=>void } */ + + const runner = new Mocha({ + ui: 'tdd' + }); + + /** + * @param {string[]} modules + */ + async function loadModules(modules) { + for (const file of modules) { + runner.suite.emit(Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, globalThis, file, runner); + const m = await new Promise((resolve, reject) => loader([file], resolve, reject)); + runner.suite.emit(Mocha.Suite.constants.EVENT_FILE_REQUIRE, m, file, runner); + runner.suite.emit(Mocha.Suite.constants.EVENT_FILE_POST_REQUIRE, globalThis, file, runner); + } + } + + /** @type { null|((callback:(err:any)=>void)=>void) } */ let loadFunc = null; if (argv.runGlob) { @@ -140,7 +165,7 @@ function main() { return test.replace(/(\.js)|(\.d\.ts)|(\.js\.map)$/, ''); }); - loader(modulesToLoad, () => cb(null), cb); + loadModules(modulesToLoad).then(() => cb(null), cb); }; glob(argv.runGlob, { cwd: src }, function (err, files) { doRun(files); }); @@ -153,7 +178,7 @@ function main() { return path.relative(src, path.resolve(test)).replace(/(\.js)|(\.js\.map)$/, '').replace(/\\/g, '/'); }); loadFunc = (cb) => { - loader(modulesToLoad, () => cb(null), cb); + loadModules(modulesToLoad).then(() => cb(null), cb); }; } else { loadFunc = (cb) => { @@ -165,7 +190,7 @@ function main() { modules.push(file.replace(/\.js$/, '')); } } - loader(modules, function () { cb(null); }, cb); + loadModules(modules).then(() => cb(null), cb); }); }; } @@ -180,7 +205,7 @@ function main() { if (!argv.run && !argv.runGlob) { // set up last test - mocha.suite('Loader', function () { + Mocha.suite('Loader', function () { test('should not explode while loading', function () { assert.ok(!didErr, 'should not explode while loading'); }); @@ -189,7 +214,7 @@ function main() { // report failing test for every unexpected error during any of the tests const unexpectedErrors = []; - mocha.suite('Errors', function () { + Mocha.suite('Errors', function () { test('should not have unexpected errors in tests', function () { if (unexpectedErrors.length) { unexpectedErrors.forEach(function (stack) { @@ -210,7 +235,7 @@ function main() { }); // fire up mocha - mocha.run(); + runner.run(failures => process.exit(failures ? 1 : 0)); }); }); }