diff --git a/package.json b/package.json index 277b213..61b8c93 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,11 @@ "Do not install BSP server if not already present." ], "description": "Installation behavior for the build server." + }, + "bazelbsp.autoExpandTarget": { + "type": "boolean", + "default": "true", + "description": "Find all tests within open files, without waiting for the file's target to be expanded in the Test Explorer." } } } diff --git a/src/language-tools/base.ts b/src/language-tools/base.ts index f134c2d..1f95a30 100644 --- a/src/language-tools/base.ts +++ b/src/language-tools/base.ts @@ -28,7 +28,7 @@ export class BaseLanguageTools implements LanguageTools { /** * No support for individual test cases or test file identification. * @param document URI of the document to be analyzed for test cases. - * @returns Result always contains isTestFile value of true, and no test cases. + * @returns Result always contains isTestFile value of false, and no test cases. */ async getDocumentTestCases( document: vscode.Uri, @@ -36,7 +36,7 @@ export class BaseLanguageTools implements LanguageTools { ): Promise { return { // Do not filter out any files. - isTestFile: true, + isTestFile: false, // No test case discovery. testCases: [], } diff --git a/src/language-tools/manager.ts b/src/language-tools/manager.ts index c9596dd..28864e7 100644 --- a/src/language-tools/manager.ts +++ b/src/language-tools/manager.ts @@ -54,4 +54,13 @@ export class LanguageToolManager { } return this.baseLanguageTools } + + getLanguageToolsForFile(document: vscode.TextDocument): LanguageTools { + if (document.languageId === 'python') { + return this.pythonLanguageTools + } else if (document.languageId === 'java') { + return this.javaLanguageTools + } + return this.baseLanguageTools + } } diff --git a/src/test-explorer/resolver.ts b/src/test-explorer/resolver.ts index af7483b..3535775 100644 --- a/src/test-explorer/resolver.ts +++ b/src/test-explorer/resolver.ts @@ -187,6 +187,8 @@ export class TestResolver implements OnModuleInit, vscode.Disposable { const directories = new Map() const buildFileName = getExtensionSetting(SettingName.BUILD_FILE_NAME) parentTest.children.replace([]) + this.store.clearTargetIdentifiers() + result.targets.forEach(target => { if (!target.capabilities.canTest) return @@ -211,6 +213,7 @@ export class TestResolver implements OnModuleInit, vscode.Disposable { buildFileUri ) relevantParent.children.add(newTest) + this.store.setTargetIdentifier(target.id, newTest) }) // If no test items were added, show the getting started message as a test message overlaid on the .bazelproject file. @@ -235,6 +238,88 @@ export class TestResolver implements OnModuleInit, vscode.Disposable { // Replace all children with the newly returned test cases. this.condenseTestItems(parentTest) updateDescription(parentTest) + + await this.resolveOpenSourceFiles() + } + + /** + * Kick off resolution of test items for files that are currently open, and watch for newly opened files. + * This allows test cases to appear without the user having to expand the target's test explorer node. + */ + private async resolveOpenSourceFiles() { + const autoExpandTarget = getExtensionSetting(SettingName.AUTO_EXPAND_TARGET) + // When disabled, tests are discovered as the test explorer tree is expanded. + if (!autoExpandTarget) return + + for (const doc of vscode.workspace.textDocuments) { + // Discovery within currently open documents. + await this.expandTargetsForDocument(doc) + } + + this.ctx.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(async doc => { + // Discovery within newly opened documents. + await this.expandTargetsForDocument(doc) + }) + ) + } + + /** + * Finds the corresponding target for a given document, if a file's target is not yet known. + * The target's test cases will then be resolved and available in test explorer. + * @param doc for which to find the target. + */ + private async expandTargetsForDocument(doc: vscode.TextDocument) { + if (doc.uri.scheme !== 'file') return + + // Avoid checking files that are already known, or not test files. + if (this.store.knownFiles.has(doc.uri.toString())) return + const tools = this.languageToolManager.getLanguageToolsForFile(doc) + const docInfo = await tools.getDocumentTestCases( + doc.uri, + this.repoRoot ?? '' + ) + if (!docInfo.isTestFile) return + + this.store.knownFiles.add(doc.uri.toString()) + const conn = await this.buildServer.getConnection() + const params: bsp.InverseSourcesParams = { + textDocument: { + uri: doc.uri.toString(), + }, + } + let result: bsp.InverseSourcesResult | undefined + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Getting target for ${path.basename(doc.uri.fsPath)}.`, + cancellable: true, + }, + async (progress, token) => { + result = await conn.sendRequest( + bsp.BuildTargetInverseSources.type, + params, + token + ) + } + ) + + if (!result) { + // TODO(IDE-1203): Add more guidance to update their sync scope. + this.outputChannel.appendLine(`Target not in scope for ${doc.fileName}.`) + return + } + + for (const target of result.targets) { + const targetItem = this.store.getTargetIdentifier(target) + if (targetItem) { + await this.resolveHandler(targetItem) + } else { + this.outputChannel.appendLine( + `Couldn't find a matching test item for ${target.uri}.` + ) + } + } } /** @@ -264,6 +349,7 @@ export class TestResolver implements OnModuleInit, vscode.Disposable { const directories = new Map() parentTest.children.replace([]) + parentTest.canResolveChildren = false const allDocumentTestItems: vscode.TestItem[] = [] result.items.forEach(target => { target.sources.forEach(source => { @@ -290,6 +376,7 @@ export class TestResolver implements OnModuleInit, vscode.Disposable { parentTarget, source ) + this.store.knownFiles.add(source.uri) relevantParent.children.add(newTest) allDocumentTestItems.push(newTest) diff --git a/src/test-explorer/store.ts b/src/test-explorer/store.ts index 537837a..f545b92 100644 --- a/src/test-explorer/store.ts +++ b/src/test-explorer/store.ts @@ -6,6 +6,7 @@ import { TEST_CONTROLLER_TOKEN, } from '../custom-providers' import {TestCaseInfo} from '../test-info/test-info' +import {BuildTargetIdentifier} from 'src/bsp/bsp' export class TestCaseStore implements OnModuleInit, vscode.Disposable { @Inject(EXTENSION_CONTEXT_TOKEN) private readonly ctx: vscode.ExtensionContext @@ -15,10 +16,14 @@ export class TestCaseStore implements OnModuleInit, vscode.Disposable { // Watcher to update a test item's children. Key corresponds to the test item ID. testItemWatchers: Map + knownFiles: Set + private targetIdentifiers: Map constructor() { this.testCaseMetadata = new WeakMap() this.testItemWatchers = new Map() + this.targetIdentifiers = new Map() + this.knownFiles = new Set() } onModuleInit() { @@ -59,4 +64,23 @@ export class TestCaseStore implements OnModuleInit, vscode.Disposable { } clear(parentTest) } + + setTargetIdentifier( + targetIdentifier: BuildTargetIdentifier, + item: vscode.TestItem + ) { + const key = JSON.stringify(targetIdentifier) + this.targetIdentifiers.set(key, item) + } + + getTargetIdentifier( + targetIdentifier: BuildTargetIdentifier + ): vscode.TestItem | undefined { + const key = JSON.stringify(targetIdentifier) + return this.targetIdentifiers.get(key) + } + + clearTargetIdentifiers() { + this.targetIdentifiers.clear() + } } diff --git a/src/test/suite/language-tools/base.test.ts b/src/test/suite/language-tools/base.test.ts index 4d6e997..dcd8fc3 100644 --- a/src/test/suite/language-tools/base.test.ts +++ b/src/test/suite/language-tools/base.test.ts @@ -30,7 +30,7 @@ suite('Base Language Tools', () => { vscode.Uri.parse('file:///repo/root/sample/my_test.py'), '/repo/root/' ) - assert.strictEqual(result.isTestFile, true) + assert.strictEqual(result.isTestFile, false) assert.strictEqual(result.testCases.length, 0) }) diff --git a/src/test/suite/language-tools/manager.test.ts b/src/test/suite/language-tools/manager.test.ts index 6b718e8..0a59516 100644 --- a/src/test/suite/language-tools/manager.test.ts +++ b/src/test/suite/language-tools/manager.test.ts @@ -52,4 +52,25 @@ suite('Language Tools Manager', () => { const result = languageTools.getLanguageTools(target) assert.strictEqual(result.constructor.name, 'BaseLanguageTools') }) + + test('get tools for file, python', async () => { + const result = languageTools.getLanguageToolsForFile({ + languageId: 'python', + } as any) + assert.strictEqual(result.constructor.name, 'PythonLanguageTools') + }) + + test('get tools for file, java', async () => { + const result = languageTools.getLanguageToolsForFile({ + languageId: 'java', + } as any) + assert.strictEqual(result.constructor.name, 'JavaLanguageTools') + }) + + test('get tools for file, other', async () => { + const result = languageTools.getLanguageToolsForFile({ + languageId: 'other', + } as any) + assert.strictEqual(result.constructor.name, 'BaseLanguageTools') + }) }) diff --git a/src/test/suite/resolver.test.ts b/src/test/suite/resolver.test.ts index fcf0bb7..2374141 100644 --- a/src/test/suite/resolver.test.ts +++ b/src/test/suite/resolver.test.ts @@ -17,7 +17,7 @@ import { contextProviderFactory, outputChannelProvider, } from '../../custom-providers' -import {createSampleMessageConnection} from './test-utils' +import {createSampleMessageConnection, sampleTestData} from './test-utils' import {MessageConnection} from 'vscode-jsonrpc' import {Utils} from '../../utils/utils' import * as bsp from '../../bsp/bsp' @@ -44,6 +44,7 @@ suite('Test Resolver', () => { let buildClientStub: sinon.SinonStubbedInstance let languageToolsStub: sinon.SinonStubbedInstance let sampleConn: MessageConnection + let extensionSettingStub: sinon.Stub const sandbox = sinon.createSandbox() @@ -82,6 +83,9 @@ suite('Test Resolver', () => { getLanguageTools: target => { return languageToolsStub }, + getLanguageToolsForFile: (document: vscode.TextDocument) => { + return languageToolsStub + }, } } else if (token === TEST_CONTROLLER_TOKEN) { return vscode.tests.createTestController('resolverTestController', '') @@ -209,6 +213,50 @@ suite('Test Resolver', () => { } } + const getSampleDocumentTestItems = (): DocumentTestItem[] => { + const result: DocumentTestItem[] = [ + { + name: 'test_case_1', + range: new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ), + uri: vscode.Uri.parse('file:///sample/path/test_file.py'), + testFilter: 'test_case_1', + }, + { + name: 'test_case_2', + range: new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ), + uri: vscode.Uri.parse('file:///sample/path/test_file.py'), + testFilter: 'test_case_2', + }, + { + name: 'sub_test_case_1', + range: new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ), + uri: vscode.Uri.parse('file:///sample/path/test_file.py'), + testFilter: 'sub_test_case_2', + }, + { + name: 'sub_test_case_2', + range: new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ), + uri: vscode.Uri.parse('file:///sample/path/test_file.py'), + testFilter: 'sub_test_case_2', + }, + ] + result[2].parent = result[0] + result[3].parent = result[1] + return result + } + beforeEach(() => { testCaseStore.onModuleInit() testResolver.onModuleInit() @@ -221,8 +269,9 @@ suite('Test Resolver', () => { }) sandbox.stub(Utils, 'getWorkspaceGitRoot').resolves('/repo/root') - sandbox - .stub(settings, 'getExtensionSetting') + extensionSettingStub = sandbox.stub(settings, 'getExtensionSetting') + + extensionSettingStub .withArgs(settings.SettingName.BAZEL_PROJECT_FILE_PATH) .onFirstCall() .returns('./projectview.bazelproject') @@ -429,48 +478,7 @@ suite('Test Resolver', () => { vscode.Uri.parse('file:///sample/path/test_file.py') ) - // Two test items with 1 child each. - const testCases: DocumentTestItem[] = [ - { - name: 'test_case_1', - range: new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(0, 0) - ), - uri: vscode.Uri.parse('file:///sample/path/test_file.py'), - testFilter: 'test_case_1', - }, - { - name: 'test_case_2', - range: new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(0, 0) - ), - uri: vscode.Uri.parse('file:///sample/path/test_file.py'), - testFilter: 'test_case_2', - }, - { - name: 'sub_test_case_1', - range: new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(0, 0) - ), - uri: vscode.Uri.parse('file:///sample/path/test_file.py'), - testFilter: 'sub_test_case_2', - }, - { - name: 'sub_test_case_2', - range: new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(0, 0) - ), - uri: vscode.Uri.parse('file:///sample/path/test_file.py'), - testFilter: 'sub_test_case_2', - }, - ] - testCases[2].parent = testCases[0] - testCases[3].parent = testCases[1] - + const testCases = getSampleDocumentTestItems() languageToolsStub.getDocumentTestCases.resolves({ isTestFile: true, testCases: testCases, @@ -505,6 +513,118 @@ suite('Test Resolver', () => { assert.equal(sourceFileTestItem.children.size, 2) }) + test('expand targets based on open files', async () => { + // Setup + const sendRequestStub = sandbox.stub(sampleConn, 'sendRequest') + + // Expect a sequence of 3 BSP requests. + sendRequestStub + .onFirstCall() + // Initial load + .resolves(sampleBuildTargetsResult) + .onSecondCall() + // Request for an unknown document's target + .resolves({targets: [sampleBuildTargetsResult.targets[0].id]}) + .onThirdCall() + // Request for the rest of the target's sources + .resolves(sampleSourceItemsResult(sampleBuildTargetsResult.targets[0])) + + extensionSettingStub + .withArgs(settings.SettingName.AUTO_EXPAND_TARGET) + .returns(true) + + const documentStub = { + uri: vscode.Uri.parse('file:///sample/path/test_file.py'), + languageId: 'python', + } + + languageToolsStub.getDocumentTestCases.resolves({ + isTestFile: true, + testCases: getSampleDocumentTestItems(), + documentTest: { + name: 'My Document', + range: new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ), + uri: documentStub.uri, + testFilter: 'test_file.py', + }, + }) + + const workspaceTextDocumentStub = sandbox + .stub(vscode.workspace, 'textDocuments') + .get(() => [documentStub]) + + const root = testCaseStore.testController.createTestItem( + 'root', + 'Bazel Test Targets' + ) + testCaseStore.testController.items.add(root) + testCaseStore.testCaseMetadata.set( + root, + new TestCaseInfo(root, undefined, TestItemType.Root) + ) + assert.ok(testCaseStore.testController.resolveHandler) + + // Execute + await testCaseStore.testController.resolveHandler(root) + + // Validate expected document positions in the tree. + const docTestItem = [ + root.children + .get('{targetdir}:/repo/root/base/directory') + ?.children.get('{targetdir}:/repo/root/base/directory/a') + ?.children.get('a') + ?.children.get( + '{sourcefile}:a:/repo/root/base/directory/a/MyFile1.language' + ), + root.children + .get('{targetdir}:/repo/root/base/directory') + ?.children.get('{targetdir}:/repo/root/base/directory/a') + ?.children.get('a') + ?.children.get( + '{sourcefile}:a:/repo/root/base/directory/a/MyFile2.language' + ), + root.children + .get('{targetdir}:/repo/root/base/directory') + ?.children.get('{targetdir}:/repo/root/base/directory/a') + ?.children.get('a') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir/1') + ?.children.get( + '{sourcefile}:a:/repo/root/base/directory/a/src/dir/1/MyFile4.language' + ), + root.children + .get('{targetdir}:/repo/root/base/directory') + ?.children.get('{targetdir}:/repo/root/base/directory/a') + ?.children.get('a') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir/2') + ?.children.get( + '{sourcefile}:a:/repo/root/base/directory/a/src/dir/2/MyFile5.language' + ), + root.children + .get('{targetdir}:/repo/root/base/directory') + ?.children.get('{targetdir}:/repo/root/base/directory/a') + ?.children.get('a') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir') + ?.children.get('{sourcedir}:a:/repo/root/base/directory/a/src/dir/2') + ?.children.get( + '{sourcefile}:a:/repo/root/base/directory/a/src/dir/2/MyFile6.language' + ), + ] + + for (const item of docTestItem) { + assert.ok(item) + assert.equal(item.children.size, 2) + item.children.forEach(child => { + assert.ok(testCaseStore.testCaseMetadata.get(child)) + assert.equal(child.children.size, 1) + }) + } + }) + test('refresh success', async () => { const sendRequestStub = sandbox .stub(sampleConn, 'sendRequest') diff --git a/src/test/suite/store.test.ts b/src/test/suite/store.test.ts index 31b9d13..1ae284a 100644 --- a/src/test/suite/store.test.ts +++ b/src/test/suite/store.test.ts @@ -19,6 +19,7 @@ import {TestItemFactory} from '../../test-info/test-item-factory' import {CoverageTracker} from '../../coverage-utils/coverage-tracker' import {LanguageToolManager} from '../../language-tools/manager' import sinon from 'sinon' +import {BuildTargetIdentifier} from 'src/bsp/bsp' suite('Test Controller', () => { let ctx: vscode.ExtensionContext @@ -141,4 +142,24 @@ suite('Test Controller', () => { assert.ok(spy.called) } }) + + test('store target identifiers', async () => { + await testCaseStore.onModuleInit() + + const item = testCaseStore.testController.createTestItem('test', '') + const targetIdentifier: BuildTargetIdentifier = { + uri: 'file:///path/to/test', + } + testCaseStore.setTargetIdentifier(targetIdentifier, item) + assert.strictEqual( + testCaseStore.getTargetIdentifier(targetIdentifier), + item + ) + + testCaseStore.clearTargetIdentifiers() + assert.strictEqual( + testCaseStore.getTargetIdentifier(targetIdentifier), + undefined + ) + }) }) diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 7a0ea6c..0406ce2 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -8,6 +8,7 @@ export enum SettingName { BSP_SERVER_VERSION = 'serverVersion', BAZEL_BINARY_PATH = 'bazelBinaryPath', SERVER_INSTALL_MODE = 'serverInstallMode', + AUTO_EXPAND_TARGET = 'autoExpandTarget', } export interface SettingTypes { @@ -16,6 +17,7 @@ export interface SettingTypes { [SettingName.BSP_SERVER_VERSION]: string [SettingName.BAZEL_BINARY_PATH]: string [SettingName.SERVER_INSTALL_MODE]: string + [SettingName.AUTO_EXPAND_TARGET]: boolean } export function getExtensionSetting(