- expose scenario file upload, listing, and content retrieval over MCP\n- expose run artifact listing and content retrieval with end-to-end coverage
847 lines
30 KiB
TypeScript
847 lines
30 KiB
TypeScript
import { INestApplication } from "@nestjs/common";
|
|
import { getRepositoryToken } from "@nestjs/typeorm";
|
|
import request from "supertest";
|
|
import * as http from "http";
|
|
import { Repository } from "typeorm";
|
|
import { buildTestApp } from "./app.harness";
|
|
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
|
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
|
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
|
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
|
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
|
|
|
/**
|
|
* 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;
|
|
let testServer: http.Server;
|
|
let testServerUrl: string;
|
|
let scheduler: ScenarioSchedulerService;
|
|
let scenarioRepo: Repository<ScenarioEntity>;
|
|
let stepRepo: Repository<ScenarioStepEntity>;
|
|
let runRepo: Repository<ScenarioRunEntity>;
|
|
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildTestApp();
|
|
scheduler = app.get(ScenarioSchedulerService);
|
|
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
|
getRepositoryToken(ScenarioEntity),
|
|
);
|
|
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
|
getRepositoryToken(ScenarioStepEntity),
|
|
);
|
|
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
|
getRepositoryToken(ScenarioRunEntity),
|
|
);
|
|
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
|
getRepositoryToken(ScenarioRunStepEntity),
|
|
);
|
|
|
|
// Start a test HTTP server for downloadFile tests
|
|
testServer = await createTestServer();
|
|
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (testServer) {
|
|
testServer.close();
|
|
}
|
|
await app.close();
|
|
});
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function createTestServer(): Promise<http.Server> {
|
|
return new Promise((resolve) => {
|
|
const server = http.createServer((req, res) => {
|
|
if (req.url === "/test-file.bin") {
|
|
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
res.end(Buffer.from("test downloaded file content"));
|
|
} else if (req.url === "/test.pdf") {
|
|
res.writeHead(200, { "Content-Type": "application/pdf" });
|
|
res.end(Buffer.from("fake pdf content"));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
server.listen(0, "localhost", () => {
|
|
resolve(server);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 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) };
|
|
}
|
|
|
|
async function createScenario(name: string) {
|
|
return scenarioRepo.save(scenarioRepo.create({ name }));
|
|
}
|
|
|
|
async function createStep(scenarioId: string, execCode: string, order = 0) {
|
|
return stepRepo.save(
|
|
stepRepo.create({
|
|
scenarioId,
|
|
order,
|
|
execCode,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function createRun(scenarioId: string) {
|
|
return runRepo.save(runRepo.create({ scenarioId, status: "pending" }));
|
|
}
|
|
|
|
async function createRunStep(runId: string, scenarioStepId: string, order = 0) {
|
|
return runStepRepo.save(
|
|
runStepRepo.create({
|
|
runId,
|
|
scenarioStepId,
|
|
order,
|
|
status: "pending",
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function waitForRunCompletion(runId: string, maxAttempts = 50) {
|
|
for (let index = 0; index < maxAttempts; index += 1) {
|
|
await scheduler.pickUpPendingRuns();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const run = await runRepo.findOneBy({ id: runId });
|
|
if (run && (run.status === "pass" || run.status === "fail")) {
|
|
return run;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Run ${runId} did not complete within timeout`);
|
|
}
|
|
|
|
// ── 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();
|
|
});
|
|
});
|
|
|
|
// ── Scenario File Tools ────────────────────────────────────────────────────
|
|
|
|
describe("upload_scenario_file", () => {
|
|
it("uploads a small text file via MCP and returns metadata", async () => {
|
|
const scRpc = (
|
|
await mcpCall("create_scenario", { name: "mcp-file-upload-test" })
|
|
).rpc;
|
|
const sc = JSON.parse(
|
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const content = "Hello, MCP!";
|
|
const contentBase64 = Buffer.from(content).toString("base64");
|
|
|
|
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
|
scenarioId: sc.id,
|
|
name: "test.txt",
|
|
contentBase64,
|
|
mimeType: "text/plain",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const uploaded = JSON.parse(result.content[0].text) as {
|
|
id: string;
|
|
name: string;
|
|
mimeType: string;
|
|
size: number;
|
|
};
|
|
expect(uploaded.name).toBe("test.txt");
|
|
expect(uploaded.mimeType).toBe("text/plain");
|
|
expect(uploaded.size).toBe(content.length);
|
|
expect(typeof uploaded.id).toBe("string");
|
|
});
|
|
|
|
it("returns MCP error for invalid base64", async () => {
|
|
const scRpc = (
|
|
await mcpCall("create_scenario", {
|
|
name: "mcp-file-bad-base64-test",
|
|
})
|
|
).rpc;
|
|
const sc = JSON.parse(
|
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
|
scenarioId: sc.id,
|
|
name: "test.txt",
|
|
contentBase64: "!@#$%^&*()",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("list_scenario_files", () => {
|
|
it("returns uploaded files with metadata", async () => {
|
|
const scRpc = (
|
|
await mcpCall("create_scenario", { name: "mcp-file-list-test" })
|
|
).rpc;
|
|
const sc = JSON.parse(
|
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
// Upload a file
|
|
const content = "Test content";
|
|
const contentBase64 = Buffer.from(content).toString("base64");
|
|
await mcpCall("upload_scenario_file", {
|
|
scenarioId: sc.id,
|
|
name: "test.txt",
|
|
contentBase64,
|
|
});
|
|
|
|
// List files
|
|
const { status, rpc } = await mcpCall("list_scenario_files", {
|
|
scenarioId: sc.id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const list = JSON.parse(result.content[0].text) as {
|
|
items: Array<{ id: string; name: string }>;
|
|
total: number;
|
|
};
|
|
expect(list.items.length).toBeGreaterThan(0);
|
|
expect(list.items[0].name).toBe("test.txt");
|
|
expect(typeof list.total).toBe("number");
|
|
});
|
|
|
|
it("returns MCP error for non-existent scenario", async () => {
|
|
const { status, rpc } = await mcpCall("list_scenario_files", {
|
|
scenarioId: "00000000-0000-0000-0000-000000000000",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("get_scenario_file_content", () => {
|
|
it("retrieves file content as base64 with metadata", async () => {
|
|
const scRpc = (
|
|
await mcpCall("create_scenario", { name: "mcp-file-content-test" })
|
|
).rpc;
|
|
const sc = JSON.parse(
|
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
// Upload a file
|
|
const originalContent = "Hello, this is test content!";
|
|
const contentBase64 = Buffer.from(originalContent).toString("base64");
|
|
const uploadRpc = (
|
|
await mcpCall("upload_scenario_file", {
|
|
scenarioId: sc.id,
|
|
name: "content-test.txt",
|
|
contentBase64,
|
|
mimeType: "text/plain",
|
|
})
|
|
).rpc;
|
|
const uploaded = JSON.parse(
|
|
(uploadRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
// Retrieve content
|
|
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
|
scenarioId: sc.id,
|
|
fileId: uploaded.id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const response = JSON.parse(result.content[0].text) as {
|
|
file: { id: string; name: string; size: number };
|
|
contentBase64: string;
|
|
encoding: string;
|
|
};
|
|
|
|
// Verify roundtrip
|
|
const retrievedContent = Buffer.from(response.contentBase64, "base64").toString(
|
|
"utf8",
|
|
);
|
|
expect(retrievedContent).toBe(originalContent);
|
|
expect(response.file.name).toBe("content-test.txt");
|
|
expect(response.file.size).toBe(originalContent.length);
|
|
expect(response.encoding).toBe("base64");
|
|
});
|
|
|
|
it("returns MCP error for unknown file ID", async () => {
|
|
const scRpc = (
|
|
await mcpCall("create_scenario", {
|
|
name: "mcp-file-unknown-id-test",
|
|
})
|
|
).rpc;
|
|
const sc = JSON.parse(
|
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
|
scenarioId: sc.id,
|
|
fileId: "00000000-0000-0000-0000-000000000000",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Run Artifact Tools ─────────────────────────────────────────────────────
|
|
|
|
async function createRunArtifactFixture(name: string) {
|
|
const scenario = await createScenario(name);
|
|
const step = await createStep(
|
|
scenario.id,
|
|
`return await context.downloadFile('${testServerUrl}/test-file.bin', { filename: 'artifact.bin' });`,
|
|
);
|
|
const run = await createRun(scenario.id);
|
|
await createRunStep(run.id, step.id);
|
|
|
|
const completedRun = await waitForRunCompletion(run.id);
|
|
expect(completedRun.status).toBe("pass");
|
|
|
|
return { sc: scenario, run: completedRun };
|
|
}
|
|
|
|
describe("list_run_files", () => {
|
|
it("returns artifacts created during a run with expiry metadata", async () => {
|
|
const { sc, run } = await createRunArtifactFixture("mcp-run-files-test");
|
|
|
|
const { status, rpc } = await mcpCall("list_run_files", {
|
|
scenarioId: sc.id,
|
|
runId: run.id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const list = JSON.parse(result.content[0].text) as {
|
|
items: Array<{
|
|
id: string;
|
|
name: string;
|
|
mimeType: string;
|
|
expiresAt: string | null;
|
|
}>;
|
|
total: number;
|
|
};
|
|
|
|
expect(list.total).toBe(1);
|
|
expect(list.items).toHaveLength(1);
|
|
expect(list.items[0].name).toBe("artifact.bin");
|
|
expect(list.items[0].mimeType).toBe("application/octet-stream");
|
|
expect(list.items[0].expiresAt).not.toBeNull();
|
|
});
|
|
|
|
it("returns MCP error for non-existent scenario", async () => {
|
|
const { status, rpc } = await mcpCall("list_run_files", {
|
|
scenarioId: "00000000-0000-0000-0000-000000000000",
|
|
runId: "00000000-0000-0000-0000-000000000000",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("get_run_file_content", () => {
|
|
it("returns run artifact bytes as base64 for a valid file", async () => {
|
|
const { sc, run } = await createRunArtifactFixture("mcp-run-file-content-test");
|
|
|
|
const listRpc = (
|
|
await mcpCall("list_run_files", {
|
|
scenarioId: sc.id,
|
|
runId: run.id,
|
|
})
|
|
).rpc;
|
|
const list = JSON.parse(
|
|
(listRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as {
|
|
items: Array<{ id: string; name: string; mimeType: string }>;
|
|
};
|
|
const file = list.items[0];
|
|
|
|
const { status, rpc } = await mcpCall("get_run_file_content", {
|
|
scenarioId: sc.id,
|
|
runId: run.id,
|
|
fileId: file.id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const response = JSON.parse(result.content[0].text) as {
|
|
file: { id: string; name: string; mimeType: string; expiresAt: string | null };
|
|
contentBase64: string;
|
|
encoding: string;
|
|
};
|
|
|
|
expect(response.file.id).toBe(file.id);
|
|
expect(response.file.name).toBe("artifact.bin");
|
|
expect(response.file.mimeType).toBe("application/octet-stream");
|
|
expect(response.file.expiresAt).not.toBeNull();
|
|
expect(response.encoding).toBe("base64");
|
|
expect(Buffer.from(response.contentBase64, "base64").toString("utf8")).toBe(
|
|
"test downloaded file content",
|
|
);
|
|
});
|
|
|
|
it("returns MCP error for mismatched run and file ids", async () => {
|
|
const { sc: firstScenario, run: firstRun } = await createRunArtifactFixture(
|
|
"mcp-run-file-mismatch-a",
|
|
);
|
|
const { sc: secondScenario, run: secondRun } = await createRunArtifactFixture(
|
|
"mcp-run-file-mismatch-b",
|
|
);
|
|
|
|
const firstListRpc = (
|
|
await mcpCall("list_run_files", {
|
|
scenarioId: firstScenario.id,
|
|
runId: firstRun.id,
|
|
})
|
|
).rpc;
|
|
const firstList = JSON.parse(
|
|
(firstListRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { items: Array<{ id: string }> };
|
|
|
|
const { status, rpc } = await mcpCall("get_run_file_content", {
|
|
scenarioId: secondScenario.id,
|
|
runId: secondRun.id,
|
|
fileId: firstList.items[0].id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Snippet CRUD tools ────────────────────────────────────────────────────
|
|
|
|
describe("list_snippets", () => {
|
|
it("returns a paginated result with a data array", async () => {
|
|
const { status, rpc } = await mcpCall("list_snippets");
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("create_snippet", () => {
|
|
it("creates a snippet and returns it with alias and title", async () => {
|
|
const { status, rpc } = await mcpCall("create_snippet", {
|
|
alias: "mcp-test-snippet",
|
|
title: "MCP Test Snippet",
|
|
description: "Created by integration test",
|
|
code: "await page.click('#btn');",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const created = JSON.parse(result.content[0].text) as {
|
|
id: string;
|
|
alias: string;
|
|
title: string;
|
|
description: string | null;
|
|
code: string;
|
|
};
|
|
expect(created.alias).toBe("mcp-test-snippet");
|
|
expect(created.title).toBe("MCP Test Snippet");
|
|
expect(created.description).toBe("Created by integration test");
|
|
expect(created.code).toBe("await page.click('#btn');");
|
|
expect(typeof created.id).toBe("string");
|
|
});
|
|
|
|
it("returns an MCP error when alias already exists", async () => {
|
|
await mcpCall("create_snippet", {
|
|
alias: "mcp-duplicate-snippet",
|
|
title: "First",
|
|
code: "return 1;",
|
|
});
|
|
const { status, rpc } = await mcpCall("create_snippet", {
|
|
alias: "mcp-duplicate-snippet",
|
|
title: "Second",
|
|
code: "return 2;",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("get_snippet", () => {
|
|
it("returns the created snippet by id", async () => {
|
|
const createRpc = (
|
|
await mcpCall("create_snippet", {
|
|
alias: "mcp-get-snippet",
|
|
title: "Get Me",
|
|
code: "return 42;",
|
|
})
|
|
).rpc;
|
|
const created = JSON.parse(
|
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const { status, rpc } = await mcpCall("get_snippet", { id: created.id });
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const fetched = JSON.parse(result.content[0].text) as {
|
|
id: string;
|
|
alias: string;
|
|
};
|
|
expect(fetched.id).toBe(created.id);
|
|
expect(fetched.alias).toBe("mcp-get-snippet");
|
|
});
|
|
|
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
|
const { status, rpc } = await mcpCall("get_snippet", {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("update_snippet", () => {
|
|
it("updates alias, title, and code of an existing snippet", async () => {
|
|
const createRpc = (
|
|
await mcpCall("create_snippet", {
|
|
alias: "mcp-upd-snippet-orig",
|
|
title: "Original Title",
|
|
code: "return 1;",
|
|
})
|
|
).rpc;
|
|
const created = JSON.parse(
|
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const { status, rpc } = await mcpCall("update_snippet", {
|
|
id: created.id,
|
|
alias: "mcp-upd-snippet-new",
|
|
title: "Updated Title",
|
|
code: "return 2;",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
const updated = JSON.parse(result.content[0].text) as {
|
|
alias: string;
|
|
title: string;
|
|
code: string;
|
|
};
|
|
expect(updated.alias).toBe("mcp-upd-snippet-new");
|
|
expect(updated.title).toBe("Updated Title");
|
|
expect(updated.code).toBe("return 2;");
|
|
});
|
|
|
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
|
const { status, rpc } = await mcpCall("update_snippet", {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
title: "Ghost",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("delete_snippet", () => {
|
|
it("deletes an existing snippet and confirms deletion", async () => {
|
|
const createRpc = (
|
|
await mcpCall("create_snippet", {
|
|
alias: "mcp-del-snippet",
|
|
title: "Delete Me",
|
|
code: "return 0;",
|
|
})
|
|
).rpc;
|
|
const created = JSON.parse(
|
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
|
) as { id: string };
|
|
|
|
const { status, rpc } = await mcpCall("delete_snippet", {
|
|
id: created.id,
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { content: { text: string }[] };
|
|
expect(result.content[0].text).toContain(created.id);
|
|
|
|
// confirm gone
|
|
const getResult = (await mcpCall("get_snippet", { id: created.id }))
|
|
.rpc.result as { isError: boolean };
|
|
expect(getResult.isError).toBe(true);
|
|
});
|
|
|
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
|
const { status, rpc } = await mcpCall("delete_snippet", {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
});
|
|
expect(status).toBe(200);
|
|
const result = rpc.result as { isError: boolean };
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|