test: Coverage more @hyperlane-xyz/utils test (#4758)

### Description
Add more coverage `utils` package test
<!--
What's included in this PR?
-->

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues

<!--
- Fixes #[issue number here]
-->

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->

### Testing

<!--
What kind of testing have these changes undergone?

None/Manual/Unit Tests
-->
More unittests function
pull/4772/head
Tien Dao 4 weeks ago committed by GitHub
parent 5dabdf3887
commit c622bfbcf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      typescript/utils/package.json
  2. 54
      typescript/utils/src/arrays.test.ts
  3. 146
      typescript/utils/src/async.test.ts
  4. 37
      typescript/utils/src/base58.test.ts
  5. 74
      typescript/utils/src/base64.test.ts
  6. 120
      typescript/utils/src/checkpoints.test.ts
  7. 2
      typescript/utils/src/checkpoints.ts
  8. 18
      typescript/utils/src/env.test.ts
  9. 52
      typescript/utils/src/ids.test.ts
  10. 39
      typescript/utils/src/logging.test.ts
  11. 5
      typescript/utils/src/math.test.ts
  12. 39
      typescript/utils/src/sets.test.ts
  13. 57
      typescript/utils/src/strings.test.ts
  14. 42
      typescript/utils/src/typeof.test.ts
  15. 13
      typescript/utils/src/validation.test.ts
  16. 33
      typescript/utils/src/yaml.test.ts
  17. 3
      yarn.lock

@ -14,9 +14,12 @@
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/mocha": "^10.0.1",
"@types/sinon": "^17.0.1",
"@types/sinon-chai": "^3.2.12",
"chai": "4.5.0",
"mocha": "^10.2.0",
"prettier": "^2.8.8",
"sinon": "^13.0.2",
"typescript": "5.3.3"
},
"homepage": "https://www.hyperlane.xyz",

@ -0,0 +1,54 @@
import { expect } from 'chai';
import { chunk, exclude, randomElement } from './arrays.js';
describe('Arrays utilities', () => {
describe('chunk', () => {
it('should split an array into chunks of the specified size', () => {
const result = chunk([1, 2, 3, 4, 5], 2);
expect(result).to.deep.equal([[1, 2], [3, 4], [5]]);
});
it('should return an empty array when input is empty', () => {
const result = chunk([], 2);
expect(result).to.deep.equal([]);
});
it('should handle chunk size larger than array length', () => {
const result = chunk([1, 2], 5);
expect(result).to.deep.equal([[1, 2]]);
});
});
describe('exclude', () => {
it('should exclude the specified item from the list', () => {
const result = exclude(2, [1, 2, 3, 2]);
expect(result).to.deep.equal([1, 3]);
});
it('should return the same list if item is not found', () => {
const result = exclude(4, [1, 2, 3]);
expect(result).to.deep.equal([1, 2, 3]);
});
it('should return an empty list if all items are excluded', () => {
const result = exclude(1, [1, 1, 1]);
expect(result).to.deep.equal([]);
});
});
describe('randomElement', () => {
beforeEach(() => {});
it('should return a random element from the list', () => {
const list = [10, 20, 30];
const result = randomElement(list);
expect(result).to.be.oneOf(list);
});
it('should handle an empty list gracefully', () => {
const result = randomElement([]);
expect(result).to.be.undefined;
});
});
});

