Skip to content

Commit

Permalink
💫 Added withNamedArgs method to the Chai emit matcher (#456)
Browse files Browse the repository at this point in the history
  • Loading branch information
naddison36 committed Jul 21, 2022
1 parent 4ce739d commit 62dd2f9
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-years-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ethereum-waffle/chai": patch
---

Added withNamedArgs method to the Chai emit matcher
25 changes: 25 additions & 0 deletions docs/source/matchers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ If you are using Waffle version :code:`3.4.4` or lower, you can't chain :code:`e
This feature will be available in Waffle version 4.

Testing the argument names in the event:

.. code-block:: ts
await expect(token.transfer(walletTo.address, 7))
.to.emit(token, 'Transfer')
.withNamedArgs({
from: wallet.address,
to: walletTo.address,
amount: 7
});
A subset of arguments in an event can be tested by only including the desired arguments:

.. code-block:: ts
await expect(token.transfer(walletTo.address, "8000000000000000000"))
.to.emit(token, 'Transfer')
.withNamedArgs({
amount: "8000000000000000000"
});
This feature will be available in Waffle version 4.

Called on contract
------------------

Expand Down
2 changes: 2 additions & 0 deletions waffle-chai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {supportChangeTokenBalances} from './matchers/changeTokenBalances';
import {supportCalledOnContract} from './matchers/calledOnContract/calledOnContract';
import {supportCalledOnContractWith} from './matchers/calledOnContract/calledOnContractWith';
import {supportWithArgs} from './matchers/withArgs';
import {supportWithNamedArgs} from './matchers/withNamedArgs';

export function waffleChai(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
supportBigNumber(chai.Assertion, utils);
Expand All @@ -35,4 +36,5 @@ export function waffleChai(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
supportCalledOnContract(chai.Assertion);
supportCalledOnContractWith(chai.Assertion);
supportWithArgs(chai.Assertion);
supportWithNamedArgs(chai.Assertion);
}
20 changes: 20 additions & 0 deletions waffle-chai/src/matchers/misc/struct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const isStruct = (arr: any[]) => {
if (!Array.isArray(arr)) return false;
const keys = Object.keys(arr);
const hasAlphaNumericKeys = keys.some((key) => key.match(/^[a-zA-Z0-9]*[a-zA-Z]+[a-zA-Z0-9]*$/));
const hasNumericKeys = keys.some((key) => key.match(/^\d+$/));
return hasAlphaNumericKeys && hasNumericKeys;
};

export const convertStructToPlainObject = (struct: any[]): any => {
const keys = Object.keys(struct).filter((key: any) => isNaN(key));
return keys.reduce(
(acc: any, key: any) => ({
...acc,
[key]: isStruct(struct[key])
? convertStructToPlainObject(struct[key])
: struct[key]
}),
{}
);
};
22 changes: 1 addition & 21 deletions waffle-chai/src/matchers/withArgs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {utils} from 'ethers';
import {convertStructToPlainObject, isStruct} from './misc/struct';

/**
* Used for testing the arguments of events or custom errors.
Expand Down Expand Up @@ -74,27 +75,6 @@ export function supportWithArgs(Assertion: Chai.AssertionStatic) {
);
};

const isStruct = (arr: any[]) => {
if (!Array.isArray(arr)) return false;
const keys = Object.keys(arr);
const hasAlphaNumericKeys = keys.some((key) => key.match(/^[a-zA-Z0-9]*[a-zA-Z]+[a-zA-Z0-9]*$/));
const hasNumericKeys = keys.some((key) => key.match(/^\d+$/));
return hasAlphaNumericKeys && hasNumericKeys;
};

const convertStructToPlainObject = (struct: any[]): any => {
const keys = Object.keys(struct).filter((key: any) => isNaN(key));
return keys.reduce(
(acc: any, key: any) => ({
...acc,
[key]: isStruct(struct[key])
? convertStructToPlainObject(struct[key])
: struct[key]
}),
{}
);
};

Assertion.addMethod('withArgs', function (this: any, ...expectedArgs: any[]) {
if (!('txMatcher' in this) || !('callPromise' in this)) {
throw new Error('withArgs() must be used after emit() or revertedWith()');
Expand Down
82 changes: 82 additions & 0 deletions waffle-chai/src/matchers/withNamedArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {BytesLike, utils} from 'ethers';
import {Hexable} from 'ethers/lib/utils';
import {convertStructToPlainObject, isStruct} from './misc/struct';

/**
* Used for testing the arguments of events or custom errors, naming the arguments.
* Can test the subset of all arguments.
* Should be used after .emit matcher.
*/
export function supportWithNamedArgs(Assertion: Chai.AssertionStatic) {
const assertArgsObjectEqual = (context: any, expectedArgs: Record<string, unknown>, arg: any) => {
const logDescription = (context.contract.interface as utils.Interface).parseLog(arg);
const actualArgs = logDescription.args;

for (const [key, expectedValue] of Object.entries(expectedArgs)) {
const paramIndex = logDescription.eventFragment.inputs.findIndex(input => input.name === key);
new Assertion(paramIndex, `"${key}" argument in the "${context.eventName}" event not found`).gte(0);
if (Array.isArray(expectedValue)) {
for (let j = 0; j < expectedValue.length; j++) {
new Assertion(
actualArgs[paramIndex][j],
`"${key}" value at index "${j}" on "${context.eventName}" event`)
.equal(expectedValue[j]);
}
} else {
if (actualArgs[paramIndex].hash !== undefined && actualArgs[paramIndex]._isIndexed) {
const expectedArgBytes = utils.isHexString(expectedValue)
? utils.arrayify(expectedValue as BytesLike | Hexable | number)
: utils.toUtf8Bytes(expectedValue as string);
new Assertion(
actualArgs[paramIndex].hash,
`value of indexed "${key}" argument in the "${context.eventName}" event ` +
`to be hash of or equal to "${expectedValue}"`
).to.be.oneOf([expectedValue, utils.keccak256(expectedArgBytes)]);
} else {
if (isStruct(actualArgs[paramIndex])) {
new Assertion(
convertStructToPlainObject(actualArgs[paramIndex])
).to.deep.equal(expectedValue);
return;
}
new Assertion(actualArgs[paramIndex],
`value of "${key}" argument in the "${context.eventName}" event`)
.equal(expectedValue);
}
}
}
};

const tryAssertArgsObjectEqual = (context: any, expectedArgs: Record<string, unknown>, args: any[]) => {
if (args.length === 1) return assertArgsObjectEqual(context, expectedArgs, args[0]);
if (context.txMatcher !== 'emit') {
throw new Error('Wrong format of arguments');
}
for (const index in args) {
try {
assertArgsObjectEqual(context, expectedArgs, args[index]);
return;
} catch {}
}
context.assert(false,
`Specified args not emitted in any of ${context.args.length} emitted "${context.eventName}" events`,
'Do not combine .not. with .withArgs()'
);
};

Assertion.addMethod('withNamedArgs', function (this: any, expectedArgs: Record<string, unknown>) {
if (!('txMatcher' in this) || !('callPromise' in this)) {
throw new Error('withNamedArgs() must be used after emit()');
}
const isNegated = this.__flags.negate === true;
this.callPromise = this.callPromise.then(() => {
const isCurrentlyNegated = this.__flags.negate === true;
this.__flags.negate = isNegated;
tryAssertArgsObjectEqual(this, expectedArgs, this.args);
this.__flags.negate = isCurrentlyNegated;
});
this.then = this.callPromise.then.bind(this.callPromise);
this.catch = this.callPromise.catch.bind(this.callPromise);
return this;
});
}
1 change: 1 addition & 0 deletions waffle-chai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ declare namespace Chai {

interface EmitAssertion extends AsyncAssertion {
withArgs(...args: any[]): AsyncAssertion;
withNamedArgs(args: Record<string, unknown>): AsyncAssertion;
}

interface RevertedWithAssertion extends AsyncAssertion {
Expand Down
3 changes: 2 additions & 1 deletion waffle-chai/test/matchers/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {describeMockProviderCases} from './MockProviderCases';
import {eventsTest} from './eventsTest';
import {eventsTest, eventsWithNamedArgs} from './eventsTest';

describeMockProviderCases('INTEGRATION: Events', (provider) => {
eventsTest(provider);
eventsWithNamedArgs(provider);
});
173 changes: 171 additions & 2 deletions waffle-chai/test/matchers/eventsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ export const eventsTest = (provider: TestProvider) => {
it('Emit struct: fail', async () => {
const struct = {
...emittedStruct,
value: BigNumber.from(2) // different
value: emittedStruct.value.add('1')
};
await expect(
expect(events.emitStruct()).to.emit(events, 'Struct').withArgs(struct)
Expand All @@ -519,7 +519,7 @@ export const eventsTest = (provider: TestProvider) => {
...emittedNestedStruct,
task: {
...emittedStruct,
value: BigNumber.from(2) // different
value: emittedStruct.value.add('1')
}
};
await expect(
Expand All @@ -531,3 +531,172 @@ export const eventsTest = (provider: TestProvider) => {
});
});
};

export const eventsWithNamedArgs = (provider: TestProvider) => {
let wallet: Wallet;
let events: Contract;
let factory: ContractFactory;

before(async () => {
const wallets = provider.getWallets();
wallet = wallets[0];
});

beforeEach(async () => {
factory = new ContractFactory(EVENTS_ABI, EVENTS_BYTECODE, wallet);
events = await factory.deploy();
});

describe('events withNamedArgs', () => {
it('all arguments', async () => {
const bytes = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('Three'));
const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes('Three'));
await expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: hash,
msg: 'Three',
bmsg: bytes,
bmsgHash: hash,
encoded: '0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
});
await expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: 'Three',
msg: 'Three',
bmsg: bytes,
bmsgHash: hash,
encoded: '0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
});
});

it('some arguments', async () => {
const bytes = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('Three'));
const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes('Three'));
await expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msg: 'Three',
bmsg: bytes,
bmsgHash: hash
});
await expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: 'Three',
bmsg: bytes,
bmsgHash: hash,
encoded: '0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
});
});

