mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AutomationDefinition, Step } from "../../../types/automations.js";
|
|
|
|
/**
|
|
* The engine reaches the database through the repository factory and the
|
|
* outside world through the step executors, so both are mocked here. What is
|
|
* under test is the run loop itself: ordering, branching, error policy,
|
|
* concurrency, recursion and dry-run.
|
|
*/
|
|
|
|
interface FakeAutomation {
|
|
id: number;
|
|
userId: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
definition: string;
|
|
concurrencyPolicy: string;
|
|
maxRunSeconds: number;
|
|
dryRun: boolean;
|
|
}
|
|
|
|
const automations = new Map<number, FakeAutomation>();
|
|
const runs: Array<Record<string, unknown>> = [];
|
|
const runSteps: Array<Record<string, unknown>> = [];
|
|
let nextRunId = 1;
|
|
let nextStepRowId = 1;
|
|
|
|
const repository = {
|
|
findById: vi.fn(async (id: number) => automations.get(id) ?? null),
|
|
createRun: vi.fn(async (input: Record<string, unknown>) => {
|
|
const run = { id: nextRunId++, ...input };
|
|
runs.push(run);
|
|
return run;
|
|
}),
|
|
finishRun: vi.fn(async (runId: number, input: Record<string, unknown>) => {
|
|
const run = runs.find((entry) => entry.id === runId);
|
|
if (run) Object.assign(run, input);
|
|
}),
|
|
createRunStep: vi.fn(async (input: Record<string, unknown>) => {
|
|
const id = nextStepRowId++;
|
|
runSteps.push({ id, ...input });
|
|
return id;
|
|
}),
|
|
finishRunStep: vi.fn(async (id: number, input: Record<string, unknown>) => {
|
|
const step = runSteps.find((entry) => entry.id === id);
|
|
if (step) Object.assign(step, input);
|
|
}),
|
|
};
|
|
|
|
const resolveHostById = vi.fn();
|
|
|
|
vi.mock("../../hosts/host-resolver.js", () => ({
|
|
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentAutomationRepository: () => repository,
|
|
}));
|
|
|
|
const executeStep = vi.fn();
|
|
vi.mock("../../automations/actions/index.js", () => ({
|
|
executeStep: (...args: unknown[]) => executeStep(...args),
|
|
}));
|
|
|
|
const { AutomationEngine } = await import("../../automations/engine.js");
|
|
|
|
function defineAutomation(
|
|
steps: Step[],
|
|
overrides: Partial<FakeAutomation> = {},
|
|
): FakeAutomation {
|
|
const definition: AutomationDefinition = {
|
|
version: 1,
|
|
trigger: { kind: "webhook", tokenHash: "x" },
|
|
steps,
|
|
};
|
|
const automation: FakeAutomation = {
|
|
id: overrides.id ?? 1,
|
|
userId: "user-1",
|
|
name: "Test",
|
|
enabled: true,
|
|
definition: JSON.stringify(definition),
|
|
concurrencyPolicy: "skip",
|
|
maxRunSeconds: 300,
|
|
dryRun: false,
|
|
...overrides,
|
|
};
|
|
automations.set(automation.id, automation);
|
|
return automation;
|
|
}
|
|
|
|
function step(partial: Partial<Step> & { id: string; type: string }): Step {
|
|
return partial as Step;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
automations.clear();
|
|
runs.length = 0;
|
|
runSteps.length = 0;
|
|
nextRunId = 1;
|
|
nextStepRowId = 1;
|
|
vi.clearAllMocks();
|
|
resolveHostById.mockResolvedValue(null);
|
|
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
|
// The singleton carries in-flight state between tests.
|
|
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
|
});
|
|
|
|
describe("AutomationEngine.run", () => {
|
|
it("adds the trigger host name to the template context", async () => {
|
|
defineAutomation([step({ id: "notify", type: "notify" })]);
|
|
resolveHostById.mockResolvedValue({
|
|
name: "Proxmox Node",
|
|
ip: "10.0.0.11",
|
|
username: "root",
|
|
port: 22,
|
|
});
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "metric_threshold",
|
|
triggerHostId: 11,
|
|
triggerContext: { hostId: 11, value: 97 },
|
|
});
|
|
|
|
const context = executeStep.mock.calls[0][1] as {
|
|
template: {
|
|
host: { id: number; name: string };
|
|
trigger: { hostName: string };
|
|
};
|
|
};
|
|
expect(context.template.host).toMatchObject({
|
|
id: 11,
|
|
name: "Proxmox Node",
|
|
});
|
|
expect(context.template.trigger.hostName).toBe("Proxmox Node");
|
|
});
|
|
it("runs steps in order and records each one", async () => {
|
|
defineAutomation([
|
|
step({ id: "a", type: "run_command" }),
|
|
step({ id: "b", type: "http" }),
|
|
]);
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("success");
|
|
expect(executeStep).toHaveBeenCalledTimes(2);
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["a", "b"]);
|
|
expect(runSteps.map((s) => s.stepIndex)).toEqual([0, 1]);
|
|
});
|
|
|
|
it("fails the run and stops when a step fails under the default policy", async () => {
|
|
defineAutomation([
|
|
step({ id: "a", type: "run_command" }),
|
|
step({ id: "b", type: "http" }),
|
|
]);
|
|
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("failed");
|
|
expect(outcome.error).toBe("boom");
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps going when a step is marked continue-on-error", async () => {
|
|
defineAutomation([
|
|
step({ id: "a", type: "run_command", onError: "continue" }),
|
|
step({ id: "b", type: "http" }),
|
|
]);
|
|
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("success");
|
|
expect(executeStep).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("skips disabled steps", async () => {
|
|
defineAutomation([
|
|
step({ id: "a", type: "run_command", enabled: false }),
|
|
step({ id: "b", type: "http" }),
|
|
]);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["b"]);
|
|
});
|
|
|
|
it("passes earlier step output to later steps", async () => {
|
|
defineAutomation([
|
|
step({ id: "first", type: "run_command" }),
|
|
step({ id: "second", type: "http" }),
|
|
]);
|
|
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
const secondCallContext = executeStep.mock.calls[1][1] as {
|
|
template: { steps: Record<string, { stdout: string }> };
|
|
};
|
|
expect(secondCallContext.template.steps.first.stdout).toBe("hello");
|
|
});
|
|
|
|
it("merges variables set by a step into the template context", async () => {
|
|
defineAutomation([
|
|
step({ id: "setter", type: "set_var" }),
|
|
step({ id: "next", type: "http" }),
|
|
]);
|
|
executeStep.mockResolvedValueOnce({
|
|
success: true,
|
|
output: "x = 1",
|
|
vars: { x: "1" },
|
|
});
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
const context = executeStep.mock.calls[1][1] as {
|
|
template: { vars: Record<string, string> };
|
|
};
|
|
expect(context.template.vars.x).toBe("1");
|
|
});
|
|
|
|
describe("if branching", () => {
|
|
it("runs the then branch when the condition matches", async () => {
|
|
defineAutomation([
|
|
step({
|
|
id: "cond",
|
|
type: "if",
|
|
condition: { left: "93", operator: ">", right: "90" },
|
|
then: [step({ id: "yes", type: "http" })],
|
|
else: [step({ id: "no", type: "http" })],
|
|
}),
|
|
]);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
|
|
});
|
|
|
|
it("runs the else branch when it does not", async () => {
|
|
defineAutomation([
|
|
step({
|
|
id: "cond",
|
|
type: "if",
|
|
condition: { left: "10", operator: ">", right: "90" },
|
|
then: [step({ id: "yes", type: "http" })],
|
|
else: [step({ id: "no", type: "http" })],
|
|
}),
|
|
]);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "no"]);
|
|
});
|
|
|
|
it("resolves templates on both sides of the condition", async () => {
|
|
defineAutomation([
|
|
step({
|
|
id: "cond",
|
|
type: "if",
|
|
condition: {
|
|
left: "{{trigger.value}}",
|
|
operator: ">=",
|
|
right: "90",
|
|
},
|
|
then: [step({ id: "yes", type: "http" })],
|
|
}),
|
|
]);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "metric_threshold",
|
|
triggerContext: { value: 95 },
|
|
});
|
|
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
|
|
});
|
|
|
|
it("treats an empty else as a no-op", async () => {
|
|
defineAutomation([
|
|
step({
|
|
id: "cond",
|
|
type: "if",
|
|
condition: { left: "1", operator: "==", right: "2" },
|
|
then: [step({ id: "yes", type: "http" })],
|
|
}),
|
|
step({ id: "after", type: "http" }),
|
|
]);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "after"]);
|
|
});
|
|
});
|
|
|
|
it("halts the run when a step returns a stop signal", async () => {
|
|
defineAutomation([
|
|
step({ id: "a", type: "run_command" }),
|
|
step({ id: "b", type: "http" }),
|
|
]);
|
|
executeStep.mockResolvedValueOnce({
|
|
success: true,
|
|
halt: { status: "success" },
|
|
});
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("success");
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("marks the run failed when a stop step asks for failure", async () => {
|
|
defineAutomation([step({ id: "a", type: "run_command" })]);
|
|
executeStep.mockResolvedValueOnce({
|
|
success: true,
|
|
halt: { status: "failed" },
|
|
});
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("failed");
|
|
});
|
|
|
|
describe("recursion protection", () => {
|
|
it("refuses to re-enter an automation already in the chain", async () => {
|
|
defineAutomation([
|
|
step({ id: "nested", type: "run_automation", automationId: 1 }),
|
|
]);
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
// The nested call is refused, and the refusal is recorded on the step.
|
|
const nestedStep = runSteps.find((s) => s.stepId === "nested");
|
|
expect(nestedStep?.status).toBe("failed");
|
|
expect(String(nestedStep?.error)).toMatch(
|
|
/already running in this chain/,
|
|
);
|
|
expect(outcome.status).toBe("failed");
|
|
});
|
|
|
|
it("refuses a mutual cycle between two automations", async () => {
|
|
defineAutomation(
|
|
[step({ id: "toB", type: "run_automation", automationId: 2 })],
|
|
{ id: 1 },
|
|
);
|
|
defineAutomation(
|
|
[step({ id: "toA", type: "run_automation", automationId: 1 })],
|
|
{ id: 2 },
|
|
);
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
const inner = runSteps.find((s) => s.stepId === "toA");
|
|
expect(inner?.status).toBe("failed");
|
|
expect(String(inner?.error)).toMatch(/already running in this chain/);
|
|
});
|
|
|
|
it("refuses to nest deeper than the maximum depth", async () => {
|
|
defineAutomation([step({ id: "a", type: "http" })]);
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
depth: 99,
|
|
});
|
|
|
|
expect(outcome.status).toBe("failed");
|
|
expect(outcome.error).toMatch(/depth/);
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("concurrency", () => {
|
|
it("records a skipped run rather than dropping it silently", async () => {
|
|
defineAutomation([step({ id: "slow", type: "run_command" })], {
|
|
concurrencyPolicy: "skip",
|
|
});
|
|
|
|
let release: () => void = () => {};
|
|
executeStep.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
release = () => resolve({ success: true, output: "done" });
|
|
}),
|
|
);
|
|
|
|
const engine = AutomationEngine.getInstance();
|
|
const first = engine.run({ automationId: 1, triggerType: "manual" });
|
|
// Let the first run register itself as in flight.
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
const second = await engine.run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
expect(second.status).toBe("skipped");
|
|
|
|
release();
|
|
await first;
|
|
|
|
const skipped = runs.find((run) => run.status === "skipped");
|
|
expect(skipped).toBeDefined();
|
|
expect(String(skipped?.error)).toMatch(/still in progress/);
|
|
});
|
|
|
|
it("skips the second of two triggers that arrive in the same tick", async () => {
|
|
defineAutomation([step({ id: "slow", type: "run_command" })], {
|
|
concurrencyPolicy: "skip",
|
|
});
|
|
|
|
executeStep.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
setTimeout(() => resolve({ success: true, output: "done" }), 20),
|
|
),
|
|
);
|
|
|
|
// No gap between the two: the in-flight slot used to be claimed several
|
|
// awaits after it was checked, so both runs got through.
|
|
const engine = AutomationEngine.getInstance();
|
|
const [first, second] = await Promise.all([
|
|
engine.run({ automationId: 1, triggerType: "manual" }),
|
|
engine.run({ automationId: 1, triggerType: "manual" }),
|
|
]);
|
|
|
|
expect([first.status, second.status].sort()).toEqual([
|
|
"skipped",
|
|
"success",
|
|
]);
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("releases the in-flight slot when the run row cannot be created", async () => {
|
|
defineAutomation([step({ id: "a", type: "run_command" })]);
|
|
repository.createRun.mockRejectedValueOnce(new Error("db down"));
|
|
|
|
const engine = AutomationEngine.getInstance();
|
|
const failed = await engine.run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
expect(failed.status).toBe("failed");
|
|
|
|
// A leaked slot would make every later run skip forever.
|
|
const after = await engine.run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
expect(after.status).toBe("success");
|
|
});
|
|
});
|
|
|
|
it("propagates the dry-run flag to executors", async () => {
|
|
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
|
|
expect(context.dryRun).toBe(true);
|
|
});
|
|
|
|
it("lets a caller force a dry run on a live automation", async () => {
|
|
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
dryRun: true,
|
|
});
|
|
|
|
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
|
|
expect(context.dryRun).toBe(true);
|
|
});
|
|
|
|
it("fails cleanly when the automation is missing", async () => {
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 404,
|
|
triggerType: "manual",
|
|
});
|
|
expect(outcome).toMatchObject({ status: "failed", runId: null });
|
|
});
|
|
|
|
it("fails cleanly when the definition is not valid JSON", async () => {
|
|
automations.set(1, {
|
|
id: 1,
|
|
userId: "user-1",
|
|
name: "Broken",
|
|
enabled: true,
|
|
definition: "not json",
|
|
concurrencyPolicy: "skip",
|
|
maxRunSeconds: 300,
|
|
dryRun: false,
|
|
});
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
expect(outcome.status).toBe("failed");
|
|
expect(outcome.error).toMatch(/not valid JSON/);
|
|
});
|
|
|
|
it("turns a thrown executor error into a failed step", async () => {
|
|
defineAutomation([step({ id: "a", type: "run_command" })]);
|
|
executeStep.mockRejectedValueOnce(new Error("connection reset"));
|
|
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("failed");
|
|
expect(runSteps[0].status).toBe("failed");
|
|
expect(String(runSteps[0].error)).toMatch(/connection reset/);
|
|
});
|
|
|
|
it("truncates very large step output", async () => {
|
|
defineAutomation([step({ id: "a", type: "run_command" })]);
|
|
executeStep.mockResolvedValueOnce({
|
|
success: true,
|
|
output: "x".repeat(40_000),
|
|
});
|
|
|
|
await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(runSteps[0].truncated).toBe(true);
|
|
expect(String(runSteps[0].output)).toMatch(/truncated/);
|
|
});
|
|
|
|
it("stops once the run deadline has passed", async () => {
|
|
defineAutomation(
|
|
[step({ id: "a", type: "run_command" }), step({ id: "b", type: "http" })],
|
|
{ maxRunSeconds: 1 },
|
|
);
|
|
|
|
// The first step consumes the whole budget, so the second must not start.
|
|
executeStep.mockImplementationOnce(async () => {
|
|
vi.setSystemTime(Date.now() + 5_000);
|
|
return { success: true, output: "slow" };
|
|
});
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const outcome = await AutomationEngine.getInstance().run({
|
|
automationId: 1,
|
|
triggerType: "manual",
|
|
});
|
|
|
|
expect(outcome.status).toBe("timeout");
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|