2026-06-25 18:34:17 +00:00
|
|
|
import { describe, expect, test } from "bun:test"
|
2026-06-26 19:41:43 +00:00
|
|
|
import { buildNode } from "@/effect/build-node"
|
2026-06-25 18:34:17 +00:00
|
|
|
import { Context, Effect, Layer } from "effect"
|
|
|
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
2026-06-26 19:41:43 +00:00
|
|
|
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree"
|
2026-06-25 18:34:17 +00:00
|
|
|
|
|
|
|
|
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
|
|
|
|
|
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
|
|
|
|
|
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
|
|
|
|
|
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
|
|
|
|
|
|
|
|
|
|
const tiers = LayerNode.tiers(["app"])
|
|
|
|
|
const make = tiers.make("app")
|
2026-06-26 19:41:43 +00:00
|
|
|
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
|
|
|
|
LayerNodeTree.compile(
|
|
|
|
|
LayerNodeTree.separate(root, tiers).app,
|
|
|
|
|
new Map(replacements?.map((item) => [item.source, item.replacement])),
|
|
|
|
|
) as Layer.Layer<A, E>
|
2026-06-25 18:34:17 +00:00
|
|
|
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
|
|
|
|
const greetingLayer = Layer.effect(
|
|
|
|
|
Greeting,
|
|
|
|
|
Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
|
|
|
|
|
)
|
|
|
|
|
const value = make({ service: Value, layer: valueLayer, deps: [] })
|
|
|
|
|
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
|
|
|
|
|
|
|
|
|
describe("layer node", () => {
|
|
|
|
|
test("builds an untiered graph", async () => {
|
|
|
|
|
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
|
|
|
|
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
2026-06-26 19:41:43 +00:00
|
|
|
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(buildNode(LayerNode.group([greeting]))))
|
2026-06-25 18:34:17 +00:00
|
|
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("builds a dependency graph", async () => {
|
|
|
|
|
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
2026-06-26 19:41:43 +00:00
|
|
|
Effect.provide(build(LayerNode.group([greeting]))),
|
2026-06-25 18:34:17 +00:00
|
|
|
)
|
|
|
|
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-26 19:41:43 +00:00
|
|
|
test("exposes roots but hides transitive dependencies", () => {
|
|
|
|
|
const layer = build(LayerNode.group([greeting]))
|
|
|
|
|
const check: Layer.Layer<Greeting> = layer
|
|
|
|
|
void check
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("preserves branch-specific implementations across roots", async () => {
|
|
|
|
|
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
|
|
|
|
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
|
|
|
|
const leftLayer = Layer.effect(Left, Effect.map(Value, (item) => Left.of({ value: item.value })))
|
|
|
|
|
const rightLayer = Layer.effect(Right, Effect.map(Value, (item) => Right.of({ value: item.value })))
|
|
|
|
|
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
|
|
|
|
|
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
|
|
|
|
|
const layer = build(LayerNode.group([left, right]))
|
|
|
|
|
const program = Effect.gen(function* () {
|
|
|
|
|
return [(yield* Left).value, (yield* Right).value]
|
|
|
|
|
}).pipe(Effect.provide(layer))
|
|
|
|
|
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("requires unbound nodes to be bound before compilation", async () => {
|
|
|
|
|
const unbound = LayerNode.unbound(Value, tiers.values.app)
|
|
|
|
|
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
|
|
|
|
const tree = LayerNodeTree.separate(LayerNode.group([greeting]), tiers).app
|
|
|
|
|
expect(() => LayerNodeTree.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
|
|
|
|
const bound = LayerNodeTree.bind(tree, unbound, value)
|
|
|
|
|
const layer = LayerNodeTree.compile(bound) as Layer.Layer<Greeting>
|
|
|
|
|
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
|
|
|
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 18:34:17 +00:00
|
|
|
test("replaces a layer by identity", async () => {
|
|
|
|
|
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
|
|
|
|
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
2026-06-26 19:41:43 +00:00
|
|
|
Effect.provide(
|
|
|
|
|
build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)]),
|
|
|
|
|
),
|
2026-06-25 18:34:17 +00:00
|
|
|
)
|
|
|
|
|
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("replaces every use of the same layer", async () => {
|
|
|
|
|
const leftLayer = Layer.effect(
|
|
|
|
|
Left,
|
|
|
|
|
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
|
|
|
|
)
|
|
|
|
|
const rightLayer = Layer.effect(
|
|
|
|
|
Right,
|
|
|
|
|
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
|
|
|
|
)
|
|
|
|
|
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
|
|
|
|
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
|
|
|
|
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
2026-06-26 19:41:43 +00:00
|
|
|
const layer = build(LayerNode.group([left, right]), [
|
|
|
|
|
LayerNode.replace(valueLayer, replacement),
|
|
|
|
|
])
|
2026-06-25 18:34:17 +00:00
|
|
|
const program = Effect.gen(function* () {
|
|
|
|
|
return [(yield* Left).value, (yield* Right).value]
|
|
|
|
|
}).pipe(Effect.provide(layer))
|
|
|
|
|
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not acquire an unused replacement", async () => {
|
|
|
|
|
let acquisitions = 0
|
|
|
|
|
const other = Layer.succeed(Value, Value.of({ value: "other" }))
|
|
|
|
|
const replacement = Layer.effect(
|
|
|
|
|
Value,
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
acquisitions++
|
|
|
|
|
return Value.of({ value: "replacement" })
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
await Effect.runPromise(
|
|
|
|
|
Effect.map(Greeting, (item) => item.value).pipe(
|
2026-06-26 19:41:43 +00:00
|
|
|
Effect.provide(
|
|
|
|
|
build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)]),
|
|
|
|
|
),
|
2026-06-25 18:34:17 +00:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
expect(acquisitions).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
})
|