From ee4a1404e70abe095f0861f71da00377398539ed Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 9 Jul 2021 02:32:39 -0500 Subject: [PATCH] src/goTestExplorer: add tests --- src/goMain.ts | 4 +- src/goTestExplorer.ts | 273 ++++++++++++++---------- test/integration/goTestExplorer.test.ts | 230 ++++++++++++++++++++ test/integration/index.ts | 1 + test/mocks/MockTest.ts | 217 +++++++++++++++++++ test/runTest.ts | 3 + 6 files changed, 614 insertions(+), 114 deletions(-) create mode 100644 test/integration/goTestExplorer.test.ts create mode 100644 test/mocks/MockTest.ts diff --git a/src/goMain.ts b/src/goMain.ts index 7f6d4fd866..5d0f981f84 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -103,7 +103,7 @@ import semver = require('semver'); import vscode = require('vscode'); import { getFormatTool } from './goFormat'; import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey'; -import { setupTestExplorer } from './goTestExplorer'; +import { TestExplorer } from './goTestExplorer'; export let buildDiagnosticCollection: vscode.DiagnosticCollection; export let lintDiagnosticCollection: vscode.DiagnosticCollection; @@ -217,7 +217,7 @@ If you would like additional configuration for diagnostics from gopls, please se ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider)); // testing - setupTestExplorer(ctx); + TestExplorer.setup(ctx); // debug ctx.subscriptions.push( diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 9c68d8b965..fcc39bc144 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -16,7 +16,10 @@ import { TestRun, TestMessageSeverity, Location, - Position + Position, + TextDocumentChangeEvent, + WorkspaceFoldersChangeEvent, + CancellationToken } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -28,30 +31,83 @@ import { getTestFlags, goTest, GoTestOutput } from './testUtils'; // We could use TestItem.data, but that may be removed const symbols = new WeakMap(); -export function setupTestExplorer(context: ExtensionContext) { - const ctrl = test.createTestController('go'); - context.subscriptions.push(ctrl); - ctrl.root.label = 'Go'; - ctrl.root.canResolveChildren = true; - ctrl.resolveChildrenHandler = (...args) => resolveChildren(ctrl, ...args); - ctrl.runHandler = (request) => { - // TODO handle cancelation - runTest(ctrl, request); - }; - - context.subscriptions.push( - workspace.onDidOpenTextDocument((e) => documentUpdate(ctrl, e).catch((err) => console.log(err))) - ); +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TestExplorer { + // exported for tests - context.subscriptions.push( - workspace.onDidChangeTextDocument((e) => documentUpdate(ctrl, e.document).catch((err) => console.log(err))) - ); + export interface FileSystem { + readFile(uri: Uri): Thenable; + readDirectory(uri: Uri): Thenable<[string, FileType][]>; + } + + export interface Workspace { + readonly fs: FileSystem; + readonly workspaceFolders: readonly WorkspaceFolder[] | undefined; + + openTextDocument(uri: Uri): Thenable; + getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined; + } +} + +export class TestExplorer { + static setup(context: ExtensionContext) { + const ctrl = test.createTestController('go'); + const inst = new this( + ctrl, + workspace, + (e) => console.log(e), + new GoDocumentSymbolProvider().provideDocumentSymbols + ); + + context.subscriptions.push(workspace.onDidOpenTextDocument((x) => inst.didOpenTextDocument(x))); + context.subscriptions.push(workspace.onDidChangeTextDocument((x) => inst.didChangeTextDocument(x))); + context.subscriptions.push(workspace.onDidChangeWorkspaceFolders((x) => inst.didChangeWorkspaceFolders(x))); + + const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); + context.subscriptions.push(watcher); + context.subscriptions.push(watcher.onDidCreate((x) => inst.didCreateFile(x))); + context.subscriptions.push(watcher.onDidDelete((x) => inst.didDeleteFile(x))); + } + + constructor( + public ctrl: TestController, + public ws: TestExplorer.Workspace, + public errored: (e: unknown) => void, + public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable + ) { + // TODO handle cancelation of test runs + ctrl.root.label = 'Go'; + ctrl.root.canResolveChildren = true; + ctrl.resolveChildrenHandler = (...args) => resolveChildren(this, ...args); + ctrl.runHandler = (request) => runTest(this, request); + } + + async didOpenTextDocument(doc: TextDocument) { + try { + await documentUpdate(this, doc); + } catch (e) { + this.errored(e); + } + } - const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); - context.subscriptions.push(watcher); - watcher.onDidCreate(async (e) => await documentUpdate(ctrl, await workspace.openTextDocument(e))); - watcher.onDidDelete(async (e) => { - const id = testID(e, 'file'); + async didChangeTextDocument(e: TextDocumentChangeEvent) { + try { + await documentUpdate(this, e.document); + } catch (e) { + this.errored(e); + } + } + + async didCreateFile(file: Uri) { + try { + await documentUpdate(this, await this.ws.openTextDocument(file)); + } catch (e) { + this.errored(e); + } + } + + async didDeleteFile(file: Uri) { + const id = testID(file, 'file'); function find(parent: TestItem): TestItem { for (const item of parent.children.values()) { if (item.id === id) { @@ -59,7 +115,7 @@ export function setupTestExplorer(context: ExtensionContext) { } const uri = Uri.parse(item.id); - if (!e.path.startsWith(uri.path)) { + if (!file.path.startsWith(uri.path)) { continue; } @@ -70,33 +126,31 @@ export function setupTestExplorer(context: ExtensionContext) { } } - const found = find(ctrl.root); + const found = find(this.ctrl.root); if (found) { found.dispose(); disposeIfEmpty(found.parent); } - }); - - context.subscriptions.push( - workspace.onDidChangeWorkspaceFolders(async (e) => { - const items = Array.from(ctrl.root.children.values()); - for (const item of items) { - const uri = Uri.parse(item.id); - if (uri.query === 'package') { - continue; - } + } - const ws = workspace.getWorkspaceFolder(uri); - if (!ws) { - item.dispose(); - } + async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { + const items = Array.from(this.ctrl.root.children.values()); + for (const item of items) { + const uri = Uri.parse(item.id); + if (uri.query === 'package') { + continue; } - if (e.added) { - await resolveChildren(ctrl, ctrl.root); + const ws = this.ws.getWorkspaceFolder(uri); + if (!ws) { + item.dispose(); } - }) - ); + } + + if (e.added) { + await resolveChildren(this, this.ctrl.root); + } + } } // Construct an ID for an item. @@ -119,7 +173,7 @@ function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestI // Create or Retrieve a child item. function getOrCreateItem( - ctrl: TestController, + { ctrl }: TestExplorer, parent: TestItem, label: string, uri: Uri, @@ -137,7 +191,7 @@ function getOrCreateItem( // Create or Retrieve a sub test or benchmark. The ID will be of the form: // file:///path/to/mod/file.go?test#TestXxx/A/B/C -function getOrCreateSubTest(ctrl: TestController, item: TestItem, name: string): TestItem { +function getOrCreateSubTest({ ctrl }: TestExplorer, item: TestItem, name: string): TestItem { let uri = Uri.parse(item.id); uri = uri.with({ fragment: `${uri.fragment}/${name}` }); const existing = item.children.get(uri.toString()); @@ -175,47 +229,47 @@ function disposeIfEmpty(item: TestItem) { } // Retrieve or create an item for a Go module. -async function getModule(ctrl: TestController, uri: Uri): Promise { - const existing = getItem(ctrl.root, uri, 'module'); +async function getModule(expl: TestExplorer, uri: Uri): Promise { + const existing = getItem(expl.ctrl.root, uri, 'module'); if (existing) { return existing; } // Use the module name as the label const goMod = Uri.joinPath(uri, 'go.mod'); - const contents = await workspace.fs.readFile(goMod); + const contents = await expl.ws.fs.readFile(goMod); const modLine = contents.toString().split('\n', 2)[0]; const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); - const item = getOrCreateItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); + const item = getOrCreateItem(expl, expl.ctrl.root, match.groups.name, uri, 'module'); item.canResolveChildren = true; item.runnable = true; return item; } // Retrieve or create an item for a workspace folder that is not a module. -async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise { - const existing = getItem(ctrl.root, ws.uri, 'workspace'); +async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise { + const existing = getItem(expl.ctrl.root, ws.uri, 'workspace'); if (existing) { return existing; } // Use the workspace folder name as the label - const item = getOrCreateItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); + const item = getOrCreateItem(expl, expl.ctrl.root, ws.name, ws.uri, 'workspace'); item.canResolveChildren = true; item.runnable = true; return item; } // Retrieve or create an item for a Go package. -async function getPackage(ctrl: TestController, uri: Uri): Promise { +async function getPackage(expl: TestExplorer, uri: Uri): Promise { let item: TestItem; const modDir = await getModFolderPath(uri, true); const wsfolder = workspace.getWorkspaceFolder(uri); if (modDir) { // If the package is in a module, add it as a child of the module - const modUri = uri.with({ path: modDir }); - const module = await getModule(ctrl, modUri); + const modUri = uri.with({ path: modDir, query: '', fragment: '' }); + const module = await getModule(expl, modUri); const existing = getItem(module, uri, 'package'); if (existing) { return existing; @@ -226,10 +280,10 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; - item = getOrCreateItem(ctrl, module, label, uri, 'package'); + item = getOrCreateItem(expl, module, label, uri, 'package'); } else if (wsfolder) { // If the package is in a workspace folder, add it as a child of the workspace - const workspace = await getWorkspace(ctrl, wsfolder); + const workspace = await getWorkspace(expl, wsfolder); const existing = getItem(workspace, uri, 'package'); if (existing) { return existing; @@ -238,17 +292,17 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { const label = uri.path.startsWith(wsfolder.uri.path) ? uri.path.substring(wsfolder.uri.path.length + 1) : uri.path; - item = getOrCreateItem(ctrl, workspace, label, uri, 'package'); + item = getOrCreateItem(expl, workspace, label, uri, 'package'); } else { // Otherwise, add it directly to the root - const existing = getItem(ctrl.root, uri, 'package'); + const existing = getItem(expl.ctrl.root, uri, 'package'); if (existing) { return existing; } const srcPath = path.join(getCurrentGoPath(uri), 'src'); const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; - item = getOrCreateItem(ctrl, ctrl.root, label, uri, 'package'); + item = getOrCreateItem(expl, expl.ctrl.root, label, uri, 'package'); } item.canResolveChildren = true; @@ -257,16 +311,16 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } // Retrieve or create an item for a Go file. -async function getFile(ctrl: TestController, uri: Uri): Promise { +async function getFile(expl: TestExplorer, uri: Uri): Promise { const dir = path.dirname(uri.path); - const pkg = await getPackage(ctrl, uri.with({ path: dir })); + const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' })); const existing = getItem(pkg, uri, 'file'); if (existing) { return existing; } const label = path.basename(uri.path); - const item = getOrCreateItem(ctrl, pkg, label, uri, 'file'); + const item = getOrCreateItem(expl, pkg, label, uri, 'file'); item.canResolveChildren = true; item.runnable = true; return item; @@ -276,13 +330,7 @@ async function getFile(ctrl: TestController, uri: Uri): Promise { // benchmark, or example function, a test item will be created for it, if one // does not already exist. If the symbol is not a function and contains // children, those children will be processed recursively. -async function processSymbol( - ctrl: TestController, - uri: Uri, - file: TestItem, - seen: Set, - symbol: DocumentSymbol -) { +async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: Set, symbol: DocumentSymbol) { // Skip TestMain(*testing.M) - allow TestMain(*testing.T) if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) { return; @@ -290,7 +338,7 @@ async function processSymbol( // Recursively process symbols that are nested if (symbol.kind !== SymbolKind.Function) { - for (const sym of symbol.children) await processSymbol(ctrl, uri, file, seen, sym); + for (const sym of symbol.children) await processSymbol(expl, uri, file, seen, sym); return; } @@ -307,7 +355,7 @@ async function processSymbol( return existing; } - const item = getOrCreateItem(ctrl, file, symbol.name, uri, kind, symbol.name); + const item = getOrCreateItem(expl, file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; item.runnable = true; // item.debuggable = true; @@ -320,11 +368,11 @@ async function processSymbol( // Any previously existing tests that no longer have a corresponding symbol in // the file will be disposed. If the document contains no tests, it will be // disposed. -async function processDocument(ctrl: TestController, doc: TextDocument) { +async function processDocument(expl: TestExplorer, doc: TextDocument) { const seen = new Set(); - const item = await getFile(ctrl, doc.uri); - const symbols = await new GoDocumentSymbolProvider().provideDocumentSymbols(doc, null); - for (const symbol of symbols) await processSymbol(ctrl, doc.uri, item, seen, symbol); + const item = await getFile(expl, doc.uri); + const symbols = await expl.provideDocumentSymbols(doc, null); + for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol); for (const child of item.children.values()) { const uri = Uri.parse(child.id); @@ -347,6 +395,7 @@ enum WalkStop { // Recursively walk a directory, breadth first. async function walk( + fs: TestExplorer.FileSystem, uri: Uri, cb: (dir: Uri, file: string, type: FileType) => Promise ): Promise { @@ -363,7 +412,7 @@ async function walk( skipDirs = false; // Scan the directory - inner: for (const [file, type] of await workspace.fs.readDirectory(uri)) { + inner: for (const [file, type] of await fs.readDirectory(uri)) { if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) { continue; } @@ -414,9 +463,9 @@ async function walk( // Walk the workspace, looking for Go modules. Returns a map indicating paths // that are modules (value == true) and paths that are not modules but contain // Go files (value == false). -async function walkWorkspaces(uri: Uri): Promise> { +async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise> { const found = new Map(); - await walk(uri, async (dir, file, type) => { + await walk(fs, uri, async (dir, file, type) => { if (type !== FileType.File) { return; } @@ -435,8 +484,8 @@ async function walkWorkspaces(uri: Uri): Promise> { // Walk the workspace, calling the callback for any directory that contains a Go // test file. -async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { - await walk(uri, async (dir, file) => { +async function walkPackages(fs: TestExplorer.FileSystem, uri: Uri, cb: (uri: Uri) => Promise) { + await walk(fs, uri, async (dir, file) => { if (file.endsWith('_test.go')) { await cb(dir); return WalkStop.Files; @@ -445,7 +494,7 @@ async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { } // Handle opened documents, document changes, and file creation. -async function documentUpdate(ctrl: TestController, doc: TextDocument) { +async function documentUpdate(expl: TestExplorer, doc: TextDocument) { if (!doc.uri.path.endsWith('_test.go')) { return; } @@ -455,29 +504,29 @@ async function documentUpdate(ctrl: TestController, doc: TextDocument) { return; } - await processDocument(ctrl, doc); + await processDocument(expl, doc); } // TestController.resolveChildrenHandler callback -async function resolveChildren(ctrl: TestController, item: TestItem) { +async function resolveChildren(expl: TestExplorer, item: TestItem) { // The user expanded the root item - find all modules and workspaces if (!item.parent) { // Dispose of package entries at the root if they are now part of a workspace folder - const items = Array.from(ctrl.root.children.values()); + const items = Array.from(expl.ctrl.root.children.values()); for (const item of items) { const uri = Uri.parse(item.id); if (uri.query !== 'package') { continue; } - if (workspace.getWorkspaceFolder(uri)) { + if (expl.ws.getWorkspaceFolder(uri)) { item.dispose(); } } // Create entries for all modules and workspaces - for (const folder of workspace.workspaceFolders || []) { - const found = await walkWorkspaces(folder.uri); + for (const folder of expl.ws.workspaceFolders || []) { + const found = await walkWorkspaces(expl.ws.fs, folder.uri); let needWorkspace = false; for (const [uri, isMod] of found.entries()) { if (!isMod) { @@ -485,12 +534,12 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { continue; } - await getModule(ctrl, Uri.parse(uri)); + await getModule(expl, Uri.parse(uri)); } // If the workspace folder contains any Go files not in a module, create a workspace entry if (needWorkspace) { - await getWorkspace(ctrl, folder); + await getWorkspace(expl, folder); } } return; @@ -500,26 +549,26 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { // The user expanded a module or workspace - find all packages if (uri.query === 'module' || uri.query === 'workspace') { - await walkPackages(uri, async (uri) => { - await getPackage(ctrl, uri); + await walkPackages(expl.ws.fs, uri, async (uri) => { + await getPackage(expl, uri); }); } // The user expanded a module or package - find all files if (uri.query === 'module' || uri.query === 'package') { - for (const [file, type] of await workspace.fs.readDirectory(uri)) { + for (const [file, type] of await expl.ws.fs.readDirectory(uri)) { if (type !== FileType.File || !file.endsWith('_test.go')) { continue; } - await getFile(ctrl, Uri.joinPath(uri, file)); + await getFile(expl, Uri.joinPath(uri, file)); } } // The user expanded a file - find all functions if (uri.query === 'file') { - const doc = await workspace.openTextDocument(uri.with({ query: '', fragment: '' })); - await processDocument(ctrl, doc); + const doc = await expl.ws.openTextDocument(uri.with({ query: '', fragment: '' })); + await processDocument(expl, doc); } // TODO(firelizzard18): If uri.query is test or benchmark, this is where we @@ -530,7 +579,7 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { // module/package/etc, minus exclusions. Map tests to the package they are // defined in, and track files. async function collectTests( - ctrl: TestController, + expl: TestExplorer, item: TestItem, excluded: TestItem[], functions: Map, @@ -545,11 +594,11 @@ async function collectTests( const uri = Uri.parse(item.id); if (!uri.fragment) { if (!item.children.size) { - await resolveChildren(ctrl, item); + await resolveChildren(expl, item); } for (const child of item.children.values()) { - await collectTests(ctrl, child, excluded, functions, docs); + await collectTests(expl, child, excluded, functions, docs); } return; } @@ -592,7 +641,7 @@ class TestRunOutput implements OutputChannel { // Resolve a test name to a test item. If the test name is TestXxx/Foo, Foo is // created as a child of TestXxx. The same is true for TestXxx#Foo and // TestXxx/#Foo. -function resolveTestName(ctrl: TestController, tests: Record, name: string): TestItem | undefined { +function resolveTestName(expl: TestExplorer, tests: Record, name: string): TestItem | undefined { if (!name) { return; } @@ -604,14 +653,14 @@ function resolveTestName(ctrl: TestController, tests: Record, } for (const part of parts.slice(1)) { - test = getOrCreateSubTest(ctrl, test, part); + test = getOrCreateSubTest(expl, test, part); } return test; } // Process benchmark events (see test_events.md) function consumeGoBenchmarkEvent( - ctrl: TestController, + expl: TestExplorer, run: TestRun, benchmarks: Record, complete: Set, @@ -619,7 +668,7 @@ function consumeGoBenchmarkEvent( ) { if (e.Test) { // Find (or create) the (sub)benchmark - const test = resolveTestName(ctrl, benchmarks, e.Test); + const test = resolveTestName(expl, benchmarks, e.Test); if (!test) { return; } @@ -655,7 +704,7 @@ function consumeGoBenchmarkEvent( } // Find (or create) the (sub)benchmark - const test = resolveTestName(ctrl, benchmarks, m.groups.name); + const test = resolveTestName(expl, benchmarks, m.groups.name); if (!test) { return; } @@ -693,13 +742,13 @@ function passBenchmarks(run: TestRun, items: Record, com // Process test events (see test_events.md) function consumeGoTestEvent( - ctrl: TestController, + expl: TestExplorer, run: TestRun, tests: Record, record: Map, e: GoTestOutput ) { - const test = resolveTestName(ctrl, tests, e.Test); + const test = resolveTestName(expl, tests, e.Test); if (!test) { return; } @@ -781,22 +830,22 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin } // Execute tests - TestController.runTest callback -async function runTest(ctrl: TestController, request: TestRunRequest) { +async function runTest(expl: TestExplorer, request: TestRunRequest) { const collected = new Map(); const docs = new Set(); for (const item of request.tests) { - await collectTests(ctrl, item, request.exclude, collected, docs); + await collectTests(expl, item, request.exclude, collected, docs); } // Save all documents that contain a test we're about to run, to ensure `go // test` has the latest changes await Promise.all( Array.from(docs).map((uri) => { - workspace.openTextDocument(uri).then((doc) => doc.save()); + expl.ws.openTextDocument(uri).then((doc) => doc.save()); }) ); - const run = ctrl.createTestRun(request); + const run = expl.ctrl.createTestRun(request); const outputChannel = new TestRunOutput(run); const goConfig = getGoConfig(); for (const [dir, items] of collected.entries()) { @@ -835,7 +884,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { outputChannel, dir: uri.fsPath, functions: testFns, - goTestOutputConsumer: (e) => consumeGoTestEvent(ctrl, run, tests, record, e) + goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, e) }); } @@ -850,7 +899,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { dir: uri.fsPath, functions: benchmarkFns, isBenchmark: true, - goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, complete, e) + goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e) }); // Explicitly pass any incomplete benchmarks (see test_events.md) diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts new file mode 100644 index 0000000000..2df7e7d578 --- /dev/null +++ b/test/integration/goTestExplorer.test.ts @@ -0,0 +1,230 @@ +import assert = require('assert'); +import path = require('path'); +import { DocumentSymbol, FileType, TestItem, Uri, TextDocument, SymbolKind, Range, Position } from 'vscode'; +import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; +import { TestExplorer } from '../../src/goTestExplorer'; +import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; + +type Files = Record; + +interface ResolveChildrenTestCase { + workspace: string[]; + files: Files; + item?: [string, string][]; + expect: string[]; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function symbols(doc: TextDocument, token: unknown): Thenable { + const syms: DocumentSymbol[] = []; + const range = new Range(new Position(0, 0), new Position(0, 0)); + doc.getText().replace(/^func (Test|Benchmark|Example)([A-Z]\w+)(\(.*\))/gm, (m, type, name, details) => { + syms.push(new DocumentSymbol(type + name, details, SymbolKind.Function, range, range)); + return m; + }); + return Promise.resolve(syms); +} + +function rethrow(e: unknown) { + throw e; +} + +function setup(folders: string[], files: Files) { + const ws = MockTestWorkspace.from(folders, files); + const ctrl = new MockTestController(); + const expl = new TestExplorer(ctrl, ws, rethrow, symbols); + + function walk(dir: Uri, modpath?: string) { + const dirs: Uri[] = []; + for (const [name, type] of ws.fs.dirs.get(dir.toString())) { + const uri = dir.with({ path: path.join(dir.path, name) }); + if (type === FileType.Directory) { + dirs.push(uri); + } else if (name === 'go.mod') { + modpath = dir.path; + } + } + pkg2mod[dir.path] = modpath; + for (const dir of dirs) { + walk(dir, modpath); + } + } + + // prevent getModFolderPath from actually doing anything; + for (const pkg in pkg2mod) delete pkg2mod[pkg]; + for (const dir of folders) walk(Uri.file(dir)); + + return { ctrl, expl }; +} + +async function testResolveChildren(tc: ResolveChildrenTestCase) { + const { workspace, files, expect } = tc; + const { ctrl } = setup(workspace, files); + + let item: TestItem = ctrl.root; + for (const [id, label] of tc.item || []) { + const uri = Uri.parse(id).with({ query: '' }); + item = ctrl.createTestItem(id, label, item, uri); + } + await ctrl.resolveChildrenHandler(item); + + const actual = Array.from(item.children.values()).map((x) => x.id); + assert.deepStrictEqual(actual, expect); +} + +suite('Test Explorer', () => { + suite('Items', () => { + const cases: Record> = { + Root: { + 'Basic module': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj?module'] + }, + 'Basic workspace': { + workspace: ['/src/proj'], + files: { + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj?workspace'] + }, + 'Module and workspace': { + workspace: ['/src/proj1', '/src/proj2'], + files: { + '/src/proj1/go.mod': 'module test', + '/src/proj2/main.go': 'package main' + }, + expect: ['file:///src/proj1?module', 'file:///src/proj2?workspace'] + }, + 'Module in workspace': { + workspace: ['/src/proj'], + files: { + '/src/proj/mod/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj/mod?module', 'file:///src/proj?workspace'] + } + }, + Module: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: [] + }, + 'Root package': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: ['file:///src/proj/main_test.go?file'] + }, + 'Sub packages': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo/main_test.go': 'package main', + '/src/proj/bar/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: ['file:///src/proj/foo?package', 'file:///src/proj/bar?package'] + }, + 'Nested packages': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main', + '/src/proj/foo/main_test.go': 'package main', + '/src/proj/foo/bar/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: [ + 'file:///src/proj/foo?package', + 'file:///src/proj/foo/bar?package', + 'file:///src/proj/main_test.go?file' + ] + } + }, + Package: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/main.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: [] + }, + 'Flat': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/main_test.go': 'package main', + '/src/proj/pkg/sub/main_test.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: ['file:///src/proj/pkg/main_test.go?file'] + }, + 'Sub package': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/sub/main_test.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: [] + } + }, + File: { + 'One of each': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': ` + package main + + func TestMain(*testing.M) {} + func TestFoo(*testing.T) {} + func BenchmarkBar(*testing.B) {} + func ExampleBaz() {} + `.replace(/^\s+/gm, '') + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/main_test.go?file', 'main_test.go'] + ], + expect: [ + 'file:///src/proj/main_test.go?test#TestFoo', + 'file:///src/proj/main_test.go?benchmark#BenchmarkBar', + 'file:///src/proj/main_test.go?example#ExampleBaz' + ] + } + } + }; + + for (const n in cases) { + suite(n, () => { + for (const m in cases[n]) { + test(m, () => testResolveChildren(cases[n][m])); + } + }); + } + }); +}); diff --git a/test/integration/index.ts b/test/integration/index.ts index d6549f0144..efc7404d58 100644 --- a/test/integration/index.ts +++ b/test/integration/index.ts @@ -9,6 +9,7 @@ import * as Mocha from 'mocha'; import * as path from 'path'; export function run(): Promise { const mocha = new Mocha({ + grep: process.env.MOCHA_GREP, ui: 'tdd' }); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts new file mode 100644 index 0000000000..8b6df4f421 --- /dev/null +++ b/test/mocks/MockTest.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import path = require('path'); +import { + CancellationToken, + EndOfLine, + FileSystem, + FileType, + MarkdownString, + Position, + Range, + TestController, + TestItem, + TestRun, + TestRunRequest, + TextDocument, + TextLine, + Uri, + WorkspaceFolder +} from 'vscode'; +import { TestExplorer } from '../../src/goTestExplorer'; + +export class MockTestItem implements TestItem { + constructor( + public id: string, + public label: string, + public parent: TestItem | undefined, + public uri: Uri | undefined, + public data: T + ) {} + + canResolveChildren: boolean; + busy: boolean; + description?: string; + range?: Range; + error?: string | MarkdownString; + runnable: boolean; + debuggable: boolean; + + children = new Map(); + + invalidate(): void {} + + dispose(): void { + if (this.parent instanceof MockTestItem) { + this.parent.children.delete(this.id); + } + } +} + +export class MockTestController implements TestController { + root = new MockTestItem('Go', 'Go', void 0, void 0, void 0); + + resolveChildrenHandler?: (item: TestItem) => void | Thenable; + runHandler?: (request: TestRunRequest, token: CancellationToken) => void | Thenable; + + createTestItem( + id: string, + label: string, + parent: TestItem, + uri?: Uri, + data?: TChild + ): TestItem { + if (parent.children.has(id)) { + throw new Error(`Test item ${id} already exists`); + } + const item = new MockTestItem(id, label, parent, uri, data); + (parent).children.set(id, item); + return item; + } + + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { + throw new Error('Method not implemented.'); + } + + dispose(): void {} +} + +type DirEntry = [string, FileType]; + +export class MockTestFileSystem implements TestExplorer.FileSystem { + constructor(public dirs: Map, public files: Map) {} + + readDirectory(uri: Uri): Thenable<[string, FileType][]> { + const k = uri.with({ query: '', fragment: '' }).toString(); + return Promise.resolve(this.dirs.get(k) || []); + } + + readFile(uri: Uri): Thenable { + const k = uri.with({ query: '', fragment: '' }).toString(); + const s = this.files.get(k)?.getText(); + return Promise.resolve(Buffer.from(s || '')); + } +} + +export class MockTestWorkspace implements TestExplorer.Workspace { + static from(folders: string[], contents: Record) { + const wsdirs: WorkspaceFolder[] = []; + const dirs = new Map(); + const files = new Map(); + + for (const i in folders) { + const uri = Uri.parse(folders[i]); + wsdirs.push({ uri, index: Number(i), name: path.basename(uri.path) }); + } + + function push(uri: Uri, child: FileType) { + const entry: DirEntry = [path.basename(uri.path), child]; + const dir = uri.with({ path: path.dirname(uri.path) }); + if (dirs.has(dir.toString())) { + dirs.get(dir.toString()).push(entry); + return; + } + + if (path.dirname(dir.path) !== dir.path) { + push(dir, FileType.Directory); + } + dirs.set(dir.toString(), [entry]); + } + + for (const k in contents) { + const uri = Uri.parse(k); + const entry = contents[k]; + + let doc: TextDocument; + if (typeof entry === 'object') { + doc = new MockTestDocument(uri, entry.contents, entry.language); + } else if (path.basename(uri.path) === 'go.mod') { + doc = new MockTestDocument(uri, entry, 'go.mod'); + } else { + doc = new MockTestDocument(uri, entry); + } + + files.set(uri.toString(), doc); + push(uri, FileType.File); + } + + return new this(wsdirs, new MockTestFileSystem(dirs, files)); + } + + constructor(public workspaceFolders: WorkspaceFolder[], public fs: MockTestFileSystem) {} + + openTextDocument(uri: Uri): Thenable { + return Promise.resolve(this.fs.files.get(uri.toString())); + } + + getWorkspaceFolder(uri: Uri): WorkspaceFolder { + return this.workspaceFolders.filter((x) => x.uri === uri)[0]; + } +} + +export class MockTestDocument implements TextDocument { + constructor( + public uri: Uri, + private contents: string, + public languageId: string = 'go', + public isUntitled: boolean = false, + public isDirty: boolean = false + ) {} + + readonly version: number = 1; + readonly eol: EndOfLine = EndOfLine.LF; + + get lineCount() { + return this.contents.split('\n').length; + } + + get fileName() { + return path.basename(this.uri.path); + } + + save(): Thenable { + if (!this.isDirty) { + return Promise.resolve(false); + } + + this.isDirty = false; + return Promise.resolve(true); + } + + get isClosed(): boolean { + throw new Error('Method not implemented.'); + } + + lineAt(line: number): TextLine; + lineAt(position: Position): TextLine; + lineAt(position: any): TextLine { + throw new Error('Method not implemented.'); + } + + offsetAt(position: Position): number { + throw new Error('Method not implemented.'); + } + + positionAt(offset: number): Position { + throw new Error('Method not implemented.'); + } + + getText(range?: Range): string { + if (range) { + throw new Error('Method not implemented.'); + } + return this.contents; + } + + getWordRangeAtPosition(position: Position, regex?: RegExp): Range { + throw new Error('Method not implemented.'); + } + + validateRange(range: Range): Range { + throw new Error('Method not implemented.'); + } + + validatePosition(position: Position): Position { + throw new Error('Method not implemented.'); + } +} diff --git a/test/runTest.ts b/test/runTest.ts index de9eaa8479..27a23f4322 100644 --- a/test/runTest.ts +++ b/test/runTest.ts @@ -6,6 +6,9 @@ import { runTests } from 'vscode-test'; async function main() { // We are in test mode. process.env['VSCODE_GO_IN_TEST'] = '1'; + if (process.argv.length > 2) { + process.env['MOCHA_GREP'] = process.argv[2]; + } // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath`