Skip to content

Commit 04f0246

Browse files
committed
Add environment setting and detection. Add 'run tests' command
1 parent 282d0df commit 04f0246

File tree

5 files changed

+300
-1
lines changed

5 files changed

+300
-1
lines changed

package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@
210210
"type": "array",
211211
"default": [],
212212
"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/\"]"
213+
},
214+
"solidity.developmentEnvironment": {
215+
"type": "string",
216+
"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.",
217+
"enum": [
218+
"",
219+
"hardhat",
220+
"forge"
221+
],
222+
"default": ""
223+
},
224+
"solidity.test.command": {
225+
"type": "string",
226+
"default": "",
227+
"description": "Command to run to invoke tests"
213228
}
214229
}
215230
},
@@ -330,6 +345,10 @@
330345
{
331346
"command": "solidity.changeDefaultCompilerType",
332347
"title": "Solidity: Change the default workspace compiler to Remote, Local, NodeModule, Embedded"
348+
},
349+
{
350+
"command": "solidity.runTests",
351+
"title": "Solidity: Run tests"
333352
}
334353
],
335354
"menus": {

src/environments/env.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export enum DevelopmentEnvironment {
2+
Forge = 'forge',
3+
Hardhat = 'hardhat',
4+
None = 'none',
5+
NotSet = ''
6+
}
7+
8+
type ConfigDefaultAndTargetValue = {
9+
default: any;
10+
target: any;
11+
}
12+
13+
export const defaultEnvironmentConfiguration: Partial<Record<DevelopmentEnvironment, Record<string, ConfigDefaultAndTargetValue>>> = {
14+
[DevelopmentEnvironment.Forge]: {
15+
'test.command': {
16+
default: '',
17+
target: 'forge test --silent --json'
18+
},
19+
}
20+
}
21+

src/environments/forge.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ContractTestResults, TestResults } from "./tests";
2+
3+
type forgeTestResult = {
4+
success: boolean,
5+
reason?: string,
6+
counterexample?: null,
7+
decoded_logs: string[],
8+
};
9+
10+
export const parseForgeTestResults = (data: string): TestResults | null => {
11+
try {
12+
const parsed = JSON.parse(data);
13+
const contractResults = Object.entries(parsed).map(([key, rest]: [string, any]) => {
14+
const [file, contract] = key.split(':');
15+
const results = Object.entries(rest.test_results).map(([name, res]: [string, forgeTestResult]) => {
16+
return {
17+
name,
18+
pass: res.success,
19+
reason: res.reason,
20+
logs: res.decoded_logs,
21+
};
22+
});
23+
const out: ContractTestResults = {
24+
file,
25+
contract,
26+
results,
27+
};
28+
return out;
29+
});
30+
return {
31+
contracts: contractResults,
32+
};
33+
} catch (err) {
34+
return null;
35+
}
36+
};

