From 51bee4708b749b96e8a90bca2edae597ae9445a8 Mon Sep 17 00:00:00 2001 From: leon3108 Date: Wed, 13 Dec 2023 22:41:35 +0000 Subject: [PATCH] feat(solidity/extension): added TestManager class to interact with vscode testing API --- toolchains/solidity/extension/package.json | 2 +- .../solidity/extension/src/extension.ts | 12 +- .../extension/src/{ => tests}/foundry-test.ts | 7 +- .../extension/src/tests/test-manager.ts | 234 ++++++++++++++++++ 4 files changed, 246 insertions(+), 9 deletions(-) rename toolchains/solidity/extension/src/{ => tests}/foundry-test.ts (92%) create mode 100644 toolchains/solidity/extension/src/tests/test-manager.ts diff --git a/toolchains/solidity/extension/package.json b/toolchains/solidity/extension/package.json index d28d82ee..fb96961a 100644 --- a/toolchains/solidity/extension/package.json +++ b/toolchains/solidity/extension/package.json @@ -16,7 +16,7 @@ "Other" ], "activationEvents": [ - "onLanguage:solidity" + "workspaceContains:solidity" ], "main": "./dist/extension.js", "contributes": { diff --git a/toolchains/solidity/extension/src/extension.ts b/toolchains/solidity/extension/src/extension.ts index ede3ca2f..398437c2 100644 --- a/toolchains/solidity/extension/src/extension.ts +++ b/toolchains/solidity/extension/src/extension.ts @@ -5,30 +5,34 @@ import * as path from 'path'; import { workspace, ExtensionContext } from 'vscode'; -[] import { LanguageClient, } from 'vscode-languageclient/node'; import { createLinterClient } from './linter'; import { createFoundryCompilerClient } from './foundry-compiler'; import { createTestsPositionsClient } from './tests-positions'; +import { TestManager } from './tests/test-manager'; let linterClient: LanguageClient; let foundryCompilerClient: LanguageClient; let testsPositionsClient: LanguageClient; +let testManager: TestManager; export async function activate(context: ExtensionContext) { linterClient = createLinterClient(context); foundryCompilerClient = createFoundryCompilerClient(context); testsPositionsClient = await createTestsPositionsClient(context); + if (vscode.workspace.workspaceFolders?.length) + testManager = new TestManager(testsPositionsClient, vscode.workspace.workspaceFolders[0].uri.fsPath); - context.subscriptions.push(linterClient); - context.subscriptions.push(foundryCompilerClient); + // Push the disposable to the context's subscriptions so that the + // client can be deactivated on extension deactivation + context.subscriptions.push(linterClient, foundryCompilerClient, testsPositionsClient, testManager.testController); const folders = workspace.workspaceFolders; if (folders) { - const files = await workspace.findFiles('**/*.sol', `${folders[0].uri.fsPath}/**`); + const files = await vscode.workspace.findFiles('**/*.sol', `${folders[0].uri.fsPath}/**`); files.forEach(file => { if (!file.path.includes('forge-std')) workspace.openTextDocument(file); diff --git a/toolchains/solidity/extension/src/foundry-test.ts b/toolchains/solidity/extension/src/tests/foundry-test.ts similarity index 92% rename from toolchains/solidity/extension/src/foundry-test.ts rename to toolchains/solidity/extension/src/tests/foundry-test.ts index badd021c..73b17b55 100644 --- a/toolchains/solidity/extension/src/foundry-test.ts +++ b/toolchains/solidity/extension/src/tests/foundry-test.ts @@ -77,7 +77,7 @@ const testContract = (workspace: string, contractName: string): Promise { if (error) { @@ -99,8 +99,7 @@ const testFunction = (workspace: string, contractName: string, functionName: str if (!(await hasForge(workspace))) { reject("No forge found"); } - - exec('forge test --json --match-contract ' + contractName + " --match-test " + functionName, { + exec(`forge test --json --match-contract '${contractName}' --match-test '${functionName}'`, { cwd: workspace }, (error, stdout, stderr) => { if (error) { @@ -118,4 +117,4 @@ const testFunction = (workspace: string, contractName: string, functionName: str }; -export {hasForge, testAll, testContract, testFunction}; \ No newline at end of file +export {hasForge, testAll, testContract, testFunction, FileResult, SuiteResult, TestResult}; \ No newline at end of file diff --git a/toolchains/solidity/extension/src/tests/test-manager.ts b/toolchains/solidity/extension/src/tests/test-manager.ts new file mode 100644 index 00000000..df9ebc8c --- /dev/null +++ b/toolchains/solidity/extension/src/tests/test-manager.ts @@ -0,0 +1,234 @@ +import { LanguageClient } from 'vscode-languageclient/node'; +import * as vscode from 'vscode'; +import { testAll, testContract, testFunction, FileResult, hasForge } from './foundry-test'; + +enum ItemType { + File, + ContractCase, + TestCase +} + +export class TestManager { + public testController: vscode.TestController; + private fileChangedEmitter : vscode.EventEmitter; + private watchingTests : Map; + private testData = new WeakMap(); + + + constructor(private client: LanguageClient, private workspace: string) { + this.testController = vscode.tests.createTestController("solidityTestController", "Solidity test controller"); + this.fileChangedEmitter = new vscode.EventEmitter(); + this.watchingTests = new Map(); + + this.testController.resolveHandler = (test) => { + console.log("controller resolve"); + return this.resolve(test); + }; + this.testController.createRunProfile("Run tests", vscode.TestRunProfileKind.Run, (request, token) => this.runHandler(false, request, token)) + this.testController.createRunProfile("Debug tests", vscode.TestRunProfileKind.Run, (request, token) => this.runHandler(true, request, token)) + + vscode.workspace.onDidOpenTextDocument(this.parseTestsInDocument); + + console.log("Test manager created"); + } + + private async runHandler( + shouldDebug: boolean, + request: vscode.TestRunRequest, + token: vscode.CancellationToken + ) { + console.log("Run handler called"); + const run = this.testController.createTestRun(request); + const queue: vscode.TestItem[] = []; + // const getType = (testItem: vscode.TestItem) => this.testData.get(testItem); + + // Loop through all included tests, or all known tests, and add them to our queue + if (request.include) { + request.include.forEach(test => queue.push(test)); + } else { + this.testController.items.forEach(test => queue.push(test)); + } + + // For every test that was queued, try to run it. Call run.passed() or run.failed(). + // The `TestMessage` can contain extra information, like a failing location or + // a diff output. But here we'll just give it a textual message. + while (queue.length > 0 && !token.isCancellationRequested) { + const test = queue.pop()!; + + // Skip tests the user asked to exclude + if (request.exclude?.includes(test)) { + continue; + } + + const date = Date.now(); + try { + switch (this.testData.get(test)!) { + case ItemType.File: + // If we're running a file and don't know what it contains yet, parse it now + if (test.children.size === 0) { + await this.parseTestsInFileContents(test); + } + break; + case ItemType.ContractCase: + //get result form foundry wrapper for contract test + const contractResult = await testContract(this.workspace, test.label); + const contractTime = Date.now() - date; + if (this.analyzeTestResults(contractResult)) { + run.passed(test, contractTime); + } else { + run.failed(test, new vscode.TestMessage("Contract test failed"), contractTime); + } + break; + case ItemType.TestCase: + //get result form foundry wrapper for test case + const functionResult = await testFunction(this.workspace, test.parent!.label, test.label); + const functionTime = Date.now() - date; + if (this.analyzeTestResults(functionResult)) { + run.passed(test, functionTime); + } else { + run.failed(test, new vscode.TestMessage("Contract test failed"), functionTime); + } + break; + } + } catch (e: any) { + run.appendOutput(JSON.stringify(e)); + run.failed(test, new vscode.TestMessage("Test failed")); + if (e === "No forge found") { + vscode.window.showErrorMessage("No forge found. Please install forge and make sure it's in your PATH"); + } + } + + test.children.forEach(test => queue.push(test)); + } + + // Make sure to end the run after all tests have been executed: + run.end(); + } + + private analyzeTestResults(result : FileResult) { + console.log("analyzeTestResults"); + console.log(result); + Object.values(result).forEach((suiteResult) => { + Object.values(suiteResult.test_results).forEach((testResult) => { + if (testResult.status !== "Success") { + return false; + } + }); + }); + return true; + + } + + + + private async getTestsPositions(content: string): Promise { + console.log("getTestsPositions"); + return this.client.sendRequest('osmium/getTestsPositions', { + file_content: content + }); + } + + private getOrCreateTestFileItem(uri: vscode.Uri) { + console.log("getOrCreateTestFileItem"); + const existing = this.testController.items.get(uri.toString()); + if (existing) { + return existing; + } + + const file = this.testController.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri); + this.testData.set(file, ItemType.File); + file.canResolveChildren = true; + this.testController.items.add(file); + return file; + } + + private async resolve(test?: vscode.TestItem) { + console.log("resolve"); + if (!test) { + await this.discoverAllFilesInWorkspace(); + } else { + await this.parseTestsInFileContents(test); + } + } + + private async discoverAllFilesInWorkspace() { + console.log("discoverAllFilesInWorkspace"); + if (!vscode.workspace.workspaceFolders) { + return []; // handle the case of no open folders + } + + return Promise.all( + vscode.workspace.workspaceFolders.map(async workspaceFolder => { + const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.t.sol'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + // When files are created, make sure there's a corresponding "file" node in the tree + watcher.onDidCreate(uri => this.getOrCreateTestFileItem(uri)); + // When files change, re-parse them. Note that you could optimize this so + // that you only re-parse children that have been resolved in the past. + watcher.onDidChange(uri => this.parseTestsInFileContents(this.getOrCreateTestFileItem(uri))); + // And, finally, delete TestItems for removed files. This is simple, since + // we use the URI as the TestItem's ID. + watcher.onDidDelete(uri => this.testController.items.delete(uri.toString())); + + for (const file of await vscode.workspace.findFiles(pattern)) { + this.getOrCreateTestFileItem(file); + } + + return watcher; + }) + ); + } + + private parseTestsInDocument(e: vscode.TextDocument) { + console.log("parseTestsInDocument"); + if (e.uri.scheme === 'file' && e.uri.path.endsWith('.t.sol')) { + this.parseTestsInFileContents(this.getOrCreateTestFileItem(e.uri), e.getText()); + } + } + + private async parseTestsInFileContents(file: vscode.TestItem, contents?: string) { + console.log("parseTestsInFileContents"); + // If a document is open, VS Code already knows its contents. If this is being + // called from the resolveHandler when a document isn't open, we'll need to + // read them from disk ourselves. + if (contents === undefined) { + const rawContent = await vscode.workspace.fs.readFile(file.uri!); + contents = new TextDecoder().decode(rawContent); + } + + // some custom logic to fill in test.children from the contents... + + if (contents != undefined) { + // CALL getTestPositions and fill children + await this.getTestsPositions(contents).then((testPositions) => { + testPositions.contracts.forEach((contract: any) => { + const contractName = contract.name.replace(" ", ""); + const contractItem = this.testController.createTestItem(contractName, contract.name, file.uri); + contractItem.range = convertRange(contract.range); + console.log("Contract range", JSON.stringify(contractItem.range)); + this.testData.set(contractItem, ItemType.ContractCase); + file.children.add(contractItem); + + contract.tests.forEach((test: any) => { + const functionItem = this.testController.createTestItem(`${contractName}_${test.name}`, test.name, file.uri); + functionItem.range = convertRange(test.range); + console.log("Test range", JSON.stringify(functionItem.range)); + this.testData.set(functionItem, ItemType.TestCase); + contractItem.children.add(functionItem) + }); + }); + + }); + } + } +} + +function convertRange(lspRange: any): vscode.Range { + const range = new vscode.Range( + new vscode.Position(lspRange.start.line - 1, lspRange.start.character), + new vscode.Position(lspRange.end.line - 1, lspRange.end.character), + ) + console.log(range); + return range; +} \ No newline at end of file