- 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
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { INestApplication } from "@nestjs/common";
|
|
import request from "supertest";
|
|
import { buildTestApp } from "./app.harness";
|
|
|
|
/**
|
|
* MCP controller integration tests.
|
|
*
|
|
* The MCP endpoint speaks the Model Context Protocol (streamable HTTP
|
|
* transport). We test:
|
|
* - that the endpoint is reachable and returns a recognised MCP response
|
|
* - that tool invocations for read-only, non-browser tools work end-to-end
|
|
* - that tools with bad inputs return error payloads (not HTTP 5xx)
|
|
*
|
|
* Browser-dependent tools (open_url, exec_code) require a live Playwright
|
|
* session and are not covered here.
|
|
*/
|
|
describe("McpController", () => {
|
|
let app: INestApplication;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildTestApp();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
/** Parse the JSON-RPC payload from an SSE response body. */
|
|
function parseSse(text: string): Record<string, unknown> {
|
|
const match = text.match(/^data:\s*(.+)$/m);
|
|
if (!match) throw new Error(`No SSE data line found in: ${text}`);
|
|
return JSON.parse(match[1]) as Record<string, unknown>;
|
|
}
|
|
|
|
/** Send a single MCP tool call and return the parsed response body. */
|
|
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
|
|
const res = await request(app.getHttpServer())
|
|
.post("/mcp")
|
|
.set("Content-Type", "application/json")
|
|
.set("Accept", "application/json, text/event-stream")
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: { name: toolName, arguments: args },
|
|
});
|
|
return { status: res.status, rpc: parseSse(res.text) };
|
|
}
|
|
|
|
// ── Connectivity ───────────────────────────────────────────────────────────
|
|
|
|
describe("POST /mcp — connectivity", () => {
|
|
it("is reachable and returns a non-5xx status", async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post("/mcp")
|
|
.set("Content-Type", "application/json")
|
|
.set("Accept", "application/json, text/event-stream")
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: {},
|
|
clientInfo: { name: "test", version: "0" },
|
|
},
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ── list_keys tool (removed) ──────────────────────────────────────────────
|
|
|
|
describe("list_keys", () => {
|
|
it("returns MCP error for removed tool", async () => {
|
|
const { status, rpc } = await mcpCall("list_keys");
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as {
|
|
isError: boolean;
|
|
content?: { text: string }[];
|
|
};
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── list_sessions tool ─────────────────────────────────────────────────────
|
|
|
|
describe("list_sessions", () => {
|
|
it("returns a paginated result with a data array", async () => {
|
|
const { status, rpc } = await mcpCall("list_sessions");
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const body = JSON.parse(result.content[0].text) as {
|
|
data: unknown[];
|
|
total: number;
|
|
};
|
|
expect(Array.isArray(body.data)).toBe(true);
|
|
expect(typeof body.total).toBe("number");
|
|
});
|
|
});
|
|
|
|
// ── list_environments tool ─────────────────────────────────────────────────
|
|
|
|
describe("list_environments", () => {
|
|
it("returns a paginated result with a data array", async () => {
|
|
const { status, rpc } = await mcpCall("list_environments");
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const body = JSON.parse(result.content[0].text) as {
|
|
data: unknown[];
|
|
total: number;
|
|
};
|
|
expect(Array.isArray(body.data)).toBe(true);
|
|
expect(typeof body.total).toBe("number");
|
|
});
|
|
});
|
|
|
|
// ── create_environment tool ────────────────────────────────────────────────
|
|
|
|
describe("create_environment", () => {
|
|
it("creates an environment via MCP", async () => {
|
|
const { status, rpc } = await mcpCall("create_environment", {
|
|
name: "mcp-test-env",
|
|
data: { id: "https://id.example.com" },
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const created = JSON.parse(result.content[0].text) as { name: string };
|
|
expect(created.name).toBe("mcp-test-env");
|
|
});
|
|
});
|
|
|
|
// ── delete_session tool with unknown id ────────────────────────────────────
|
|
|
|
describe("delete_session", () => {
|
|
it("returns an MCP error result for a non-existent session id", async () => {
|
|
const { status, rpc } = await mcpCall("delete_session", { id: 999999 });
|
|
expect(status).toBe(200);
|
|
// MCP wraps service errors as isError:true content, not HTTP errors
|
|
const result = rpc.result as { isError: boolean };
|
|
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();
|
|
});
|
|
});
|
|
});
|