Skip to content

Commit c687c2c

Browse files
committed
test: add formatBuildSuccessResponse, detectTemplateMismatch, scenario and generator tests
1 parent 4278d94 commit c687c2c

9 files changed

Lines changed: 928 additions & 5 deletions

File tree

packages/pluggable-widgets-mcp/src/__test-utils__/mcp-test-harness.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import type { SessionState } from "@/tools/session-state";
55

6+
// ─── Protocol Recording Types ────────────────────────────────────────────────
7+
8+
export interface ProtocolRecord {
9+
timestamp: number;
10+
direction: "client-to-server" | "server-to-client";
11+
message: unknown;
12+
}
13+
14+
export interface ToolCallRecord {
15+
name: string;
16+
arguments: unknown;
17+
timestamp: number;
18+
}
19+
20+
export interface RecordingMcpTestContext extends McpTestContext {
21+
records: ProtocolRecord[];
22+
getToolCalls(): ToolCallRecord[];
23+
getToolCallSequence(): string[];
24+
assertToolOrder(expectedNames: string[]): void;
25+
}
26+
627
type ToolRegistrationFn = (server: McpServer, state: SessionState) => void;
728

829
interface McpTestContext {
@@ -30,6 +51,8 @@ export function isError(result: Awaited<ReturnType<Client["callTool"]>>): boolea
3051
return "isError" in result && result.isError === true;
3152
}
3253