src/environments/tests.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { workspace } from "vscode";
2+
import { DevelopmentEnvironment } from "./env";
3+
import { parseForgeTestResults } from "./forge";
4+
5+
export type TestResults = {
6+
contracts: ContractTestResults[];
7+
};
8+
9+
export type ContractTestResults = {
10+
file: string;
11+
contract: string;
12+
results: TestResult[];
13+
};
14+
15+
export type TestResult = TestResultPass | TestResultFailure;
16+
17+
export type TestResultPass = {
18+
name: string;
19+
pass: true;
20+
logs: string[];
21+
};
22+
23+
export type TestResultFailure = {
24+
name: string;
25+
pass: false;
26+
reason: string;
27+
logs: string[];
28+
};
29+
30+
export const testResultIsFailure = (r: TestResult): r is TestResultFailure => {
31+
return !r.pass;
32+
};
33+
34+
/**
35+
* parseTestResults parses raw test result data into a format which can be interpreted
36+
* by the extension.
37+
* It currently only supports output from 'forge'.
38+
* @param data Raw data from test command
39+
* @returns TestResults, or null if unable to interpret the data
40+
*/
41+
export const parseTestResults = (data: string): TestResults | null => {
42+
const devEnv = workspace.getConfiguration('solidity').get<DevelopmentEnvironment>('developmentEnvironment');
43+
if (devEnv === DevelopmentEnvironment.Forge) {
44+
return parseForgeTestResults(data);
45+
}
46+
return null
47+
}
48+
49+
/**
50+
* Construct output to be printed which summarizes test results.
51+
* @param results Parsed test results
52+
* @returns Array of lines which produce a test run summary.
53+
*/
54+
export const constructTestResultOutput = (results: TestResults): string[] => {
55+
const lines = [];
56+
57+
const withFailures = results.contracts.filter(c => {
58+
return c.results.filter(r => !r.pass).length > 0;
59+
});
60+
const hasFailures = withFailures.length > 0;
61+
62+
if (hasFailures) {
63+
lines.push('Tests FAILED');
64+
lines.push('------------');
65+
}
66+
results.contracts.forEach((c) => {
67+
lines.push(`${c.contract} in ${c.file}:`);
68+
69+
const passes = c.results.filter(f => f.pass);
70+
const failures = c.results.filter(f => !f.pass) as TestResultFailure[];
71+
72+
passes.forEach(r => {
73+
lines.push(`\tPASS ${r.name}`);
74+
});
75+
76+
failures.forEach((r) => {
77+
lines.push(`\tFAIL ${r.name}`);
78+
if (r.reason) {
79+
lines.push(`\t REVERTED with reason: ${r.reason}`);
80+
}
81+
82+
r.logs.forEach((log) => {
83+
lines.push(`\t\t ${log}`);
84+
});
85+
});
86+
// Add some spacing between contract results
87+
lines.push('');
88+
});
89+
90+
if (!hasFailures) {
91+
lines.push('All tests passed.');
92+
return lines;
93+
}
94+
95+
lines.push('\nSummary:');
96+
withFailures.forEach(f => {
97+
const numFailures = f.results.filter(r => !r.pass).length;
98+
lines.push(`\t${numFailures} failure(s) in ${f.contract} (${f.file})`);
99+
});
100+
return lines;
101+
};

src/extension.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22
import * as path from 'path';
33
import * as vscode from 'vscode';
4+
import * as cp from 'child_process';
45
import { compileAllContracts } from './client/compileAll';
56
import { Compiler } from './client/compiler';
67
import { compileActiveContract, initDiagnosticCollection } from './client/compileActive';
@@ -18,13 +19,16 @@ import {
1819

1920
import { lintAndfixCurrentDocument } from './server/linter/soliumClientFixer';
2021
// tslint:disable-next-line:no-duplicate-imports
21-
import { workspace, WorkspaceFolder } from 'vscode';
22+
import { workspace } from 'vscode';
2223
import { formatDocument } from './client/formatter/formatter';
2324
import { compilerType } from './common/solcCompiler';
2425
import * as workspaceUtil from './client/workspaceUtil';
26+
import { defaultEnvironmentConfiguration, DevelopmentEnvironment } from './environments/env';
27+
import { constructTestResultOutput, parseTestResults } from './environments/tests';
2528

2629
let diagnosticCollection: vscode.DiagnosticCollection;
2730
let compiler: Compiler;
31+
let testOutputChannel: vscode.OutputChannel;
2832

2933
export async function activate(context: vscode.ExtensionContext) {
3034
const ws = workspace.workspaceFolders;
@@ -48,6 +52,36 @@ export async function activate(context: vscode.ExtensionContext) {
4852
});
4953
*/
5054

55+
// Attach a handler which sets the default configuration for environment related config.
56+
// This triggers both when a user sets it or when we detect it automatically.
57+
context.subscriptions.push(workspace.onDidChangeConfiguration(async (event) => {
58+
if (!event.affectsConfiguration('solidity.developmentEnvironment')) {
59+
return;
60+
}
61+
const newConfig = vscode.workspace.getConfiguration('solidity')
62+
const newEnv = newConfig.get<DevelopmentEnvironment>('developmentEnvironment');
63+
const vaulesToSet = defaultEnvironmentConfiguration[newEnv];
64+
if (!vaulesToSet) {
65+
return;
66+
}
67+
for (const [k, v] of Object.entries(vaulesToSet)) {
68+
if (newConfig.get(k) === v.default) {
69+
newConfig.update(k, v.target, null);
70+
}
71+
}
72+
}));
73+
74+
// Detect development environment if the configuration is not already set.
75+
const loadTimeConfig = vscode.workspace.getConfiguration('solidity')
76+
const existingDevEnv = loadTimeConfig.get<DevelopmentEnvironment>('developmentEnvironment');
77+
console.log({existingDevEnv})
78+
if (!existingDevEnv) {
79+
const detectedEnv = await detectDevelopmentEnvironment();
80+
if (detectedEnv) {
81+
loadTimeConfig.update('developmentEnvironment', detectedEnv, null);
82+
}
83+
}
84+
5185
context.subscriptions.push(diagnosticCollection);
5286

5387
initDiagnosticCollection(diagnosticCollection);
@@ -185,6 +219,51 @@ export async function activate(context: vscode.ExtensionContext) {
185219
},
186220
}));
187221

