import { describe, expect, it } from "bun:test" import { access, accessEff, attempt, bracket, catchAll, type Eff, Exit, ensure, fail, fiberId, flatMap, foldEff, fromPromise, mapEff, pipe, provide, repeatEff, retry, runPromise, runPromiseExit, sleep, succeed, sync, tapBoth, tapEff, tapEffect, tapErr, tapError, timeout, yieldNow, } from "../src/index" describe("Effect - Basic Operations", () => { describe("constructors", () => { it("succeed creates successful effect", async () => { const result = await runPromise(succeed(42)) expect(result).toBe(42) }) it("fail creates failed effect", async () => { const exit = await runPromiseExit(fail("error")) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(exit.error).toBe("error") } }) it("sync lifts synchronous computation", async () => { const result = await runPromise(sync(() => 1 + 1)) expect(result).toBe(2) }) it("attempt catches thrown errors", async () => { const effect = attempt(() => { throw new Error("boom") }) const exit = await runPromiseExit(effect) expect(Exit.isFailure(exit)).toBe(true) }) it("fromPromise wraps promises", async () => { const result = await runPromise(fromPromise(() => Promise.resolve(42))) expect(result).toBe(42) }) }) describe("transformations", () => { it("mapEff transforms success value", async () => { const result = await runPromise( pipe( succeed(5), mapEff((x) => x * 2), ), ) expect(result).toBe(10) }) it("flatMap chains effects", async () => { const result = await runPromise( pipe( succeed(5), flatMap((x) => succeed(x * 2)), flatMap((x) => succeed(x + 1)), ), ) expect(result).toBe(11) }) it("foldEff handles both success and failure", async () => { const handle = foldEff( (e: string) => succeed(`error: ${e}`), (a: number) => succeed(`success: ${a}`), ) expect(await runPromise(pipe(succeed(42), handle))).toBe("success: 42") expect(await runPromise(pipe(fail("oops"), handle))).toBe("error: oops") }) it("catchAll recovers from errors", async () => { const result = await runPromise( pipe( fail("error"), catchAll(() => succeed("recovered")), ), ) expect(result).toBe("recovered") }) it("flatMap widens error types automatically", async () => { type DbError = { _tag: "DbError" } type NotFound = { _tag: "NotFound" } const dbEffect: Eff = succeed(42) const result = pipe( dbEffect, flatMap( (n): Eff => n > 0 ? succeed(String(n)) : fail({ _tag: "NotFound" }), ), ) expect(await runPromise(result)).toBe("42") }) it("flatMap widens requirement types automatically", async () => { type Db = { db: string } type Logger = { logger: string } const dbEffect: Eff = access((_: Db) => 1) const result = pipe( dbEffect, flatMap((n) => access((env: Logger) => env.logger + n)), ) const value = await runPromise( pipe(result, provide({ db: "pg", logger: "console" } as Db & Logger)), ) expect(value).toBe("console1") }) }) describe("ensure", () => { it("passes value through when predicate is true", async () => { const result = await runPromise( pipe( succeed(42), ensure( (n) => n > 0, (n) => ({ _tag: "NotPositive" as const, value: n }), ), ), ) expect(result).toBe(42) }) it("fails with mapped error when predicate is false", async () => { const exit = await runPromiseExit( pipe( succeed(-1), ensure( (n) => n > 0, (n) => ({ _tag: "NotPositive" as const, value: n }), ), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(exit.error).toEqual({ _tag: "NotPositive", value: -1 }) } }) it("does not run predicate on failed effect", async () => { let called = false const exit = await runPromiseExit( pipe( fail("original"), ensure( () => { called = true return true }, () => "never", ), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("original") expect(called).toBe(false) }) }) describe("tapErr", () => { it("runs side effect on error", async () => { let observed: string | null = null const exit = await runPromiseExit( pipe( fail("boom"), tapErr((e: string) => { observed = e }), ), ) expect(Exit.isFailure(exit)).toBe(true) expect(observed as unknown as string).toBe("boom") }) it("error still propagates after tap", async () => { const exit = await runPromiseExit( pipe( fail("boom"), tapErr(() => {}), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") }) it("does not run side effect on success", async () => { let called = false const result = await runPromise( pipe( succeed(42), tapErr(() => { called = true }), ), ) expect(result).toBe(42) expect(called).toBe(false) }) }) describe("accessEff", () => { type Config = { baseUrl: string } it("reads environment and returns an effect", async () => { const getUrl = accessEff((env: Config) => succeed(`${env.baseUrl}/users`)) const result = await runPromise( pipe(getUrl, provide({ baseUrl: "https://api.example.com" })), ) expect(result).toBe("https://api.example.com/users") }) it("can return a failing effect from environment", async () => { const maybeUrl = accessEff((env: Config) => env.baseUrl === "" ? fail("empty url" as const) : succeed(env.baseUrl), ) const exit = await runPromiseExit( pipe(maybeUrl, provide({ baseUrl: "" })), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("empty url") }) }) describe("tapEff", () => { it("runs side effect on success and returns original value", async () => { let observed: number | null = null const result = await runPromise( pipe( succeed(42), tapEff((a: number) => { observed = a }), ), ) expect(result).toBe(42) expect(observed as unknown as number).toBe(42) }) it("does not run on failure", async () => { let called = false const exit = await runPromiseExit( pipe( fail("boom") as Eff, tapEff((_a: number) => { called = true }), ), ) expect(Exit.isFailure(exit)).toBe(true) expect(called).toBe(false) }) }) describe("dependency injection", () => { type Config = { baseUrl: string } it("access reads from environment", async () => { const getBaseUrl = access((env) => env.baseUrl) const result = await runPromise( pipe( getBaseUrl, provide({ baseUrl: "https://api.example.com" }), ), ) expect(result).toBe("https://api.example.com") }) }) describe("bracket", () => { it("runs acquire, use, and release in order", async () => { const log: string[] = [] const result = await runPromise( bracket( sync(() => { log.push("acquire") return "resource" }), (r) => sync(() => { log.push(`release:${r}`) }), (r) => sync(() => { log.push(`use:${r}`) return 42 }), ), ) expect(result).toBe(42) expect(log).toEqual(["acquire", "use:resource", "release:resource"]) }) it("runs release even when use fails", async () => { const log: string[] = [] const exit = await runPromiseExit( bracket( sync(() => { log.push("acquire") return "resource" }), (r) => sync(() => { log.push(`release:${r}`) }), (_r) => { log.push("use") return fail("boom") }, ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") expect(log).toEqual(["acquire", "use", "release:resource"]) }) it("skips use and release when acquire fails", async () => { const log: string[] = [] const exit = await runPromiseExit( bracket( fail("acquire-fail") as Eff, (r) => sync(() => { log.push(`release:${r}`) }), (r) => sync(() => { log.push(`use:${r}`) return 42 }), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("acquire-fail") expect(log).toEqual([]) }) }) describe("tapEffect (effectful)", () => { it("on success, side-effect Eff runs and original value is returned", async () => { let observed: number | null = null const result = await runPromise( pipe( succeed(42), tapEffect((a: number) => sync(() => { observed = a }), ), ), ) expect(result).toBe(42) expect(observed as unknown as number).toBe(42) }) it("on failure, side-effect Eff does NOT run and error propagates unchanged", async () => { let called = false const exit = await runPromiseExit( pipe( fail("boom") as Eff, tapEffect((_a: number) => sync(() => { called = true }), ), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") expect(called).toBe(false) }) it("when side-effect Eff fails, that error propagates", async () => { const exit = await runPromiseExit( pipe( succeed(42), tapEffect((_a: number) => fail("side-effect-error")), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") }) it("works with pipe() in a multi-step chain", async () => { const log: string[] = [] const result = await runPromise( pipe( succeed(10), tapEffect((n: number) => sync(() => { log.push(`first:${n}`) }), ), mapEff((n: number) => n * 2), tapEffect((n: number) => sync(() => { log.push(`second:${n}`) }), ), ), ) expect(result).toBe(20) expect(log).toEqual(["first:10", "second:20"]) }) it("works with async side-effects", async () => { let observed: number | null = null const result = await runPromise( pipe( succeed(42), tapEffect((a: number) => fromPromise(() => Promise.resolve().then(() => { observed = a }), ), ), ), ) expect(result).toBe(42) expect(observed as unknown as number).toBe(42) }) }) describe("tapError (effectful)", () => { it("on failure, side-effect Eff runs and original error re-propagates", async () => { let observed: string | null = null const exit = await runPromiseExit( pipe( fail("original-error") as Eff, tapError((e: string) => sync(() => { observed = e }), ), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("original-error") expect(observed as unknown as string).toBe("original-error") }) it("on success, side-effect Eff does NOT run and value passes through", async () => { let called = false const result = await runPromise( pipe( succeed(99), tapError((_e: string) => sync(() => { called = true }), ), ), ) expect(result).toBe(99) expect(called).toBe(false) }) it("when side-effect Eff fails, that error propagates", async () => { const exit = await runPromiseExit( pipe( fail("original") as Eff, tapError((_e: string) => fail("side-effect-error")), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") }) }) describe("tapBoth", () => { it("on success, onSuccess runs and onFailure does NOT run", async () => { let successCalled = false let failureCalled = false const result = await runPromise( pipe( succeed(7), tapBoth({ onSuccess: (_a: number) => sync(() => { successCalled = true }), onFailure: (_e: string) => sync(() => { failureCalled = true }), }), ), ) expect(result).toBe(7) expect(successCalled).toBe(true) expect(failureCalled).toBe(false) }) it("on failure, onFailure runs and onSuccess does NOT run", async () => { let successCalled = false let failureCalled = false const exit = await runPromiseExit( pipe( fail("err") as Eff, tapBoth({ onSuccess: (_a: number) => sync(() => { successCalled = true }), onFailure: (_e: string) => sync(() => { failureCalled = true }), }), ), ) expect(Exit.isFailure(exit)).toBe(true) expect(successCalled).toBe(false) expect(failureCalled).toBe(true) }) it("original value is preserved through onSuccess tap", async () => { const result = await runPromise( pipe( succeed(55), tapBoth({ onSuccess: (_a: number) => succeed("discarded"), onFailure: (_e: string) => succeed("discarded"), }), ), ) expect(result).toBe(55) }) it("original error is preserved through onFailure tap", async () => { const exit = await runPromiseExit( pipe( fail("original-err") as Eff, tapBoth({ onSuccess: (_a: number) => succeed("discarded"), onFailure: (_e: string) => succeed("discarded"), }), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("original-err") }) it("when onSuccess side-effect fails, that error propagates", async () => { const exit = await runPromiseExit( pipe( succeed(42), tapBoth({ onSuccess: (_a: number) => fail("side-effect-error"), onFailure: (_e: string) => succeed(undefined), }), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") }) it("when onFailure side-effect fails, that error propagates", async () => { const exit = await runPromiseExit( pipe( fail("original") as Eff, tapBoth({ onSuccess: (_a: number) => succeed(undefined), onFailure: (_e: string) => fail("side-effect-error"), }), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") }) }) describe("effectful tap - composability", () => { it("pipe with tapEffect, tapError, and mapEff works end-to-end on success", async () => { const log: string[] = [] const result = await runPromise( pipe( succeed(5), tapEffect((n: number) => sync(() => { log.push(`tap:${n}`) }), ), tapError((_e: string) => sync(() => { log.push("tapError:called") }), ), mapEff((n: number) => n * 3), ), ) expect(result).toBe(15) expect(log).toEqual(["tap:5"]) }) it("pipe with tapEffect, tapError, and mapEff works end-to-end on failure", async () => { const log: string[] = [] const exit = await runPromiseExit( pipe( fail("err") as Eff, tapEffect((n: number) => sync(() => { log.push(`tap:${n}`) }), ), tapError((e: string) => sync(() => { log.push(`tapError:${e}`) }), ), mapEff((n: number) => n * 3), ), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.error).toBe("err") expect(log).toEqual(["tapError:err"]) }) it("tapEffect with access() and provide() demonstrates requirement widening", async () => { type Logger = { log: (msg: string) => void } const messages: string[] = [] const logger: Logger = { log: (msg) => messages.push(msg) } const result = await runPromise( pipe( succeed(42), tapEffect((n: number) => access((env: Logger) => env.log(`value:${n}`)), ), provide(logger), ), ) expect(result).toBe(42) expect(messages).toEqual(["value:42"]) }) it("tapBoth in a pipeline preserves value through multiple taps", async () => { const log: string[] = [] const result = await runPromise( pipe( succeed(3), tapBoth({ onSuccess: (n: number) => sync(() => { log.push(`both1:${n}`) }), onFailure: (_e: string) => sync(() => {}), }), tapEffect((n: number) => sync(() => { log.push(`tap:${n}`) }), ), tapBoth({ onSuccess: (n: number) => sync(() => { log.push(`both2:${n}`) }), onFailure: (_e: string) => sync(() => {}), }), ), ) expect(result).toBe(3) expect(log).toEqual(["both1:3", "tap:3", "both2:3"]) }) }) describe("retry - edge cases", () => { it("retry(0) executes once on success", async () => { let attempts = 0 const eff = sync(() => { attempts++ return 42 }) const result = await runPromise(pipe(eff, retry(0))) expect(result).toBe(42) expect(attempts).toBe(1) }) it("retry(0) propagates error without retrying", async () => { let attempts = 0 const exit = await runPromiseExit( pipe( attempt(() => { attempts++ throw "boom" }), retry(0), ), ) expect(Exit.isFailure(exit)).toBe(true) expect(attempts).toBe(1) }) it("retry(3) runs initial attempt plus 3 retries when all fail", async () => { let attempts = 0 const alwaysFail: Eff = sync(() => { attempts++ return undefined as unknown as number }) // Use attempt to make it fail const failing: Eff = flatMap( () => fail("err") as Eff, )(alwaysFail) const exit = await runPromiseExit(pipe(failing, retry(3))) expect(Exit.isFailure(exit)).toBe(true) expect(attempts).toBe(4) }) }) describe("repeatEff - edge cases", () => { it("repeatEff with negative count executes once", async () => { let count = 0 const eff = sync(() => { count++ return count }) const result = await runPromise(pipe(eff, repeatEff(-5))) expect(count).toBe(1) expect(result).toBe(1) }) }) describe("timeout - edge cases", () => { it("timeout(0) fires immediately when effect is slow", async () => { const result = await runPromise(pipe(sleep(1000), timeout(0))) expect(result).toBeNull() }) it("timeout on sync effect returns value before timeout fires", async () => { const result = await runPromise(pipe(succeed(42), timeout(100))) expect(result).toBe(42) }) }) describe("bracket - edge cases", () => { it("bracket release that throws propagates as uncaught error", async () => { // release is typed Eff, but sync(() => { throw }) causes a runtime throw. // The fiber runtime will surface this as a rejection. const released: string[] = [] const run = runPromiseExit( bracket( succeed("res"), (_r) => sync(() => { released.push("before-throw") throw new Error("release-boom") }) as unknown as Eff, (_r) => succeed(1), ), ) // We expect either the promise to reject or exit to be failure due to the throw let didThrow = false try { const exit = await run // If runtime catches it, it should be a failure if (Exit.isFailure(exit)) didThrow = true } catch { didThrow = true } expect(didThrow).toBe(true) expect(released).toContain("before-throw") }) }) describe("yieldNow", () => { it("completes successfully after yielding", async () => { const result = await runPromise( pipe( yieldNow, flatMap(() => succeed(42)), ), ) expect(result).toBe(42) }) }) describe("fiberId", () => { it("returns a FiberId with numeric id", async () => { const fid = await runPromise(fiberId) expect(fid._tag).toBe("FiberId") expect(typeof fid.id).toBe("number") }) }) })