@ -0,0 +1,146 @@
import { expect } from 'chai';
import {
concurrentMap,
fetchWithTimeout,
pollAsync,
raceWithContext,
retryAsync,
runWithTimeout,
sleep,
timeout,
} from './async.js';
describe('Async Utilities', () => {
describe('sleep', () => {
it('should resolve after sleep duration', async () => {
const start = Date.now();
await sleep(100);
const duration = Date.now() - start;
expect(duration).to.be.at.least(100);
expect(duration).to.be.lessThan(200);
});
});
describe('timeout', () => {
it('should timeout a promise', async () => {
const promise = new Promise((resolve) => setTimeout(resolve, 200));
try {
await timeout(promise, 100);
throw new Error('Expected timeout error');
} catch (error: any) {
expect(error.message).to.equal('Timeout reached');
}
});
});
describe('runWithTimeout', () => {
it('should run a callback with a timeout', async () => {
const result = await runWithTimeout(100, async () => {
await sleep(50);
return 'success';
});
expect(result).to.equal('success');
});
});
describe('fetchWithTimeout', () => {
it('should fetch with timeout', async () => {
// Mock fetch for testing
global.fetch = async () => {
await sleep(50);
return new Response('ok');
};
const response = await fetchWithTimeout('https://example.com', {}, 100);
expect(await response.text()).to.equal('ok');
});
});
describe('retryAsync', () => {
it('should retry async function with exponential backoff', async () => {
let attempt = 0;
const runner = async () => {
attempt++;
if (attempt < 3) throw new Error('fail');
return 'success';
};
const result = await retryAsync(runner, 5, 10);
expect(result).to.equal('success');
});
});
describe('pollAsync', () => {
it('should poll async function until success', async () => {
let attempt = 0;
const runner = async () => {
attempt++;
if (attempt < 3) throw new Error('fail');
return 'success';
};
const result = await pollAsync(runner, 10, 5);
expect(result).to.equal('success');
});
it('should fail after reaching max retries', async () => {
let attempt = 0;
const runner = async () => {
attempt++;
throw new Error('fail');
};
try {
await pollAsync(runner, 10, 3); // Set maxAttempts to 3
throw new Error('Expected pollAsync to throw an error');
} catch (error: any) {
expect(attempt).to.equal(3); // Ensure it attempted 3 times
expect(error.message).to.equal('fail');
}
});
});
describe('raceWithContext', () => {
it('should race with context', async () => {
const promises = [
sleep(50).then(() => 'first'),
sleep(100).then(() => 'second'),
];
const result = await raceWithContext(promises);
expect(result.resolved).to.equal('first');
expect(result.index).to.equal(0);
});
});
describe('concurrentMap', () => {
it('should map concurrently with correct results', async () => {
const xs = [1, 2, 3, 4, 5, 6];
const mapFn = async (val: number) => {
await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate async work
return val * 2;
};
const result = await concurrentMap(2, xs, mapFn);
expect(result).to.deep.equal([2, 4, 6, 8, 10, 12]);
});
it('should respect concurrency limit', async () => {
const xs = [1, 2, 3, 4, 5, 6];
const concurrency = 2;
let activeTasks = 0;
let maxActiveTasks = 0;
const mapFn = async (val: number) => {
activeTasks++;
maxActiveTasks = Math.max(maxActiveTasks, activeTasks);
await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate async work
activeTasks--;
return val * 2;
};
await concurrentMap(concurrency, xs, mapFn);
expect(maxActiveTasks).to.equal(concurrency);
});
});
});

@ -0,0 +1,37 @@
import { expect } from 'chai';
import { utils } from 'ethers';
import { base58ToBuffer, bufferToBase58, hexOrBase58ToHex } from './base58.js';
describe('Base58 Utilities', () => {
describe('base58ToBuffer', () => {
it('should convert a base58 string to a buffer', () => {
const base58String = '3mJr7AoUXx2Wqd';
const expectedBuffer = Buffer.from(utils.base58.decode(base58String));
expect(base58ToBuffer(base58String)).to.deep.equal(expectedBuffer);
});
});
describe('bufferToBase58', () => {
it('should convert a buffer to a base58 string', () => {
const buffer = Buffer.from([1, 2, 3, 4]);
const expectedBase58String = utils.base58.encode(buffer);
expect(bufferToBase58(buffer)).to.equal(expectedBase58String);
});
});
describe('hexOrBase58ToHex', () => {
it('should return the hex string as is if it starts with 0x', () => {
const hexString = '0x1234abcd';
expect(hexOrBase58ToHex(hexString)).to.equal(hexString);
});
it('should convert a base58 string to a hex string', () => {
const base58String = '3mJr7AoUXx2Wqd';
const expectedHexString = utils.hexlify(
Buffer.from(utils.base58.decode(base58String)),
);
expect(hexOrBase58ToHex(base58String)).to.equal(expectedHexString);
});
});
});

