feat(scenarios): add timeoutSeconds to scenarios and steps

- add timeoutSeconds field to scenario and step entities
- add timeoutSeconds to create/update DTOs for scenario and step
- enforce timeout via scheduler: abort run if step exceeds limit
- expose timeoutSeconds in MCP create/update scenario and step tools
- add client-side type, API, i18n, and form support for timeoutSeconds
- add integration tests for timeout persistence via REST and MCP
This commit is contained in:
2026-04-17 13:46:30 +03:00
parent 8dbd319bba
commit a50dce2755
19 changed files with 402 additions and 12 deletions
+143
View File
@@ -141,4 +141,147 @@ describe("McpController", () => {
expect(result.isError).toBe(true);
});
});
// ── create_scenario / update_scenario timeoutSeconds ──────────────────────
describe("create_scenario with timeoutSeconds", () => {
it("saves timeoutSeconds on the created scenario", async () => {
const { status, rpc } = await mcpCall("create_scenario", {
name: "mcp-timeout-sc",
timeoutSeconds: 300,
});
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
const created = JSON.parse(result.content[0].text) as {
name: string;
timeoutSeconds: number | null;
};
expect(created.name).toBe("mcp-timeout-sc");
expect(created.timeoutSeconds).toBe(300);
});
it("stores null timeoutSeconds when not provided", async () => {
const { rpc } = await mcpCall("create_scenario", {
name: "mcp-no-timeout",
});
const result = rpc.result as { content: { text: string }[] };
const created = JSON.parse(result.content[0].text) as {
timeoutSeconds: number | null;
};
expect(created.timeoutSeconds).toBeNull();
});
});
describe("update_scenario timeoutSeconds", () => {
it("updates and clears timeoutSeconds", async () => {
// create
const createRpc = (
await mcpCall("create_scenario", { name: "mcp-upd-timeout" })
).rpc;
const created = JSON.parse(
(createRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
// set
const setRpc = (
await mcpCall("update_scenario", {
id: created.id,
timeoutSeconds: 120,
})
).rpc;
const updated = JSON.parse(
(setRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(updated.timeoutSeconds).toBe(120);
// clear
const clearRpc = (
await mcpCall("update_scenario", {
id: created.id,
timeoutSeconds: null,
})
).rpc;
const cleared = JSON.parse(
(clearRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(cleared.timeoutSeconds).toBeNull();
});
});
// ── create_scenario_step / update_scenario_step timeoutSeconds ────────────
describe("create_scenario_step with timeoutSeconds", () => {
it("saves timeoutSeconds on the created step", async () => {
// create a scenario first
const scRpc = (
await mcpCall("create_scenario", { name: "mcp-step-timeout-parent" })
).rpc;
const sc = JSON.parse(
(scRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
const { status, rpc } = await mcpCall("create_scenario_step", {
scenarioId: sc.id,
order: 0,
type: "exec",
execCode: "return 1;",
timeoutSeconds: 45,
});
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
const step = JSON.parse(result.content[0].text) as {
timeoutSeconds: number | null;
};
expect(step.timeoutSeconds).toBe(45);
});
});
describe("update_scenario_step timeoutSeconds", () => {
it("updates and clears step timeoutSeconds", async () => {
// create scenario + step
const scRpc = (
await mcpCall("create_scenario", { name: "mcp-step-upd-parent" })
).rpc;
const sc = JSON.parse(
(scRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
const stepRpc = (
await mcpCall("create_scenario_step", {
scenarioId: sc.id,
order: 0,
type: "exec",
})
).rpc;
const step = JSON.parse(
(stepRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
// set
const setRpc = (
await mcpCall("update_scenario_step", {
scenarioId: sc.id,
stepId: step.id,
timeoutSeconds: 90,
})
).rpc;
const updated = JSON.parse(
(setRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(updated.timeoutSeconds).toBe(90);
// clear
const clearRpc = (
await mcpCall("update_scenario_step", {
scenarioId: sc.id,
stepId: step.id,
timeoutSeconds: null,
})
).rpc;
const cleared = JSON.parse(
(clearRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(cleared.timeoutSeconds).toBeNull();
});
});
});