it('invalid msgHashed hash argument', async () => {
const bytes = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('Three'));
const invalidHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes('Four'));
await expect(
expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: invalidHash,
msg: 'Three',
bmsg: bytes
})
).to.be.eventually.rejectedWith(
AssertionError,
'value of indexed "msgHashed" argument in the "Index" event to be hash of or equal to ' +
'"0x4fae3e1262df6a76b5264b9d4166a5e6091b6b71d48e83583b2221b15d01023a": expected ' +
'\'0x67f5166e905e8b1b2000651b3d5254f5a93e1ae1a689ce8930cd41a6d56ac00f\' to be one of [ Array(2) ]'
);
});

it('invalid msgHashed unhashed argument', async () => {
const bytes = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('Three'));
await expect(
expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: 'Four',
msg: 'Three',
bmsg: bytes
})
).to.be.eventually.rejectedWith(
AssertionError,
'value of indexed "msgHashed" argument in the "Index" event to be hash of or equal to ' +
'"Four": expected ' +
'\'0x67f5166e905e8b1b2000651b3d5254f5a93e1ae1a689ce8930cd41a6d56ac00f\' to be one of [ Array(2) ]'
);
});

it('invalid msg argument', async () => {
const bytes = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('Three'));
await expect(
expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
msgHashed: 'Three',
msg: 'Four',
bmsg: bytes
})
).to.be.eventually.rejectedWith(
AssertionError,
'value of "msg" argument in the "Index" event: expected ' +
'\'Three\' to equal \'Four\''
);
});

