import { test } from "node:test"; import assert from "node:assert/strict"; import { DatabaseSync } from "node:sqlite"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; import { createStudio } from "./studio.mjs"; import { nodeTypes, validateGraph } from "./flow-catalog.mjs"; import { executeStep, simulatedQuery, exampleClients } from "./flow-steps.mjs"; import { evaluate, interpolate, timeValues } from "./flow-values.mjs"; import { compileCron } from "./flow-schedule.mjs"; import { templates } from "./flow-templates.mjs"; const node = (type, config = {}, id = type) => ({ id, type, label: type, x: 50, y: 50, config: { ...structuredClone(nodeTypes[type].defaults), ...config }, }); const graph = (...nodes) => ({ nodes, edges: nodes .slice(1) .map((n, i) => ({ from: nodes[i].id, to: n.id, port: "out" })), }); function fixture(options = {}) { const directory = mkdtempSync(join(tmpdir(), "botflow-engine-")), db = new DatabaseSync(join(directory, "test.sqlite")); const calls = [], states = []; const studio = createStudio(db, directory, { scheduler: false, transport: async (c, command, params) => { calls.push({ command, params }); return simulatedQuery(command); }, eventFactory: (c, onEvent, onState, settings) => { states.push({ c, onEvent, onState, settings }); onState({ status: "connected", fingerprint: "a".repeat(64) }); return { close() {}, ready: true }; }, ...options, }); async function invoke( path, method = "GET", body = {}, role = "Dono", extraHeaders = {}, ) { const req = Readable.from( method === "GET" ? [] : [Buffer.from(JSON.stringify(body))], ); req.method = method; req.headers = { origin: "http://localhost:3080", ...extraHeaders }; let result; const url = new URL(path, "http://localhost:3080"); if ( !(await studio.handleWebhook( req, url, (status, data) => (result = { status, data }), )) ) await studio.route( req, url, { role, name: "Tester" }, (status, data) => (result = { status, data }), ); return result; } const connection = async (extras = {}) => ( await invoke("/api/connections", "POST", { name: "Test", url: "http://teamspeak6:10080", serverId: 1, apiKey: "test-key", ...extras, }) ).data; const bot = async (g, connectionId = "", extras = {}) => { const created = ( await invoke("/api/bots", "POST", { import: { name: "Test", graph: g } }) ).data; const updated = await invoke(`/api/bots/${created.id}`, "PUT", { connectionId, ...extras, }); assert.equal(updated.status, 200, updated.data.error); return updated.data; }; return { db, directory, studio, calls, states, invoke, connection, bot, async close() { await studio.close(); db.close(); rmSync(directory, { recursive: true, force: true }); }, }; } async function until(fn) { for (let i = 0; i < 100; i++) { const result = await fn(); if (result) return result; await new Promise((r) => setTimeout(r, 10)); } throw new Error("Timed out waiting for execution"); } test("safe expressions, filters and time support calculations without JavaScript access", () => { const context = { server: { total: "8", query: "2" }, clock: timeValues(new Date("2026-10-10T12:34:56Z"), "UTC"), text: "olá", temp: { list: [1, 2] }, }; assert.equal( evaluate("round((server.total - server.query) / 4)", context), 2, ); assert.equal( evaluate('server.total > 4 && contains(text, "ol")', context), true, ); assert.equal(evaluate("false && missing > 0", context), false); assert.equal(evaluate("true || missing > 0", context), true); assert.equal( interpolate( "{{server.total > 4 || false}} · {{text | upper}} · {{time.time}}:{{time.seconds}}", context, ), "true · OLÁ · 12:34:56", ); assert.equal(interpolate('{{contains("a|b", "|")}}', context), "true"); assert.equal(interpolate("{{temp.list | json}}", context), "[1,2]"); assert.throws(() => evaluate('constructor.constructor("return process")()', context), ); assert.throws(() => evaluate("process.exit()", context)); assert.throws(() => evaluate("1 / 0", context), /zero/); assert.throws(() => evaluate("x = 2", context)); assert.equal(interpolate("{{server.constructor}}", context), ""); }); test("cron evaluates seconds, ranges, day/week OR and timezone", () => { const date = new Date("2026-10-10T12:34:56Z"); assert.equal(compileCron("* * * * * *", "UTC")(date), true); assert.equal(compileCron("* * * * *", "UTC")(date), false); assert.equal( compileCron("* * * * *", "UTC")(new Date("2026-10-10T12:34:00Z")), true, ); assert.equal(compileCron("*/2 30-35 12 10 10 0", "UTC")(date), true); assert.equal(compileCron("56 34 13 * * *", "Europe/Lisbon")(date), true); assert.throws(() => compileCron("61 * * * * *"), /intervalo/); assert.throws(() => compileCron("* * *"), /campos/); assert.throws(() => compileCron("* * * * * *", "Invalid/Timezone")); }); test("every advertised action executes in simulation without network calls", async () => { let network = 0; for (const [name, type] of Object.entries(nodeTypes)) { if (type.trigger || name === "foreach") continue; const context = { server: simulatedQuery("serverinfo")[0], event: { clid: 1, cid: 21, client_database_id: 10, client_nickname: "Pessoa", }, clients: structuredClone(exampleClients), count: 3, temp: {}, variables: {}, clock: timeValues(), time: "12:34:56", text: "Texto", top: "Top", }; const n = node(name, { banId: 1, url: name === "discord" ? "https://discord.com/api/webhooks/1/test" : "http://localhost", groupId: 9, }); const result = await executeStep(n, context, { simulation: true, call: () => network++, httpTransport: () => network++, channelMessage: () => network++, persist: () => network++, }); assert.equal(typeof result.message, "string", name); } assert.equal(network, 0); for (const template of templates) assert.doesNotThrow(() => validateGraph(template.graph), template.name); }); test("TeamSpeak actions distinguish session IDs and database IDs; permissions use correct scope", async () => { const calls = [], context = { temp: {}, event: { clid: "23", client_database_id: "456" }, variables: {}, clock: timeValues(), }; const run = async (type, config = {}) => executeStep(node(type, config), context, { call: async (command, params) => { calls.push({ command, params }); return [{ sgid: "9" }]; }, persist() {}, signal: new AbortController().signal, }); await run("move", { channelId: 21 }); await run("groupAdd", { groupId: 9 }); await run("clientPermissionSet", { value: "-1", skip: "1" }); await run("channelPermissionSet", { channelId: 21 }); await run("servergroupPermissionSet", { groupId: 9, negated: "1", skip: "1", }); await run("channelclientPermissionRemove", { channelId: 21 }); assert.deepEqual(calls[0], { command: "clientmove", params: { clid: 23, cid: 21 }, }); assert.deepEqual(calls[1], { command: "servergroupaddclient", params: { cldbid: 456, sgid: 9 }, }); assert.equal(calls[2].params.cldbid, 456); assert.equal(calls[2].params.permvalue, -1); assert.equal(calls[2].params.permskip, "1"); assert.equal(calls[2].params.permnegated, undefined); assert.equal(calls[3].params.permskip, undefined); assert.equal(calls[3].params.permnegated, undefined); assert.equal(calls[4].params.permnegated, "1"); assert.equal(calls[4].params.permskip, "1"); assert.deepEqual(calls[5], { command: "channelclientdelperm", params: { cldbid: 456, cid: 21, permsid: "i_client_talk_power" }, }); await run("groups"); assert.ok( Array.isArray(context.temp.groups), "single-result lists remain arrays", ); await assert.rejects(() => run("move", { clientId: "not-an-id" }), /inteiro/); }); test("foreach executes Each sequentially and Done once; simulations never persist variables", async () => { const f = fixture(); try { const g = graph( node("manual"), node("clients"), node("foreach"), node("variable", { name: "total", scope: "persistent", operation: "increment", value: "1", }), node("template", { text: "Total {{variables.total}}" }, "done"), ); g.edges = [ { from: "manual", to: "clients", port: "out" }, { from: "clients", to: "foreach", port: "out" }, { from: "foreach", to: "variable", port: "each" }, { from: "foreach", to: "done", port: "done" }, ]; const c = await f.connection(), b = await f.bot(g, c.id, { variables: { total: 0 } }); const simulated = (await f.invoke(`/api/bots/${b.id}/simulate`, "POST")) .data; assert.equal(simulated.output, "Total 3"); assert.equal( simulated.steps.filter((s) => s.type === "variable").length, 3, ); assert.equal(f.calls.length, 0); assert.equal( (await f.invoke("/api/dashboard")).data.bots.find((x) => x.id === b.id) .variables.total, 0, ); const real = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data; assert.equal(real.status, "success"); assert.equal(real.output, "Total 3"); assert.equal( (await f.invoke("/api/dashboard")).data.bots.find((x) => x.id === b.id) .variables.total, 3, ); } finally { await f.close(); } }); test("retry and error branches retain failures in the execution timeline", async () => { let attempts = 0; const f = fixture({ transport: async () => { attempts++; throw new Error("Query refused"); }, }); try { const g = graph( node("manual"), node("serverinfo", { retries: 1 }), node("template", { text: "Tratado: {{temp.error.message}}" }), ); g.edges[1].port = "error"; const c = await f.connection(), b = await f.bot(g, c.id), result = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data; assert.equal(result.status, "warning"); assert.equal(attempts, 2); assert.equal(result.steps[1].attempts, 2); assert.equal(result.steps[1].status, "error"); assert.equal(result.output, "Tratado: Query refused"); } finally { await f.close(); } }); test("cancellation interrupts waits, blocks overlap, and allows subsequent runs", async () => { const f = fixture(); try { const c = await f.connection(), b = await f.bot( graph(node("manual"), node("delay", { seconds: 100 })), c.id, ); const pending = f.invoke(`/api/bots/${b.id}/run`, "POST"); await until(() => f.studio.running.has(b.id)); assert.equal((await f.invoke(`/api/bots/${b.id}/run`, "POST")).status, 400); assert.equal( (await f.invoke(`/api/bots/${b.id}/cancel`, "POST")).status, 200, ); assert.equal((await pending).data.status, "cancelled"); assert.equal(f.studio.running.size, 0); assert.equal( (await f.invoke(`/api/bots/${b.id}/simulate`, "POST")).data.status, "success", ); } finally { await f.close(); } }); test("event filters, group-restricted chat commands, multiple triggers and encrypted secrets", async () => { const f = fixture(); try { const c = await f.connection({ eventsEnabled: true, sshUsername: "query", sshPassword: "ssh-secret", }); const g = graph( node("event", { filters: { client_type: "0" } }), node("command", { command: "!info", allowedGroup: "9" }), node("template", { text: "{{event.client_nickname}} · {{event.command_args}}", }), ); g.edges = [ { from: "event", to: "template", port: "out" }, { from: "command", to: "template", port: "out" }, ]; const b = await f.bot(g, c.id, { enabled: true }); assert.equal(f.states.length, 1); assert.equal(f.states[0].c.sshPassword, "ssh-secret"); const stored = f.db .prepare("SELECT data FROM studio_connections") .get().data; assert.ok(!stored.includes("ssh-secret")); assert.match(stored, /a{64}/); await f.studio.receive(c.id, "notifycliententerview", { clid: "1", client_type: "1", client_nickname: "Query", }); assert.equal((await f.invoke("/api/dashboard")).data.executions.length, 0); await f.studio.receive(c.id, "notifycliententerview", { clid: "1", client_type: "0", client_nickname: "Pessoa", }); await until(async () => (await f.invoke("/api/dashboard")).data.executions.some( (e) => e.status === "success", ), ); await f.studio.receive(c.id, "notifytextmessage", { invokerid: "1", invokername: "Pessoa", msg: "!info teste", __listenerChannel: "0", }); const runs = await until(async () => { const list = (await f.invoke("/api/dashboard")).data.executions; return list.filter((e) => e.status === "success").length === 2 && list; }); assert.equal(runs[0].output, "Pessoa A · teste"); await f.invoke(`/api/bots/${b.id}`, "PUT", { graph: { ...g, nodes: g.nodes.map((n) => n.type === "command" ? { ...n, config: { ...n.config, allowedGroup: "999" } } : n, ), }, }); await f.studio.receive(c.id, "notifytextmessage", { invokerid: "1", msg: "!info", __listenerChannel: "0", }); assert.equal((await f.invoke("/api/dashboard")).data.executions.length, 2); assert.equal( ( await f.invoke(`/api/bots/${b.id}/simulate`, "POST", { triggerId: "missing", }) ).status, 400, ); } finally { await f.close(); } }); test("webhooks authenticate before enqueueing; exports remove secrets and imports remain disabled", async () => { const f = fixture(); try { const c = await f.connection(), secret = "secret-for-test-12345", g = graph( node("webhook", { secret, filters: { kind: "update" } }), node("http", { headers: { Authorization: "secret-header" } }), node("template", { text: "{{event.webhook_body.name}}" }), ); const b = await f.bot(g, c.id, { enabled: true }); const stored = f.db .prepare("SELECT data FROM studio_bots WHERE id=?") .get(b.id).data; assert.ok(!stored.includes(secret)); assert.ok(!stored.includes("secret-header")); const reader = ( await f.invoke("/api/dashboard", "GET", {}, "Leitor") ).data.bots.find((x) => x.id === b.id); assert.equal(reader.graph.nodes[0].config.secret, undefined); assert.equal(reader.graph.nodes[1].config.headers, undefined); assert.equal( ( await f.invoke( `/api/hooks/${b.id}`, "POST", { kind: "update" }, {}, { "x-webhook-secret": "bad" }, ) ).status, 401, ); const exported = (await f.invoke(`/api/bots/${b.id}/export`, "POST")).data; assert.equal(exported.format, "ts6-botflow/v1"); assert.equal(exported.bot.graph.nodes[0].config.secret, undefined); assert.equal(exported.bot.connectionId, undefined); const imported = ( await f.invoke("/api/bots", "POST", { import: exported.bot }) ).data; assert.equal(imported.enabled, false); assert.equal(imported.connectionId, ""); assert.ok(imported.graph.nodes[0].config.secret.length >= 16); await f.invoke(`/api/bots/${b.id}`, "PUT", { graph: graph( node("webhook", { secret }), node("template", { text: "{{event.webhook_body.name}}" }), ), }); assert.equal( ( await f.invoke( `/api/hooks/${b.id}`, "POST", { name: "Webhook" }, "Dono", { "x-webhook-secret": secret }, ) ).status, 202, ); const result = await until(async () => (await f.invoke("/api/dashboard")).data.executions.find( (e) => e.status === "success", ), ); assert.equal(result.output, "Webhook"); const copy = (await f.invoke(`/api/bots/${b.id}/duplicate`, "POST")).data; assert.notEqual(copy.id, b.id); assert.equal(copy.enabled, false); assert.equal( ( await f.invoke("/api/bots", "POST", { import: { graph: graph(node("manual")), variables: { constructor: "bad" }, }, }) ).status, 400, ); } finally { await f.close(); } }); test("channel messages serialize a temporary Query move and restore the original channel on failure", async () => { const calls = []; const f = fixture({ transport: async (c, command, params) => { calls.push({ command, params }); if (command === "whoami") return [{ client_id: "50", client_channel_id: "7" }]; if (command === "sendtextmessage") throw new Error("Message refused"); return []; }, }); try { const c = await f.connection(), b = await f.bot( graph( node("manual"), node("message", { mode: "channel", target: 21, text: "Test" }), ), c.id, ); const result = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data; assert.equal(result.status, "error"); assert.deepEqual( calls.map((c) => c.command), ["whoami", "clientmove", "sendtextmessage", "clientmove"], ); assert.deepEqual(calls.at(-1).params, { clid: 50, cid: "7" }); } finally { await f.close(); } }); test("validation rejects cycles and preserves legitimate multiple-trigger branches", () => { const g = graph(node("manual"), node("log"), node("delay")); g.edges.push({ from: "delay", to: "log", port: "out" }); assert.throws(() => validateGraph(g), /ciclo/); assert.doesNotThrow(() => validateGraph({ nodes: [node("manual"), node("timer"), node("log")], edges: [ { from: "manual", to: "log", port: "out" }, { from: "timer", to: "log", port: "out" }, ], }), ); assert.throws( () => validateGraph(graph(node("manual"), node("timer"))), /inválida/, ); }); test("legacy graphs, encrypted keys and sessions survive reopening the database", async () => { const f = fixture(); let next; try { const c = await f.connection(), b = await f.bot(templates[0].graph, c.id); const legacy = JSON.parse( f.db.prepare("SELECT data FROM studio_bots WHERE id=?").get(b.id).data, ); for (const n of legacy.graph.nodes) { delete n.secureConfig; if (["clients", "serverinfo"].includes(n.type)) n.config = {}; } delete legacy.timeoutSeconds; f.db .prepare("UPDATE studio_bots SET data=? WHERE id=?") .run(JSON.stringify(legacy), b.id); const oldConnection = JSON.parse( f.db.prepare("SELECT data FROM studio_connections WHERE id=?").get(c.id) .data, ); for (const key of Object.keys(oldConnection)) if (!["id", "name", "url", "serverId", "apiKey"].includes(key)) delete oldConnection[key]; f.db .prepare("UPDATE studio_connections SET data=? WHERE id=?") .run(JSON.stringify(oldConnection), c.id); f.db.exec( "CREATE TABLE sessions(id TEXT PRIMARY KEY,user TEXT,expires INTEGER)", ); f.db .prepare("INSERT INTO sessions VALUES(?,?,?)") .run("session-fixture", "owner", Date.now() + 10000); await f.studio.close(); next = createStudio(f.db, f.directory, { scheduler: false, transport: async (connection, command) => { assert.equal(connection.apiKey, "test-key"); return simulatedQuery(command); }, }); assert.equal(f.db.prepare("SELECT user FROM sessions").get().user, "owner"); assert.equal( f.db.prepare("SELECT count(*) AS count FROM studio_bots").get().count, 2, ); const req = Readable.from([Buffer.from("{}")]); req.method = "POST"; req.headers = { origin: "http://localhost:3080" }; let response; await next.route( req, new URL(`/api/bots/${b.id}/run`, "http://localhost:3080"), { role: "Dono", name: "Test" }, (status, data) => (response = { status, data }), ); assert.equal(response.status, 200); assert.equal(response.data.status, "success"); assert.equal(response.data.steps.length, 7); assert.match(response.data.output, /Online: 3 \/ 32/); } finally { await next?.close(); await f.close(); } }); test("typed variables support lists and shutdown waits for cancellation before closing storage", async () => { const f = fixture(); try { const g = graph( node("manual"), node("variable", { name: "list", valueType: "json", value: "[1,2,3]" }), node("template", { text: "{{length(temp.list)}}" }), ); const c = await f.connection(), b = await f.bot(g, c.id), simulation = (await f.invoke(`/api/bots/${b.id}/simulate`, "POST")).data; assert.equal(simulation.output, "3"); await f.invoke(`/api/bots/${b.id}`, "PUT", { graph: graph(node("manual"), node("delay", { seconds: 100 })), }); const pending = f.invoke(`/api/bots/${b.id}/run`, "POST"); await until(() => f.studio.running.has(b.id)); await f.studio.close(); assert.equal((await pending).data.status, "cancelled"); assert.equal(f.studio.running.size, 0); assert.equal( f.db.prepare("SELECT count(*) AS count FROM studio_executions").get() .count, 2, ); } finally { await f.close(); } });