mirror of
https://github.com/trezor/trezor-suite.git
synced 2026-03-04 06:25:11 +01:00
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { scheduleAction } from '../src/scheduleAction';
|
|
import { mockTime, unmockTime } from './utils/mockTime';
|
|
|
|
const ERR_SIGNAL = 'Aborted by signal';
|
|
const ERR_DEADLINE = 'Aborted by deadline';
|
|
const ERR_TIMEOUT = 'Aborted by timeout';
|
|
|
|
const MAX_LISTENERS = 6; // scheduleAction should never use more than six abort listeners in parallel
|
|
|
|
describe('scheduleAction', () => {
|
|
const spyAdd = jest.spyOn(AbortSignal.prototype, 'addEventListener');
|
|
const spyRemove = jest.spyOn(AbortSignal.prototype, 'removeEventListener');
|
|
|
|
const checkListeners = () => {
|
|
const addings = spyAdd.mock.invocationCallOrder;
|
|
const removals = spyRemove.mock.invocationCallOrder;
|
|
|
|
let [a, r] = [0, 0];
|
|
while (a < addings.length && r < removals.length) {
|
|
if (addings[a] < removals[r]) a++;
|
|
else r++;
|
|
if (a - r > MAX_LISTENERS) return `More than ${MAX_LISTENERS} simultaneous listeners`;
|
|
if (r > a) return `More listeners removed than added (shouldn't happen)`;
|
|
}
|
|
|
|
if (addings.length !== removals.length)
|
|
return `${addings.length - removals.length} listeners not cleaned`;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockTime();
|
|
});
|
|
|
|
afterEach(() => {
|
|
unmockTime();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('delay', () =>
|
|
new Promise<void>(done => {
|
|
const spy = jest.fn(() => Promise.resolve());
|
|
scheduleAction(spy, { delay: 1000 });
|
|
expect(spy).toHaveBeenCalledTimes(0);
|
|
|
|
setTimeout(() => {
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(checkListeners()).toBeUndefined();
|
|
done();
|
|
}, 1005);
|
|
}));
|
|
|
|
it('delay aborted', () =>
|
|
new Promise<void>(done => {
|
|
const aborter = new AbortController();
|
|
const spy = jest.fn(() => Promise.resolve());
|
|
scheduleAction(spy, { delay: 1000, signal: aborter.signal }).catch(e => {
|
|
expect(e.message).toMatch(ERR_SIGNAL);
|
|
expect(spy).toHaveBeenCalledTimes(0);
|
|
expect(checkListeners()).toBeUndefined();
|
|
done();
|
|
});
|
|
|
|
aborter.abort();
|
|
}));
|
|
|
|
it('deadline on always error action', async () => {
|
|
const spy = jest.fn(() => {
|
|
throw new Error('Runtime error');
|
|
});
|
|
|
|
await expect(() => scheduleAction(spy, { deadline: Date.now() + 1000 })).rejects.toThrow(
|
|
ERR_DEADLINE,
|
|
);
|
|
|
|
// more thant 100 attempts
|
|
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(100);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('deadline aborted after 3rd attempt', async () => {
|
|
let i = 0;
|
|
const aborter = new AbortController();
|
|
const spy = jest.fn(() => {
|
|
if (i >= 2) {
|
|
// abort on third attempt
|
|
aborter.abort();
|
|
}
|
|
i++;
|
|
throw new Error('Runtime error');
|
|
});
|
|
|
|
await expect(() =>
|
|
scheduleAction(spy, { deadline: Date.now() + 1000, signal: aborter.signal }),
|
|
).rejects.toThrow(ERR_SIGNAL);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(3);
|
|
await Promise.resolve(); // let the last event listener clear itself
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('deadline resolved after 3rd attempt', async () => {
|
|
let i = 0;
|
|
const spy = jest.fn(() => {
|
|
if (i >= 2) {
|
|
return Promise.resolve('Foo');
|
|
}
|
|
i++;
|
|
throw new Error('Runtime error');
|
|
});
|
|
|
|
const result = await scheduleAction(spy, { deadline: Date.now() + 1000 });
|
|
|
|
expect(result).toEqual('Foo');
|
|
|
|
expect(spy).toHaveBeenCalledTimes(3);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('attempt timeout', async () => {
|
|
const spy = jest.fn(
|
|
(signal?: AbortSignal) =>
|
|
new Promise((_, reject) => {
|
|
const onAbort = () => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
reject(new Error('Runtime error'));
|
|
};
|
|
signal?.addEventListener('abort', onAbort);
|
|
}),
|
|
);
|
|
await expect(() => scheduleAction(spy, { timeout: 500 })).rejects.toThrow(ERR_TIMEOUT);
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('attempt timeout aborted', async () => {
|
|
const aborter = new AbortController();
|
|
const spy = jest.fn(
|
|
() =>
|
|
new Promise(resolve => {
|
|
setTimeout(resolve, 1000);
|
|
}),
|
|
);
|
|
|
|
const start = Date.now();
|
|
const promise = scheduleAction(spy, { timeout: 300, signal: aborter.signal });
|
|
|
|
// abort before timeout
|
|
setTimeout(() => aborter.abort(), 300);
|
|
|
|
await expect(promise).rejects.toThrow(ERR_SIGNAL);
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(Date.now() - start).toBeLessThanOrEqual(350);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('attempt error handler', async () => {
|
|
const spy = jest.fn(
|
|
(signal?: AbortSignal) =>
|
|
new Promise<any>((_, reject) => {
|
|
signal?.addEventListener('abort', () => reject(new Error('Runtime error')));
|
|
}),
|
|
);
|
|
let i = 0;
|
|
await expect(() =>
|
|
scheduleAction(spy, {
|
|
timeout: 500,
|
|
attempts: 5,
|
|
attemptFailureHandler: () => {
|
|
if (i > 1) {
|
|
// throw on 3rd attempt
|
|
throw new Error('Unexpected');
|
|
}
|
|
i++;
|
|
},
|
|
}),
|
|
).rejects.toThrow('Unexpected');
|
|
expect(spy).toHaveBeenCalledTimes(3);
|
|
|
|
i = 0;
|
|
spy.mockClear();
|
|
await expect(() =>
|
|
scheduleAction(spy, {
|
|
timeout: 500,
|
|
attempts: 5,
|
|
attemptFailureHandler: () => {
|
|
if (i > 1) {
|
|
// return error on 3rd attempt
|
|
return new Error('Unexpected');
|
|
}
|
|
i++;
|
|
},
|
|
}),
|
|
).rejects.toThrow('Unexpected');
|
|
expect(spy).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('deadline with attempt timeout', async () => {
|
|
const spy = jest.fn(
|
|
(signal?: AbortSignal) =>
|
|
new Promise<any>((_, reject) => {
|
|
const onAbort = () => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
reject(new Error('Runtime error'));
|
|
};
|
|
signal?.addEventListener('abort', onAbort);
|
|
}),
|
|
);
|
|
await expect(() =>
|
|
scheduleAction(spy, { deadline: Date.now() + 2000, timeout: 500 }),
|
|
).rejects.toThrow(ERR_DEADLINE);
|
|
expect(spy).toHaveBeenCalledTimes(4); // 4 attempts till deadline, each timeouted after 500 ms
|
|
await Promise.resolve(); // let the last event listener clear itself
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it('max attempts', async () => {
|
|
const spy = jest.fn(() => {
|
|
throw new Error('Runtime error');
|
|
});
|
|
|
|
await expect(() => scheduleAction(spy, { timeout: 500, attempts: 2 })).rejects.toThrow(
|
|
/Runtime error/,
|
|
);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(2);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it("don't abort after success", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
const action = (sig?: AbortSignal) => {
|
|
signal = sig;
|
|
|
|
return Promise.resolve(true);
|
|
};
|
|
const result = await scheduleAction(action, {});
|
|
expect(result).toBe(true);
|
|
expect(signal?.aborted).toBe(false);
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
|
|
it("don't reject after abort (graceful: true)", async () => {
|
|
let ctrl = new AbortController();
|
|
const action = (sig?: AbortSignal) =>
|
|
new Promise(resolve => {
|
|
sig?.addEventListener('abort', () => {
|
|
setTimeout(() => resolve({ success: false }), 1000);
|
|
});
|
|
});
|
|
|
|
let resultPromise = scheduleAction(action, {
|
|
signal: ctrl.signal,
|
|
});
|
|
new Promise(resolve => setTimeout(resolve, 1));
|
|
ctrl.abort();
|
|
await expect(() => resultPromise).rejects.toThrow(ERR_SIGNAL);
|
|
|
|
ctrl = new AbortController();
|
|
resultPromise = scheduleAction(action, {
|
|
signal: ctrl.signal,
|
|
graceful: true,
|
|
timeout: 2000,
|
|
});
|
|
await new Promise(resolve => setTimeout(resolve, 1));
|
|
ctrl.abort();
|
|
const result = await resultPromise;
|
|
expect(result).toEqual({ success: false });
|
|
});
|
|
|
|
it('variable timeouts', async () => {
|
|
const TIMEOUTS = [50, 150, 100];
|
|
const MARGIN = 10;
|
|
|
|
const times: number[] = [Date.now()];
|
|
const action = (signal?: AbortSignal) => {
|
|
const onAbort = () => {
|
|
times.push(Date.now());
|
|
signal?.removeEventListener('abort', onAbort);
|
|
};
|
|
signal?.addEventListener('abort', onAbort);
|
|
|
|
return new Promise(() => {});
|
|
};
|
|
|
|
await expect(() =>
|
|
scheduleAction(action, {
|
|
attempts: TIMEOUTS.map(timeout => ({ timeout })),
|
|
}),
|
|
).rejects.toThrow(ERR_TIMEOUT);
|
|
|
|
expect(times.length).toEqual(TIMEOUTS.length + 1);
|
|
for (let i = 0; i < TIMEOUTS.length; i++) {
|
|
const diff = times[i + 1] - times[i];
|
|
expect(diff).toBeGreaterThanOrEqual(TIMEOUTS[i] - MARGIN);
|
|
expect(diff).toBeLessThanOrEqual(TIMEOUTS[i] + MARGIN);
|
|
}
|
|
expect(checkListeners()).toBeUndefined();
|
|
});
|
|
});
|