From 403f226a209e611d381703752457d00636012447 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Thu, 14 Apr 2022 01:29:17 -0500 Subject: [PATCH] feat(testing): add utility for faking time (#2069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartek IwaƄczuk --- testing/README.md | 53 +++ testing/_time.ts | 10 + testing/mock_examples/interval.ts | 4 + testing/mock_examples/interval_test.ts | 26 ++ testing/time.ts | 460 ++++++++++++++++++++ testing/time_test.ts | 568 +++++++++++++++++++++++++ 6 files changed, 1121 insertions(+) create mode 100644 testing/_time.ts create mode 100644 testing/mock_examples/interval.ts create mode 100644 testing/mock_examples/interval_test.ts create mode 100644 testing/time.ts create mode 100644 testing/time_test.ts diff --git a/testing/README.md b/testing/README.md index edc9d370d..fe0aff5a8 100644 --- a/testing/README.md +++ b/testing/README.md @@ -796,3 +796,56 @@ Deno.test("randomMultiple uses randomInt to generate random multiples between -1 assertSpyCalls(randomIntStub, 2); }); ``` + +### Faking time + +Say we have a function that has time based behavior that we would like to test. +With real time, that could cause tests to take much longer than they should. If +you fake time, you could simulate how your function would behave over time +starting from any point in time. Below is an example where we want to test that +the callback is called every second. + +```ts +// https://deno.land/std@$STD_VERSION/testing/mock_examples/interval.ts +export function secondInterval(cb: () => void): number { + return setInterval(cb, 1000); +} +``` + +With `FakeTime` we can do that. When the `FakeTime` instance is created, it +splits from real time. The `Date`, `setTimeout`, `clearTimeout`, `setInterval` +and `clearInterval` globals are replaced with versions that use the fake time +until real time is restored. You can control how time ticks forward with the +`tick` method on the `FakeTime` instance. + +```ts +// https://deno.land/std@$STD_VERSION/testing/mock_examples/interval_test.ts +import { + assertSpyCalls, + spy, +} from "https://deno.land/std@$STD_VERSION/testing/mock.ts"; +import { FakeTime } from "https://deno.land/std@$STD_VERSION/testing/time.ts"; +import { secondInterval } from "https://deno.land/std@$STD_VERSION/testing/mock_examples/interval.ts"; + +Deno.test("secondInterval calls callback every second and stops after being cleared", () => { + const time = new FakeTime(); + + try { + const cb = spy(); + const intervalId = secondInterval(cb); + assertSpyCalls(cb, 0); + time.tick(500); + assertSpyCalls(cb, 0); + time.tick(500); + assertSpyCalls(cb, 1); + time.tick(3500); + assertSpyCalls(cb, 4); + + clearInterval(intervalId); + time.tick(1000); + assertSpyCalls(cb, 4); + } finally { + time.restore(); + } +}); +``` diff --git a/testing/_time.ts b/testing/_time.ts new file mode 100644 index 000000000..ba0db3ae8 --- /dev/null +++ b/testing/_time.ts @@ -0,0 +1,10 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +/** Used internally for testing that fake time uses real time correctly. */ +export const _internals = { + Date, + setTimeout, + clearTimeout, + setInterval, + clearInterval, +}; diff --git a/testing/mock_examples/interval.ts b/testing/mock_examples/interval.ts new file mode 100644 index 000000000..62059bf9e --- /dev/null +++ b/testing/mock_examples/interval.ts @@ -0,0 +1,4 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. +export function secondInterval(cb: () => void): number { + return setInterval(cb, 1000); +} diff --git a/testing/mock_examples/interval_test.ts b/testing/mock_examples/interval_test.ts new file mode 100644 index 000000000..69d2174de --- /dev/null +++ b/testing/mock_examples/interval_test.ts @@ -0,0 +1,26 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. +import { assertSpyCalls, spy } from "../mock.ts"; +import { FakeTime } from "../time.ts"; +import { secondInterval } from "./interval.ts"; + +Deno.test("secondInterval calls callback every second and stops after being cleared", () => { + const time = new FakeTime(); + const cb = spy(); + + try { + const intervalId = secondInterval(cb); + assertSpyCalls(cb, 0); + time.tick(500); + assertSpyCalls(cb, 0); + time.tick(500); + assertSpyCalls(cb, 1); + time.tick(3500); + assertSpyCalls(cb, 4); + + clearInterval(intervalId); + time.tick(1000); + assertSpyCalls(cb, 4); + } finally { + time.restore(); + } +}); diff --git a/testing/time.ts b/testing/time.ts new file mode 100644 index 000000000..e8d5e60d0 --- /dev/null +++ b/testing/time.ts @@ -0,0 +1,460 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +/** This module is browser compatible. */ + +import { ascend, RBTree } from "../collections/rb_tree.ts"; +import { DelayOptions } from "../async/delay.ts"; +import { _internals } from "./_time.ts"; + +/** An error related to faking time. */ +export class TimeError extends Error { + constructor(message: string) { + super(message); + this.name = "TimeError"; + } +} + +function isFakeDate(instance: unknown): instance is FakeDate { + return instance instanceof FakeDate; +} + +interface FakeDate extends Date { + date: Date; +} + +function FakeDate(this: void): string; +function FakeDate(this: FakeDate): void; +function FakeDate( + this: FakeDate, + value: string | number | Date, +): void; +function FakeDate( + this: FakeDate, + year: number, + month: number, + date?: number, + hours?: number, + minutes?: number, + seconds?: number, + ms?: number, +): void; +function FakeDate( + this: FakeDate | void, + // deno-lint-ignore no-explicit-any + ...args: any[] +): string | void { + if (args.length === 0) args.push(FakeDate.now()); + if (isFakeDate(this)) { + this.date = new _internals.Date(...(args as [])); + } else { + return new _internals.Date(args[0]).toString(); + } +} + +FakeDate.parse = Date.parse; +FakeDate.UTC = Date.UTC; +FakeDate.now = () => time?.now ?? _internals.Date.now(); +Object.getOwnPropertyNames(Date.prototype).forEach((name: string) => { + const propName: keyof Date = name as keyof Date; + FakeDate.prototype[propName] = function ( + this: FakeDate, + // deno-lint-ignore no-explicit-any + ...args: any[] + // deno-lint-ignore no-explicit-any + ): any { + // deno-lint-ignore no-explicit-any + return (this.date[propName] as (...args: any[]) => any).apply( + this.date, + args, + ); + }; +}); +Object.getOwnPropertySymbols(Date.prototype).forEach((name: symbol) => { + const propName: keyof Date = name as unknown as keyof Date; + FakeDate.prototype[propName] = function ( + this: FakeDate, + // deno-lint-ignore no-explicit-any + ...args: any[] + // deno-lint-ignore no-explicit-any + ): any { + // deno-lint-ignore no-explicit-any + return (this.date[propName] as (...args: any[]) => any).apply( + this.date, + args, + ); + }; +}); + +interface Timer { + id: number; + // deno-lint-ignore no-explicit-any + callback: (...args: any[]) => void; + delay: number; + args: unknown[]; + due: number; + repeat: boolean; +} + +export interface FakeTimeOptions { + /** + * The rate relative to real time at which fake time is updated. + * By default time only moves forward through calling tick or setting now. + * Set to 1 to have the fake time automatically tick foward at the same rate in milliseconds as real time. + */ + advanceRate: number; + /** + * The frequency in milliseconds at which fake time is updated. + * If advanceRate is set, we will update the time every 10 milliseconds by default. + */ + advanceFrequency?: number; +} + +interface DueNode { + due: number; + timers: Timer[]; +} + +let time: FakeTime | undefined = undefined; + +function fakeSetTimeout( + // deno-lint-ignore no-explicit-any + callback: (...args: any[]) => void, + delay = 0, + // deno-lint-ignore no-explicit-any + ...args: any[] +): number { + if (!time) throw new TimeError("no fake time"); + return setTimer(callback, delay, args, false); +} + +function fakeClearTimeout(id?: number): void { + if (!time) throw new TimeError("no fake time"); + if (typeof id === "number" && dueNodes.has(id)) { + dueNodes.delete(id); + } +} + +function fakeSetInterval( + // deno-lint-ignore no-explicit-any + callback: (...args: any[]) => unknown, + delay = 0, + // deno-lint-ignore no-explicit-any + ...args: any[] +): number { + if (!time) throw new TimeError("no fake time"); + return setTimer(callback, delay, args, true); +} + +function fakeClearInterval(id?: number): void { + if (!time) throw new TimeError("no fake time"); + if (typeof id === "number" && dueNodes.has(id)) { + dueNodes.delete(id); + } +} + +function setTimer( + // deno-lint-ignore no-explicit-any + callback: (...args: any[]) => void, + delay = 0, + args: unknown[], + repeat = false, +): number { + const id: number = timerId.next().value; + delay = Math.max(repeat ? 1 : 0, Math.floor(delay)); + const due: number = now + delay; + let dueNode: DueNode | null = dueTree.find({ due } as DueNode); + if (dueNode === null) { + dueNode = { due, timers: [] }; + dueTree.insert(dueNode); + } + dueNode.timers.push({ + id, + callback, + args, + delay, + due, + repeat, + }); + dueNodes.set(id, dueNode); + return id; +} + +function overrideGlobals(): void { + globalThis.Date = FakeDate as DateConstructor; + globalThis.setTimeout = fakeSetTimeout; + globalThis.clearTimeout = fakeClearTimeout; + globalThis.setInterval = fakeSetInterval; + globalThis.clearInterval = fakeClearInterval; +} + +function restoreGlobals(): void { + globalThis.Date = _internals.Date; + globalThis.setTimeout = _internals.setTimeout; + globalThis.clearTimeout = _internals.clearTimeout; + globalThis.setInterval = _internals.setInterval; + globalThis.clearInterval = _internals.clearInterval; +} + +function* timerIdGen() { + let i = 1; + while (true) yield i++; +} + +let startedAt: number; +let now: number; +let initializedAt: number; +let advanceRate: number; +let advanceFrequency: number; +let advanceIntervalId: number | undefined; +let timerId: Generator; +let dueNodes: Map; +let dueTree: RBTree; + +/** + * Overrides the real Date object and timer functions with fake ones that can be + * controlled through the fake time instance. + * + * ```ts + * // https://deno.land/std@$STD_VERSION/testing/mock_examples/interval_test.ts + * import { + * assertSpyCalls, + * spy, + * } from "https://deno.land/std@$STD_VERSION/testing/mock.ts"; + * import { FakeTime } from "https://deno.land/std@$STD_VERSION/testing/time.ts"; + * import { secondInterval } from "https://deno.land/std@$STD_VERSION/testing/mock_examples/interval.ts"; + * + * Deno.test("secondInterval calls callback every second and stops after being cleared", () => { + * const time = new FakeTime(); + * + * try { + * const cb = spy(); + * const intervalId = secondInterval(cb); + * assertSpyCalls(cb, 0); + * time.tick(500); + * assertSpyCalls(cb, 0); + * time.tick(500); + * assertSpyCalls(cb, 1); + * time.tick(3500); + * assertSpyCalls(cb, 4); + * + * clearInterval(intervalId); + * time.tick(1000); + * assertSpyCalls(cb, 4); + * } finally { + * time.restore(); + * } + * }); + * ``` + */ +export class FakeTime { + constructor( + start?: number | string | Date | null, + options?: FakeTimeOptions, + ) { + if (time) time.restore(); + initializedAt = _internals.Date.now(); + startedAt = start instanceof Date + ? start.valueOf() + : typeof start === "number" + ? Math.floor(start) + : typeof start === "string" + ? (new Date(start)).valueOf() + : initializedAt; + if (Number.isNaN(startedAt)) throw new TimeError("invalid start"); + now = startedAt; + + timerId = timerIdGen(); + dueNodes = new Map(); + dueTree = new RBTree( + (a: DueNode, b: DueNode) => ascend(a.due, b.due), + ); + + overrideGlobals(); + time = this; + + advanceRate = Math.max( + 0, + options?.advanceRate ? options.advanceRate : 0, + ); + advanceFrequency = Math.max( + 0, + options?.advanceFrequency ? options.advanceFrequency : 10, + ); + advanceIntervalId = advanceRate > 0 + ? _internals.setInterval.call(null, () => { + this.tick(advanceRate * advanceFrequency); + }, advanceFrequency) + : undefined; + } + + /** Restores real time. */ + static restore(): void { + if (!time) throw new TimeError("time already restored"); + time.restore(); + } + + /** + * Restores real time temporarily until callback returns and resolves. + */ + static async restoreFor( + // deno-lint-ignore no-explicit-any + callback: (...args: any[]) => Promise | T, + // deno-lint-ignore no-explicit-any + ...args: any[] + ): Promise { + if (!time) throw new TimeError("no fake time"); + let result: T; + restoreGlobals(); + try { + result = await callback.apply(null, args); + } finally { + overrideGlobals(); + } + return result; + } + + /** + * The amount of milliseconds elapsed since January 1, 1970 00:00:00 UTC for the fake time. + * When set, it will call any functions waiting to be called between the current and new fake time. + * If the timer callback throws, time will stop advancing forward beyond that timer. + */ + get now(): number { + return now; + } + set now(value: number) { + if (value < now) throw new Error("time cannot go backwards"); + let dueNode: DueNode | null = dueTree.min(); + while (dueNode && dueNode.due <= value) { + const timer: Timer | undefined = dueNode.timers.shift(); + if (timer && dueNodes.has(timer.id)) { + now = timer.due; + if (timer.repeat) { + const due: number = timer.due + timer.delay; + let dueNode: DueNode | null = dueTree.find({ due } as DueNode); + if (dueNode === null) { + dueNode = { due, timers: [] }; + dueTree.insert(dueNode); + } + dueNode.timers.push({ ...timer, due }); + dueNodes.set(timer.id, dueNode); + } else { + dueNodes.delete(timer.id); + } + timer.callback.apply(null, timer.args); + } else if (!timer) { + dueTree.remove(dueNode); + dueNode = dueTree.min(); + } + } + now = value; + } + + /** The initial amount of milliseconds elapsed since January 1, 1970 00:00:00 UTC for the fake time. */ + get start(): number { + return startedAt; + } + set start(value: number) { + throw new Error("cannot change start time after initialization"); + } + + /** Resolves after the given number of milliseconds using real time. */ + async delay(ms: number, options: DelayOptions = {}): Promise { + const { signal } = options; + if (signal?.aborted) { + return Promise.reject( + new DOMException("Delay was aborted.", "AbortError"), + ); + } + return await new Promise((resolve, reject) => { + let timer: number | null = null; + const abort = () => + FakeTime + .restoreFor(() => { + if (timer) clearTimeout(timer); + }) + .then(() => + reject(new DOMException("Delay was aborted.", "AbortError")) + ); + const done = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + FakeTime.restoreFor(() => setTimeout(done, ms)) + .then((id) => timer = id); + signal?.addEventListener("abort", abort, { once: true }); + }); + } + + /** Runs all pending microtasks. */ + async runMicrotasks(): Promise { + await this.delay(0); + } + + /** + * Adds the specified number of milliseconds to the fake time. + * This will call any functions waiting to be called between the current and new fake time. + */ + tick(ms = 0): void { + this.now += ms; + } + + /** + * Runs all pending microtasks then adds the specified number of milliseconds to the fake time. + * This will call any functions waiting to be called between the current and new fake time. + */ + async tickAsync(ms = 0): Promise { + await this.runMicrotasks(); + this.now += ms; + } + + /** + * Advances time to when the next scheduled timer is due. + * If there are no pending timers, time will not be changed. + * Returns true when there is a scheduled timer and false when there is not. + */ + next(): boolean { + const next = dueTree.min(); + if (next) this.now = next.due; + return !!next; + } + + /** + * Runs all pending microtasks then advances time to when the next scheduled timer is due. + * If there are no pending timers, time will not be changed. + */ + async nextAsync(): Promise { + await this.runMicrotasks(); + return this.next(); + } + + /** + * Advances time forward to the next due timer until there are no pending timers remaining. + * If the timers create additional timers, they will be run too. If there is an interval, + * time will keep advancing forward until the interval is cleared. + */ + runAll(): void { + while (!dueTree.isEmpty()) { + this.next(); + } + } + + /** + * Advances time forward to the next due timer until there are no pending timers remaining. + * If the timers create additional timers, they will be run too. If there is an interval, + * time will keep advancing forward until the interval is cleared. + * Runs all pending microtasks before each timer. + */ + async runAllAsync(): Promise { + while (!dueTree.isEmpty()) { + await this.nextAsync(); + } + } + + /** Restores time related global functions to their original state. */ + restore(): void { + if (!time) throw new TimeError("time already restored"); + time = undefined; + restoreGlobals(); + if (advanceIntervalId) clearInterval(advanceIntervalId); + } +} diff --git a/testing/time_test.ts b/testing/time_test.ts new file mode 100644 index 000000000..082d69304 --- /dev/null +++ b/testing/time_test.ts @@ -0,0 +1,568 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. +import { + assert, + assertEquals, + assertInstanceOf, + assertNotEquals, + assertRejects, + assertStrictEquals, +} from "./asserts.ts"; +import { FakeTime } from "./time.ts"; +import { _internals } from "./_time.ts"; +import { assertSpyCall, MockError, spy, SpyCall, stub } from "./mock.ts"; + +function fromNow(): () => number { + const start: number = Date.now(); + return () => Date.now() - start; +} + +Deno.test("Date unchanged if FakeTime is uninitialized", () => { + assertStrictEquals(Date, _internals.Date); +}); + +Deno.test("Date is fake if FakeTime is initialized", () => { + const time = new FakeTime(9001); + try { + assertNotEquals(Date, _internals.Date); + } finally { + time.restore(); + } + assertStrictEquals(Date, _internals.Date); +}); + +Deno.test("Fake Date parse and UTC behave the same", () => { + const expectedUTC = Date.UTC(96, 1, 2, 3, 4, 5); + const expectedParse = Date.parse("04 Dec 1995 00:12:00 GMT"); + + const time = new FakeTime(); + try { + assertEquals( + Date.UTC(96, 1, 2, 3, 4, 5), + expectedUTC, + ); + assertEquals( + Date.parse("04 Dec 1995 00:12:00 GMT"), + expectedParse, + ); + } finally { + time.restore(); + } +}); + +Deno.test("Fake Date.now returns current fake time", () => { + const time: FakeTime = new FakeTime(9001); + const now = spy(_internals.Date, "now"); + try { + assertEquals(Date.now(), 9001); + assertEquals(now.calls.length, 0); + time.tick(1523); + assertEquals(Date.now(), 10524); + assertEquals(now.calls.length, 0); + } finally { + time.restore(); + now.restore(); + } +}); + +Deno.test("Fake Date instance methods passthrough to real Date instance methods", () => { + const time = new FakeTime(); + try { + const now = new Date("2020-05-25T05:00:00.12345Z"); + assertEquals(now.toISOString(), "2020-05-25T05:00:00.123Z"); + Object.getOwnPropertyNames(_internals.Date.prototype).forEach( + (method: string) => { + if ( + typeof _internals.Date.prototype[method as keyof Date] === "function" + ) { + if (typeof now[method as keyof Date] !== "function") { + throw new MockError(`FakeDate missing ${method} method`); + } + const returned = Symbol(); + const func = stub( + _internals.Date.prototype, + method as keyof Date, + () => returned, + ); + try { + const args = Array(5).fill(undefined).map(() => Symbol()); + (now[method as keyof Date] as CallableFunction)(...args); + assertSpyCall(func, 0, { + args, + returned, + }); + assertInstanceOf(func.calls[0].self, _internals.Date); + } finally { + func.restore(); + } + } + }, + ); + Object.getOwnPropertySymbols(_internals.Date.prototype).forEach( + (method: symbol) => { + if ( + typeof _internals.Date.prototype[method as keyof Date] === "function" + ) { + if (typeof now[method as keyof Date] !== "function") { + throw new MockError(`FakeDate missing ${method.toString()} method`); + } + const returned = Symbol(); + const func = stub( + _internals.Date.prototype, + method as keyof Date, + () => returned, + ); + try { + const args = Array(5).fill(undefined).map(() => Symbol()); + (now[method as keyof Date] as CallableFunction)(...args); + assertSpyCall(func, 0, { + args, + returned, + }); + assertInstanceOf(func.calls[0].self, _internals.Date); + } finally { + func.restore(); + } + } + }, + ); + } finally { + time.restore(); + } +}); + +Deno.test("timeout functions unchanged if FakeTime is uninitialized", () => { + assertStrictEquals(setTimeout, _internals.setTimeout); + assertStrictEquals(clearTimeout, _internals.clearTimeout); +}); + +Deno.test("timeout functions are fake if FakeTime is initialized", () => { + const time: FakeTime = new FakeTime(); + try { + assertNotEquals(setTimeout, _internals.setTimeout); + assertNotEquals(clearTimeout, _internals.clearTimeout); + } finally { + time.restore(); + } + assertStrictEquals(setTimeout, _internals.setTimeout); + assertStrictEquals(clearTimeout, _internals.clearTimeout); +}); + +Deno.test("FakeTime only ticks forward when setting now or calling tick", () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + + try { + assertEquals(Date.now(), start); + time.tick(5); + assertEquals(Date.now(), start + 5); + time.now = start + 1000; + assertEquals(Date.now(), start + 1000); + assert(_internals.Date.now() < start + 1000); + } finally { + time.restore(); + } +}); + +Deno.test("FakeTime controls timeouts", () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const expected: SpyCall[] = []; + + try { + setTimeout(cb, 1000); + time.tick(250); + assertEquals(cb.calls, expected); + time.tick(250); + assertEquals(cb.calls, expected); + time.tick(500); + expected.push({ args: [], returned: 1000 }); + assertEquals(cb.calls, expected); + time.tick(2500); + assertEquals(cb.calls, expected); + + setTimeout(cb, 1000, "a"); + setTimeout(cb, 2000, "b"); + setTimeout(cb, 1500, "c"); + assertEquals(cb.calls, expected); + time.tick(2500); + expected.push({ args: ["a"], returned: 4500 }); + expected.push({ args: ["c"], returned: 5000 }); + expected.push({ args: ["b"], returned: 5500 }); + assertEquals(cb.calls, expected); + + setTimeout(cb, 1000, "a"); + setTimeout(cb, 1500, "b"); + const timeout: number = setTimeout(cb, 1750, "c"); + setTimeout(cb, 2000, "d"); + time.tick(1250); + expected.push({ args: ["a"], returned: 7000 }); + assertEquals(cb.calls, expected); + assertEquals(Date.now(), start + 7250); + clearTimeout(timeout); + time.tick(500); + expected.push({ args: ["b"], returned: 7500 }); + assertEquals(cb.calls, expected); + assertEquals(Date.now(), start + 7750); + time.tick(250); + expected.push({ args: ["d"], returned: 8000 }); + assertEquals(cb.calls, expected); + } finally { + time.restore(); + } +}); + +Deno.test("interval functions unchanged if FakeTime is uninitialized", () => { + assertStrictEquals(setInterval, _internals.setInterval); + assertStrictEquals(clearInterval, _internals.clearInterval); +}); + +Deno.test("interval functions are fake if FakeTime is initialized", () => { + const time: FakeTime = new FakeTime(); + try { + assertNotEquals(setInterval, _internals.setInterval); + assertNotEquals(clearInterval, _internals.clearInterval); + } finally { + time.restore(); + } + assertStrictEquals(setInterval, _internals.setInterval); + assertStrictEquals(clearInterval, _internals.clearInterval); +}); + +Deno.test("FakeTime controls intervals", () => { + const time: FakeTime = new FakeTime(); + const cb = spy(fromNow()); + const expected: SpyCall[] = []; + try { + const interval: number = setInterval(cb, 1000); + time.tick(250); + assertEquals(cb.calls, expected); + time.tick(250); + assertEquals(cb.calls, expected); + time.tick(500); + expected.push({ args: [], returned: 1000 }); + assertEquals(cb.calls, expected); + time.tick(2500); + expected.push({ args: [], returned: 2000 }); + expected.push({ args: [], returned: 3000 }); + assertEquals(cb.calls, expected); + + clearInterval(interval); + time.tick(1000); + assertEquals(cb.calls, expected); + } finally { + time.restore(); + } +}); + +Deno.test("FakeTime calls timeout and interval callbacks in correct order", () => { + const time: FakeTime = new FakeTime(); + const cb = spy(fromNow()); + const timeoutCb = spy(cb); + const intervalCb = spy(cb); + const expected: SpyCall[] = []; + const timeoutExpected: SpyCall[] = []; + const intervalExpected: SpyCall[] = []; + try { + const interval: number = setInterval(intervalCb, 1000); + setTimeout(timeoutCb, 500); + time.tick(250); + assertEquals(intervalCb.calls, intervalExpected); + time.tick(250); + setTimeout(timeoutCb, 1000); + let expect: SpyCall = { args: [], returned: 500 }; + expected.push(expect); + timeoutExpected.push(expect); + assertEquals(cb.calls, expected); + assertEquals(timeoutCb.calls, timeoutExpected); + assertEquals(cb.calls, expected); + assertEquals(intervalCb.calls, intervalExpected); + time.tick(500); + expect = { args: [], returned: 1000 }; + expected.push(expect); + intervalExpected.push(expect); + assertEquals(cb.calls, expected); + assertEquals(intervalCb.calls, intervalExpected); + time.tick(2500); + expect = { args: [], returned: 1500 }; + expected.push(expect); + timeoutExpected.push(expect); + expect = { args: [], returned: 2000 }; + expected.push(expect); + intervalExpected.push(expect); + expect = { args: [], returned: 3000 }; + expected.push(expect); + intervalExpected.push(expect); + assertEquals(cb.calls, expected); + assertEquals(timeoutCb.calls, timeoutExpected); + assertEquals(intervalCb.calls, intervalExpected); + + clearInterval(interval); + time.tick(1000); + assertEquals(cb.calls, expected); + assertEquals(timeoutCb.calls, timeoutExpected); + assertEquals(intervalCb.calls, intervalExpected); + } finally { + time.restore(); + } +}); + +Deno.test("FakeTime restoreFor restores real time temporarily", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + + try { + assertEquals(Date.now(), start); + time.tick(1000); + assertEquals(Date.now(), start + 1000); + assert(_internals.Date.now() < start + 1000); + await FakeTime.restoreFor(() => { + assert(Date.now() < start + 1000); + }); + assertEquals(Date.now(), start + 1000); + assert(_internals.Date.now() < start + 1000); + } finally { + time.restore(); + } +}); + +Deno.test("delay uses real time", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + + try { + assertEquals(Date.now(), start); + await time.delay(20); + assert(_internals.Date.now() >= start + 20); + assertEquals(Date.now(), start); + } finally { + time.restore(); + } +}); + +Deno.test("delay runs all microtasks before resolving", async () => { + const time: FakeTime = new FakeTime(); + + try { + const seq = []; + queueMicrotask(() => seq.push(2)); + queueMicrotask(() => seq.push(3)); + seq.push(1); + await time.delay(20); + seq.push(4); + assertEquals(seq, [1, 2, 3, 4]); + } finally { + time.restore(); + } +}); + +Deno.test("delay with abort", async () => { + const time: FakeTime = new FakeTime(); + + try { + const seq = []; + const abort = new AbortController(); + const { signal } = abort; + const delayedPromise = time.delay(100, { signal }); + seq.push(1); + await FakeTime.restoreFor(() => { + setTimeout(() => { + seq.push(2); + abort.abort(); + }, 0); + }); + await assertRejects( + () => delayedPromise, + DOMException, + "Delay was aborted", + ); + seq.push(3); + assertEquals(seq, [1, 2, 3]); + } finally { + time.restore(); + } +}); + +Deno.test("runMicrotasks runs all microtasks before resolving", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + + try { + const seq = []; + queueMicrotask(() => seq.push(2)); + queueMicrotask(() => seq.push(3)); + seq.push(1); + await time.runMicrotasks(); + seq.push(4); + assertEquals(seq, [1, 2, 3, 4]); + assertEquals(Date.now(), start); + } finally { + time.restore(); + } +}); + +Deno.test("tickAsync runs all microtasks and runs timers if ticks past due", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const expected: SpyCall[] = []; + const seq: number[] = []; + + try { + setTimeout(cb, 1000); + queueMicrotask(() => seq.push(2)); + queueMicrotask(() => seq.push(3)); + seq.push(1); + await time.tickAsync(250); + seq.push(4); + assertEquals(cb.calls, expected); + await time.tickAsync(250); + assertEquals(cb.calls, expected); + queueMicrotask(() => seq.push(6)); + seq.push(5); + await time.tickAsync(500); + seq.push(7); + expected.push({ args: [], returned: 1000 }); + assertEquals(cb.calls, expected); + assertEquals(Date.now(), start + 1000); + assertEquals(seq, [1, 2, 3, 4, 5, 6, 7]); + } finally { + time.restore(); + } +}); + +Deno.test("runNext runs next timer without running microtasks", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const seq: number[] = []; + + try { + setTimeout(cb, 1000); + queueMicrotask(() => seq.push(3)); + queueMicrotask(() => seq.push(4)); + seq.push(1); + time.next(); + seq.push(2); + const expectedCalls = [{ args: [], returned: 1000 }]; + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1000); + await time.runMicrotasks(); + + queueMicrotask(() => seq.push(7)); + queueMicrotask(() => seq.push(8)); + seq.push(5); + time.next(); + seq.push(6); + await time.runMicrotasks(); + + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1000); + assertEquals(seq, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + time.restore(); + } +}); + +Deno.test("runNextAsync runs all microtasks and next timer", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const seq: number[] = []; + + try { + setTimeout(cb, 1000); + queueMicrotask(() => seq.push(2)); + queueMicrotask(() => seq.push(3)); + seq.push(1); + await time.nextAsync(); + seq.push(4); + const expectedCalls = [{ args: [], returned: 1000 }]; + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1000); + + queueMicrotask(() => seq.push(6)); + queueMicrotask(() => seq.push(7)); + seq.push(5); + await time.nextAsync(); + seq.push(8); + + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1000); + assertEquals(seq, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + time.restore(); + } +}); + +Deno.test("runAll runs all timers without running microtasks", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const seq: number[] = []; + + try { + setTimeout(cb, 1000); + setTimeout(cb, 1500); + queueMicrotask(() => seq.push(3)); + queueMicrotask(() => seq.push(4)); + seq.push(1); + time.runAll(); + seq.push(2); + const expectedCalls = [ + { args: [], returned: 1000 }, + { args: [], returned: 1500 }, + ]; + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1500); + await time.runMicrotasks(); + + queueMicrotask(() => seq.push(7)); + queueMicrotask(() => seq.push(8)); + seq.push(5); + time.runAll(); + seq.push(6); + await time.runMicrotasks(); + + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1500); + assertEquals(seq, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + time.restore(); + } +}); + +Deno.test("runAllAsync runs all microtasks and timers", async () => { + const time: FakeTime = new FakeTime(); + const start: number = Date.now(); + const cb = spy(fromNow()); + const seq: number[] = []; + + try { + setTimeout(cb, 1000); + setTimeout(cb, 1500); + queueMicrotask(() => seq.push(2)); + queueMicrotask(() => seq.push(3)); + seq.push(1); + await time.runAllAsync(); + seq.push(4); + const expectedCalls = [ + { args: [], returned: 1000 }, + { args: [], returned: 1500 }, + ]; + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1500); + + queueMicrotask(() => seq.push(6)); + queueMicrotask(() => seq.push(7)); + seq.push(5); + await time.runAllAsync(); + seq.push(8); + + assertEquals(cb.calls, expectedCalls); + assertEquals(Date.now(), start + 1500); + assertEquals(seq, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + time.restore(); + } +});