public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { afterAll, beforeAll, describe, expect, jest, test } from "bun:test";
import { FetchTask, Flow } from "../../src/flows";
let _oldFetch: typeof fetch;
function mockFetch(responseMethods: Partial<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(() => Promise.resolve(responseMethods));
}
function mockFetch2(newFetch: (input: RequestInfo, init: RequestInit) => Promise<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(newFetch);
}
function unmockFetch() {
global.fetch = _oldFetch;
}
beforeAll(() =>
/*mockFetch({
ok: true,
status: 200,
json: () => Promise.resolve({ todos: [1, 2] })
})*/
mockFetch2(async (input, init) => {
const request = {
url: String(input),
method: init?.method ?? "GET",
// @ts-ignore
headers: Object.fromEntries(init?.headers?.entries() ?? []),
body: init?.body
};
return new Response(JSON.stringify({ todos: [1, 2], request }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
);
afterAll(unmockFetch);
describe("FetchTask", async () => {
test("Simple fetch", async () => {
const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET",
headers: [{ key: "Content-Type", value: "application/json" }]
});
const result = await task.run();
//console.log("result", result);
expect(result.output!.todos).toEqual([1, 2]);
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
});
test("verify config", async () => {
// // @ts-expect-error
expect(() => new FetchTask("", {})).toThrow();
expect(
// // @ts-expect-error
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 })
).toThrow();
expect(
new FetchTask("", {
url: "https://jsonplaceholder.typicode.com",
method: "invalid"
}).execute()
).rejects.toThrow(/^Invalid method/);
expect(
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" })
).toBeDefined();
expect(() => new FetchTask("", { url: "", method: "Invalid" })).toThrow();
});
test("template", async () => {
const task = new FetchTask("fetch", {
url: "https://example.com/?email={{ flow.output.email }}",
method: "{{ flow.output.method }}",
headers: [
{ key: "Content-{{ flow.output.headerKey }}", value: "application/json" },
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" }
],
body: JSON.stringify({
email: "{{ flow.output.email }}"
})
});
const inputs = {
headerKey: "Type",
apiKey: 123,
email: "what@else.com",
method: "PATCH"
};
const flow = new Flow("", [task]);
const exec = await flow.start(inputs);
console.log("errors", exec.getErrors());
expect(exec.hasErrors()).toBe(false);
const { request } = exec.getResponse();
expect(request.url).toBe(`https://example.com/?email=${inputs.email}`);
expect(request.method).toBe(inputs.method);
expect(request.headers["content-type"]).toBe("application/json");
expect(request.headers.authorization).toBe(`Bearer ${inputs.apiKey}`);
expect(request.body).toBe(JSON.stringify({ email: inputs.email }));
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
describe("SubFlowTask", async () => {
test("Simple Subflow", async () => {
const subTask = new RenderTask("render", {
render: "subflow"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow
});
const task3 = new RenderTask("render2", {
render: "Subflow output: {{ sub.output }}"
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow");
});
test("Simple loop", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: [1, 2, 3]
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start();
console.log("errors", execution.getErrors());
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
});
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}"
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start([4, 5, 6]);
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils";
import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
describe("Task", async () => {
test("resolveParams: template with parse", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Number()) }),
{
test: "{{ some.path }}"
},
{
some: {
path: 1
}
}
);
expect(result.test).toBe(1);
});
test("resolveParams: with string", async () => {
const result = await Task.resolveParams(
Type.Object({ test: Type.String() }),
{
test: "{{ some.path }}"
},
{
some: {
path: "1/1"
}
}
);
expect(result.test).toBe("1/1");
});
test("resolveParams: with object", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })) }),
{
test: { key: "path", value: "{{ some.path }}" }
},
{
some: {
path: "1/1"
}
}
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() }))
}),
{
test: "{{ some | json }}"
},
{
some: {
key: "path",
value: "1/1"
}
}
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String()))
}),
{
test: '{{ "1,2,3" | split: "," | json }}'
}
);
expect(result.test).toEqual(["1", "2", "3"]);
});
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean())
}),
{
test: "{{ true }}"
}
);
expect(result.test).toEqual(true);
});
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat)
}),
{
test: "{{ 3.14 }}"
}
);
expect(result.test).toEqual(3.14);
});
});

View File