54+
// ─── Core Test Context ────────────────────────────────────────────────────────
55+
3356
export async function createMcpTestContext(...registerFns: ToolRegistrationFn[]): Promise<McpTestContext> {
3457
const state: SessionState = { projectDir: undefined };
3558

@@ -65,3 +88,99 @@ export async function createMcpTestContext(...registerFns: ToolRegistrationFn[])
6588
}
6689
};
6790
}
91+
92+
// ─── Recording Test Context ───────────────────────────────────────────────────
93+
94+
/**
95+
* Creates an MCP test context that records all JSON-RPC messages in both directions.
96+
* Wraps InMemoryTransport.send and onmessage post-connection to intercept traffic.
97+
*/
98+
export async function createRecordingMcpTestContext(
99+
...registerFns: ToolRegistrationFn[]
100+
): Promise<RecordingMcpTestContext> {
101+
const records: ProtocolRecord[] = [];
102+
const state: SessionState = { projectDir: undefined };
103+
104+
const server = new McpServer(
105+
{ name: "test-server", version: "0.0.0" },
106+
{ capabilities: { tools: {}, logging: {} } }
107+
);
108+
109+
if (registerFns.length === 0) {
110+
const { registerProjectTools } = await import("@/tools/project.tools");
111+
registerProjectTools(server, state);
112+
} else {
113+
for (const fn of registerFns) {
114+
fn(server, state);
115+
}
116+
}
117+
118+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
119+
const client = new Client({ name: "test-client", version: "0.0.0" });
120+
121+
await server.connect(serverTransport);
122+
await client.connect(clientTransport);
123+
124+
// Intercept outgoing client messages (client → server)
125+
const originalClientSend = clientTransport.send.bind(clientTransport);
126+
clientTransport.send = async (message: unknown, options?: unknown) => {
127+
records.push({ timestamp: Date.now(), direction: "client-to-server", message });
128+
return (originalClientSend as (m: unknown, o?: unknown) => Promise<void>)(message, options);
129+
};
130+
131+
// Intercept incoming client messages (server → client)
132+
const originalOnMessage = clientTransport.onmessage;
133+
clientTransport.onmessage = (message: unknown, extra?: unknown) => {
134+
records.push({ timestamp: Date.now(), direction: "server-to-client", message });
135+
(originalOnMessage as ((m: unknown, e?: unknown) => void) | undefined)?.(message, extra);
136+
};
137+
138+
const getToolCalls = (): ToolCallRecord[] =>
139+
records
140+
.filter(r => {
141+
const msg = r.message as Record<string, unknown>;
142+
return r.direction === "client-to-server" && msg.method === "tools/call";
143+
})
144+
.map(r => {
145+
const msg = r.message as Record<string, unknown>;
146+
const params = msg.params as Record<string, unknown>;
147+
return {
148+
name: params.name as string,
149+
arguments: params.arguments,
150+
timestamp: r.timestamp
151+
};
152+
});
153+
154+
const getToolCallSequence = (): string[] => getToolCalls().map(c => c.name);
155+
156+
const assertToolOrder = (expectedNames: string[]): void => {
157+
const actual = getToolCallSequence();
158+
if (actual.length < expectedNames.length) {
159+
throw new Error(
160+
`Expected tool call sequence ${JSON.stringify(expectedNames)} but only got ${JSON.stringify(actual)}`
161+
);
162+
}
163+
for (let i = 0; i < expectedNames.length; i++) {
164+
if (actual[i] !== expectedNames[i]) {
165+
throw new Error(
166+
`Tool call at index ${i}: expected "${expectedNames[i]}" but got "${actual[i]}"\n` +
167+
`Full sequence: ${JSON.stringify(actual)}`
168+
);
169+
}
170+
}
171+
};
172+
173+
return {
174+
server,
175+
client,
176+
state,
177+
records,
178+
getToolCalls,
179+
getToolCallSequence,
180+
assertToolOrder,
181+
cleanup: async () => {
182+
await client.close();
183+
await server.close();
184+
}
185+
};
186+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* Integration scenario tests that simulate complete Maia workflows.
3+
* Uses the recording harness to verify tool ordering and state transitions.
4+
* The generator is mocked so tests run fast without Yeoman scaffolding.
5+
*/
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
import {
8+
createMcpTestContext,
9+
createRecordingMcpTestContext,
10+
getResultText,
11+
isError
12+
} from "@/__test-utils__/mcp-test-harness";
13+
import { createTempMendixProject } from "@/__test-utils__/temp-dir";
14+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
15+
import type { SessionState } from "@/tools/session-state";
16+
17+
vi.mock("@/tools/utils/generator", () => ({
18+
buildWidgetOptions: (args: Record<string, unknown>) => ({
19+
name: args.name ?? "ScenarioWidget",
20+
description: args.description ?? "scenario test",
21+
version: "1.0.0",
22+
author: "Mendix",
23+
license: "Apache-2.0",
24+
organization: "Mendix",
25+
template: "empty",
26+
programmingLanguage: "typescript",
27+
unitTests: false,
28+
e2eTests: false
29+
}),
30+
runWidgetGenerator: vi.fn().mockResolvedValue(undefined),
31+
SCAFFOLD_PROGRESS: {
32+
START: { progress: 0, message: "Starting..." },
33+
COMPLETE: { progress: 100, message: "Done!" }
34+
}
35+
}));
36+
37+
describe("discovery workflow", () => {
38+
let client: Client;
39+
let state: SessionState;
40+
let cleanup: () => Promise<void>;
41+
const tempCleanups: Array<() => void> = [];
42+
43+
beforeEach(async () => {
44+
({ client, state, cleanup } = await createMcpTestContext());
45+
});
46+
47+
afterEach(async () => {
48+
await cleanup();
49+
for (const c of tempCleanups) c();
50+
tempCleanups.length = 0;
51+
});
52+
53+
it("get-project-info (no project) → set-project-directory → get-project-info (success)", async () => {
54+
// Step 1: get-project-info with no project configured
55+
const noProjectResult = await client.callTool({ name: "get-project-info", arguments: {} });
56+
expect(isError(noProjectResult)).toBe(true);
57+
expect(getResultText(noProjectResult)).toContain("ERR_PROJECT_NOT_CONFIGURED");
58+
59+
// Step 2: set-project-directory with a valid project
60+
const { dir, cleanup: tempCleanup } = createTempMendixProject({ projectName: "DiscoveryApp" });
61+
tempCleanups.push(tempCleanup);
62+
63+
const setResult = await client.callTool({
64+
name: "set-project-directory",
65+
arguments: { projectDir: dir }
66+
});
67+
expect(isError(setResult)).toBe(false);
68+
expect(state.projectDir).toBe(dir);
69+
70+
// Step 3: get-project-info now succeeds with project name
71+
const infoResult = await client.callTool({ name: "get-project-info", arguments: {} });
72+
expect(isError(infoResult)).toBe(false);
73+
expect(getResultText(infoResult)).toContain("DiscoveryApp");
74+
});
75+
});
76+
77+
describe("scaffold workflow", () => {
78+
let client: Client;
79+
let state: SessionState;
80+
let cleanup: () => Promise<void>;
81+
const tempCleanups: Array<() => void> = [];
82+
83+
beforeEach(async () => {
84+
const { registerScaffoldingTools } = await import("@/tools/scaffolding.tools");
85+
const { registerFileOperationTools } = await import("@/tools/file-operations.tools");
86+
({ client, state, cleanup } = await createMcpTestContext(registerScaffoldingTools, registerFileOperationTools));
87+
});
88+
89+
afterEach(async () => {
90+
await cleanup();
91+
for (const c of tempCleanups) c();
92+
tempCleanups.length = 0;
93+
});
94+
95+
it("set-project-directory → create-widget returns valid path", async () => {
96+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
97+
tempCleanups.push(tempCleanup);
98+
state.projectDir = dir;
99+
100+
// create-widget with outputPath inside projectDir (passes sandbox check)
101+
const outputPath = dir + "/widgets-out";
102+
const createResult = await client.callTool({
103+
name: "create-widget",
104+
arguments: {
105+
name: "ScenarioWidget",
106+
description: "scenario test",
107+
outputPath
108+
}
109+
});
110+
expect(isError(createResult)).toBe(false);
111+
const text = getResultText(createResult);
112+
expect(text).toContain("ScenarioWidget");
113+
expect(text).toContain("created successfully");
114+
});
115+
});
116+
117+
describe("error recovery", () => {
118+
let client: Client;
119+
let state: SessionState;
120+
let cleanup: () => Promise<void>;
121+
const tempCleanups: Array<() => void> = [];
122+
123+
beforeEach(async () => {
124+
({ client, state, cleanup } = await createMcpTestContext());
125+
});
126+
127+
afterEach(async () => {
128+
await cleanup();
129+
for (const c of tempCleanups) c();
130+
tempCleanups.length = 0;
131+
});
132+
133+
it("get-project-info on invalid path → returns structured error with suggestion", async () => {
134+
state.projectDir = "/nonexistent/path/to/project";
135+
136+
const result = await client.callTool({ name: "get-project-info", arguments: {} });
137+
expect(isError(result)).toBe(true);
138+
const text = getResultText(result);
139+
140+
// Error is structured and actionable
141+
expect(text).toContain("ERR_PROJECT_NOT_CONFIGURED");
142+
expect(text).toContain("Suggestion");
143+
});
144+
145+
it("set-project-directory with invalid path → state unchanged, actionable error returned", async () => {
146+
state.projectDir = undefined;
147+
148+
const result = await client.callTool({
149+
name: "set-project-directory",
150+
arguments: { projectDir: "/completely/nonexistent" }
151+
});
152+
expect(isError(result)).toBe(true);
153+
expect(state.projectDir).toBeUndefined();
154+
const text = getResultText(result);
155+
expect(text).toContain("ERR_PROJECT_NOT_CONFIGURED");
156+
157+
// Retry with valid path
158+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
159+
tempCleanups.push(tempCleanup);
160+
161+
const retryResult = await client.callTool({
162+
name: "set-project-directory",
163+
arguments: { projectDir: dir }
164+
});
165+
expect(isError(retryResult)).toBe(false);
166+
expect(state.projectDir).toBe(dir);
167+
});
168+
});
169+
170+
describe("protocol recording captures tool sequence", () => {
171+
let cleanup: () => Promise<void>;
172+
const tempCleanups: Array<() => void> = [];
173+
174+
afterEach(async () => {
175+
await cleanup();
176+
for (const c of tempCleanups) c();
177+
tempCleanups.length = 0;
178+
});
179+
180+
it("records tool calls in order and assertToolOrder passes", async () => {
181+
const ctx = await createRecordingMcpTestContext();
182+
cleanup = ctx.cleanup;
183+
184+
const { dir, cleanup: tempCleanup } = createTempMendixProject({ projectName: "RecordingApp" });
185+
tempCleanups.push(tempCleanup);
186+
187+
// Call two tools in sequence
188+
await ctx.client.callTool({ name: "get-project-info", arguments: {} });
189+
ctx.state.projectDir = dir;
190+
await ctx.client.callTool({ name: "get-project-info", arguments: {} });
191+
192+
// Verify recording captured both calls
193+
const sequence = ctx.getToolCallSequence();
194+
expect(sequence.length).toBeGreaterThanOrEqual(2);
195+
expect(sequence[0]).toBe("get-project-info");
196+
expect(sequence[1]).toBe("get-project-info");
197+
198+
// assertToolOrder should not throw
199+
expect(() => ctx.assertToolOrder(["get-project-info", "get-project-info"])).not.toThrow();
200+
});
201+
202+
it("records messages in both directions", async () => {
203+
const ctx = await createRecordingMcpTestContext();
204+
cleanup = ctx.cleanup;
205+
206+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
207+
tempCleanups.push(tempCleanup);
208+
ctx.state.projectDir = dir;
209+
210+
await ctx.client.callTool({ name: "get-project-info", arguments: {} });
211+
212+
const clientToServer = ctx.records.filter(r => r.direction === "client-to-server");
213+
const serverToClient = ctx.records.filter(r => r.direction === "server-to-client");
214+
215+
expect(clientToServer.length).toBeGreaterThan(0);
216+
expect(serverToClient.length).toBeGreaterThan(0);
217+
});
218+
219+
it("getToolCalls returns name and arguments", async () => {
220+
const ctx = await createRecordingMcpTestContext();
221+
cleanup = ctx.cleanup;
222+
223+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
224+
tempCleanups.push(tempCleanup);
225+
226+
await ctx.client.callTool({
227+
name: "set-project-directory",
228+
arguments: { projectDir: dir }
229+
});
230+
231+
const toolCalls = ctx.getToolCalls();
232+
expect(toolCalls).toHaveLength(1);
233+
expect(toolCalls[0].name).toBe("set-project-directory");
234+
expect((toolCalls[0].arguments as Record<string, unknown>).projectDir).toBe(dir);
235+
expect(toolCalls[0].timestamp).toBeGreaterThan(0);
236+
});
237+
238+
it("assertToolOrder throws when sequence is wrong", async () => {
239+
const ctx = await createRecordingMcpTestContext();
240+
cleanup = ctx.cleanup;
241+
242+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
243+
tempCleanups.push(tempCleanup);
244+
ctx.state.projectDir = dir;
245+
246+
await ctx.client.callTool({ name: "get-project-info", arguments: {} });
247+
248+
expect(() => ctx.assertToolOrder(["set-project-directory"])).toThrow();
249+
});
250+
});

0 commit comments

Comments
 (0)