返回 DeepSeek-Reasonix
mcp-interaction.test.tsx
根目录 / desktop / frontend / src / __tests__ / mcp-interaction.test.tsx
1 // Run: tsx src/__tests__/mcp-interaction.test.tsx
2 // MCP elicitation: wire event → reducer state, StructuredForm schema
3 // normalization/coercion, MCPInteractionCard rendering and answer wiring.
4
5 import { JSDOM } from "jsdom";
6 import { readFileSync } from "node:fs";
7 import { registerHooks } from "node:module";
8 import React from "react";
9 import { act } from "react";
10 import { createRoot } from "react-dom/client";
11
12 registerHooks({
13 resolve(specifier, context, nextResolve) {
14 if (specifier.endsWith(".css") || specifier.endsWith(".svg")) {
15 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
16 }
17 return nextResolve(specifier, context);
18 },
19 });
20
21 import { LocaleProvider } from "../lib/i18n";
22 import type { WireEvent } from "../lib/types";
23 import { initialState, reducer } from "../lib/useController";
24 import { app, onEvent } from "../lib/bridge";
25 import { DECISION_SURFACE_MOCK_TRIGGERS } from "../lib/decisionSurfaceMock";
26 import {
27 coerceStructuredValues,
28 initialStructuredValues,
29 missingStructuredRequired,
30 normalizeStructuredSchema,
31 parseStructuredSchema,
32 } from "../components/StructuredForm";
33
34 const { MCPInteractionCard } = await import("../components/MCPInteractionCard");
35
36 let passed = 0;
37 let failed = 0;
38
39 function ok(value: boolean, label: string) {
40 if (value) {
41 process.stdout.write(` PASS ${label}\n`);
42 passed += 1;
43 } else {
44 process.stdout.write(` FAIL ${label}\n`);
45 failed += 1;
46 }
47 }
48
49 type ControllerState = Parameters<typeof reducer>[0];
50
51 const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
52 const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
53 ok(
54 /\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
55 "App decision surface recomputes when an MCP interaction arrives",
56 );
57
58 const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>");
59 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
60 globalThis.window = dom.window as unknown as Window & typeof globalThis;
61 globalThis.document = dom.window.document;
62 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
63 globalThis.Node = dom.window.Node;
64 globalThis.HTMLElement = dom.window.HTMLElement;
65 globalThis.Event = dom.window.Event;
66 globalThis.CustomEvent = dom.window.CustomEvent;
67 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
68 globalThis.MouseEvent = dom.window.MouseEvent;
69 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void; detachEvent: () => void }).attachEvent = () => {};
70 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void; detachEvent: () => void }).detachEvent = () => {};
71
72 // ── Schema normalization ─────────────────────────────────────────────────────
73
74 {
75 const fields = normalizeStructuredSchema({
76 type: "object",
77 required: ["code", "count"],
78 properties: {
79 code: { type: "string", title: "Device code", minLength: 4, maxLength: 8 },
80 count: { type: "integer", minimum: 1, maximum: 5, default: 2 },
81 ok: { type: "boolean" },
82 flavor: { enum: ["vanilla", "mint"], enumNames: ["Vanilla bean", "Fresh mint"] },
83 region: { type: "string", oneOf: [{ const: "us", title: "United States" }, { const: "sg", title: "Singapore" }] },
84 scopes: {
85 type: "array",
86 items: { anyOf: [{ const: "read", title: "Read data" }, { const: "write", title: "Write data" }] },
87 minItems: 1,
88 maxItems: 2,
89 default: ["read"],
90 },
91 email: { type: "string", format: "email" },
92 },
93 });
94 ok(fields.length === 7, "old and new flat schema properties become fields");
95 const code = fields.find((f) => f.key === "code");
96 ok(code?.kind === "string" && code.required && code.minLength === 4, "string field carries required + bounds");
97 const count = fields.find((f) => f.key === "count");
98 ok(count?.kind === "integer" && count.defaultValue === 2 && count.maximum === 5, "integer field carries default + max");
99 ok(fields.find((f) => f.key === "ok")?.kind === "boolean", "boolean field detected");
100 ok(fields.find((f) => f.key === "flavor")?.options?.[0].label === "Vanilla bean", "legacy enumNames labels stay compatible");
101 ok(fields.find((f) => f.key === "region")?.options?.[1].label === "Singapore", "2025-11-25 titled single-select is normalized");
102 const scopes = fields.find((f) => f.key === "scopes");
103 ok(scopes?.kind === "multi-enum" && Array.isArray(scopes.defaultValue) && scopes.defaultValue[0] === "read", "2025-11-25 titled multi-select carries defaults");
104 ok(fields.find((f) => f.key === "email")?.format === "email", "string formats reach the renderer");
105 const nested = parseStructuredSchema({ type: "object", properties: { account: { type: "object" } } });
106 ok(nested.unsupported && nested.fields[0]?.kind === "unsupported", "nested schemas fail closed instead of becoming text");
107 ok(normalizeStructuredSchema(null).length === 0, "null schema yields no fields");
108 ok(normalizeStructuredSchema({}).length === 0, "schema without properties yields no fields");
109 }
110
111 // ── Value coercion ───────────────────────────────────────────────────────────
112
113 {
114 const fields = normalizeStructuredSchema({
115 type: "object",
116 required: ["name"],
117 properties: {
118 name: { type: "string", minLength: 2 },
119 age: { type: "integer" },
120 pi: { type: "number" },
121 ok: { type: "boolean", default: true },
122 roles: { type: "array", items: { type: "string", enum: ["reader", "writer"] }, minItems: 1 },
123 },
124 });
125 const defaults = initialStructuredValues(fields);
126 ok(defaults.ok === true, "boolean default stays explicit");
127 ok(missingStructuredRequired(fields, defaults)[0] === "name", "missing required reported by label");
128 const { content, invalid } = coerceStructuredValues(fields, {
129 ...defaults,
130 name: "jo",
131 age: "41",
132 pi: "3.5",
133 roles: ["reader"],
134 });
135 ok(invalid.length === 0, "valid values coerce without errors");
136 ok(content.name === "jo" && content.age === 41 && content.pi === 3.5 && content.ok === true, "scalar values coerce to JSON types");
137 ok(Array.isArray(content.roles) && content.roles[0] === "reader", "multi-select answers remain string arrays");
138 const bad = coerceStructuredValues(fields, { ...defaults, name: "j", age: "4.2", roles: [] });
139 ok(bad.invalid.includes("name") && bad.invalid.includes("age") && bad.invalid.includes("roles"), "bounds, fractional integers, and selection limits are rejected");
140 }
141
142 // ── Reducer ──────────────────────────────────────────────────────────────────
143
144 {
145 const event: WireEvent = {
146 kind: "mcp_interaction",
147 turnId: "t1",
148 itemId: "42",
149 mcpInteraction: {
150 id: "42",
151 server: "github",
152 mode: "form",
153 message: "confirm",
154 requestedSchema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] },
155 },
156 } as unknown as WireEvent;
157 const next = reducer({ ...initialState }, { type: "event", e: event } as never);
158 ok((next as ControllerState).mcpInteraction?.id === "42", "mcp_interaction event sets state");
159 ok((next as ControllerState).pendingPrompt === true, "mcp_interaction waits for the user");
160
161 const answered = reducer(next, {
162 type: "event",
163 e: { kind: "prompt_answered", turnId: "t1", itemId: "42" } as unknown as WireEvent,
164 } as never);
165 ok((answered as ControllerState).mcpInteraction === undefined, "prompt_answered clears the card");
166 }
167
168 // ── Browser mock lifecycle ──────────────────────────────────────────────────
169
170 {
171 const events: WireEvent[] = [];
172 const unsubscribe = onEvent((event) => events.push(event));
173 await app.SubmitToTabWithID("mock-mcp-tab", DECISION_SURFACE_MOCK_TRIGGERS.mcp_interaction, "mock-mcp-submission");
174 const interaction = events.find((event) => event.kind === "mcp_interaction");
175 ok(interaction?.tabId === "mock-mcp-tab", "browser mock routes the MCP interaction to its origin tab");
176 ok(interaction?.mcpInteraction?.server === "github", "browser mock identifies the requesting MCP server");
177 const schema = interaction?.mcpInteraction?.requestedSchema as { properties?: Record<string, unknown> } | undefined;
178 ok(Object.keys(schema?.properties ?? {}).join(",") === "code,environment,permissions,remember", "browser mock exposes old and new structured controls");
179 await app.AnswerMCPInteractionForTab("mock-mcp-tab", interaction?.mcpInteraction?.id ?? "", "accept", {
180 code: "123-456",
181 environment: "staging",
182 permissions: ["repo:read"],
183 remember: false,
184 });
185 ok(events.some((event) => event.kind === "prompt_answered" && event.tabId === "mock-mcp-tab"), "browser mock acknowledges the MCP answer on the origin tab");
186 ok(events.some((event) => event.kind === "turn_done" && event.tabId === "mock-mcp-tab"), "browser mock completes the preview turn after an answer");
187 await app.SubmitToTabWithID("mock-mcp-tab", DECISION_SURFACE_MOCK_TRIGGERS.mcp_interaction, "mock-mcp-submission-2");
188 const interactions = events.filter((event) => event.kind === "mcp_interaction");
189 ok(interactions.length === 2, "browser mock can be triggered repeatedly");
190 ok(interactions[0].mcpInteraction?.id !== interactions[1].mcpInteraction?.id, "repeated browser mocks use distinct prompt ids");
191 await app.AnswerMCPInteractionForTab("mock-mcp-tab", interactions[1].mcpInteraction?.id ?? "", "cancel", null);
192 unsubscribe();
193 }
194
195 // ── Card rendering + submit wiring ───────────────────────────────────────────
196
197 {
198 const answers: { id: string; action: string; content?: Record<string, unknown> }[] = [];
199 const root = createRoot(document.getElementById("root")!);
200 await act(async () => {
201 root.render(
202 <LocaleProvider>
203 <MCPInteractionCard
204 instanceKey="test:7"
205 interaction={{
206 id: "7",
207 server: "github",
208 mode: "form",
209 message: "Enter the device code",
210 requestedSchema: {
211 type: "object",
212 required: ["code"],
213 properties: { code: { type: "string", title: "Device code", default: "123-456" } },
214 },
215 }}
216 busy={false}
217 onAnswer={(id, action, content) => answers.push({ id, action, content })}
218 />
219 </LocaleProvider>,
220 );
221 });
222 const text = document.body.textContent ?? "";
223 ok(text.includes("github") && text.includes("Device code"), "card shows server and field label");
224 const dialog = document.querySelector("[role='dialog']");
225 const descriptionID = dialog?.getAttribute("aria-describedby") ?? "";
226 ok(Boolean(descriptionID) && document.getElementById(descriptionID)?.textContent === "Enter the device code", "server message describes the dialog without crowding its title");
227
228 const input = document.querySelector(".structured-form-control") as HTMLInputElement | null;
229 ok(input !== null, "form field rendered as input");
230 // jsdom cannot reliably drive React controlled-input keystrokes; the default
231 // value prefills the field so the accept path is exercised end-to-end, and
232 // typed-value coercion is covered above.
233 ok(input?.value === "123-456", "required field prefilled from schema default");
234 ok(document.activeElement === input, "new form focuses its first field");
235 ok(document.querySelectorAll("[role='option']").length === 0, "immediate MCP actions are not exposed as listbox options");
236 ok(document.querySelector(".prompt-shelf-bar-actions")?.getAttribute("role") === "group", "MCP actions expose button-group semantics");
237 const submit = Array.from(document.querySelectorAll("button, [role='button']")).find((b) =>
238 (b.textContent ?? "").toLowerCase().includes("submit"),
239 );
240 ok(submit !== undefined, "submit action rendered");
241 if (submit) {
242 await act(async () => {
243 submit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
244 });
245 }
246
247 ok(
248 answers.length === 1 && answers[0].action === "accept" && (answers[0].content as Record<string, unknown>)?.code === "123-456",
249 "submit sends accept with the typed form values",
250 );
251 await act(async () => {
252 root.unmount();
253 });
254 }
255
256 // ── Unsupported schema fallback ─────────────────────────────────────────────
257
258 {
259 const root = createRoot(document.getElementById("root")!);
260 await act(async () => {
261 root.render(
262 <LocaleProvider>
263 <MCPInteractionCard
264 instanceKey="test:unsupported"
265 interaction={{
266 id: "unsupported",
267 server: "future-server",
268 mode: "form",
269 message: "Provide account details",
270 requestedSchema: { type: "object", properties: { account: { type: "object" } } },
271 }}
272 busy={false}
273 onAnswer={() => {}}
274 />
275 </LocaleProvider>,
276 );
277 });
278 const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit");
279 const cancel = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Cancel");
280 ok(submit?.disabled === true, "unknown nested schema cannot be submitted as lossy text");
281 ok(cancel?.disabled === false && Boolean(document.querySelector("[role='alert']")), "unsupported form keeps a clear safe exit and explanation");
282 await act(async () => root.unmount());
283 }
284
285 // ── Required-field validation ───────────────────────────────────────────────
286
287 {
288 const answers: string[] = [];
289 const root = createRoot(document.getElementById("root")!);
290 await act(async () => {
291 root.render(
292 <LocaleProvider>
293 <MCPInteractionCard
294 instanceKey="test:8"
295 interaction={{
296 id: "8",
297 server: "calendar-with-a-deliberately-long-server-name",
298 mode: "form",
299 message: "Please provide the account identifier used for this calendar connection.",
300 requestedSchema: {
301 type: "object",
302 required: ["account"],
303 properties: { account: { type: "string", title: "Account email", format: "email" } },
304 },
305 }}
306 busy={false}
307 onAnswer={(_id, action) => answers.push(action)}
308 />
309 </LocaleProvider>,
310 );
311 });
312 const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit");
313 await act(async () => submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
314 const input = document.querySelector(".structured-form-control") as HTMLInputElement | null;
315 ok(answers.length === 0, "invalid form does not answer the blocked MCP request");
316 ok(input?.required === true && input.getAttribute("aria-invalid") === "true", "required field exposes native and ARIA validation state");
317 ok(Boolean(input?.getAttribute("aria-describedby")), "field error is associated with its control");
318 ok(document.activeElement === input, "failed submit returns focus to the first invalid field");
319 await act(async () => root.unmount());
320 }
321
322 // ── URL card ─────────────────────────────────────────────────────────────────
323
324 {
325 const answers: { id: string; action: string; content?: Record<string, unknown> }[] = [];
326 const opened: string[] = [];
327 const root = createRoot(document.getElementById("root")!);
328 await act(async () => {
329 root.render(
330 <LocaleProvider>
331 <MCPInteractionCard
332 instanceKey="test:9"
333 interaction={{
334 id: "9",
335 server: "linear",
336 mode: "url",
337 message: "Finish sign-in",
338 url: "https://auth.example.com/cb?state=xyz",
339 }}
340 busy={false}
341 onAnswer={(id, action, content) => answers.push({ id, action, content })}
342 onOpenLink={(url) => opened.push(url)}
343 />
344 </LocaleProvider>,
345 );
346 });
347 const text = document.body.textContent ?? "";
348 const urlServer = document.querySelector(".mcp-interaction-url > span:first-child")?.textContent;
349 const urlHost = document.querySelector(".mcp-interaction-url > strong")?.textContent;
350 ok(urlServer === "linear" && urlHost === "auth.example.com", "url card shows the exact server and target host");
351 ok(!text.includes("?state=xyz"), "url card hides query params from the summary line");
352 const urlButtons = Array.from(document.querySelectorAll(".prompt-shelf-bar-actions button"));
353 const open = urlButtons.find((button) => (button.textContent ?? "").startsWith("Open "));
354 ok(urlButtons.some((button) => button.textContent === "Cancel"), "url mode keeps cancel distinct from decline");
355 if (open) {
356 await act(async () => {
357 open.dispatchEvent(new MouseEvent("click", { bubbles: true }));
358 });
359 }
360 ok(opened.length === 1 && opened[0] === "https://auth.example.com/cb?state=xyz", "open link passes the exact URL once");
361 const accept = Array.from(document.querySelectorAll("button, [role='button']")).find((b) =>
362 (b.textContent ?? "").toLowerCase() === "accept",
363 );
364 if (accept) {
365 await act(async () => {
366 accept.dispatchEvent(new MouseEvent("click", { bubbles: true }));
367 });
368 }
369 ok(answers.length === 1 && answers[0].action === "accept" && answers[0].content === undefined, "accept without form content");
370 await act(async () => {
371 root.unmount();
372 });
373 }
374
375 // ── Request-instance replacement ─────────────────────────────────────────────
376
377 {
378 const root = createRoot(document.getElementById("root")!);
379 const interaction = {
380 id: "reused-form-id",
381 server: "identity-server",
382 mode: "form" as const,
383 message: "Enter an account",
384 requestedSchema: {
385 type: "object",
386 required: ["account"],
387 properties: {
388 account: { type: "string", title: "Account", format: "email" },
389 remember: { type: "boolean", title: "Remember this account" },
390 },
391 },
392 };
393 const render = (instanceKey: string) => (
394 <LocaleProvider>
395 <MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} />
396 </LocaleProvider>
397 );
398 await act(async () => root.render(render("request:first")));
399 const firstInput = document.querySelector(".structured-form-control") as HTMLInputElement;
400 const firstCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement;
401 await act(async () => firstCheckbox.click());
402 const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit");
403 await act(async () => submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
404 ok(firstCheckbox.checked && firstInput.getAttribute("aria-invalid") === "true", "first MCP request owns its field value and validation error");
405
406 await act(async () => root.render(render("request:replacement")));
407 const replacementInput = document.querySelector(".structured-form-control") as HTMLInputElement;
408 const replacementCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement;
409 ok(!replacementCheckbox.checked && replacementInput.getAttribute("aria-invalid") !== "true", "same MCP id/schema resets values and errors for a new request instance");
410 await act(async () => root.unmount());
411 }
412
413 {
414 const root = createRoot(document.getElementById("root")!);
415 const interaction = {
416 id: "reused-url-id",
417 server: "identity-server",
418 mode: "url" as const,
419 message: "Authorize access",
420 url: "https://auth.example.com/reused",
421 };
422 const render = (instanceKey: string) => (
423 <LocaleProvider>
424 <MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} onOpenLink={() => {}} />
425 </LocaleProvider>
426 );
427 await act(async () => root.render(render("url:first")));
428 const open = Array.from(document.querySelectorAll("button")).find((button) => (button.textContent ?? "").startsWith("Open "));
429 await act(async () => open?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
430 ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Finish in your browser, then accept.", "first MCP URL request records that its link was opened");
431
432 await act(async () => root.render(render("url:replacement")));
433 ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Open the link, finish in your browser, then accept.", "same MCP id resets opened-link state for a new request instance");
434 await act(async () => root.unmount());
435 }
436
437 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
438 if (failed > 0) process.exit(1);
439
439 lines Plain Text