diff --git a/package.json b/package.json index a1f0223..233d142 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,21 @@ "type": "array", "default": [], "description": "Unix Remappings to resolve contracts to local Unix files / directories (Note this overrides the generic remapping settings if the OS is Unix based), i.e: [\"@openzeppelin/=/opt/lib/openzeppelin-contracts\",\"ds-test/=/opt/lib/ds-test/src/\"]" + }, + "solidity.developmentEnvironment": { + "type": "string", + "description": "Sets the development environment for the project. This is used to perform environment specific commands (like testing) and to parse output from relevant tools.", + "enum": [ + "", + "hardhat", + "forge" + ], + "default": "" + }, + "solidity.test.command": { + "type": "string", + "default": "", + "description": "Command to run to invoke tests" } } }, @@ -330,6 +345,10 @@ { "command": "solidity.changeDefaultCompilerType", "title": "Solidity: Change the default workspace compiler to Remote, Local, NodeModule, Embedded" + }, + { + "command": "solidity.runTests", + "title": "Solidity: Run tests" } ], "menus": { diff --git a/src/environments/env.ts b/src/environments/env.ts new file mode 100644 index 0000000..7abfad1 --- /dev/null +++ b/src/environments/env.ts @@ -0,0 +1,21 @@ +export enum DevelopmentEnvironment { + Forge = 'forge', + Hardhat = 'hardhat', + None = 'none', + NotSet = '', +} + +type ConfigDefaultAndTargetValue = { + default: any; + target: any; +}; + +export const defaultEnvironmentConfiguration: Partial>> = { + [DevelopmentEnvironment.Forge]: { + 'test.command': { + default: '', + target: 'forge test --silent --json', + }, + }, +}; + diff --git a/src/environments/forge.ts b/src/environments/forge.ts new file mode 100644 index 0000000..f4e9575 --- /dev/null +++ b/src/environments/forge.ts @@ -0,0 +1,36 @@ +import { ContractTestResults, TestResults } from './tests'; + +type forgeTestResult = { + success: boolean, + reason?: string, + counterexample?: null, + decoded_logs: string[], +}; + +export const parseForgeTestResults = (data: string): TestResults | null => { + try { + const parsed = JSON.parse(data); + const contractResults = Object.entries(parsed).map(([key, rest]: [string, any]) => { + const [file, contract] = key.split(':'); + const results = Object.entries(rest.test_results).map(([name, res]: [string, forgeTestResult]) => { + return { + name, + pass: res.success, + reason: res.reason, + logs: res.decoded_logs, + }; + }); + const out: ContractTestResults = { + file, + contract, + results, + }; + return out; + }); + return { + contracts: contractResults, + }; + } catch (err) { + return null; + } +}; diff --git a/src/environments/tests.ts b/src/environments/tests.ts new file mode 100644 index 0000000..d569cc9 --- /dev/null +++ b/src/environments/tests.ts @@ -0,0 +1,101 @@ +import { workspace } from 'vscode'; +import { DevelopmentEnvironment } from './env'; +import { parseForgeTestResults } from './forge'; + +export type TestResults = { + contracts: ContractTestResults[]; +}; + +export type ContractTestResults = { + file: string; + contract: string; + results: TestResult[]; +}; + +export type TestResult = TestResultPass | TestResultFailure; + +export type TestResultPass = { + name: string; + pass: true; + logs: string[]; +}; + +export type TestResultFailure = { + name: string; + pass: false; + reason: string; + logs: string[]; +}; + +export const testResultIsFailure = (r: TestResult): r is TestResultFailure => { + return !r.pass; +}; + +/** + * parseTestResults parses raw test result data into a format which can be interpreted + * by the extension. + * It currently only supports output from 'forge'. + * @param data Raw data from test command + * @returns TestResults, or null if unable to interpret the data + */ +export const parseTestResults = (data: string): TestResults | null => { + const devEnv = workspace.getConfiguration('solidity').get('developmentEnvironment'); + if (devEnv === DevelopmentEnvironment.Forge) { + return parseForgeTestResults(data); + } + return null; +}; + +/** + * Construct output to be printed which summarizes test results. + * @param results Parsed test results + * @returns Array of lines which produce a test run summary. + */ +export const constructTestResultOutput = (results: TestResults): string[] => { + const lines = []; + + const withFailures = results.contracts.filter(c => { + return c.results.filter(r => !r.pass).length > 0; + }); + const hasFailures = withFailures.length > 0; + + if (hasFailures) { + lines.push('Tests FAILED'); + lines.push('------------'); + } + results.contracts.forEach((c) => { + lines.push(`${c.contract} in ${c.file}:`); + + const passes = c.results.filter(f => f.pass); + const failures = c.results.filter(f => !f.pass) as TestResultFailure[]; + + passes.forEach(r => { + lines.push(`\tPASS ${r.name}`); + }); + + failures.forEach((r) => { + lines.push(`\tFAIL ${r.name}`); + if (r.reason) { + lines.push(`\t REVERTED with reason: ${r.reason}`); + } + + r.logs.forEach((log) => { + lines.push(`\t\t ${log}`); + }); + }); + // Add some spacing between contract results + lines.push(''); + }); + + if (!hasFailures) { + lines.push('All tests passed.'); + return lines; + } + + lines.push('\nSummary:'); + withFailures.forEach(f => { + const numFailures = f.results.filter(r => !r.pass).length; + lines.push(`\t${numFailures} failure(s) in ${f.contract} (${f.file})`); + }); + return lines; +}; diff --git a/src/extension.ts b/src/extension.ts index 7df3cf5..b4e6fc2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ 'use strict'; import * as path from 'path'; import * as vscode from 'vscode'; +import * as cp from 'child_process'; import { compileAllContracts } from './client/compileAll'; import { Compiler } from './client/compiler'; import { compileActiveContract, initDiagnosticCollection } from './client/compileActive'; @@ -18,13 +19,16 @@ import { import { lintAndfixCurrentDocument } from './server/linter/soliumClientFixer'; // tslint:disable-next-line:no-duplicate-imports -import { workspace, WorkspaceFolder } from 'vscode'; +import { workspace } from 'vscode'; import { formatDocument } from './client/formatter/formatter'; import { compilerType } from './common/solcCompiler'; import * as workspaceUtil from './client/workspaceUtil'; +import { defaultEnvironmentConfiguration, DevelopmentEnvironment } from './environments/env'; +import { constructTestResultOutput, parseTestResults } from './environments/tests'; let diagnosticCollection: vscode.DiagnosticCollection; let compiler: Compiler; +let testOutputChannel: vscode.OutputChannel; export async function activate(context: vscode.ExtensionContext) { const ws = workspace.workspaceFolders; @@ -48,6 +52,35 @@ export async function activate(context: vscode.ExtensionContext) { }); */ + // Attach a handler which sets the default configuration for environment related config. + // This triggers both when a user sets it or when we detect it automatically. + context.subscriptions.push(workspace.onDidChangeConfiguration(async (event) => { + if (!event.affectsConfiguration('solidity.developmentEnvironment')) { + return; + } + const newConfig = vscode.workspace.getConfiguration('solidity'); + const newEnv = newConfig.get('developmentEnvironment'); + const vaulesToSet = defaultEnvironmentConfiguration[newEnv]; + if (!vaulesToSet) { + return; + } + for (const [k, v] of Object.entries(vaulesToSet)) { + if (newConfig.get(k) === v.default) { + newConfig.update(k, v.target, null); + } + } + })); + + // Detect development environment if the configuration is not already set. + const loadTimeConfig = vscode.workspace.getConfiguration('solidity'); + const existingDevEnv = loadTimeConfig.get('developmentEnvironment'); + if (!existingDevEnv) { + const detectedEnv = await detectDevelopmentEnvironment(); + if (detectedEnv) { + loadTimeConfig.update('developmentEnvironment', detectedEnv, null); + } + } + context.subscriptions.push(diagnosticCollection); initDiagnosticCollection(diagnosticCollection); @@ -185,6 +218,51 @@ export async function activate(context: vscode.ExtensionContext) { }, })); + context.subscriptions.push(vscode.commands.registerCommand('solidity.runTests', async (params) => { + const testCommand = vscode.workspace.getConfiguration('solidity').get('test.command'); + if (!testCommand) { + return; + } + if (!testOutputChannel) { + testOutputChannel = vscode.window.createOutputChannel('Solidity Tests'); + } + + // If no URI supplied to task, use the current active editor. + let uri = params?.uri; + if (!uri) { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document) { + uri = editor.document.uri; + } + } + + const rootFolder = getFileRootPath(uri); + if (!rootFolder) { + console.error("Couldn't determine root folder for document", {uri}); + return; + } + + testOutputChannel.clear(); + testOutputChannel.show(); + testOutputChannel.appendLine(`Running '${testCommand}'...`); + testOutputChannel.appendLine(''); + try { + const result = await executeTask(rootFolder, testCommand, false); + const parsed = parseTestResults(result); + // If we couldn't parse the output, just write it to the window. + if (!parsed) { + testOutputChannel.appendLine(result); + return; + } + + const out = constructTestResultOutput(parsed); + out.forEach(testOutputChannel.appendLine); + + } catch (err) { + console.log('Unexpected error running tests:', err); + } + })); + const serverModule = path.join(__dirname, 'server.js'); const serverOptions: ServerOptions = { debug: { @@ -228,3 +306,46 @@ export async function activate(context: vscode.ExtensionContext) { // client can be deactivated on extension deactivation context.subscriptions.push(clientDisposable); } + +const detectDevelopmentEnvironment = async (): Promise => { + const foundry = await workspace.findFiles('foundry.toml'); + const hardhat = await workspace.findFiles('hardhat.config.js'); + + // If we found evidence of multiple workspaces, don't select a default. + if (foundry.length && hardhat.length) { + return DevelopmentEnvironment.None; + } + + if (foundry.length) { + return DevelopmentEnvironment.Forge; + } + + if (hardhat.length) { + return DevelopmentEnvironment.Hardhat; + } + return DevelopmentEnvironment.None; +}; + +const getFileRootPath = (uri: vscode.Uri): string | null => { + const folders = vscode.workspace.workspaceFolders; + for (const f of folders) { + if (uri.path.startsWith(f.uri.path)) { + return f.uri.path; + } + } + return null; +}; + +const executeTask = (dir: string, cmd: string, rejectOnFailure: boolean) => { + return new Promise((resolve, reject) => { + cp.exec(cmd, {cwd: dir, maxBuffer: 1024 * 1024 * 10}, (err, out) => { + if (err) { + if (rejectOnFailure) { + return reject({out, err}); + } + return resolve(out); + } + return resolve(out); + }); + }); +};