222+
context.subscriptions.push(vscode.commands.registerCommand('solidity.runTests', async (params) => {
223+
const testCommand = vscode.workspace.getConfiguration('solidity').get<string>('test.command');
224+
if (!testCommand) {
225+
return;
226+
}
227+
if (!testOutputChannel) {
228+
testOutputChannel = vscode.window.createOutputChannel('Solidity Tests');
229+
}
230+
231+
// If no URI supplied to task, use the current active editor.
232+
let uri = params?.uri;
233+
if (!uri) {
234+
const editor = vscode.window.activeTextEditor;
235+
if (editor && editor.document) {
236+
uri = editor.document.uri;
237+
}
238+
}
239+
240+
const rootFolder = getFileRootPath(uri);
241+
if (!rootFolder) {
242+
console.error("Couldn't determine root folder for document", {uri});
243+
return;
244+
}
245+
246+
testOutputChannel.clear();
247+
testOutputChannel.show();
248+
testOutputChannel.appendLine(`Running '${testCommand}'...`);
249+
testOutputChannel.appendLine('');
250+
try {
251+
const result = await executeTask(rootFolder, testCommand, false);
252+
const parsed = parseTestResults(result);
253+
// If we couldn't parse the output, just write it to the window.
254+
if (!parsed) {
255+
testOutputChannel.appendLine(result);
256+
return;
257+
}
258+
259+
const out = constructTestResultOutput(parsed);
260+
out.forEach(testOutputChannel.appendLine);
261+
262+
} catch (err) {
263+
console.log('Unexpected error running tests:', err);
264+
}
265+
}));
266+
188267
const serverModule = path.join(__dirname, 'server.js');
189268
const serverOptions: ServerOptions = {
190269
debug: {
@@ -228,3 +307,46 @@ export async function activate(context: vscode.ExtensionContext) {
228307
// client can be deactivated on extension deactivation
229308
context.subscriptions.push(clientDisposable);
230309
}
310+
311+
const detectDevelopmentEnvironment = async (): Promise<string> => {
312+
const foundry = await workspace.findFiles('foundry.toml');
313+
const hardhat = await workspace.findFiles('hardhat.config.js');
314+
315+
// If we found evidence of multiple workspaces, don't select a default.
316+
if (foundry.length && hardhat.length) {
317+
return DevelopmentEnvironment.None;
318+
}
319+
320+
if (foundry.length) {
321+
return DevelopmentEnvironment.Forge;
322+
}
323+
324+
if (hardhat.length) {
325+
return DevelopmentEnvironment.Hardhat;
326+
}
327+
return DevelopmentEnvironment.None;
328+
}
329+
330+
const getFileRootPath = (uri: vscode.Uri): string | null => {
331+
const folders = vscode.workspace.workspaceFolders;
332+
for (const f of folders) {
333+
if (uri.path.startsWith(f.uri.path)) {
334+
return f.uri.path;
335+
}
336+
}
337+
return null;
338+
};
339+
340+
const executeTask = (dir: string, cmd: string, rejectOnFailure: boolean) => {
341+
return new Promise<string>((resolve, reject) => {
342+
cp.exec(cmd, {cwd: dir, maxBuffer: 1024 * 1024 * 10}, (err, out) => {
343+
if (err) {
344+
if (rejectOnFailure) {
345+
return reject({out, err});
346+
}
347+
return resolve(out);
348+
}
349+
return resolve(out);
350+
});
351+
});
352+
};

0 commit comments

Comments
 (0)