Skip to content

Commit

Permalink
add testing to governance package, start with interactive builder uni…
Browse files Browse the repository at this point in the history
…t tests
  • Loading branch information
aaronmgdr committed Nov 7, 2024
1 parent 76045eb commit 00969d9
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 122 deletions.
1 change: 1 addition & 0 deletions packages/cli/proposalTransactions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"contract":"Governance","function":"setConstitution","args":["0x19F78d207493Bf6f7E8D54900d01bb387F211b28","0xa91ee0dc","900000000000000000000000"],"value":"0"}]
2 changes: 1 addition & 1 deletion packages/sdk/connect/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
preset: 'ts-jest',
testMatch: ['<rootDir>/src/**/?(*.)+(spec|test).ts?(x)'],
testMatch: ['<rootDir>/src/**/?(*.)+(spec|test).ts'],
verbose: true,
}
7 changes: 7 additions & 0 deletions packages/sdk/governance/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testMatch: ['<rootDir>/src/**/?(*.)+(test).ts'],
setupFilesAfterEnv: ['@celo/dev-utils/lib/matchers', '<rootDir>/jest_setup.ts'],
globalSetup: '<rootDir>/src/test-utils/setup.global.ts',
verbose: true,
}
12 changes: 12 additions & 0 deletions packages/sdk/governance/jest_setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { URL } from 'node:url'
// @ts-ignore
global.URL = URL

// @ts-ignore
const fetchMock = require('fetch-mock')

const fetchMockSandbox = fetchMock.sandbox()
jest.mock('cross-fetch', () => fetchMockSandbox)

// @ts-ignore
global.fetchMock = fetchMockSandbox
5 changes: 3 additions & 2 deletions packages/sdk/governance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"build": "yarn run --top-level tsc -b .",
"clean": "yarn run --top-level tsc -b . --clean",
"docs": "yarn run --top-level typedoc",
"test": "yarn run --top-level jest --runInBand --passWithNoTests",
"test": "NODE_OPTIONS='--experimental-vm-modules' yarn run --top-level jest --runInBand s --workerIdleMemoryLimit=0.1",
"lint": "yarn run --top-level eslint -c .eslintrc.js ",
"prepublishOnly": "yarn build"
},
Expand All @@ -32,12 +32,13 @@
"@types/inquirer": "^6.5.0",
"bignumber.js": "^9.0.0",
"debug": "^4.1.1",
"inquirer": "^7.0.5"
"inquirer": "^7.3.3"
},
"engines": {
"node": ">=8.14.2"
},
"devDependencies": {
"@celo/dev-utils": "0.0.6-beta.0",
"@celo/typescript": "workspace:^",
"@types/debug": "^4.1.12"
}
Expand Down
99 changes: 99 additions & 0 deletions packages/sdk/governance/src/interactive-proposal-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { newKitFromWeb3 } from '@celo/contractkit'
import inquirer from 'inquirer'
import { InteractiveProposalBuilder } from './interactive-proposal-builder'
import { ProposalBuilder } from './proposals'
jest.mock('inquirer')

import { testWithAnvilL2 } from '@celo/dev-utils/lib/anvil-test'

