diff --git a/src/jest/index.ts b/src/jest/index.ts index abad5e11..1e9f4569 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -8,7 +8,7 @@ import { toHaveRecord } from './kinesis'; import { toHaveObject } from './s3'; import { toHaveMessage } from './sqs'; import { toBeAtState, toHaveState } from './stepFunctions'; -import { wrapWithRetries } from './utils'; +import { wrapWithRetries, wrapWithRetryUntilPass } from './utils'; declare global { namespace jest { @@ -32,7 +32,7 @@ declare global { expect.extend({ toBeAtState: wrapWithRetries(toBeAtState) as typeof toBeAtState, toHaveItem: wrapWithRetries(toHaveItem) as typeof toHaveItem, - toHaveLog: wrapWithRetries(toHaveLog) as typeof toHaveLog, + toHaveLog: wrapWithRetryUntilPass(toHaveLog) as typeof toHaveLog, toHaveMessage: wrapWithRetries(toHaveMessage) as typeof toHaveMessage, toHaveObject: wrapWithRetries(toHaveObject) as typeof toHaveObject, toHaveRecord, // has built in timeout mechanism due to how kinesis consumer works diff --git a/src/jest/utils.test.ts b/src/jest/utils.test.ts index c49ba5d3..99405a52 100644 --- a/src/jest/utils.test.ts +++ b/src/jest/utils.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { ICommonProps } from '../common'; -import { wrapWithRetries } from './utils'; +import { wrapWithRetries, wrapWithRetryUntilPass } from './utils'; jest.mock('../common'); @@ -111,4 +111,95 @@ describe('utils', () => { expect(sleep).toHaveBeenCalledWith(500); // default pollEvery }); }); + + describe('wrapWithRetryUntilPass', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should retry once on pass === true', async () => { + const toWrap = jest.fn(); + const expectedResult = { pass: true, message: () => '' }; + toWrap.mockReturnValue(Promise.resolve(expectedResult)); + + const matcherUtils = {} as jest.MatcherUtils; + + const props = { region: 'region' } as ICommonProps; + const key = 'key'; + + const wrapped = wrapWithRetryUntilPass(toWrap); + const result = await wrapped.bind(matcherUtils)(props, key); + + expect(toWrap).toHaveBeenCalledTimes(1); + expect(toWrap).toHaveBeenCalledWith(props, key); + expect(result).toBe(expectedResult); + }); + + test('should exhaust timeout on pass === false', async () => { + const { sleep } = require('../common'); + + const mockedNow = jest.fn(); + Date.now = mockedNow; + mockedNow.mockReturnValueOnce(0); + mockedNow.mockReturnValueOnce(250); + mockedNow.mockReturnValueOnce(500); + mockedNow.mockReturnValueOnce(750); + mockedNow.mockReturnValueOnce(1000); + mockedNow.mockReturnValueOnce(1250); + + const toWrap = jest.fn(); + const expectedResult = { pass: false, message: () => '' }; + toWrap.mockReturnValue(Promise.resolve(expectedResult)); + + const matcherUtils = {} as jest.MatcherUtils; + + const props = { timeout: 1001, pollEvery: 250 } as ICommonProps; + const key = 'key'; + + const wrapped = wrapWithRetryUntilPass(toWrap); + const result = await wrapped.bind(matcherUtils)(props, key); + + expect(toWrap).toHaveBeenCalledTimes(5); + expect(toWrap).toHaveBeenCalledWith(props, key); + expect(result).toBe(expectedResult); + expect(sleep).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledWith(props.pollEvery); + }); + + test('should retry twice, { pass: false, isNot: false } => { pass: true, isNot: false }', async () => { + const { sleep } = require('../common'); + + const mockedNow = jest.fn(); + Date.now = mockedNow; + mockedNow.mockReturnValueOnce(0); + mockedNow.mockReturnValueOnce(250); + mockedNow.mockReturnValueOnce(500); + + const toWrap = jest.fn(); + // first attempt returns pass === false + toWrap.mockReturnValueOnce( + Promise.resolve({ pass: false, message: () => '' }), + ); + + // second attempt returns pass === true + const expectedResult = { pass: true, message: () => '' }; + toWrap.mockReturnValueOnce(Promise.resolve(expectedResult)); + + const matcherUtils = { + isNot: false, + } as jest.MatcherUtils; + + const props = {} as ICommonProps; + const key = 'key'; + + const wrapped = wrapWithRetryUntilPass(toWrap); + const result = await wrapped.bind(matcherUtils)(props, key); + + expect(toWrap).toHaveBeenCalledTimes(2); + expect(toWrap).toHaveBeenCalledWith(props, key); + expect(result).toBe(expectedResult); + expect(sleep).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledWith(500); // default pollEvery + }); + }); }); diff --git a/src/jest/utils.ts b/src/jest/utils.ts index 0b41d28f..969689d2 100644 --- a/src/jest/utils.ts +++ b/src/jest/utils.ts @@ -42,3 +42,37 @@ export const wrapWithRetries = ( } return wrapped; }; + +export const wrapWithRetryUntilPass = ( + matcher: (this: jest.MatcherUtils, ...args: any[]) => Promise, +) => { + async function wrapped( + this: jest.MatcherUtils, + props: ICommonProps, + ...args: any[] + ) { + const { timeout = 2500, pollEvery = 500 } = props; + + const start = Date.now(); + let result = await (matcher.apply(this, [ + props, + ...args, + ]) as Promise); + while (Date.now() - start < timeout) { + // return since result is found + if (result.pass) { + return result; + } + + // retry until result is found or timeout + await sleep(pollEvery); + + result = await (matcher.apply(this, [ + props, + ...args, + ]) as Promise); + } + return result; + } + return wrapped; +};