From 2d4b753e9d2432fa2d79914d148c5c29e9eb0d3c Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Thu, 14 Dec 2023 17:31:01 +0100 Subject: [PATCH 01/14] 1st draft of mutation testing script --- gambitTester.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 gambitTester.ts diff --git a/gambitTester.ts b/gambitTester.ts new file mode 100644 index 0000000000..ceb4c01d84 --- /dev/null +++ b/gambitTester.ts @@ -0,0 +1,56 @@ +import { execSync } from 'child_process' +import { existsSync, readdirSync, copyFileSync, unlinkSync } from 'fs' +import * as path from 'path' + +const contractPath = 'contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol' +const mutantsDirectory = 'gambit_out/mutants' + +runMutationTesting().catch(error => { + console.error('Error during mutation testing:', error) +}) + +async function runMutationTesting() { + // Step 1: Generate mutants + execSync( + `gambit mutate --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` + ) + + // Check if mutants directory exists + if (!existsSync(mutantsDirectory)) { + throw new Error('Mutants directory not found after running gambit mutate') + } + + // Step 2: Loop through mutants + const mutantDirs = readdirSync(mutantsDirectory) + const results = [] + + for (const dir of mutantDirs) { + const mutantPath = path.join(mutantsDirectory, dir, contractPath) + if (existsSync(mutantPath)) { + console.log(`Testing mutant: ${mutantPath}`) + + // Replace original file with mutant + copyFileSync(mutantPath, contractPath) + + // Step 3: Re-build project + execSync('forge build') + + // Step 4: Run test suite + try { + execSync('forge test') + results.push({ mutant: mutantPath, status: 'Survived' }) + } catch (error) { + results.push({ mutant: mutantPath, status: 'Killed' }) + } + + // Restore original file + unlinkSync(contractPath) + } + } + + // Step 5: Print summary + console.log('Mutation Testing Results:') + results.forEach(result => { + console.log(`${result.mutant}: ${result.status}`) + }) +} From a1fa10413aabeb1a2feeaaa5f251a5725026ae8c Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Fri, 15 Dec 2023 17:04:44 +0100 Subject: [PATCH 02/14] Parse mutants from JSON file --- gambitTester.ts | 58 +++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/gambitTester.ts b/gambitTester.ts index ceb4c01d84..f31efdb593 100644 --- a/gambitTester.ts +++ b/gambitTester.ts @@ -1,9 +1,20 @@ import { execSync } from 'child_process' import { existsSync, readdirSync, copyFileSync, unlinkSync } from 'fs' import * as path from 'path' +import * as fs from 'fs' const contractPath = 'contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol' -const mutantsDirectory = 'gambit_out/mutants' +const gambitDir = 'gambit_out/' +const mutantsListFile = 'gambit_out/gambit_results.json' + +interface Mutant { + description: string + diff: string + id: string + name: string + original: string + sourceroot: string +} runMutationTesting().catch(error => { console.error('Error during mutation testing:', error) @@ -12,43 +23,34 @@ runMutationTesting().catch(error => { async function runMutationTesting() { // Step 1: Generate mutants execSync( - `gambit mutate --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` + `gambit mutate -n 3 --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` ) - // Check if mutants directory exists - if (!existsSync(mutantsDirectory)) { - throw new Error('Mutants directory not found after running gambit mutate') - } + // read mutants + const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) - // Step 2: Loop through mutants - const mutantDirs = readdirSync(mutantsDirectory) + // test mutants const results = [] + for (const mutant of mutants) { + console.log(`Testing mutant: ${mutant.id}`) - for (const dir of mutantDirs) { - const mutantPath = path.join(mutantsDirectory, dir, contractPath) - if (existsSync(mutantPath)) { - console.log(`Testing mutant: ${mutantPath}`) - - // Replace original file with mutant - copyFileSync(mutantPath, contractPath) + // Replace original file with mutant + copyFileSync(path.join(gambitDir, mutant.name), mutant.original) - // Step 3: Re-build project + // Re-build and test + try { execSync('forge build') - - // Step 4: Run test suite - try { - execSync('forge test') - results.push({ mutant: mutantPath, status: 'Survived' }) - } catch (error) { - results.push({ mutant: mutantPath, status: 'Killed' }) - } - - // Restore original file - unlinkSync(contractPath) + execSync('forge test') + results.push({ mutant: mutant.id, status: 'Survived' }) + } catch (error) { + results.push({ mutant: mutant.id, status: 'Killed' }) } + + // Restore original file + unlinkSync(contractPath) } - // Step 5: Print summary + // Print summary console.log('Mutation Testing Results:') results.forEach(result => { console.log(`${result.mutant}: ${result.status}`) From f65ed8805f719660926e122646540375a664729e Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Fri, 15 Dec 2023 17:24:30 +0100 Subject: [PATCH 03/14] refactor to separate func --- gambitTester.ts | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/gambitTester.ts b/gambitTester.ts index f31efdb593..c53f9ccb27 100644 --- a/gambitTester.ts +++ b/gambitTester.ts @@ -15,13 +15,19 @@ interface Mutant { original: string sourceroot: string } +interface TestResult { + mutant: string + status: string +} + +const testResults: TestResult[] = [] runMutationTesting().catch(error => { console.error('Error during mutation testing:', error) }) async function runMutationTesting() { - // Step 1: Generate mutants + // generate mutants execSync( `gambit mutate -n 3 --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` ) @@ -30,29 +36,32 @@ async function runMutationTesting() { const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) // test mutants - const results = [] for (const mutant of mutants) { - console.log(`Testing mutant: ${mutant.id}`) - - // Replace original file with mutant - copyFileSync(path.join(gambitDir, mutant.name), mutant.original) - - // Re-build and test - try { - execSync('forge build') - execSync('forge test') - results.push({ mutant: mutant.id, status: 'Survived' }) - } catch (error) { - results.push({ mutant: mutant.id, status: 'Killed' }) - } - - // Restore original file - unlinkSync(contractPath) + testMutant(mutant) } // Print summary console.log('Mutation Testing Results:') - results.forEach(result => { + testResults.forEach(result => { console.log(`${result.mutant}: ${result.status}`) }) } + +function testMutant(mutant: Mutant) { + console.log(`Testing mutant: ${mutant.id}`) + + // Replace original file with mutant + copyFileSync(path.join(gambitDir, mutant.name), mutant.original) + + // Re-build and test + try { + execSync('forge build') + execSync('forge test') + testResults.push({ mutant: mutant.id, status: 'Survived' }) + } catch (error) { + testResults.push({ mutant: mutant.id, status: 'Killed' }) + } + + // Restore original file + unlinkSync(contractPath) +} From b11dda4966e8175b66819eb86ef0d92bc668c493 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Fri, 15 Dec 2023 17:50:09 +0100 Subject: [PATCH 04/14] Test mutants in separate folders --- gambitTester.ts | 36 +++++++++++++++++++++++++++--------- package.json | 2 ++ yarn.lock | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/gambitTester.ts b/gambitTester.ts index c53f9ccb27..007713b8a8 100644 --- a/gambitTester.ts +++ b/gambitTester.ts @@ -1,11 +1,20 @@ import { execSync } from 'child_process' -import { existsSync, readdirSync, copyFileSync, unlinkSync } from 'fs' +import { copyFileSync, unlinkSync } from 'fs' import * as path from 'path' import * as fs from 'fs' +import * as fsExtra from 'fs-extra' const contractPath = 'contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol' const gambitDir = 'gambit_out/' const mutantsListFile = 'gambit_out/gambit_results.json' +const testItems = [ + 'contracts', + 'lib', + 'foundry.toml', + 'node_modules', + 'remappings.txt', + 'test-foundry', +] interface Mutant { description: string @@ -47,21 +56,30 @@ async function runMutationTesting() { }) } -function testMutant(mutant: Mutant) { - console.log(`Testing mutant: ${mutant.id}`) +async function testMutant(mutant: Mutant) { + const testDirectory = path.join(__dirname, `test_mutant`, mutant.id) + + console.log(`Testing mutant: ${mutant.id} in ${testDirectory}`) + + await fsExtra.ensureDir(testDirectory) + for (const item of testItems) { + const sourcePath = path.join(__dirname, item) + const destPath = path.join(testDirectory, item) + await fsExtra.copy(sourcePath, destPath) + } // Replace original file with mutant - copyFileSync(path.join(gambitDir, mutant.name), mutant.original) + copyFileSync( + path.join(gambitDir, mutant.name), + path.join(testDirectory, mutant.original) + ) // Re-build and test try { - execSync('forge build') - execSync('forge test') + execSync(`forge build --root ${testDirectory}`) + execSync(`forge test --root ${testDirectory}`) testResults.push({ mutant: mutant.id, status: 'Survived' }) } catch (error) { testResults.push({ mutant: mutant.id, status: 'Killed' }) } - - // Restore original file - unlinkSync(contractPath) } diff --git a/package.json b/package.json index dc16eb10c5..a2f84ac427 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@typechain/ethers-v5": "^7.0.1", "@typechain/hardhat": "^2.3.0", "@types/chai": "^4.2.15", + "@types/fs-extra": "^11.0.4", "@types/mocha": "^9.0.0", "@types/node": "^14.14.28", "@types/prompts": "^2.0.14", @@ -66,6 +67,7 @@ "eslint-plugin-prettier": "^4.0.0", "ethereum-waffle": "^3.2.0", "ethers": "^5.4.5", + "fs-extra": "^11.2.0", "hardhat": "2.17.3", "hardhat-contract-sizer": "^2.10.0", "hardhat-deploy": "^0.9.1", diff --git a/yarn.lock b/yarn.lock index bc1e836c0b..a5a8260c81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1285,6 +1285,14 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^11.0.4": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" + integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + "@types/glob@^7.1.1": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" @@ -1303,6 +1311,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== +"@types/jsonfile@*": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.4.tgz#614afec1a1164e7d670b4a7ad64df3e7beb7b702" + integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ== + dependencies: + "@types/node" "*" + "@types/keyv@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" @@ -5310,6 +5325,15 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^4.0.2, fs-extra@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" From 33ba42bf301647e75692ac133a38c9b68b4d07eb Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Mon, 18 Dec 2023 10:06:00 +0100 Subject: [PATCH 05/14] Paralellize tests --- .gitignore | 4 ++++ gambitTester.ts | 44 ++++++++++++++++++++++++++------------------ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index a024a9429f..974dcdf95e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ test/storage/*-old # local deployment files network.json + +# Gambit (mutation test) files +gambit_out/ +test_mutant/ \ No newline at end of file diff --git a/gambitTester.ts b/gambitTester.ts index 007713b8a8..91066092de 100644 --- a/gambitTester.ts +++ b/gambitTester.ts @@ -1,5 +1,7 @@ -import { execSync } from 'child_process' -import { copyFileSync, unlinkSync } from 'fs' +import { exec } from 'child_process' +import { copyFileSync } from 'fs' +import { promisify } from 'util' +import os from 'os' import * as path from 'path' import * as fs from 'fs' import * as fsExtra from 'fs-extra' @@ -15,6 +17,8 @@ const testItems = [ 'remappings.txt', 'test-foundry', ] +const MAX_TASKS = os.cpus().length +const execAsync = promisify(exec) interface Mutant { description: string @@ -29,38 +33,41 @@ interface TestResult { status: string } -const testResults: TestResult[] = [] - runMutationTesting().catch(error => { console.error('Error during mutation testing:', error) }) async function runMutationTesting() { // generate mutants - execSync( - `gambit mutate -n 3 --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` + await execAsync( + `gambit mutate --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` ) - // read mutants + // test mutants const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) + const results: TestResult[] = [] + for (let i = 0; i < mutants.length; i += MAX_TASKS) { + const currentBatch = mutants.slice(i, i + MAX_TASKS) + const batchPromises = currentBatch.map(mutant => { + return testMutant(mutant) + }) - // test mutants - for (const mutant of mutants) { - testMutant(mutant) + // Wait for the current batch of tests to complete + const batchResults = await Promise.all(batchPromises) + console.log('Batch results:', batchResults) + results.push(...batchResults) } // Print summary console.log('Mutation Testing Results:') - testResults.forEach(result => { + results.forEach(result => { console.log(`${result.mutant}: ${result.status}`) }) } -async function testMutant(mutant: Mutant) { +async function testMutant(mutant: Mutant): Promise { const testDirectory = path.join(__dirname, `test_mutant`, mutant.id) - console.log(`Testing mutant: ${mutant.id} in ${testDirectory}`) - await fsExtra.ensureDir(testDirectory) for (const item of testItems) { const sourcePath = path.join(__dirname, item) @@ -76,10 +83,11 @@ async function testMutant(mutant: Mutant) { // Re-build and test try { - execSync(`forge build --root ${testDirectory}`) - execSync(`forge test --root ${testDirectory}`) - testResults.push({ mutant: mutant.id, status: 'Survived' }) + console.log(`Building and testing mutant ${mutant.id} in ${testDirectory}`) + await execAsync(`forge build --root ${testDirectory}`) + await execAsync(`forge test --root ${testDirectory}`) + return { mutant: mutant.id, status: 'Survived' } } catch (error) { - testResults.push({ mutant: mutant.id, status: 'Killed' }) + return { mutant: mutant.id, status: 'Killed' } } } From cd68b7c574ff7e23222b1401258e77d68720c058 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Mon, 18 Dec 2023 11:04:04 +0100 Subject: [PATCH 06/14] Extract input config to JSON file --- .gitignore | 2 +- test-mutation/config.json | 10 ++++++++++ gambitTester.ts => test-mutation/gambitTester.ts | 10 ++++------ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 test-mutation/config.json rename gambitTester.ts => test-mutation/gambitTester.ts (86%) diff --git a/.gitignore b/.gitignore index 974dcdf95e..74466b7179 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,4 @@ network.json # Gambit (mutation test) files gambit_out/ -test_mutant/ \ No newline at end of file +test-mutation/mutant_test_env/ \ No newline at end of file diff --git a/test-mutation/config.json b/test-mutation/config.json new file mode 100644 index 0000000000..c5dd04f417 --- /dev/null +++ b/test-mutation/config.json @@ -0,0 +1,10 @@ +[ + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + } +] diff --git a/gambitTester.ts b/test-mutation/gambitTester.ts similarity index 86% rename from gambitTester.ts rename to test-mutation/gambitTester.ts index 91066092de..31a570c8c5 100644 --- a/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -6,7 +6,6 @@ import * as path from 'path' import * as fs from 'fs' import * as fsExtra from 'fs-extra' -const contractPath = 'contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol' const gambitDir = 'gambit_out/' const mutantsListFile = 'gambit_out/gambit_results.json' const testItems = [ @@ -39,9 +38,8 @@ runMutationTesting().catch(error => { async function runMutationTesting() { // generate mutants - await execAsync( - `gambit mutate --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f ${contractPath}` - ) + console.log('Generating mutants') + await execAsync(`gambit mutate --json test-mutation/config.json`) // test mutants const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) @@ -66,11 +64,11 @@ async function runMutationTesting() { } async function testMutant(mutant: Mutant): Promise { - const testDirectory = path.join(__dirname, `test_mutant`, mutant.id) + const testDirectory = path.join(__dirname, `mutant_test_env`, mutant.id) await fsExtra.ensureDir(testDirectory) for (const item of testItems) { - const sourcePath = path.join(__dirname, item) + const sourcePath = path.join(__dirname, '..', item) const destPath = path.join(testDirectory, item) await fsExtra.copy(sourcePath, destPath) } From 5cdebfa1c94f83023151167c185af1c9e2a49b03 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Mon, 18 Dec 2023 14:20:03 +0100 Subject: [PATCH 07/14] Do not copy complete node_modules --- test-mutation/config.json | 10 ++++++++++ test-mutation/gambitTester.ts | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/test-mutation/config.json b/test-mutation/config.json index c5dd04f417..0992ac08b5 100644 --- a/test-mutation/config.json +++ b/test-mutation/config.json @@ -2,6 +2,16 @@ { "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", "sourceroot": "..", + "num_mutants": 10, + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol", + "sourceroot": "..", + "num_mutants": 10, "solc_remappings": [ "@openzeppelin=../node_modules/@openzeppelin", "@arbitrum=../node_modules/@arbitrum" diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 31a570c8c5..3d043b9462 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -12,9 +12,11 @@ const testItems = [ 'contracts', 'lib', 'foundry.toml', - 'node_modules', 'remappings.txt', 'test-foundry', + 'node_modules/@openzeppelin', + 'node_modules/@arbitrum', + 'node_modules/@offchainlabs', ] const MAX_TASKS = os.cpus().length const execAsync = promisify(exec) @@ -61,6 +63,9 @@ async function runMutationTesting() { results.forEach(result => { console.log(`${result.mutant}: ${result.status}`) }) + + // // Delete test env + await fsExtra.remove(path.join(__dirname, 'mutant_test_env')) } async function testMutant(mutant: Mutant): Promise { From 1f45d5c04d7f03113a5a38468fcda7bcae145f42 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Mon, 18 Dec 2023 14:50:24 +0100 Subject: [PATCH 08/14] Refactor and add nicer printout --- test-mutation/gambitTester.ts | 77 ++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 3d043b9462..35827d476b 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -30,8 +30,13 @@ interface Mutant { sourceroot: string } interface TestResult { - mutant: string - status: string + mutantId: string + fileName: string + status: MutantStatus +} +enum MutantStatus { + KILLED = 'KILLED', + SURVIVED = 'SURVIVED', } runMutationTesting().catch(error => { @@ -39,36 +44,44 @@ runMutationTesting().catch(error => { }) async function runMutationTesting() { - // generate mutants - console.log('Generating mutants') + console.log('====== Generating mutants') + await _generateMutants() + + console.log('\n====== Test mutants') + const results = await _testAllMutants() + + // Print summary + console.log('\n====== Results\n') + _printResults(results) + + // Delete test env + await fsExtra.remove(path.join(__dirname, 'mutant_test_env')) +} + +async function _generateMutants() { await execAsync(`gambit mutate --json test-mutation/config.json`) +} - // test mutants +async function _testAllMutants(): Promise { const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) const results: TestResult[] = [] for (let i = 0; i < mutants.length; i += MAX_TASKS) { const currentBatch = mutants.slice(i, i + MAX_TASKS) + console.log(`Testing mutant batch ${i}..${i + MAX_TASKS}`) + const batchPromises = currentBatch.map(mutant => { - return testMutant(mutant) + return _testMutant(mutant) }) // Wait for the current batch of tests to complete const batchResults = await Promise.all(batchPromises) - console.log('Batch results:', batchResults) results.push(...batchResults) } - // Print summary - console.log('Mutation Testing Results:') - results.forEach(result => { - console.log(`${result.mutant}: ${result.status}`) - }) - - // // Delete test env - await fsExtra.remove(path.join(__dirname, 'mutant_test_env')) + return results } -async function testMutant(mutant: Mutant): Promise { +async function _testMutant(mutant: Mutant): Promise { const testDirectory = path.join(__dirname, `mutant_test_env`, mutant.id) await fsExtra.ensureDir(testDirectory) @@ -86,11 +99,37 @@ async function testMutant(mutant: Mutant): Promise { // Re-build and test try { - console.log(`Building and testing mutant ${mutant.id} in ${testDirectory}`) await execAsync(`forge build --root ${testDirectory}`) await execAsync(`forge test --root ${testDirectory}`) - return { mutant: mutant.id, status: 'Survived' } + return { + mutantId: mutant.id, + fileName: path.basename(mutant.name), + status: MutantStatus.SURVIVED, + } } catch (error) { - return { mutant: mutant.id, status: 'Killed' } + return { + mutantId: mutant.id, + fileName: path.basename(mutant.name), + status: MutantStatus.KILLED, + } } } + +function _printResults(results: TestResult[]) { + const separator = '----------------------------------------------' + console.log('Mutant ID | File Name | Status ') + console.log(separator) + + let lastFileName = '' + results.forEach(result => { + if (result.fileName !== lastFileName) { + console.log(separator) + lastFileName = result.fileName + } + console.log( + `${result.mutantId.padEnd(9)} | ${result.fileName.padEnd(20)} | ${ + result.status + }` + ) + }) +} From e19254f6070010ed5982d37fd940ce37eba77e7d Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Mon, 18 Dec 2023 17:27:43 +0100 Subject: [PATCH 09/14] Print total stats --- test-mutation/config.json | 10 +++++++-- test-mutation/gambitTester.ts | 42 +++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/test-mutation/config.json b/test-mutation/config.json index 0992ac08b5..9fe720829d 100644 --- a/test-mutation/config.json +++ b/test-mutation/config.json @@ -2,7 +2,6 @@ { "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", "sourceroot": "..", - "num_mutants": 10, "solc_remappings": [ "@openzeppelin=../node_modules/@openzeppelin", "@arbitrum=../node_modules/@arbitrum" @@ -11,7 +10,14 @@ { "filename": "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol", "sourceroot": "..", - "num_mutants": 10, + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol", + "sourceroot": "..", "solc_remappings": [ "@openzeppelin=../node_modules/@openzeppelin", "@arbitrum=../node_modules/@arbitrum" diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 35827d476b..4cacd6da9c 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -6,8 +6,7 @@ import * as path from 'path' import * as fs from 'fs' import * as fsExtra from 'fs-extra' -const gambitDir = 'gambit_out/' -const mutantsListFile = 'gambit_out/gambit_results.json' +const GAMBIT_OUT = 'gambit_out/' const testItems = [ 'contracts', 'lib', @@ -45,10 +44,10 @@ runMutationTesting().catch(error => { async function runMutationTesting() { console.log('====== Generating mutants') - await _generateMutants() + const mutants: Mutant[] = await _generateMutants() console.log('\n====== Test mutants') - const results = await _testAllMutants() + const results = await _testAllMutants(mutants) // Print summary console.log('\n====== Results\n') @@ -58,12 +57,17 @@ async function runMutationTesting() { await fsExtra.remove(path.join(__dirname, 'mutant_test_env')) } -async function _generateMutants() { +async function _generateMutants(): Promise { await execAsync(`gambit mutate --json test-mutation/config.json`) + const mutants: Mutant[] = JSON.parse( + fs.readFileSync(`${GAMBIT_OUT}/gambit_results.json`, 'utf8') + ) + console.log(`Generated ${mutants.length} mutants in ${GAMBIT_OUT}`) + + return mutants } -async function _testAllMutants(): Promise { - const mutants: Mutant[] = JSON.parse(fs.readFileSync(mutantsListFile, 'utf8')) +async function _testAllMutants(mutants: Mutant[]): Promise { const results: TestResult[] = [] for (let i = 0; i < mutants.length; i += MAX_TASKS) { const currentBatch = mutants.slice(i, i + MAX_TASKS) @@ -93,7 +97,7 @@ async function _testMutant(mutant: Mutant): Promise { // Replace original file with mutant copyFileSync( - path.join(gambitDir, mutant.name), + path.join(GAMBIT_OUT, mutant.name), path.join(testDirectory, mutant.original) ) @@ -117,10 +121,14 @@ async function _testMutant(mutant: Mutant): Promise { function _printResults(results: TestResult[]) { const separator = '----------------------------------------------' - console.log('Mutant ID | File Name | Status ') + console.log('Mutant ID | File Name | Status ') console.log(separator) let lastFileName = '' + let killedCount = 0 + let survivedCount = 0 + + /// print table and count stats results.forEach(result => { if (result.fileName !== lastFileName) { console.log(separator) @@ -131,5 +139,21 @@ function _printResults(results: TestResult[]) { result.status }` ) + + if (result.status === MutantStatus.KILLED) { + killedCount++ + } else { + survivedCount++ + } }) + + // print totals + const totalCount = results.length + const killedPercentage = ((killedCount / totalCount) * 100).toFixed(2) + const survivedPercentage = ((survivedCount / totalCount) * 100).toFixed(2) + + console.log(separator) + console.log(`Total Mutants: ${totalCount}`) + console.log(`Killed: ${killedCount} (${killedPercentage}%)`) + console.log(`Survived: ${survivedCount} (${survivedPercentage}%)`) } From 8f585c2779918e637a1a7d07237c0bbd4a8c3adc Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Tue, 19 Dec 2023 14:20:05 +0100 Subject: [PATCH 10/14] Optimize for parallelization --- remappings.txt | 5 ++--- test-mutation/gambitTester.ts | 42 ++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/remappings.txt b/remappings.txt index 18d5a0c9ed..1c717ff353 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,6 +2,5 @@ ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ @openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ - - - +@arbitrum=node_modules/@arbitrum +@offchainlabs=node_modules/@offchainlabs diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 4cacd6da9c..8ad8ff39bc 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -9,16 +9,13 @@ import * as fsExtra from 'fs-extra' const GAMBIT_OUT = 'gambit_out/' const testItems = [ 'contracts', - 'lib', 'foundry.toml', 'remappings.txt', 'test-foundry', - 'node_modules/@openzeppelin', - 'node_modules/@arbitrum', - 'node_modules/@offchainlabs', ] -const MAX_TASKS = os.cpus().length +const MAX_TASKS = os.cpus().length - 1 const execAsync = promisify(exec) +const symlink = promisify(fs.symlink) interface Mutant { description: string @@ -87,14 +84,25 @@ async function _testAllMutants(mutants: Mutant[]): Promise { async function _testMutant(mutant: Mutant): Promise { const testDirectory = path.join(__dirname, `mutant_test_env`, mutant.id) - await fsExtra.ensureDir(testDirectory) + + // copy necessary files for (const item of testItems) { const sourcePath = path.join(__dirname, '..', item) const destPath = path.join(testDirectory, item) await fsExtra.copy(sourcePath, destPath) } + // link lib and node_modules + await symlink( + path.join(__dirname, '..', 'lib'), + path.join(testDirectory, 'lib') + ) + await symlink( + path.join(__dirname, '..', 'node_modules'), + path.join(testDirectory, 'node_modules') + ) + // Replace original file with mutant copyFileSync( path.join(GAMBIT_OUT, mutant.name), @@ -102,20 +110,22 @@ async function _testMutant(mutant: Mutant): Promise { ) // Re-build and test + let mutantStatus: MutantStatus try { await execAsync(`forge build --root ${testDirectory}`) await execAsync(`forge test --root ${testDirectory}`) - return { - mutantId: mutant.id, - fileName: path.basename(mutant.name), - status: MutantStatus.SURVIVED, - } + mutantStatus = MutantStatus.SURVIVED } catch (error) { - return { - mutantId: mutant.id, - fileName: path.basename(mutant.name), - status: MutantStatus.KILLED, - } + mutantStatus = MutantStatus.KILLED + } + + // delete test folder + await fsExtra.remove(path.join(testDirectory)) + + return { + mutantId: mutant.id, + fileName: path.basename(mutant.name), + status: mutantStatus, } } From 64da6cc596843ab27caa27bb18deaa5c06665f56 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Tue, 19 Dec 2023 15:54:21 +0100 Subject: [PATCH 11/14] Add timeout for forge tasks --- package.json | 1 + test-mutation/gambitTester.ts | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a2f84ac427..dc86d43987 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:unit": "forge test", "test:e2e:local-env": "yarn hardhat test test-e2e/*", "test:storage": "./scripts/storage_layout_test.bash", + "test:mutation": "ts-node test-mutation/gambitTester.ts", "deploy:local:token-bridge": "ts-node ./scripts/local-deployment/deployCreatorAndCreateTokenBridge.ts", "deploy:token-bridge-creator": "ts-node ./scripts/deployment/deployTokenBridgeCreator.ts", "create:token-bridge": "ts-node ./scripts/deployment/createTokenBridge.ts", diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 8ad8ff39bc..050ef2ab4a 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -14,6 +14,7 @@ const testItems = [ 'test-foundry', ] const MAX_TASKS = os.cpus().length - 1 +const TASK_TIMEOUT = 2 * 60 * 1000 const execAsync = promisify(exec) const symlink = promisify(fs.symlink) @@ -33,6 +34,7 @@ interface TestResult { enum MutantStatus { KILLED = 'KILLED', SURVIVED = 'SURVIVED', + TIMEOUT = 'TIMEOUT', } runMutationTesting().catch(error => { @@ -112,11 +114,23 @@ async function _testMutant(mutant: Mutant): Promise { // Re-build and test let mutantStatus: MutantStatus try { - await execAsync(`forge build --root ${testDirectory}`) - await execAsync(`forge test --root ${testDirectory}`) - mutantStatus = MutantStatus.SURVIVED + await Promise.race([ + (async () => { + await execAsync(`forge build --root ${testDirectory}`) + await execAsync(`forge test --root ${testDirectory}`) + mutantStatus = MutantStatus.SURVIVED + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), TASK_TIMEOUT) + ), + ]) } catch (error) { - mutantStatus = MutantStatus.KILLED + if (error instanceof Error) { + mutantStatus = + error.message === 'Timeout' ? MutantStatus.TIMEOUT : MutantStatus.KILLED + } else { + mutantStatus = MutantStatus.KILLED + } } // delete test folder @@ -125,7 +139,7 @@ async function _testMutant(mutant: Mutant): Promise { return { mutantId: mutant.id, fileName: path.basename(mutant.name), - status: mutantStatus, + status: mutantStatus!, } } @@ -137,6 +151,7 @@ function _printResults(results: TestResult[]) { let lastFileName = '' let killedCount = 0 let survivedCount = 0 + let timeoutCount = 0 /// print table and count stats results.forEach(result => { @@ -152,8 +167,10 @@ function _printResults(results: TestResult[]) { if (result.status === MutantStatus.KILLED) { killedCount++ - } else { + } else if (result.status === MutantStatus.SURVIVED) { survivedCount++ + } else { + timeoutCount++ } }) From f92127d66b8e9ab8a1d8dda13166c9e7c95ba35a Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Tue, 19 Dec 2023 16:26:36 +0100 Subject: [PATCH 12/14] Print test time --- test-mutation/gambitTester.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index 050ef2ab4a..a4a372095b 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -7,14 +7,15 @@ import * as fs from 'fs' import * as fsExtra from 'fs-extra' const GAMBIT_OUT = 'gambit_out/' -const testItems = [ +const TEST_TIMES = [ 'contracts', 'foundry.toml', 'remappings.txt', 'test-foundry', ] const MAX_TASKS = os.cpus().length - 1 -const TASK_TIMEOUT = 2 * 60 * 1000 +const TASK_TIMEOUT = 3 * 60 * 1000 // 3min + const execAsync = promisify(exec) const symlink = promisify(fs.symlink) @@ -42,6 +43,8 @@ runMutationTesting().catch(error => { }) async function runMutationTesting() { + const startTime = Date.now() + console.log('====== Generating mutants') const mutants: Mutant[] = await _generateMutants() @@ -54,6 +57,10 @@ async function runMutationTesting() { // Delete test env await fsExtra.remove(path.join(__dirname, 'mutant_test_env')) + + // Print time testing took + const endTime = Date.now() + console.log(`\n====== Done in ${(endTime - startTime) / (60 * 1000)} min`) } async function _generateMutants(): Promise { @@ -89,7 +96,7 @@ async function _testMutant(mutant: Mutant): Promise { await fsExtra.ensureDir(testDirectory) // copy necessary files - for (const item of testItems) { + for (const item of TEST_TIMES) { const sourcePath = path.join(__dirname, '..', item) const destPath = path.join(testDirectory, item) await fsExtra.copy(sourcePath, destPath) From 9187f390cb7afed8d3de1f598df2d21452874e75 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Tue, 19 Dec 2023 17:32:55 +0100 Subject: [PATCH 13/14] Add more test files --- test-mutation/all-configs/config.single.json | 10 ++ .../config.tokenbridge-ethereum.json | 106 ++++++++++++++++++ test-mutation/config.json | 84 +++++++++++++- test-mutation/gambitTester.ts | 31 ++--- 4 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 test-mutation/all-configs/config.single.json create mode 100644 test-mutation/all-configs/config.tokenbridge-ethereum.json diff --git a/test-mutation/all-configs/config.single.json b/test-mutation/all-configs/config.single.json new file mode 100644 index 0000000000..c5dd04f417 --- /dev/null +++ b/test-mutation/all-configs/config.single.json @@ -0,0 +1,10 @@ +[ + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + } +] diff --git a/test-mutation/all-configs/config.tokenbridge-ethereum.json b/test-mutation/all-configs/config.tokenbridge-ethereum.json new file mode 100644 index 0000000000..13da9e7018 --- /dev/null +++ b/test-mutation/all-configs/config.tokenbridge-ethereum.json @@ -0,0 +1,106 @@ +[ + { + "filename": "../contracts/tokenbridge/ethereum/L1ArbitrumMessenger.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ArbitrumExtendedGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ForceOnlyReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitGatewayRouter.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1WethGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + } +] diff --git a/test-mutation/config.json b/test-mutation/config.json index 9fe720829d..13da9e7018 100644 --- a/test-mutation/config.json +++ b/test-mutation/config.json @@ -1,6 +1,6 @@ [ { - "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", + "filename": "../contracts/tokenbridge/ethereum/L1ArbitrumMessenger.sol", "sourceroot": "..", "solc_remappings": [ "@openzeppelin=../node_modules/@openzeppelin", @@ -8,7 +8,15 @@ ] }, { - "filename": "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol", + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ArbitrumExtendedGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol", "sourceroot": "..", "solc_remappings": [ "@openzeppelin=../node_modules/@openzeppelin", @@ -22,5 +30,77 @@ "@openzeppelin=../node_modules/@openzeppelin", "@arbitrum=../node_modules/@arbitrum" ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ForceOnlyReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitGatewayRouter.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1OrbitReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1ReverseCustomGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] + }, + { + "filename": "../contracts/tokenbridge/ethereum/gateway/L1WethGateway.sol", + "sourceroot": "..", + "solc_remappings": [ + "@openzeppelin=../node_modules/@openzeppelin", + "@arbitrum=../node_modules/@arbitrum" + ] } ] diff --git a/test-mutation/gambitTester.ts b/test-mutation/gambitTester.ts index a4a372095b..f99fa251d7 100644 --- a/test-mutation/gambitTester.ts +++ b/test-mutation/gambitTester.ts @@ -14,7 +14,6 @@ const TEST_TIMES = [ 'test-foundry', ] const MAX_TASKS = os.cpus().length - 1 -const TASK_TIMEOUT = 3 * 60 * 1000 // 3min const execAsync = promisify(exec) const symlink = promisify(fs.symlink) @@ -35,7 +34,6 @@ interface TestResult { enum MutantStatus { KILLED = 'KILLED', SURVIVED = 'SURVIVED', - TIMEOUT = 'TIMEOUT', } runMutationTesting().catch(error => { @@ -60,7 +58,9 @@ async function runMutationTesting() { // Print time testing took const endTime = Date.now() - console.log(`\n====== Done in ${(endTime - startTime) / (60 * 1000)} min`) + console.log( + `\n====== Done in ${((endTime - startTime) / (60 * 1000)).toFixed(2)} min` + ) } async function _generateMutants(): Promise { @@ -121,23 +121,13 @@ async function _testMutant(mutant: Mutant): Promise { // Re-build and test let mutantStatus: MutantStatus try { - await Promise.race([ - (async () => { - await execAsync(`forge build --root ${testDirectory}`) - await execAsync(`forge test --root ${testDirectory}`) - mutantStatus = MutantStatus.SURVIVED - })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), TASK_TIMEOUT) - ), - ]) + await execAsync(`forge build --root ${testDirectory}`) + await execAsync( + `forge test --fail-fast --gas-limit 30000000 --root ${testDirectory}` + ) + mutantStatus = MutantStatus.SURVIVED } catch (error) { - if (error instanceof Error) { - mutantStatus = - error.message === 'Timeout' ? MutantStatus.TIMEOUT : MutantStatus.KILLED - } else { - mutantStatus = MutantStatus.KILLED - } + mutantStatus = MutantStatus.KILLED } // delete test folder @@ -158,7 +148,6 @@ function _printResults(results: TestResult[]) { let lastFileName = '' let killedCount = 0 let survivedCount = 0 - let timeoutCount = 0 /// print table and count stats results.forEach(result => { @@ -176,8 +165,6 @@ function _printResults(results: TestResult[]) { killedCount++ } else if (result.status === MutantStatus.SURVIVED) { survivedCount++ - } else { - timeoutCount++ } }) From d3e5df6a9f0c4c6a797bc7274cfca5461e422f9d Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Tue, 16 Jan 2024 10:24:51 +0100 Subject: [PATCH 14/14] Add instructions doc --- docs/mutation_testing.md | 140 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/mutation_testing.md diff --git a/docs/mutation_testing.md b/docs/mutation_testing.md new file mode 100644 index 0000000000..4f95dce391 --- /dev/null +++ b/docs/mutation_testing.md @@ -0,0 +1,140 @@ +# Mutation testing using Certora's Gambit framework + +[Gambit tool](https://docs.certora.com/en/latest/docs/gambit/gambit.html) takes Solidity file or list of files as input and it will generate "mutants" as output. Those are copies of original file, but each with an atomic modification of original file, ie. flipped operator, mutated 'if' condition etc. Next step is running the test suite against the mutant. If all tests still pass, mutant survived, that's bad - either there's faulty test or there is missing coverage. If some test(s) fail, mutant's been killed, that's good. + +## How to install Gambit +To build from source (assumes Rust and Cargo are installed), clone the Gambit repo +``` +git clone git@github.com:Certora/gambit.git +``` + +Then run +``` +cargo install --path . +``` + +Alternatively, prebuilt binaries can be used - more info [here](https://docs.certora.com/en/latest/docs/gambit/gambit.html#installation). + + +## Using Gambit +CLI command `gambit mutate` is used to generated mutants. It can take single file as an input, ie. let's say we want to generate mutants for file `L1ERC20Gateway.sol`: +``` +gambit mutate --solc_remappings "@openzeppelin=node_modules/@openzeppelin" "@arbitrum=node_modules/@arbitrum" -f contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol +``` + +Command will output `gambit_out` folder containing list of mutants (modified Solidity files) and `gambit_results.json` which contains info about all the generated mutants. Another way to view the mutant info is to run: +``` +gambit summary +``` + +Gambit can also take set of Solidity files as input in JSON format, ie: +``` +gambit mutate --json test-mutation/config.json +``` + +List of all configuration options for the JSON file can be found [here](https://docs.certora.com/en/latest/docs/gambit/gambit.html#configuration-files). + +Gambit only generates the mutants, it will not execute any tests. To check if mutant gets killed or survives, we need to copy modified Solidity file over the original one, re-compile the project and re-run the test suite. This process has been automated and described in next chapter. + +## Gambit integration with Foundry +To get the most benefits from Gambit, we have integrated it with Foundry and automated testing and reporting process. This is how `gambitTester` script works: +- generates mutants for files specified in test-mutation/config.json +- for each mutant do in parallel (in batches): + - replace original file with mutant + - re-compile and run foundry suite + - track results +- report results + +Here are practical steps to run mutation test over the set of Arbitrum token bridge contracts. First we need to update `test-mutation/config.json` with the list of solidity files to be tested. In this case we use config file that was prepared in advance: +``` +cp test-mutation/all-configs/config.tokenbridge-ethereum.json test-mutation/config.json +``` + +Now run the script, it will generate mutants and start compiling/testing them in parallel: +``` +yarn run test:mutation +``` + +It will take some time for script to execute (depends on the underlying HW). + +Script output looks like this: +``` +❯ yarn run test:mutation + +yarn run v1.22.19 +$ ts-node test-mutation/gambitTester.ts +====== Generating mutants +Generated 209 mutants in gambit_out/ + +====== Test mutants +Testing mutant batch 0..7 +Testing mutant batch 7..14 +Testing mutant batch 14..21 +Testing mutant batch 21..28 +Testing mutant batch 28..35 +Testing mutant batch 35..42 +Testing mutant batch 42..49 +Testing mutant batch 49..56 +Testing mutant batch 56..63 +Testing mutant batch 63..70 +Testing mutant batch 70..77 +Testing mutant batch 77..84 +Testing mutant batch 84..91 +Testing mutant batch 91..98 +Testing mutant batch 98..105 +Testing mutant batch 105..112 +Testing mutant batch 112..119 +Testing mutant batch 119..126 +Testing mutant batch 126..133 +Testing mutant batch 133..140 +Testing mutant batch 140..147 +Testing mutant batch 147..154 +Testing mutant batch 154..161 +Testing mutant batch 161..168 +Testing mutant batch 168..175 +Testing mutant batch 175..182 +Testing mutant batch 182..189 +Testing mutant batch 189..196 +Testing mutant batch 196..203 +Testing mutant batch 203..210 + +====== Results + +Mutant ID | File Name | Status +---------------------------------------------- +---------------------------------------------- +1 | L1ArbitrumMessenger.sol | SURVIVED +2 | L1ArbitrumMessenger.sol | SURVIVED +3 | L1ArbitrumMessenger.sol | KILLED +---------------------------------------------- +4 | L1ArbitrumExtendedGateway.sol | KILLED +5 | L1ArbitrumExtendedGateway.sol | KILLED +6 | L1ArbitrumExtendedGateway.sol | KILLED +7 | L1ArbitrumExtendedGateway.sol | KILLED +8 | L1ArbitrumExtendedGateway.sol | KILLED +9 | L1ArbitrumExtendedGateway.sol | KILLED +10 | L1ArbitrumExtendedGateway.sol | KILLED +11 | L1ArbitrumExtendedGateway.sol | KILLED + +... + +205 | L1WethGateway.sol | SURVIVED +206 | L1WethGateway.sol | SURVIVED +207 | L1WethGateway.sol | SURVIVED +208 | L1WethGateway.sol | SURVIVED +209 | L1WethGateway.sol | SURVIVED +---------------------------------------------- +Total Mutants: 209 +Killed: 133 (63.64%) +Survived: 76 (36.36%) + +====== Done in 20.91 min +``` + +We're insterested to analyze the mutants which survived. The 1st column in output, `Mutant ID`, can be used to find the exact mutation that was applied by looking into the matching entry in the `gambit_results.json` file. + +## Other considerations +Mutation testing script is time-intensive due to all the re-compiling work. For that reason, list of input files should be optimized to give the most benefit for the limited time period available for testing. Ie. single Solidity files can be targeted and tested manually, and the broader scope of files can be tested overnight. + +Other params that can be adjusted are type of mutations to use, number of mutants to be generated, randomness seed to use in generation, etc. +