@ -0,0 +1,74 @@
import { expect } from 'chai';
import Sinon from 'sinon';
import { fromBase64, toBase64 } from './base64.js';
import { rootLogger } from './logging.js';
describe('Base64 Utility Functions', () => {
let loggerStub: sinon.SinonStub;
beforeEach(() => {
loggerStub = Sinon.stub(rootLogger, 'error');
});
afterEach(() => {
loggerStub.restore();
});
describe('toBase64', () => {
it('should encode a valid object to a base64 string', () => {
const data = { key: 'value' };
const result = toBase64(data);
expect(result).to.be.a('string');
expect(result).to.equal(btoa(JSON.stringify(data)));
});
it('should return undefined for null or undefined input', () => {
expect(toBase64(null)).to.be.undefined;
expect(toBase64(undefined)).to.be.undefined;
});
it('should log an error for invalid input', () => {
toBase64(null);
expect(loggerStub.calledOnce).to.be.true;
expect(
loggerStub.calledWith(
'Unable to serialize + encode data to base64',
null,
),
).to.be.true;
});
});
describe('fromBase64', () => {
it('should decode a valid base64 string to an object', () => {
const data = { key: 'value' };
const base64String = btoa(JSON.stringify(data));
const result = fromBase64(base64String);
expect(result).to.deep.equal(data);
});
it('should return undefined for null or undefined input', () => {
expect(fromBase64(null as any)).to.be.undefined;
expect(fromBase64(undefined as any)).to.be.undefined;
});
it('should handle array input and decode the first element', () => {
const data = { key: 'value' };
const base64String = btoa(JSON.stringify(data));
const result = fromBase64([base64String, 'anotherString']);
expect(result).to.deep.equal(data);
});
it('should log an error for invalid base64 input', () => {
fromBase64('invalidBase64');
expect(loggerStub.calledOnce).to.be.true;
expect(
loggerStub.calledWith(
'Unable to decode + deserialize data from base64',
'invalidBase64',
),
).to.be.true;
});
});
});

@ -0,0 +1,120 @@
import { expect } from 'chai';
import {
isCheckpoint,
isS3Checkpoint,
isS3CheckpointWithId,
isValidSignature,
} from './checkpoints.js';
import { Checkpoint, S3Checkpoint, S3CheckpointWithId } from './types.js';
describe('Checkpoints', () => {
describe('isValidSignature', () => {
it('should return true for valid string signature', () => {
const signature = '0x' + 'a'.repeat(130); // Example of a valid hex string
expect(isValidSignature(signature)).to.be.true;
});
it('should return true for valid object signature', () => {
const signature = {
r: '0x' + 'a'.repeat(64),
s: '0x' + 'b'.repeat(64),
v: 27,
};
expect(isValidSignature(signature)).to.be.true;
});
it('should return false for invalid signature', () => {
const signature = {
r: '0x' + 'a'.repeat(64),
s: '0x' + 'b'.repeat(64),
v: 'invalid',
};
expect(isValidSignature(signature)).to.be.false;
});
});
describe('isCheckpoint', () => {
it('should return true for valid checkpoint', () => {
const checkpoint: Checkpoint = {
root: '0x' + 'a'.repeat(64),
index: 1,
merkle_tree_hook_address: '0x' + 'b'.repeat(40),
mailbox_domain: 123,
};
expect(isCheckpoint(checkpoint)).to.be.true;
});
it('should return false for invalid checkpoint', () => {
const checkpoint = {
root: 'invalid',
index: 'invalid',
merkle_tree_hook_address: 'invalid',
mailbox_domain: 'invalid',
};
expect(isCheckpoint(checkpoint)).to.be.false;
});
});
describe('isS3Checkpoint', () => {
it('should return true for valid S3Checkpoint', () => {
const s3Checkpoint: S3Checkpoint = {
signature: '0x' + 'a'.repeat(130),
value: {
root: '0x' + 'a'.repeat(64),
index: 1,
merkle_tree_hook_address: '0x' + 'b'.repeat(40),
mailbox_domain: 123,
},
};
expect(isS3Checkpoint(s3Checkpoint)).to.be.true;
});
it('should return false for invalid S3Checkpoint', () => {
const s3Checkpoint = {
signature: 'invalid',
value: {
root: 'invalid',
index: 'invalid',
merkle_tree_hook_address: 'invalid',
mailbox_domain: 'invalid',
},
};
expect(isS3Checkpoint(s3Checkpoint)).to.be.false;
});
});
describe('isS3CheckpointWithId', () => {
it('should return true for valid S3CheckpointWithId', () => {
const s3CheckpointWithId: S3CheckpointWithId = {
signature: '0x' + 'a'.repeat(130),
value: {
checkpoint: {
root: '0x' + 'a'.repeat(64),
index: 1,
merkle_tree_hook_address: '0x' + 'b'.repeat(40),
mailbox_domain: 123,
},
message_id: '0x' + 'c'.repeat(64),
},
};
expect(isS3CheckpointWithId(s3CheckpointWithId)).to.be.true;
});
it('should return false for invalid S3CheckpointWithId', () => {
const s3CheckpointWithId = {
signature: 'invalid',
value: {
checkpoint: {
root: 'invalid',
index: 'invalid',
merkle_tree_hook_address: 'invalid',
mailbox_domain: 'invalid',
},
message_id: 'invalid',
},
};
expect(isS3CheckpointWithId(s3CheckpointWithId)).to.be.false;
});
});
});