it('missing argument', async () => {
await expect(
expect(events.emitIndex())
.to.emit(events, 'Index')
.withNamedArgs({
invalid: 'XXXX'
})
).to.be.eventually.rejectedWith(
AssertionError,
'"invalid" argument in the "Index" event not found: expected -1 to be at least 0'
);
});

it('Emit struct: success', async () => {
await expect(events.emitStruct())
.to.emit(events, 'Struct')
.withNamedArgs({task: emittedStruct});
});

it('Emit struct: fail', async () => {
const struct = {
...emittedStruct,
value: emittedStruct.value.add('1')
};
await expect(
expect(events.emitStruct()).to.emit(events, 'Struct').withNamedArgs({task: struct})
).to.be.eventually.rejectedWith(
AssertionError,
'expected { Object (hash, value, ...) } to deeply equal { Object (hash, value, ...) }'
);
});

it('Emit nested struct: success', async () => {
await expect(events.emitNestedStruct())
.to.emit(events, 'NestedStruct')
.withNamedArgs({nested: emittedNestedStruct});
});

it('Emit nested struct: fail', async () => {
const nestedStruct = {
...emittedNestedStruct,
task: {
...emittedStruct,
value: emittedStruct.value.add('1')
}
};
await expect(
expect(events.emitNestedStruct()).to.emit(events, 'NestedStruct').withNamedArgs({nested: nestedStruct})
).to.be.eventually.rejectedWith(
AssertionError,
'{ Object (hash, value, ...) } to deeply equal { Object (hash, value, ...) }'
);
});
});
};

0 comments on commit 62dd2f9

Please sign in to comment.