From d56fa8ec4e4e4b0c3bc10101e6557dcdd87bf9c8 Mon Sep 17 00:00:00 2001 From: Mikhail Kireev <29187880+kireevmp@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:48:09 +0100 Subject: [PATCH 1/2] feat(compose): immutable compose tree --- .../src/compose/__tests__/compose.bench.ts | 8 +- .../src/compose/__tests__/compose.test.ts | 58 +++++++++++++ .../src/compose/__tests__/guard.test.ts | 83 ++++++++++++++++--- .../src/compose/__tests__/observer.test.ts | 35 +++++++- packages/app-compose/src/compose/compose.ts | 15 ++-- packages/app-compose/src/compose/guard.ts | 2 +- .../src/runnable/__tests__/task.test.ts | 25 ++++++ 7 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 packages/app-compose/src/compose/__tests__/compose.test.ts create mode 100644 packages/app-compose/src/runnable/__tests__/task.test.ts diff --git a/packages/app-compose/src/compose/__tests__/compose.bench.ts b/packages/app-compose/src/compose/__tests__/compose.bench.ts index 16491a5..59f3ee1 100644 --- a/packages/app-compose/src/compose/__tests__/compose.bench.ts +++ b/packages/app-compose/src/compose/__tests__/compose.bench.ts @@ -8,7 +8,7 @@ const double = (x: number) => x * 2 describe("multi layer, compute shapes", () => { const rootTag = createTag({ name: "root" }) - const app = compose() + let app = compose() .meta({ name: "bench" }) .step(bind(rootTag, literal(1))) @@ -30,7 +30,7 @@ describe("multi layer, compute shapes", () => { }, }) - app + app = app .step([bind(a, shape(l, double)), bind(b, shape(l, double))]) .step([bind(c, shape(l, double)), bind(d, shape(l, double))]) .step(task) @@ -48,12 +48,12 @@ describe("multi layer, compute shapes", () => { }) describe("single layer, wide sequence", () => { - const layer = compose() + let layer = compose() for (let i = 0; i < 100; i++) { const task = createTask({ name: `task:${i}`, run: { fn: vi.fn() } }) - layer.step(task) + layer = layer.step(task) } const app = compose().meta({ name: "bench" }).step(layer) diff --git a/packages/app-compose/src/compose/__tests__/compose.test.ts b/packages/app-compose/src/compose/__tests__/compose.test.ts new file mode 100644 index 0000000..c52d637 --- /dev/null +++ b/packages/app-compose/src/compose/__tests__/compose.test.ts @@ -0,0 +1,58 @@ +import { literal } from "@computable" +import { bind, createTag, createTask } from "@runnable" +import { LIBRARY_NAME } from "@shared" +import { describe, expect, it } from "vitest" +import { compose } from "../compose" + +describe("compose", () => { + describe("step", () => { + it("rejects non-composable arguments", () => { + const app = compose() + const message = `${LIBRARY_NAME} Invalid argument passed to step.` + + expect( + // @ts-expect-error + () => app.step({ value: 123 }), + ).toThrowError(message) + }) + }) + + describe("guard", () => { + it("throws on warning graph", () => { + const tag = createTag({ name: "alpha" }) + const app = compose().step(bind(tag, literal(1))) + + const message = `Unused Binding found with name Tag[alpha] in step root > #1.` + + expect(() => app.guard()).toThrowError(message) + }) + + it("throws on error graph", () => { + const tag = createTag({ name: "alpha" }) + + const app = compose() + .step(bind(tag, literal(1))) + .step(bind(tag, literal(2))) + + const message = `A duplicate Binding found with name Tag[alpha] in step root > #2.` + + expect(() => app.guard()).toThrowError(message) + }) + }) + + describe("graph", () => { + it("provides correct graph structure", () => { + const task = createTask({ name: "task", run: { fn: () => {} } }) + const tag = createTag({ name: "alpha" }) + + const graph = compose() + .meta({ name: "app" }) + .step(bind(tag, literal(1))) + .step(task) + .graph() + + const result = { type: "seq", meta: { name: "app" }, children: [{ type: "run" }, { type: "run" }] } + expect(graph).toMatchObject(result) + }) + }) +}) diff --git a/packages/app-compose/src/compose/__tests__/guard.test.ts b/packages/app-compose/src/compose/__tests__/guard.test.ts index 8b17382..9ec06e4 100644 --- a/packages/app-compose/src/compose/__tests__/guard.test.ts +++ b/packages/app-compose/src/compose/__tests__/guard.test.ts @@ -1,4 +1,4 @@ -import { literal, reference } from "@computable" +import { literal, reference, shape } from "@computable" import { bind, createTag, createTask } from "@runnable" import { beforeEach, describe, expect, it, vi } from "vitest" import { compose, Node$ } from "../compose" @@ -133,17 +133,66 @@ describe("unsatisfied guard", () => { it("calls error on binding missing task context", () => { const provider = createTask({ name: "alpha", run: { fn: () => 0 } }) - const intermediate = createTag({ name: "beta" }) + const intermediate = createTag({ name: "beta" }) - const app = compose().step(bind(intermediate, provider.result)) + const app = compose().step(bind(intermediate, provider.status)) guard(app[Node$]) const message = - "Unsatisfied dependencies found for Binding with name Tag[beta] in step root > #1: missing Task[alpha]::result." + "Unsatisfied dependencies found for Binding with name Tag[beta] in step root > #1: missing Task[alpha]::status." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) + it("calls error on task missing context in its local con thread", () => { + const provider = createTag({ name: "alpha" }) + + const taskA = createTask({ name: "A", run: { fn: vi.fn(), context: provider.value } }) + const taskB = createTask({ name: "B", run: { fn: vi.fn(), context: provider.value } }) + + const app = compose().step([ + compose() + .step(bind(provider, literal(1))) + .step(taskA), + compose().step(taskB), + ]) + + guard(app[Node$]) + + const message = + "Unsatisfied dependencies found for Task with name Task[B] in step root > #1 > #2 > #1: missing Tag[alpha]." + expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) + }) + + describe("missing list", () => { + const alpha = createTag({ name: "alpha" }) + const beta = createTag({ name: "beta" }) + + const task = createTask({ name: "beta", run: { fn: vi.fn(), context: [alpha.value, beta.value] } }) + + it("calls error with a full missing list", () => { + const app = compose().step(task) + + guard(app[Node$]) + + const message = + "Unsatisfied dependencies found for Task with name Task[beta] in step root > #1: missing Tag[alpha], Tag[beta]." + expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) + }) + + it("only reports missing dependencies for the task", () => { + const app = compose() + .step(bind(alpha, literal(1))) + .step(task) + + guard(app[Node$]) + + const message = + "Unsatisfied dependencies found for Task with name Task[beta] in step root > #2: missing Tag[beta]." + expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) + }) + }) + it("provides rich path", () => { const provider = createTag({ name: "alpha" }) const consumer = createTask({ name: "beta", run: { fn, context: provider.value } }) @@ -187,14 +236,28 @@ describe("unsatisfied guard", () => { }) describe("unused guard", () => { - const tag = createTag({ name: "alpha" }) + const alpha = createTag({ name: "alpha" }) - it("warns on unused binding", () => { - const node = compose().step(bind(tag, literal(1))) + it("warns on unused binding (seq)", () => { + const node = compose().step(bind(alpha, literal(1))) guard(node[Node$]) - const message = "Unused Binding found with name: Tag[alpha] in step root > #1." + const message = "Unused Binding found with name Tag[alpha] in step root > #1." + expect(handler.warn).toHaveBeenCalledWith(message) + }) + + it("warns on unused binding (con)", () => { + const beta = createTag({ name: "alpha" }) + const task = createTask({ name: "test", run: { fn: vi.fn(), context: beta.value } }) + + const node = compose() + .step([bind(alpha, literal(1)), bind(beta, literal(2))]) + .step(task) + + guard(node[Node$]) + + const message = "Unused Binding found with name Tag[alpha] in step root > #1 > #1." expect(handler.warn).toHaveBeenCalledWith(message) }) @@ -204,12 +267,12 @@ describe("unused guard", () => { .step( compose() .meta({ name: "Layout" }) - .step(bind(tag, literal(2))), + .step(bind(alpha, literal(2))), ) guard(node[Node$]) - const message = "Unused Binding found with name: Tag[alpha] in step MyApp > #1 (Layout) > #1." + const message = "Unused Binding found with name Tag[alpha] in step MyApp > #1 (Layout) > #1." expect(handler.warn).toHaveBeenCalledWith(message) }) }) diff --git a/packages/app-compose/src/compose/__tests__/observer.test.ts b/packages/app-compose/src/compose/__tests__/observer.test.ts index 9bcc370..5189c8e 100644 --- a/packages/app-compose/src/compose/__tests__/observer.test.ts +++ b/packages/app-compose/src/compose/__tests__/observer.test.ts @@ -1,4 +1,5 @@ -import { createTask } from "@runnable" +import { literal, shape } from "@computable" +import { bind, createTag, createTask } from "@runnable" import { describe, expect, it, vi } from "vitest" import { compose } from "../compose" import type { ComposeHookMap } from "../observer" @@ -7,13 +8,41 @@ describe("observer", () => { describe("onTaskFail", () => { it("is called when a task fails", async () => { const onTaskFail = vi.fn() - const task = createTask({ name: "alpha", run: { fn: () => Promise.reject("test") } }) + const task = createTask({ name: "alpha", run: { fn: () => Promise.reject("error") } }) const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(task) await app.run() - expect(onTaskFail).toHaveBeenCalledExactlyOnceWith({ task, error: "test" }) + expect(onTaskFail).toHaveBeenCalledExactlyOnceWith({ task, error: "error" }) + }) + + it("is not called when a task succeeds", async () => { + const onTaskFail = vi.fn() + const task = createTask({ name: "alpha", run: { fn: () => "okay" } }) + + const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(task) + + await app.run() + + expect(onTaskFail).not.toHaveBeenCalled() + }) + + it("is not called when another runnable fails", async () => { + const onTaskFail = vi.fn() + + const tag = createTag({ name: "test" }) + const task = createTask({ name: "alpha", run: { fn: () => "okay", context: tag.value } }) + + const value = shape(literal(1), () => { + throw new Error("error") + }) + + const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(bind(tag, value)).step(task) + + await app.run() + + expect(onTaskFail).not.toHaveBeenCalled() }) }) }) diff --git a/packages/app-compose/src/compose/compose.ts b/packages/app-compose/src/compose/compose.ts index 8291837..923dbc3 100644 --- a/packages/app-compose/src/compose/compose.ts +++ b/packages/app-compose/src/compose/compose.ts @@ -28,7 +28,7 @@ type Composer = { const normalize = (arg: Composable): ComposeNode => { if (Node$ in arg) return arg[Node$] else if (Execute$ in arg) return { type: "run", value: arg as Runnable } - else throw new Error(/* TODO: better error messaging, but unreachable given correct types */) + else throw new Error(`${LIBRARY_NAME} Invalid argument passed to step.`) } const raiseOnGuard = (message: string): never => { @@ -36,16 +36,15 @@ const raiseOnGuard = (message: string): never => { } const builder = (node: ComposeInner): Composer => { - const self: Composer = { + return { [Node$]: node, - meta: (meta) => ((node.meta = { ...node.meta, ...meta }), self), + meta: (meta) => builder({ ...node, meta: { ...node.meta, ...meta } }), step: (arg) => { - if (Array.isArray(arg)) node.children.push({ type: "con", children: arg.map(normalize) }) - else node.children.push(normalize(arg)) - - return self + if (Array.isArray(arg)) + return builder({ ...node, children: [...node.children, { type: "con", children: arg.map(normalize) }] }) + return builder({ ...node, children: [...node.children, normalize(arg)] }) }, run: () => { @@ -64,8 +63,6 @@ const builder = (node: ComposeInner): Composer => { graph: () => graph(node), } - - return self } const compose = (): Composer => { diff --git a/packages/app-compose/src/compose/guard.ts b/packages/app-compose/src/compose/guard.ts index a5a45b1..c1063d2 100644 --- a/packages/app-compose/src/compose/guard.ts +++ b/packages/app-compose/src/compose/guard.ts @@ -38,7 +38,7 @@ const createNotify = (handler: GuardHandler) => ({ }, unused: ({ type, name, stack }: NotifyContext) => { - const message = `Unused ${TypeMap[type]} found with name: ${NameMap[type]}[${name}] in step ${stackToName(stack)}.` + const message = `Unused ${TypeMap[type]} found with name ${NameMap[type]}[${name}] in step ${stackToName(stack)}.` handler.warn(message) }, }) diff --git a/packages/app-compose/src/runnable/__tests__/task.test.ts b/packages/app-compose/src/runnable/__tests__/task.test.ts new file mode 100644 index 0000000..46b3329 --- /dev/null +++ b/packages/app-compose/src/runnable/__tests__/task.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest" +import { Execute$, type RunnableInternal } from "../definition" +import { createTask, type TaskExecutionValue } from "../task" + +describe("task", () => { + describe("execute.enabled", () => { + it("skips on false result", async () => { + const task = createTask({ name: "test", run: { fn: vi.fn() }, enabled: { fn: () => false } }) + const runnable = task as unknown as RunnableInternal + + const result = await runnable[Execute$]({ run: undefined, enabled: undefined }) + + expect(result).toStrictEqual({ status: "skip" }) + }) + + it("fails on throw", async () => { + const task = createTask({ name: "test", run: { fn: vi.fn() }, enabled: { fn: () => Promise.reject("error") } }) + const runnable = task as unknown as RunnableInternal + + const result = await runnable[Execute$]({ run: undefined, enabled: undefined }) + + expect(result).toStrictEqual({ status: "fail", error: "error" }) + }) + }) +}) From 7596f0d41a938f7519b7a7b14d643ac92afcdf9f Mon Sep 17 00:00:00 2001 From: Mikhail Kireev <29187880+kireevmp@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:34:39 +0100 Subject: [PATCH 2/2] chore: expand test suite --- .../app-compose/src/compose/__tests__/guard.test.ts | 2 +- .../src/compose/__tests__/resolver.test.ts | 3 ++- .../app-compose/src/compose/__tests__/runner.test.ts | 12 ++++++++++++ .../src/computable/__tests__/shape.test.ts | 12 +++++++----- packages/app-compose/src/computable/optional.ts | 2 +- .../app-compose/src/runnable/__tests__/task.test.ts | 2 +- 6 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/app-compose/src/compose/__tests__/guard.test.ts b/packages/app-compose/src/compose/__tests__/guard.test.ts index 9ec06e4..f754e43 100644 --- a/packages/app-compose/src/compose/__tests__/guard.test.ts +++ b/packages/app-compose/src/compose/__tests__/guard.test.ts @@ -1,4 +1,4 @@ -import { literal, reference, shape } from "@computable" +import { literal, reference } from "@computable" import { bind, createTag, createTask } from "@runnable" import { beforeEach, describe, expect, it, vi } from "vitest" import { compose, Node$ } from "../compose" diff --git a/packages/app-compose/src/compose/__tests__/resolver.test.ts b/packages/app-compose/src/compose/__tests__/resolver.test.ts index 1fa22c6..51dbb54 100644 --- a/packages/app-compose/src/compose/__tests__/resolver.test.ts +++ b/packages/app-compose/src/compose/__tests__/resolver.test.ts @@ -1,4 +1,5 @@ import { build, literal, optional, reference, Spot$, type SpotInternal } from "@computable" +import { LIBRARY_NAME } from "@shared" import { describe, expect, it } from "vitest" import { resolve } from "../resolver" @@ -83,6 +84,6 @@ describe("throws", () => { it("on raw literal value in build context", () => { const ctx = build({ a: 42 }) - expect(() => resolve(ctx)).toThrow("Literal value found in context") + expect(() => resolve(ctx)).toThrow(`${LIBRARY_NAME} Literal value found in context`) }) }) diff --git a/packages/app-compose/src/compose/__tests__/runner.test.ts b/packages/app-compose/src/compose/__tests__/runner.test.ts index 6e8ac8f..4bf024d 100644 --- a/packages/app-compose/src/compose/__tests__/runner.test.ts +++ b/packages/app-compose/src/compose/__tests__/runner.test.ts @@ -264,6 +264,18 @@ describe("runner", () => { expect(log).toStrictEqual(["a", "b"]) // b isn't locked by a }) + + it("exposes concurrent tasks in scope", async () => { + const taskA = createTask({ name: "a", run: { fn: () => "a" } }) + const taskB = createTask({ name: "b", run: { fn: () => "b" } }) + + const app = compose().step([taskA, taskB]) + + const scope = await run(app[Node$]) + + expect(scope.get(taskA.result)).toBe("a") + expect(scope.get(taskB.status)).toBe("done") + }) }) describe("nested compose", () => { diff --git a/packages/app-compose/src/computable/__tests__/shape.test.ts b/packages/app-compose/src/computable/__tests__/shape.test.ts index f985e7d..4eb271f 100644 --- a/packages/app-compose/src/computable/__tests__/shape.test.ts +++ b/packages/app-compose/src/computable/__tests__/shape.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import { createComputer } from "../computer" -import { Missing$, type SpotInternal } from "../definition" +import { Compute$, Missing$, type SpotInternal } from "../definition" import { literal } from "../literal" import { shape } from "../shape" @@ -38,13 +38,15 @@ describe("shape", () => { }) it("skips mapping over missing value", () => { - const fn = vi.fn((x: number) => x + 1) + const fn = vi.fn((x: unknown) => x) + const value = shape(literal(0), fn) as SpotInternal - const a = shape(literal(1), () => Missing$ as unknown as number) - const b = shape(a, fn) + // note: unrealistic, computer does not run `fn` if `build` is missing + value[Compute$].unshift(() => Missing$) - compute(b as SpotInternal) + const result = compute(value as SpotInternal) expect(fn).not.toHaveBeenCalled() + expect(result).toBe(Missing$) }) }) diff --git a/packages/app-compose/src/computable/optional.ts b/packages/app-compose/src/computable/optional.ts index dbca78d..0bf7152 100644 --- a/packages/app-compose/src/computable/optional.ts +++ b/packages/app-compose/src/computable/optional.ts @@ -2,7 +2,7 @@ import { Optional$, type Spot, type SpotInternal } from "./definition" import { proxy } from "./lens" const optional = (spot: Spot): Spot => { - const self = proxy.self(spot as SpotInternal) + const self = proxy.self(spot as SpotInternal) const updated: SpotInternal = { ...self, [Optional$]: true } return updated diff --git a/packages/app-compose/src/runnable/__tests__/task.test.ts b/packages/app-compose/src/runnable/__tests__/task.test.ts index 46b3329..198491f 100644 --- a/packages/app-compose/src/runnable/__tests__/task.test.ts +++ b/packages/app-compose/src/runnable/__tests__/task.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import { Execute$, type RunnableInternal } from "../definition" -import { createTask, type TaskExecutionValue } from "../task" +import { createTask } from "../task" describe("task", () => { describe("execute.enabled", () => {