UNPKG

@effect-ts/system

Version:

Effect-TS is a zero dependency set of libraries to write highly productive, purely functional TypeScript at scale.

460 lines (423 loc) 14.4 kB
// ets_tracing: off import { Tagged, TaggedADT } from "../../Case/index.js" import * as ClockId from "../../Clock/id.js" import * as Clock from "../../Clock/index.js" import * as Chunk from "../../Collections/Immutable/Chunk/index.js" import * as HashMap from "../../Collections/Immutable/HashMap/index.js" import * as List from "../../Collections/Immutable/List/index.js" import * as SortedSet from "../../Collections/Immutable/SortedSet/index.js" import * as Tuple from "../../Collections/Immutable/Tuple/index.js" import * as T from "../../Effect/index.js" import * as Fiber from "../../Fiber/index.js" import { identity, pipe } from "../../Function/index.js" import type { Has } from "../../Has/index.js" import { tag } from "../../Has/index.js" import * as L from "../../Layer/index.js" import * as M from "../../Managed/index.js" import * as O from "../../Option/index.js" import * as Ord from "../../Ord/index.js" import * as Promise from "../../Promise/index.js" import * as Ref from "../../Ref/index.js" import * as RefM from "../../RefM/index.js" import * as St from "../../Structural/index.js" import { Annotations } from "../Annotations/index.js" import { fiberSet } from "../FiberSet/index.js" import { Live } from "../Live/index.js" import type { Restorable } from "../Restorable/index.js" import { fibers } from "../TestAnnotation/index.js" export interface DurationBrand { readonly DurationBrand: unique symbol } export type Duration = number & DurationBrand export function Duration(n: number): Duration { return n as Duration } /** * `TestClock` makes it easy to deterministically and efficiently test * effects involving the passage of time. * * Instead of waiting for actual time to pass, `sleep` and methods * implemented in terms of it schedule effects to take place at a given clock * time. Users can adjust the clock time using the `adjust` and `setTime` * methods, and all effects scheduled to take place on or before that time * will automatically be run in order. * * For example, here is how we can test `ZIO#timeout` using `TestClock: * * {{{ * import zio.ZIO * import zio.duration._ * import zio.test.environment.TestClock * * for { * fiber <- ZIO.sleep(5.minutes).timeout(1.minute).fork * _ <- TestClock.adjust(1.minute) * result <- fiber.join * } yield result == None * }}} * * Note how we forked the fiber that `sleep` was invoked on. Calls to `sleep` * and methods derived from it will semantically block until the time is set * to on or after the time they are scheduled to run. If we didn't fork the * fiber on which we called sleep we would never get to set the time on the * line below. Thus, a useful pattern when using `TestClock` is to fork the * effect being tested, then adjust the clock time, and finally verify that * the expected effects have been performed. * * For example, here is how we can test an effect that recurs with a fixed * delay: * * {{{ * import zio.Queue * import zio.duration._ * import zio.test.environment.TestClock * * for { * q <- Queue.unbounded[Unit] * _ <- q.offer(()).delay(60.minutes).forever.fork * a <- q.poll.map(_.isEmpty) * _ <- TestClock.adjust(60.minutes) * b <- q.take.as(true) * c <- q.poll.map(_.isEmpty) * _ <- TestClock.adjust(60.minutes) * d <- q.take.as(true) * e <- q.poll.map(_.isEmpty) * } yield a && b && c && d && e * }}} * * Here we verify that no effect is performed before the recurrence period, * that an effect is performed after the recurrence period, and that the * effect is performed exactly once. The key thing to note here is that after * each recurrence the next recurrence is scheduled to occur at the * appropriate time in the future, so when we adjust the clock by 60 minutes * exactly one value is placed in the queue, and when we adjust the clock by * another 60 minutes exactly one more value is placed in the queue. */ export interface TestClock extends Restorable { readonly serviceId: Clock.ClockId readonly adjust: (duration: number) => T.UIO<void> readonly setTime: (duration: number) => T.UIO<void> readonly sleeps: T.UIO<List.List<Duration>> } export const TestClock = tag<TestClock>(Clock.ClockId) /** * `Data` represents the state of the `TestClock`, including the clock time */ export class Data extends Tagged("Data")<{ readonly duration: Duration readonly sleeps: List.List<Tuple.Tuple<[Duration, Promise.Promise<never, void>]>> }> {} /** * `WarningData` describes the state of the warning message that is * displayed if a test is using time by is not advancing the `TestClock`. * The possible states are `Start` if a test has not used time, `Pending` * if a test has used time but has not adjusted the `TestClock`, and `Done` * if a test has adjusted the `TestClock` or the warning message has * already been displayed. */ export type WarningData = Start | Done | Pending export class Start extends TaggedADT<WarningData>()("Start")<{}> {} export class Done extends TaggedADT<WarningData>()("Done")<{}> {} export class Pending extends TaggedADT<WarningData>()("Pending")<{ readonly fiber: Fiber.Fiber<never, void> }> {} export class Test implements TestClock { readonly serviceId: Clock.ClockId = ClockId.ClockId constructor( readonly clockState: Ref.Ref<Data>, readonly live: Live, readonly annotations: Annotations, readonly warningState: RefM.RefM<WarningData> ) {} /** * Increments the current clock time by the specified duration. Any * effects that were scheduled to occur on or before the new time will be * run in order. */ readonly adjust: (duration: number) => T.UIO<void> = (duration) => { return T.zipRight_( this.warningDone, this.run((_) => Duration(_ + duration)) ) } /** * Returns the current clock time. */ readonly currentTime: T.UIO<Duration> = pipe( Ref.get(this.clockState), T.map((d) => d.duration) ) /** * Saves the `TestClock`'s current state in an effect which, when run, * will restore the `TestClock` state to the saved state */ readonly save: T.UIO<T.UIO<void>> = pipe( T.do, T.bind("clockData", () => Ref.get(this.clockState)), T.map(({ clockData }) => Ref.set_(this.clockState, clockData)) ) /** * Sets the current clock time to the specified time in terms of duration * since the epoch. Any effects that were scheduled to occur on or before * the new time will immediately be run in order. */ readonly setTime: (duration: number) => T.UIO<void> = (dateTime) => pipe(this.warningDone, T.zipRight(this.run(() => Duration(dateTime)))) /** * Semantically blocks the current fiber until the clock time is equal * to or greater than the specified duration. Once the clock time is * adjusted to on or after the duration, the fiber will automatically be * resumed. */ readonly sleep: (duration: Duration) => T.UIO<void> = (duration) => pipe( T.do, T.bind("promise", () => Promise.make<never, void>()), T.bind("shouldAwait", ({ promise }) => pipe( this.clockState, Ref.modify((data) => { const end = Duration(data.duration + duration) if (end > data.duration) { return Tuple.tuple( true, data.copy({ sleeps: pipe(data.sleeps, List.prepend(Tuple.tuple(end, promise))) }) ) } else { return Tuple.tuple(false, data) } }) ) ), T.tap(({ promise, shouldAwait }) => shouldAwait ? pipe(this.warningStart, T.zipRight(Promise.await(promise))) : Promise.succeed_(promise, void 0) ), T.map(() => void 0) ) /** * Returns a list of the times at which all queued effects are scheduled * to resume. */ readonly sleeps: T.UIO<List.List<Duration>> = pipe( this.clockState, Ref.get, T.map((d) => pipe( d.sleeps, List.map((_) => _.get(0)) ) ) ) /** * The warning message that will be displayed if a test is using time but * is not advancing the `TestClock`. */ private warning = "Warning: A test is using time, but is not advancing the test clock, " + "which may result in the test hanging. Use TestClock.adjust to " + "manually advance the time." /** * Forks a fiber that will display a warning message if a test is using * time but is not advancing the `TestClock`. */ private warningStart: T.UIO<void> = pipe( this.warningState, RefM.updateSome((_) => { switch (_._tag) { case "Start": { return pipe( T.do, T.bind("fiber", () => this.live.provide( pipe( T.succeedWith(() => { console.log(this.warning) }), T.delay(5_000), T.interruptible, T.fork ) ) ), T.map(({ fiber }) => Pending.make({ fiber })), O.some ) } default: return O.none } }) ) /** * Cancels the warning message that is displayed if a test is using time * but is not advancing the `TestClock`. */ readonly warningDone: T.UIO<void> = pipe( this.warningState, RefM.updateSome((_) => { switch (_._tag) { case "Start": { return O.some(T.succeed(Done.make())) } case "Pending": { return pipe(_.fiber, Fiber.interrupt, T.as(Done.make()), O.some) } default: return O.none } }) ) /** * Returns a set of all fibers in this test. */ readonly supervisedFibers: T.UIO< SortedSet.SortedSet<Fiber.Runtime<unknown, unknown>> > = T.descriptorWith((d) => pipe( this.annotations.get(fibers), T.chain((fa) => { switch (fa._tag) { case "Left": { return T.succeed(fiberSet) } case "Right": { return pipe( fa.right, T.forEach((ref) => T.succeedWith(() => ref.get)), T.map(Chunk.reduce(fiberSet, SortedSet.union_)), T.map(SortedSet.filter((_) => !St.equals(_.id, d.id))) ) } } }) ) ) /** * Captures a "snapshot" of the identifier and status of all fibers in * this test other than the current fiber. Fails with the `Unit` value if * any of these fibers are not done or suspended. Note that because we * cannot synchronize on the status of multiple fibers at the same time * this snapshot may not be fully consistent. */ readonly freeze: T.IO<void, HashMap.HashMap<Fiber.FiberID, Fiber.Status>> = pipe( this.supervisedFibers, T.chain( T.reduce(HashMap.make<Fiber.FiberID, Fiber.Status>(), (map, fiber) => pipe( fiber.status, T.chain((status) => { switch (status._tag) { case "Done": { return T.succeed(HashMap.set_(map, fiber.id, status)) } case "Suspended": { return T.succeed(HashMap.set_(map, fiber.id, status)) } default: return T.fail(void 0) } }) ) ) ) ) /** * Delays for a short period of time. */ readonly delay = this.live.provide(T.sleep(5)) /** * Returns whether all descendants of this fiber are done or suspended. */ readonly suspended: T.IO<void, HashMap.HashMap<Fiber.FiberID, Fiber.Status>> = pipe( this.freeze, T.zip(pipe(this.delay, T.zipRight(this.freeze))), T.chain(({ tuple: [first, last] }) => St.equals(first, last) ? T.succeed(first) : T.fail<void>(void 0) ) ) /** * Polls until all descendants of this fiber are done or suspended. */ readonly awaitSuspended: T.UIO<void> = pipe( this.suspended, T.zipWith( pipe(this.live.provide(T.sleep(10)), T.zipRight(this.suspended)), St.equals ), T.filterOrFail(identity, () => void 0 as void), T.eventually, T.asUnit ) /** * Runs all effects scheduled to occur on or before the specified * duration, which may depend on the current time, in order. */ private run: (f: (d: Duration) => Duration) => T.UIO<void> = (f) => pipe( this.awaitSuspended, T.zipRight( pipe( this.clockState, Ref.modify((data) => { const end = f(data.duration) const sorted = List.sortWith_( data.sleeps, Ord.contramap_(Ord.number, (_) => _.get(0)) ) if (!List.isEmpty(sorted)) { const { tuple: [duration, promise] } = List.unsafeFirst(sorted)! const sleeps = List.tail(sorted) if (duration <= end) { return Tuple.tuple( O.some(Tuple.tuple(end, promise)), new Data({ duration, sleeps }) ) } } return Tuple.tuple(O.none, new Data({ duration: end, sleeps: data.sleeps })) }), T.chain((o) => { switch (o._tag) { case "None": { return T.unit } case "Some": { return pipe( Promise.succeed_(o.value.get(1), void 0), T.zipRight(T.yieldNow), T.zipRight(this.run(() => o.value.get(0))) ) } } }) ) ) ) } export function live(data: Data) { return M.gen(function* (_) { const live = yield* _(Live) const annotations = yield* _(Annotations) const ref = yield* _(Ref.makeRef(data)) const refM = yield* _(RefM.makeRefM<WarningData>(Start.make())) const test = yield* _( T.succeedWith(() => new Test(ref, live, annotations, refM))["|>"]( M.make((_) => _.warningDone) ) ) const testClock = TestClock.has(test) return testClock as Has<Clock.Clock> & Has<TestClock> })["|>"](L.fromRawManaged) } export const defaultTestClock = live( new Data({ duration: Duration(0), sleeps: List.empty() }) )