testWithAnvilL2('InteractiveProposalBuilder', (web3) => {
let builder: ProposalBuilder
let interactiveBuilder: InteractiveProposalBuilder
let fromJsonTxSpy: jest.SpyInstance

beforeEach(() => {
const kit = newKitFromWeb3(web3)
builder = new ProposalBuilder(kit)
fromJsonTxSpy = jest.spyOn(builder, 'fromJsonTx')
interactiveBuilder = new InteractiveProposalBuilder(builder)
})

describe('promptTransactions', () => {
it('should prompt for transactions and return them', async () => {
const mockPrompt = jest.spyOn(inquirer, 'prompt')
// TODO the mock order is not right for the inputs
mockPrompt
.mockResolvedValueOnce({ 'Celo Contract': 'Governance' })
.mockResolvedValueOnce({ 'Governance Function': 'setConstitution' })
.mockResolvedValueOnce({ destination: '0x19F78d207493Bf6f7E8D54900d01bb387F211b28' })
.mockResolvedValueOnce({ functionId: '0xa91ee0dc' })
.mockResolvedValueOnce({ threshold: '900000000000000000000000' })
.mockResolvedValueOnce({ 'Celo Contract': '✔ done' })

const transactions = await interactiveBuilder.promptTransactions()

expect(transactions).toEqual([
{
contract: 'Governance',
function: 'setConstitution',
args: [
'0x19F78d207493Bf6f7E8D54900d01bb387F211b28',
'0xa91ee0dc',
'900000000000000000000000',
],
value: '0',
},
])
expect(fromJsonTxSpy.mock.calls).toMatchInlineSnapshot(`
[
[
{
"args": [
"0x19F78d207493Bf6f7E8D54900d01bb387F211b28",
"0xa91ee0dc",
"900000000000000000000000",
],
"contract": "Governance",
"function": "setConstitution",
"value": "0",
},
],
]
`)
})

it('should handle invalid transactions and retry', async () => {
const mockPrompt = jest.spyOn(inquirer, 'prompt')
mockPrompt
.mockResolvedValueOnce({ 'Celo Contract': 'Governance' })
.mockResolvedValueOnce({ 'Governance Function': 'setConstitution' })
.mockResolvedValueOnce({ destination: 'invalid' })
.mockResolvedValueOnce({ functionId: 'invalid' })
.mockResolvedValueOnce({ threshold: '900000000000000000000000' })
.mockResolvedValueOnce({ 'Celo Contract': '✔ done' })

const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()

const transactions = await interactiveBuilder.promptTransactions()

expect(transactions).toEqual([]) // No valid transactions added
expect(fromJsonTxSpy.mock.calls).toMatchInlineSnapshot(`
[
[
{
"args": [
"invalid",
"invalid",
"900000000000000000000000",
],
"contract": "Governance",
"function": "setConstitution",
"value": "0",
},
],
]
`)
consoleErrorSpy.mockRestore()
})
})
})
121 changes: 121 additions & 0 deletions packages/sdk/governance/src/interactive-proposal-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { isHexString } from '@celo/base/lib/address'
import { ABIDefinition } from '@celo/connect'
import { CeloContract, RegisteredContracts } from '@celo/contractkit'
import { isValidAddress } from '@celo/utils/lib/address'
import BigNumber from 'bignumber.js'
import inquirer from 'inquirer'
import type { ProposalTransactionJSON } from './proposals'
import { ProposalBuilder } from './proposals'

const DONE_CHOICE = '✔ done'

export class InteractiveProposalBuilder {
constructor(private readonly builder: ProposalBuilder) {}

async outputTransactions() {
const transactionList = this.builder.build()
console.log(JSON.stringify(transactionList, null, 2))
}

async promptTransactions() {
const transactions: ProposalTransactionJSON[] = []
// eslint-disable-next-line no-constant-condition
while (true) {
console.log(`Transaction #${transactions.length + 1}:`)

// prompt for contract
const contractPromptName = 'Celo Contract'
const contractAnswer = await inquirer.prompt({
name: contractPromptName,
type: 'list',
choices: [DONE_CHOICE, ...RegisteredContracts],
})
const choice = contractAnswer[contractPromptName]
if (choice === DONE_CHOICE) {
break
}

const contractName = choice as CeloContract
console.warn('DEBUG', contractPromptName, contractName, contractAnswer)

const contractABI = require('@celo/abis/web3/' + contractName).ABI as ABIDefinition[]

const txMethods = contractABI.filter(
(def) => def.type === 'function' && def.stateMutability !== 'view'
)
const txMethodNames = txMethods.map((def) => def.name!)
// prompt for function
const functionPromptName = contractName + ' Function'
const functionAnswer = await inquirer.prompt<Record<string, string>>({
name: functionPromptName,
type: 'list',
choices: txMethodNames,
})
const functionName = functionAnswer[functionPromptName]
const idx = txMethodNames.findIndex((m) => m === functionName)
const txDefinition = txMethods[idx]
// prompt individually for each argument
const args = []
for (const functionInput of txDefinition.inputs!) {
const inputAnswer: Record<string, string> = await inquirer.prompt({
name: functionInput.name,
type: 'input',
validate: async (input: string) => {
switch (functionInput.type) {
case 'uint256':
try {
new BigNumber(input)
return true
} catch (e) {
return false
}
case 'boolean':
return input === 'true' || input === 'false'
case 'address':
return isValidAddress(input)
case 'bytes':
return isHexString(input)
default:
return true
}
},
})

const answer: string = inputAnswer[functionInput.name]
// transformedValue may not be in scientific notation
const transformedValue =
functionInput.type === 'uint256' ? new BigNumber(answer).toString(10) : answer
args.push(transformedValue)
}
// prompt for value only when tx is payable
let value: string
if (txDefinition.payable) {
const valuePromptName = 'Value'
const valueAnswer = await inquirer.prompt({
name: valuePromptName,
type: 'input',
})
value = valueAnswer[valuePromptName]
} else {
value = '0'
}
const tx: ProposalTransactionJSON = {
contract: contractName,
function: functionName,
args,
value,
}

try {
// use fromJsonTx as well-formed tx validation
await this.builder.fromJsonTx(tx)
transactions.push(tx)
} catch (error) {
console.error(error)
console.error('Please retry forming this transaction')
}
}

return transactions
}
}
Loading

0 comments on commit 00969d9

Please sign in to comment.