@ -7,7 +7,7 @@ import {
SignatureLike,
} from './types.js';
function isValidSignature(signature: any): signature is SignatureLike {
export function isValidSignature(signature: any): signature is SignatureLike {
return typeof signature === 'string'
? utils.isHexString(signature)
: utils.isHexString(signature.r) &&

@ -0,0 +1,18 @@
import { expect } from 'chai';
import { safelyAccessEnvVar } from './env.js';
describe('Env Utilities', () => {
describe('safelyAccessEnvVar', () => {
it('should return the environment variable', () => {
process.env.TEST_VAR = '0xTEST_VAR';
expect(safelyAccessEnvVar('TEST_VAR')).to.equal('0xTEST_VAR');
expect(safelyAccessEnvVar('TEST_VAR', true)).to.equal('0xtest_var');
});
it('should return undefined if the environment variable is not set', () => {
expect(safelyAccessEnvVar('NON_EXISTENT_VAR')).to.be.undefined;
expect(safelyAccessEnvVar('NON_EXISTENT_VAR', true)).to.be.undefined;
});
});
});

@ -0,0 +1,52 @@
import { expect } from 'chai';
import { utils } from 'ethers';
import { canonizeId, evmId } from './ids.js';
describe('ID Utilities', () => {
describe('canonizeId', () => {
it('should convert a 20-byte ID to a 32-byte ID', () => {
const id = '0x1234567890123456789012345678901234567890';
const result = canonizeId(id);
expect(result).to.be.instanceOf(Uint8Array);
expect(result.length).to.equal(32);
expect(utils.hexlify(result)).to.equal(
'0x0000000000000000000000001234567890123456789012345678901234567890',
);
});
it('should throw an error for IDs longer than 32 bytes', () => {
const id = '0x' + '12'.repeat(33);
expect(() => canonizeId(id)).to.throw('Too long');
});
it('should throw an error for IDs not 20 or 32 bytes', () => {
const id = '0x1234567890';
expect(() => canonizeId(id)).to.throw(
'bad input, expect address or bytes32',
);
});
});
describe('evmId', () => {
it('should convert a 32-byte ID to a 20-byte EVM address', () => {
const id =
'0x' + '00'.repeat(12) + '1234567890123456789012345678901234567890';
const result = evmId(id);
expect(result).to.equal('0x1234567890123456789012345678901234567890');
});
it('should return the same 20-byte ID as a 20-byte EVM address', () => {
const id = '0x1234567890123456789012345678901234567890';
const result = evmId(id);
expect(result).to.equal(id);
});
it('should throw an error for IDs not 20 or 32 bytes', () => {
const id = '0x1234567890';
expect(() => evmId(id)).to.throw(
'Invalid id length. expected 20 or 32. Got 5',
);
});
});
});

@ -0,0 +1,39 @@
import { expect } from 'chai';
import { BigNumber } from 'ethers';
import { ethersBigNumberSerializer } from './logging.js';
describe('Logging Utilities', () => {
describe('ethersBigNumberSerializer', () => {
it('should serialize a BigNumber object correctly', () => {
const key = 'testKey';
const value = {
type: 'BigNumber',
hex: '0x1a',
};
const result = ethersBigNumberSerializer(key, value);
expect(result).to.equal(BigNumber.from(value.hex).toString());
});
it('should return the value unchanged if it is not a BigNumber', () => {
const key = 'testKey';
const value = { some: 'object' };
const result = ethersBigNumberSerializer(key, value);
expect(result).to.equal(value);
});
it('should return the value unchanged if it is null', () => {
const key = 'testKey';
const value = null;
const result = ethersBigNumberSerializer(key, value);
expect(result).to.equal(value);
});
it('should return the value unchanged if it is not an object', () => {
const key = 'testKey';
const value = 'string';
const result = ethersBigNumberSerializer(key, value);
expect(result).to.equal(value);
});
});
});

@ -20,6 +20,7 @@ describe('Math Utility Functions', () => {
describe('sum', () => {
it('should return the sum of an array', () => {
expect(sum([1, 2, 3, 4])).to.equal(10);
expect(sum([1, -2, 3, 4])).to.equal(6);
});
});
@ -33,6 +34,10 @@ describe('Math Utility Functions', () => {
it('should return the standard deviation of an array', () => {
expect(stdDev([1, 2, 3, 4])).to.be.closeTo(1.118, 0.001);
});
it('should return the standard deviation of an array with negative numbers', () => {
expect(stdDev([-1, -2, -3, -4])).to.be.closeTo(1.118, 0.001);
});
});
describe('randomInt', () => {

@ -0,0 +1,39 @@
import { expect } from 'chai';
import { difference, setEquality, symmetricDifference } from './sets.js';
describe('Set Operations', () => {
describe('difference', () => {
it('should return the difference of two sets', () => {
const setA = new Set([1, 2, 3, undefined]);
const setB = new Set([2, 3, 4]);
const result = difference(setA, setB);
expect(result).to.deep.equal(new Set([1, undefined]));
});
});
describe('symmetricDifference', () => {
it('should return the symmetric difference of two sets', () => {
const setA = new Set([1, 2, 3]);
const setB = new Set([2, 3, 4]);
const result = symmetricDifference(setA, setB);
expect(result).to.deep.equal(new Set([1, 4]));
});
});
describe('setEquality', () => {
it('should return true for equal sets', () => {
const setA = new Set([1, 2, 3]);
const setB = new Set([1, 2, 3]);
const result = setEquality(setA, setB);
expect(result).to.be.true;
});
it('should return false for non-equal sets', () => {
const setA = new Set([1, 2, 3]);
const setB = new Set([1, 2, 4]);
const result = setEquality(setA, setB);
expect(result).to.be.false;
});
});
});

@ -0,0 +1,57 @@
import { expect } from 'chai';
import { Readable } from 'stream';
import {
errorToString,
fromHexString,
sanitizeString,
streamToString,
toHexString,
toTitleCase,
trimToLength,
} from './strings.js';
describe('String Utilities', () => {
it('should convert string to title case', () => {
expect(toTitleCase('hello world')).to.equal('Hello World');
expect(toTitleCase('HELLO WORLD')).to.equal('Hello World');
expect(toTitleCase('4ELLO WORLD')).to.equal('4ello World');
expect(toTitleCase('')).to.equal('');
});
it('should sanitize string by removing non-alphanumeric characters', () => {
expect(sanitizeString('Hello, World!')).to.equal('helloworld');
expect(sanitizeString('123-456')).to.equal('123456');
expect(sanitizeString('')).to.equal('');
});
it('should trim string to specified length', () => {
expect(trimToLength('Hello, World!', 5)).to.equal('Hello...');
expect(trimToLength('Short', 10)).to.equal('Short');
expect(trimToLength('', 10)).to.equal('');
});
it('should convert stream to string', async () => {
const stream = new Readable();
stream.push('Hello, ');
stream.push('World!');
stream.push(null);
const result = await streamToString(stream);
expect(result).to.equal('Hello, World!');
});
it('should convert error to string', () => {
expect(errorToString('Error message')).to.equal('Error message');
expect(errorToString({ message: 'Error object' })).to.equal('Error object');
expect(errorToString(404)).to.equal('Error code: 404');
expect(errorToString(null)).to.equal('Unknown Error');
});
it('should convert hex string to buffer and back', () => {
const hexString = '0x48656c6c6f';
const buffer = fromHexString(hexString);
expect(buffer.toString('utf8')).to.equal('Hello');
expect(toHexString(buffer)).to.equal(hexString);
});
});

@ -0,0 +1,42 @@
import { expect } from 'chai';
import { isNullish, isNumeric } from './typeof.js';
describe('isNullish', () => {
it('should return true for null', () => {
expect(isNullish(null)).to.be.true;
});
it('should return true for undefined', () => {
expect(isNullish(undefined)).to.be.true;
});
it('should return false for non-nullish values', () => {
expect(isNullish('')).to.be.false;
expect(isNullish(0)).to.be.false;
expect(isNullish(false)).to.be.false;
});
});
describe('isNumeric', () => {
it('should return true for numeric strings', () => {
expect(isNumeric('123')).to.be.true;
});
it('should return true for numbers', () => {
expect(isNumeric(123)).to.be.true;
});
it('should return true for negative numbers', () => {
expect(isNumeric(-123)).to.be.true;
});
it('should return true for floating point numbers', () => {
expect(isNumeric(123.45)).to.be.true;
});
it('should return false for non-numeric strings', () => {
expect(isNumeric('abc')).to.be.false;
expect(isNumeric('123abc')).to.be.false;
});
});

@ -0,0 +1,13 @@
import { expect } from 'chai';
import { assert } from './validation.js';
describe('assert', () => {
it('should not throw an error when the predicate is true', () => {
expect(() => assert(true, 'Error message')).to.not.throw();
});
it('should throw an error when the predicate is false', () => {
expect(() => assert(false, 'Error message')).to.throw('Error message');
});
});

@ -0,0 +1,33 @@
import { expect } from 'chai';
import { tryParseJsonOrYaml } from './yaml.js';
describe('tryParseJsonOrYaml', () => {
it('should parse valid JSON string', () => {
const jsonString = '{"key": "value"}';
const result: any = tryParseJsonOrYaml(jsonString);
expect(result.success).to.be.true;
expect(result.data).to.deep.equal({ key: 'value' });
});
it('should parse valid YAML string', () => {
const yamlString = 'key: value';
const result: any = tryParseJsonOrYaml(yamlString);
expect(result.success).to.be.true;
expect(result.data).to.deep.equal({ key: 'value' });
});
it('should fail for invalid JSON string', () => {
const invalidJsonString = '{"key": "value"';
const result: any = tryParseJsonOrYaml(invalidJsonString);
expect(result.success).to.be.false;
expect(result.error).to.equal('Input is not valid JSON or YAML');
});
it('should fail for invalid YAML string', () => {
const invalidYamlString = 'key: value:';
const result: any = tryParseJsonOrYaml(invalidYamlString);
expect(result.success).to.be.false;
expect(result.error).to.equal('Input is not valid JSON or YAML');
});
});

@ -8107,6 +8107,8 @@ __metadata:
"@solana/web3.js": "npm:^1.78.0"
"@types/lodash-es": "npm:^4.17.12"
"@types/mocha": "npm:^10.0.1"
"@types/sinon": "npm:^17.0.1"
"@types/sinon-chai": "npm:^3.2.12"
bignumber.js: "npm:^9.1.1"
chai: "npm:4.5.0"
ethers: "npm:^5.7.2"
@ -8114,6 +8116,7 @@ __metadata:
mocha: "npm:^10.2.0"
pino: "npm:^8.19.0"
prettier: "npm:^2.8.8"
sinon: "npm:^13.0.2"
typescript: "npm:5.3.3"
yaml: "npm:2.4.5"
languageName: unknown

Loading…
Cancel
Save