Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/test/flow-draft.test.mjs
iris d0a55502a8 swarm-ui: draw notification flows as graphs in the list and make a new or copied flow a draft until saved
The flows list draws each flow as the editor's graph, read-only and with the
dry run's hit counts in the boxes, in place of the indented tree. A box's line
of text is cut by the browser at the box's edge, so it no longer runs out of it.

create flow (a primary button beside the refresh interval) and duplicate open
the editor on an unsaved draft at /notifications/new, with ?from= naming the
flow to copy; the flow is made by save, under an id taken from its name. Edit
and duplicate are buttons in the flow's header row. The editor's head and
inspector are laid out as sections.
2026-10-25 02:05:54 +02:00

396 lines
13 KiB
JavaScript

// The notification flow editor's draft edits. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
addChild,
blankNode,
DEFAULT_LIMITS,
depthOf,
duplicateFlow,
heightBelow,
isDirty,
isLeafKind,
limitsOf,
moveAmongSiblings,
NEW_FLOW_ID,
newFlow,
newNodeId,
paletteKinds,
removeNode,
reparent,
replaceNode,
savedAs,
saveOutcome,
saveRequest,
seedFlow,
siblingPosition,
slugId,
subtreeIds,
withFlow,
withoutFlow,
withOp,
} from "../src/pages/notifications/FlowDraft.ts";
const todo = (id) => ({
id,
kind: "todo",
to: { agent: "triage" },
op: "upsert",
key: "k",
prefix: false,
text: "look",
});
// source -> a -> b -> t1, source -> c
const flow = () => ({
id: "f",
name: "f",
enabled: true,
nodes: [
{ id: "source", kind: "source", events: ["issues"] },
{ id: "a", kind: "match", when: [] },
{ id: "b", kind: "match", when: [] },
{ id: "c", kind: "match", when: [] },
todo("t1"),
],
edges: [
{ from: "source", to: "a" },
{ from: "a", to: "b" },
{ from: "b", to: "t1" },
{ from: "source", to: "c" },
],
});
const ids = (f) => f.nodes.map((n) => n.id).sort();
test("depth, height and subtree", () => {
const f = flow();
assert.equal(depthOf(f, "source"), 0);
assert.equal(depthOf(f, "t1"), 3);
assert.equal(heightBelow(f, "source"), 3);
assert.equal(heightBelow(f, "c"), 0);
assert.deepEqual(subtreeIds(f, "a").sort(), ["a", "b", "t1"]);
});
test("a cycle in the data cannot hang the walks", () => {
const f = flow();
f.edges.push({ from: "t1", to: "a" });
assert.ok(depthOf(f, "b") < 10);
assert.ok(heightBelow(f, "a") < 10);
assert.ok(subtreeIds(f, "a").length <= f.nodes.length);
});
test("addChild adds the node and its edge", () => {
const r = addChild(flow(), "c", todo("t2"));
assert.ok(r.ok);
assert.ok(r.flow.nodes.some((n) => n.id === "t2"));
assert.deepEqual(r.flow.edges.at(-1), { from: "c", to: "t2" });
});
test("addChild refuses what a save would", () => {
const f = flow();
assert.equal(addChild(f, "t1", todo("t2")).ok, false, "under a todo");
assert.equal(addChild(f, "nope", todo("t2")).ok, false, "no parent");
assert.equal(addChild(f, "c", todo("a")).ok, false, "id in use");
assert.equal(addChild(f, "c", todo("bad id")).ok, false, "bad id");
assert.equal(
addChild(f, "c", { id: "s2", kind: "source", events: ["issues"] }).ok,
false,
"second source",
);
const small = { maxNodes: 5, maxDepth: 16, maxFlows: 100 };
assert.equal(addChild(f, "c", todo("t2"), small).ok, false, "node cap");
const shallow = { maxNodes: 64, maxDepth: 2, maxFlows: 100 };
assert.equal(addChild(f, "b", todo("t2"), shallow).ok, false, "depth cap");
assert.ok(addChild(f, "a", todo("t2"), shallow).ok, "right at the cap");
});
test("removeNode takes the branch and keeps the rest", () => {
const r = removeNode(flow(), "a");
assert.ok(r.ok);
assert.deepEqual(ids(r.flow), ["c", "source"]);
assert.deepEqual(r.flow.edges, [{ from: "source", to: "c" }]);
assert.equal(removeNode(flow(), "source").ok, false);
assert.equal(removeNode(flow(), "nope").ok, false);
});
test("reparent moves a branch and keeps one parent per node", () => {
const r = reparent(flow(), "b", "c");
assert.ok(r.ok);
assert.equal(r.flow.edges.filter((e) => e.to === "b").length, 1);
assert.ok(r.flow.edges.some((e) => e.from === "c" && e.to === "b"));
assert.ok(r.flow.edges.some((e) => e.from === "b" && e.to === "t1"));
assert.equal(r.flow.nodes.length, 5);
});
test("reparent refuses a cycle, a todo parent and the source", () => {
const f = flow();
assert.equal(reparent(f, "a", "b").ok, false, "under its own child");
assert.equal(reparent(f, "a", "a").ok, false, "under itself");
assert.equal(reparent(f, "c", "t1").ok, false, "under a todo");
assert.equal(reparent(f, "source", "c").ok, false, "the source");
assert.equal(reparent(f, "nope", "c").ok, false, "unknown node");
});
test("reparent counts the whole branch against the depth cap", () => {
const limits = { maxNodes: 64, maxDepth: 4, maxFlows: 100 };
// a (1) -> b -> t1 is a branch of height 2; under b's child would be deeper.
const f = flow();
// c is at depth 1; moving a (height 2) under c puts t1 at depth 4.
assert.ok(reparent(f, "a", "c", limits).ok);
const tight = { ...limits, maxDepth: 3 };
assert.equal(reparent(f, "a", "c", tight).ok, false);
});
test("replaceNode keeps the place and the kind class", () => {
const f = flow();
const r = replaceNode(f, {
id: "a",
kind: "match",
when: [{ field: "action", equals: "x" }],
});
assert.ok(r.ok);
assert.equal(r.flow.nodes.find((n) => n.id === "a").when.length, 1);
assert.equal(replaceNode(f, { ...todo("a") }).ok, false, "a has children");
assert.ok(replaceNode(f, { ...todo("c") }).ok, "a leaf may become a todo");
assert.equal(
replaceNode(f, { id: "source", kind: "match", when: [] }).ok,
false,
);
assert.equal(
replaceNode(f, { id: "c", kind: "source", events: ["issues"] }).ok,
false,
);
assert.equal(replaceNode(f, todo("nope")).ok, false);
});
test("withOp drops what the new op has no place for", () => {
const clearing = { ...todo("t"), op: "clear", prefix: true, text: "" };
const up = withOp(clearing, "upsert");
assert.equal(up.op, "upsert");
assert.equal(up.prefix, false, "only a clear may be a prefix");
const back = withOp({ ...todo("t"), prefix: false }, "clear");
assert.equal(back.text, "", "a clear carries no text");
assert.equal(withOp(clearing, "clear").prefix, true, "kept on a clear");
});
test("new ids do not collide", () => {
const f = flow();
assert.equal(newNodeId(f, "match"), "match1");
const r = addChild(f, "c", blankNode(f, "match"));
assert.ok(r.ok);
assert.equal(newNodeId(r.flow, "match"), "match2");
assert.equal(blankNode(f, "todo").kind, "todo");
});
test("slugId builds an id the controller accepts and avoids taken ones", () => {
assert.equal(slugId("My Flow!", []), "my-flow");
assert.equal(slugId("My Flow!", ["my-flow"]), "my-flow-2");
assert.equal(slugId("My Flow!", ["my-flow", "my-flow-2"]), "my-flow-3");
assert.equal(slugId("!!!", []), "flow");
assert.ok(/^[A-Za-z0-9_-]{1,64}$/.test(slugId("x".repeat(200), [])));
});
test("newFlow is one source; duplicateFlow copies without sharing", () => {
const fresh = newFlow("Triage", ["triage"]);
assert.equal(fresh.id, "triage-2");
assert.equal(fresh.nodes.length, 1);
assert.equal(fresh.nodes[0].kind, "source");
assert.ok(fresh.enabled);
const src = flow();
const copy = duplicateFlow(src, "f copy", ["f"]);
assert.equal(copy.id, "f-copy");
assert.equal(copy.nodes.length, src.nodes.length);
copy.nodes[1].when.push({ field: "action", equals: "x" });
assert.equal(src.nodes[1].when.length, 0, "the original is untouched");
const off = { ...src, enabled: false };
assert.ok(duplicateFlow(off, "x", []).enabled, "a copy is on");
});
test("withFlow replaces by id or appends; withoutFlow drops", () => {
const a = { ...flow(), id: "a" };
const b = { ...flow(), id: "b" };
assert.deepEqual(
withFlow([a], b).map((f) => f.id),
["a", "b"],
);
const renamed = { ...a, name: "renamed" };
assert.equal(withFlow([a, b], renamed)[0].name, "renamed");
assert.deepEqual(
withoutFlow([a, b], "a").map((f) => f.id),
["b"],
);
});
test("isDirty compares the draft with what was read", () => {
const view = {
revision: 3,
disabled_builtins: ["x"],
builtins: [],
flows: [flow()],
};
assert.equal(isDirty(view, [flow()], ["x"]), false);
assert.equal(isDirty(view, [], ["x"]), true);
assert.equal(isDirty(view, [flow()], []), true);
});
test("saveRequest sends the whole document at the revision read", () => {
const view = { revision: 7, disabled_builtins: [], builtins: [], flows: [] };
const req = saveRequest(view, [flow()], ["b1"]);
assert.equal(req.method, "PUT");
const body = JSON.parse(req.body);
assert.equal(body.revision, 7);
assert.deepEqual(body.disabled_builtins, ["b1"]);
assert.equal(body.flows[0].id, "f");
assert.equal("builtins" in body, false, "built-ins are never sent");
});
test("saveOutcome tells a stale revision from a refused document", () => {
assert.equal(saveOutcome(200), "saved");
assert.equal(saveOutcome(409), "conflict");
assert.equal(saveOutcome(400), "invalid");
assert.equal(saveOutcome(503), "error");
assert.equal(saveOutcome(500), "error");
});
test("limitsOf takes the controller's caps and falls back to today's", () => {
assert.deepEqual(
limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }),
{ maxNodes: 5, maxDepth: 3, maxFlows: 9 },
);
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
});
const reviewer = (id) => ({ id, kind: "request_reviewer", reviewer: "argus" });
test("a request_reviewer ends a branch like a todo", () => {
assert.ok(isLeafKind("todo") && isLeafKind("request_reviewer"));
assert.ok(!isLeafKind("match") && !isLeafKind("source"));
const f = flow();
const r = addChild(f, "c", reviewer("r1"));
assert.ok(r.ok);
assert.equal(
addChild(r.flow, "r1", todo("t2")).ok,
false,
"nothing under it",
);
assert.equal(reparent(r.flow, "b", "r1").ok, false, "no move under it");
assert.equal(
replaceNode(f, reviewer("a")).ok,
false,
"a node with children cannot become one",
);
assert.ok(replaceNode(f, reviewer("c")).ok, "a leaf may");
});
test("a new reviewer request is blank and its id reads reviewer1", () => {
const f = flow();
const n = blankNode(f, "request_reviewer");
assert.deepEqual(n, {
id: "reviewer1",
kind: "request_reviewer",
reviewer: "",
});
const r = addChild(f, "c", n);
assert.ok(r.ok);
assert.equal(blankNode(r.flow, "request_reviewer").id, "reviewer2");
});
test("the palette offers what the controller lists, and the old two without a schema", () => {
const schema = (kinds) => ({
node_kinds: kinds.map((kind) => ({
kind,
description: kind + "!",
fields: [],
})),
});
assert.deepEqual(
paletteKinds(schema(["source", "match", "todo", "request_reviewer"])).map(
(p) => p.kind,
),
["match", "todo", "request_reviewer"],
);
assert.deepEqual(
paletteKinds(schema(["source", "match", "todo"])).map((p) => p.kind),
["match", "todo"],
);
assert.deepEqual(
paletteKinds(schema([])).map((p) => p.kind),
["match", "todo"],
);
assert.equal(paletteKinds(schema(["match"]))[0].description, "match!");
});
test("reparent refuses a move under the node's own parent", () => {
const r = reparent(flow(), "b", "a");
assert.equal(r.ok, false);
assert.match(r.reason, /already under/);
});
test("siblingPosition says where a branch runs among its siblings", () => {
const f = flow();
assert.deepEqual(siblingPosition(f, "a"), { index: 0, count: 2 });
assert.deepEqual(siblingPosition(f, "c"), { index: 1, count: 2 });
assert.deepEqual(siblingPosition(f, "b"), { index: 0, count: 1 });
assert.deepEqual(siblingPosition(f, "source"), { index: 0, count: 1 });
});
test("moveAmongSiblings swaps two branches and leaves other edges alone", () => {
const f = flow();
const later = moveAmongSiblings(f, "a", 1);
assert.ok(later.ok);
assert.deepEqual(
later.flow.edges.filter((e) => e.from === "source").map((e) => e.to),
["c", "a"],
);
assert.deepEqual(
later.flow.edges.filter((e) => e.from !== "source"),
f.edges.filter((e) => e.from !== "source"),
);
const back = moveAmongSiblings(later.flow, "a", -1);
assert.deepEqual(back.flow.edges, f.edges);
assert.equal(moveAmongSiblings(f, "a", -1).ok, false, "already first");
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
});
test("seedFlow opens an empty or copied draft under the placeholder id", () => {
const view = {
revision: 1,
disabled_builtins: [],
builtins: [{ ...flow(), id: "assigned", name: "Assigned" }],
flows: [{ ...flow(), id: "mine", name: "Mine" }],
};
const empty = seedFlow(view, null);
assert.equal(empty.id, NEW_FLOW_ID);
assert.equal(empty.name, "");
assert.deepEqual(
empty.nodes.map((n) => n.kind),
["source"],
);
const copy = seedFlow(view, "assigned");
assert.equal(copy.id, NEW_FLOW_ID);
assert.equal(copy.name, "Assigned (copy)");
assert.equal(copy.nodes.length, flow().nodes.length);
assert.equal(seedFlow(view, "gone"), null);
assert.equal(view.flows.length, 1, "nothing is added to the view");
});
test("savedAs takes the id from the name, clear of every saved id", () => {
const view = {
revision: 1,
disabled_builtins: [],
builtins: [{ ...flow(), id: "assigned" }],
flows: [{ ...flow(), id: "mine" }],
};
const draft = { ...flow(), id: NEW_FLOW_ID };
assert.equal(savedAs(view, { ...draft, name: "Closed" }).id, "closed");
assert.equal(savedAs(view, { ...draft, name: "Assigned" }).id, "assigned-2");
assert.equal(savedAs(view, { ...draft, name: "mine" }).id, "mine-2");
assert.equal(
savedAs(view, { ...draft, name: "New" }).id,
"new-2",
"no flow takes the id of the new-flow route",
);
});