@@ -0,0 +1,24 @@
import { Condition, Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
if (thirdRuns === 3) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("back", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 2);
back.task(third).asInputFor(fourth, Condition.success());
export { back };

View File

@@ -0,0 +1,23 @@
import { Condition, Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask(
"first",
async () => {
//throw new Error("Error");
return {
inner: {
result: 2
}
};
},
1000
);
const second = getNamedTask("second (if match)");
const third = getNamedTask("third (if error)");
const fanout = new Flow("fanout", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error(), 2);
fanout.task(first).asInputFor(second, Condition.matches("inner.result", 2));
export { fanout };

View File

@@ -0,0 +1,61 @@
import { Task } from "../../../src/flows";
// @todo: polyfill
const Handle = (props: any) => null;
type NodeProps<T> = any;
const Position = { Top: "top", Bottom: "bottom" };
class ExecTask extends Task {
type = "exec";
constructor(
name: string,
params: any,
private fn: () => any
) {
super(name, params);
}
override clone(name: string, params: any) {
return new ExecTask(name, params, this.fn);
}
async execute() {
//console.log("executing", this.name);
return await this.fn();
}
}
/*const ExecNode = ({
data,
isConnectable,
targetPosition = Position.Top,
sourcePosition = Position.Bottom,
selected,
}: NodeProps<ExecTask>) => {
//console.log("data", data, data.hasDelay());
return (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.name} ({selected ? "selected" : "exec"})
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
};*/
export function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number) {
const func =
_func ??
(async () => {
//console.log(`[DONE] Task: ${name}`);
return true;
});
return new ExecTask(
name,
{
delay
},
func
);
}

View File

@@ -0,0 +1,15 @@
import { Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask("first");
const second = getNamedTask("second", undefined, 1000);
const third = getNamedTask("third");
const fourth = getNamedTask("fourth");
const fifth = getNamedTask("fifth"); // without connection
const parallel = new Flow("Parallel", [first, second, third, fourth, fifth]);
parallel.task(first).asInputFor(second);
parallel.task(first).asInputFor(third);
parallel.task(third).asInputFor(fourth);
export { parallel };

View File

@@ -0,0 +1,18 @@
import { FetchTask, Flow, LogTask } from "../../../src/flows";
const first = new LogTask("First", { delay: 1000 });
const second = new LogTask("Second", { delay: 1000 });
const third = new LogTask("Long Third", { delay: 2500 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
});
const fifth = new LogTask("Task 4", { delay: 500 }); // without connection
const simpleFetch = new Flow("simpleFetch", [first, second, third, fourth, fifth]);
simpleFetch.task(first).asInputFor(second);
simpleFetch.task(first).asInputFor(third);
simpleFetch.task(fourth).asOutputFor(third);
simpleFetch.setRespondingTask(fourth);
export { simpleFetch };

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
class Passthrough extends Task {
type = "passthrough";
async execute(inputs: Map<string, any>) {
//console.log("executing passthrough", this.name, inputs);
return Array.from(inputs.values()).pop().output + "/" + this.name;
}
}
type OutputIn = Static<typeof OutputParamTask.schema>;
type OutputOut = StaticDecode<typeof OutputParamTask.schema>;
class OutputParamTask extends Task<typeof OutputParamTask.schema> {
type = "output-param";
static override schema = Type.Object({
number: dynamic(
Type.Number({
title: "Output number"
}),
Number.parseInt
)
});
async execute(inputs: InputsMap) {
//console.log("--***--- executing output", this.params);
return this.params.number;
}
}
class PassthroughFlowInput extends Task {
type = "passthrough-flow-input";
async execute(inputs: InputsMap) {
return inputs.get("flow")?.output;
}
}
describe("Flow task inputs", async () => {
test("types", async () => {
const schema = OutputParamTask.schema;
expect(parse(schema, { number: 123 })).toBeDefined();
expect(parse(schema, { number: "{{ some.path }}" })).toBeDefined();
const task = new OutputParamTask("", { number: 123 });
expect(task.params.number).toBe(123);
});
test("passthrough", async () => {
const task = new Passthrough("log");
const task2 = new Passthrough("log_2");
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
const exec = await flow.start("pass-through");
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe("pass-through/log/log_2");
});
test("output/input", async () => {
const task = new OutputParamTask("task1", { number: 111 });
const task2 = new OutputParamTask("task2", {
number: "{{ task1.output }}"
});
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
const exec = await flow.start();
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe(111);
});
test("input from flow", async () => {
const task = new OutputParamTask("task1", {
number: "{{flow.output.someFancyParam}}"
});
const task2 = new OutputParamTask("task2", {
number: "{{task1.output}}"
});
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
// expect to throw because of missing input
//expect(flow.start()).rejects.toThrow();
const exec = await flow.start({ someFancyParam: 123 });
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe(123);
});
test("manual event trigger with inputs", async () => {
class EventTriggerClass extends Event<{ number: number }> {
static override slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
const task = new OutputParamTask("event", {
number: "{{flow.output.number}}"
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger({
event: "test-event",
mode: "sync"
})
);
flow.setRespondingTask(task);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ number: 120 }));
const execs = flow.trigger.executions;
expect(execs.length).toBe(1);
expect(execs[0]!.getResponse()).toBe(120);
});
test("http trigger with response", async () => {
const task = new PassthroughFlowInput("");
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
flow.setRespondingTask(task);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test?input=123");
const data = await res.json();
//console.log("response", data);
const execs = flow.trigger.executions;
expect(execs.length).toBe(1);
expect(execs[0]!.getResponse()).toBeInstanceOf(Request);
expect(execs[0]!.getResponse()?.url).toBe("http://localhost/test?input=123");
});
});

View File

@@ -0,0 +1,186 @@
import { Box, Text, render, useApp, useInput } from "ink";
import React, { useEffect } from "react";
import { ExecutionEvent, type Flow, type Task } from "../../src/flows";
import { back } from "./inc/back";
import { fanout } from "./inc/fanout-condition";
import { parallel } from "./inc/parallel";
import { simpleFetch } from "./inc/simple-fetch";
const flows = {
back,
fanout,
parallel,
simpleFetch
};
const arg = process.argv[2];
if (!arg) {
console.log("Please provide a flow name:", Object.keys(flows).join(", "));
process.exit(1);
}
if (!flows[arg]) {
console.log("Flow not found:", arg, Object.keys(flows).join(", "));
process.exit(1);
}
console.log(JSON.stringify(flows[arg].toJSON(), null, 2));
process.exit();
const colors = [
"#B5E61D", // Lime Green
"#4A90E2", // Bright Blue
"#F78F1E", // Saffron
"#BD10E0", // Vivid Purple
"#50E3C2", // Turquoise
"#9013FE" // Grape
];
const colorsCache: Record<string, string> = {};
type Sequence = { source: string; target: string }[];
type Layout = Task[][];
type State = { layout: Layout; connections: Sequence };
type TaskWithStatus = { task: Task; status: string };
function TerminalFlow({ flow }: { flow: Flow }) {
const [tasks, setTasks] = React.useState<TaskWithStatus[]>([]);
const sequence = flow.getSequence();
const connections = flow.connections;
const { exit } = useApp();
useInput((input, key) => {
if (input === "q") {
exit();
return;
}
if (key.return) {
// Left arrow key pressed
console.log("Enter pressed");
} else {
console.log(input);
}
});
useEffect(() => {
setTasks(flow.tasks.map((t) => ({ task: t, status: "pending" })));
const execution = flow.createExecution();
execution.subscribe((event) => {
if (event instanceof ExecutionEvent) {
setTasks((prev) =>
prev.map((t) => {
if (t.task.name === event.task().name) {
let newStatus = "pending";
if (event.isStart()) {
newStatus = "running";
} else {
newStatus = event.succeeded() ? "success" : "failure";
}
return { task: t.task, status: newStatus };
}
return t;
})
);
}
});
execution.start().then(() => {
const response = execution.getResponse();
console.log("done", response ? response : "(no response)");
console.log(
"Executed tasks:",
execution.logs.map((l) => l.task.name)
);
console.log("Executed count:", execution.logs.length);
});
}, []);
function getColor(key: string) {
if (!colorsCache[key]) {
colorsCache[key] = colors[Object.keys(colorsCache).length];
}
return colorsCache[key];
}
if (tasks.length === 0) {
return <Text>Loading...</Text>;
}
return (
<Box flexDirection="column">
{sequence.map((step, stepIndex) => {
return (
<Box key={stepIndex} flexDirection="row">
{step.map((_task, index) => {
const find = tasks.find((t) => t.task.name === _task.name)!;
if (!find) {
//console.log("couldnt find", _task.name);
return null;
}
const { task, status } = find;
const inTasks = flow.task(task).getInTasks();
return (
<Box
key={index}
borderStyle="single"
marginX={1}
paddingX={1}
flexDirection="column"
>
{inTasks.length > 0 && (
<Box>
<Text dimColor>In: </Text>
<Box>
{inTasks.map((inTask, i) => (
<Text key={i} color={getColor(inTask.name)}>
{i > 0 ? ", " : ""}
{inTask.name}
</Text>
))}
</Box>
</Box>
)}
<Box flexDirection="column">
<Text bold color={getColor(task.name)}>
{task.name}
</Text>
<Status status={status} />
</Box>
</Box>
);
})}
</Box>
);
})}
</Box>
);
}
const Status = ({ status }: { status: string }) => {
let color: string | undefined;
switch (status) {
case "running":
color = "orange";
break;
case "success":
color = "green";
break;
case "failure":
color = "red";
break;
}
return (
<Text color={color} dimColor={!color}>
{status}
</Text>
);
};
render(<TerminalFlow flow={flows[arg]} />);

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { EventTrigger, Flow, HttpTrigger, Task } from "../../src/flows";
const ALL_TESTS = !!process.env.ALL_TESTS;
class ExecTask extends Task {
type = "exec";
constructor(
name: string,
params: any,
private fn: () => any
) {
super(name, params);
}
static create(name: string, fn: () => any) {
return new ExecTask(name, undefined, fn);
}
override clone(name: string, params: any) {
return new ExecTask(name, params, this.fn);
}
async execute() {
//console.log("executing", this.name);
return await this.fn();
}
}
describe("Flow trigger", async () => {
test("manual trigger", async () => {
let called = false;
const task = ExecTask.create("manual", () => {
called = true;
});
const flow = new Flow("", [task]);
expect(flow.trigger.type).toBe("manual");
await flow.trigger.register(flow);
expect(called).toBe(true);
});
test("event trigger", async () => {
class EventTriggerClass extends Event {
static override slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
let called = false;
const task = ExecTask.create("event", () => {
called = true;
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger({ event: "test-event", mode: "sync" })
);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ test: 1 }));
expect(called).toBe(true);
});
/*test("event trigger with match", async () => {
class EventTriggerClass extends Event<{ number: number }> {
static slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
let called: number = 0;
const task = ExecTask.create("event", () => {
called++;
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger(EventTriggerClass, "sync", (e) => e.params.number === 2)
);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ number: 1 }));
await emgr.emit(new EventTriggerClass({ number: 2 }));
expect(called).toBe(1);
});*/
test("http trigger", async () => {
let called = false;
const task = ExecTask.create("http", () => {
called = true;
});
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test");
//const data = await res.json();
//console.log("response", data);
expect(called).toBe(true);
});
test("http trigger with response", async () => {
const task = ExecTask.create("http", () => ({
called: true
}));
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
flow.setRespondingTask(task);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test");
const data = await res.json();
//console.log("response", data);
expect(data).toEqual({ called: true });
});
/*test.skipIf(ALL_TESTS)("template with email", async () => {
console.log("apikey", process.env.RESEND_API_KEY);
const task = new FetchTask("fetch", {
url: "https://api.resend.com/emails",
method: "POST",
headers: [
{ key: "Content-Type", value: "application/json" },
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" }
],
body: JSON.stringify({
from: "onboarding@resend.dev",
to: "dennis.senn@gmail.com",
subject:
"test from {% if flow.output.someFancyParam > 100 %}flow{% else %}task{% endif %}!",
html: "Hello"
})
});
const flow = new Flow("test", [task]);
const exec = await flow.start({ someFancyParam: 80, apiKey: process.env.RESEND_API_KEY });
//console.log("exec", exec.logs, exec.finished());
expect(exec.finished()).toBe(true);
expect(exec.hasErrors()).toBe(false);
});*/
});

View File

@@ -0,0 +1,449 @@
// eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp } from "../../src/core/utils";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);*/
class ExecTask extends Task<typeof ExecTask.schema> {
type = "exec";
static override schema = Type.Object({
delay: Type.Number({ default: 10 })
});
constructor(
name: string,
params: Static<typeof ExecTask.schema>,
private func: () => Promise<any>
) {
super(name, params);
}
override clone(name: string, params: Static<typeof ExecTask.schema>) {
return new ExecTask(name, params, this.func);
}
async execute() {
await new Promise((resolve) => setTimeout(resolve, this.params.delay ?? 0));
return await this.func();
}
}
function getTask(num: number = 0, delay: number = 5) {
return new ExecTask(
`Task ${num}`,
{
delay
},
async () => {
//console.log(`[DONE] Task: ${num}`);
return true;
}
);
//return new LogTask(`Log ${num}`, { delay });
}
function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number) {
const func =
_func ??
(async () => {
//console.log(`[DONE] Task: ${name}`);
return true;
});
return new ExecTask(
name,
{
delay: delay ?? 0
},
func
);
}
function getObjectDiff(obj1, obj2) {
const diff = Object.keys(obj1).reduce((result, key) => {
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
if (!obj2.hasOwnProperty(key)) {
result.push(key);
} else if (isEqual(obj1[key], obj2[key])) {
const resultKeyIndex = result.indexOf(key);
result.splice(resultKeyIndex, 1);
}
return result;
}, Object.keys(obj2));
return diff;
}
describe("Flow tests", async () => {
test("Simple single task", async () => {
const simple = getTask(0);
const result = await simple.run();
expect(result.success).toBe(true);
// @todo: add more
});
function getNamedQueue(flow: Flow) {
const namedSequence = flow.getSequence().map((step) => step.map((t) => t.name));
//console.log(namedSequence);
return namedSequence;
}
test("Simple flow", async () => {
const first = getTask(0);
const second = getTask(1);
// simple
const simple = new Flow("simple", [first, second]);
simple.task(first).asInputFor(second);
expect(getNamedQueue(simple)).toEqual([["Task 0"], ["Task 1"]]);
expect(simple.task(first).getDepth()).toBe(0);
expect(simple.task(second).getDepth()).toBe(1);
const execution = simple.createExecution();
await execution.start();
//console.log("execution", execution.logs);
//process.exit(0);
expect(execution.logs.length).toBe(2);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Test connection uniqueness", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2, 5);
const fourth = getTask(3);
// should be fine
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(third);
}).toBeDefined();
// should throw
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(second);
}).toThrow();
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(second).asInputFor(third);
condition.task(third).asInputFor(second);
condition.task(third).asInputFor(fourth); // this should fail
}).toThrow();
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(second).asInputFor(third);
condition.task(third).asInputFor(second);
condition.task(third).asInputFor(fourth, Condition.error());
}).toBeDefined();
});
test("Flow with 3 steps", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const three = new Flow("", [first, second, third]);
three.task(first).asInputFor(second);
three.task(second).asInputFor(third);
expect(getNamedQueue(three)).toEqual([["Task 0"], ["Task 1"], ["Task 2"]]);
expect(three.task(first).getDepth()).toBe(0);
expect(three.task(second).getDepth()).toBe(1);
expect(three.task(third).getDepth()).toBe(2);
const execution = three.createExecution();
await execution.start();
expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Flow with parallel tasks", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const fourth = getTask(3);
const fifth = getTask(4); // without connection
const parallel = new Flow("", [first, second, third, fourth, fifth]);
parallel.task(first).asInputFor(second);
parallel.task(first).asInputFor(third);
parallel.task(third).asInputFor(fourth);
expect(getNamedQueue(parallel)).toEqual([["Task 0"], ["Task 1", "Task 2"], ["Task 3"]]);
expect(parallel.task(first).getDepth()).toBe(0);
expect(parallel.task(second).getDepth()).toBe(1);
expect(parallel.task(third).getDepth()).toBe(1);
expect(parallel.task(fourth).getDepth()).toBe(2);
const execution = parallel.createExecution();
await execution.start();
expect(execution.logs.length).toBe(4);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Flow with condition", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(third);
});
test("Flow with back step", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns: number = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
if (thirdRuns === 4) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 2);
back.task(third).asInputFor(fourth, Condition.success());
expect(getNamedQueue(back)).toEqual([["first"], ["second"], ["third"], ["fourth"]]);
expect(
back
.task(third)
.getOutTasks()
.map((t) => t.name)
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
expect(execution.start()).rejects.toThrow();
});
test("Flow with back step: enough retries", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns: number = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
//console.log("--- third runs", thirdRuns);
if (thirdRuns === 2) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 1);
back.task(third).asInputFor(fourth, Condition.success());
expect(getNamedQueue(back)).toEqual([["first"], ["second"], ["third"], ["fourth"]]);
expect(
back
.task(third)
.getOutTasks()
.map((t) => t.name)
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
await execution.start();
});
test("flow fanout", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2, 20);
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(second);
fanout.task(first).asInputFor(third);
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("flow fanout with condition", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(second, Condition.success());
fanout.task(first).asInputFor(third, Condition.error());
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("flow fanout with condition error", async () => {
const first = getNamedTask("first", async () => {
throw new Error("Error");
});
const second = getNamedTask("second");
const third = getNamedTask("third");
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error());
fanout.task(first).asInputFor(second, Condition.success());
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "third"]);
});
test("flow fanout with condition matches", async () => {
const first = getNamedTask("first", async () => {
return {
inner: {
result: 2
}
};
});
const second = getNamedTask("second");
const third = getNamedTask("third");
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error());
fanout.task(first).asInputFor(second, Condition.matches("inner.result", 2));
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "second"]);
});
test("flow: responding task", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second", async () => ({ result: 2 }));
const third = getNamedTask("third");
const flow = new Flow("", [first, second, third]);
flow.task(first).asInputFor(second);
flow.task(second).asInputFor(third);
flow.setRespondingTask(second);
const execution = flow.createExecution();
execution.subscribe(async (event) => {
if (event instanceof ExecutionEvent) {
console.log(
"[event]",
event.isStart() ? "start" : "end",
event.task().name,
event.isStart() ? undefined : event.succeeded()
);
}
});
await execution.start();
const response = execution.getResponse();
expect(response).toEqual({ result: 2 });
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "second"]);
/*console.log("response", response);
console.log("execution.logs.length", execution.logs.length);
console.log(
"executed",
execution.logs.map((l) => l.task.name),
);*/
/*expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);*/
});
test("serialize/deserialize", async () => {
const first = new LogTask("Task 0");
const second = new LogTask("Task 1");
const third = new LogTask("Task 2", { delay: 50 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
});
const fifth = new LogTask("Task 4"); // without connection
const flow = new Flow("", [first, second, third, fourth, fifth]);
flow.task(first).asInputFor(second);
flow.task(first).asInputFor(third);
flow.task(fourth).asOutputFor(third, Condition.matches("some", 1));
flow.setRespondingTask(fourth);
const original = flow.toJSON();
//console.log("flow", original);
// @todo: fix
const deserialized = Flow.fromObject("", original, {
fetch: { cls: FetchTask },
log: { cls: LogTask }
} as any);
const diffdeep = getObjectDiff(original, deserialized.toJSON());
expect(diffdeep).toEqual([]);
expect(flow.startTask.name).toEqual(deserialized.startTask.name);
expect(flow.respondingTask?.name).toEqual(
// @ts-ignore
deserialized.respondingTask?.name
);
//console.log("--- creating original sequence");
const originalSequence = flow.getSequence();
//console.log("--- creating deserialized sequence");
const deserializedSequence = deserialized.getSequence();
//console.log("--- ");
expect(originalSequence).toEqual(deserializedSequence);
});
test("error end", async () => {
const first = getNamedTask("first", async () => "first");
const second = getNamedTask("error", async () => {
throw new Error("error");
});
const third = getNamedTask("third", async () => "third");
const errorhandlertask = getNamedTask("errorhandler", async () => "errorhandler");
const flow = new Flow("", [first, second, third, errorhandlertask]);
flow.task(first).asInputFor(second);
flow.task(second).asInputFor(third);
flow.task(second).asInputFor(errorhandlertask, Condition.error());
const exec = await flow.start();
//console.log("logs", JSON.stringify(exec.logs, null, 2));
//console.log("errors", exec.hasErrors(), exec.errorCount());
expect(exec.hasErrors()).toBe(true);
expect(exec.errorCount()).toBe(1);
expect(exec.getResponse()).toBe("errorhandler");
});
});