| ... | @@ -2,8 +2,10 @@ import { expect, it } from "@effect/vitest"; | ... | @@ -2,8 +2,10 @@ import { expect, it } from "@effect/vitest"; |
| 2 | import { NodeHttpServer } from "@effect/platform-node"; | 2 | import { NodeHttpServer } from "@effect/platform-node"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 4 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| | 8 | import * as Schema from "effect/Schema"; |
| 7 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 8 | import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; | 10 | import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; |
| 9 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; | 11 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| ... | @@ -16,6 +18,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); | ... | @@ -16,6 +18,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); |
| 16 | const threadId = ThreadId.make("thread-mcp-test"); | 18 | const threadId = ThreadId.make("thread-mcp-test"); |
| 17 | const tabId = PreviewTabId.make("tab-mcp-test"); | 19 | const tabId = PreviewTabId.make("tab-mcp-test"); |
| 18 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); | 20 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); |
| | 21 | const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 19 | const invocation = { | 22 | const invocation = { |
| 20 | environmentId, | 23 | environmentId, |
| 21 | threadId, | 24 | threadId, |
| ... | @@ -51,52 +54,179 @@ it("normalizes empty successful notification responses to accepted", () => { | ... | @@ -51,52 +54,179 @@ it("normalizes empty successful notification responses to accepted", () => { |
| 51 | expect(resultResponse.status).toBe(200); | 54 | expect(resultResponse.status).toBe(200); |
| 52 | }); | 55 | }); |
| 53 | | 56 | |
| 54 | it.effect("returns bounded structural preview snapshot failures", () => | 57 | it.effect.each([{}, { includeImage: false }])( |
| | 58 | "returns bounded structural preview snapshot failures %#", |
| | 59 | (input) => |
| | 60 | Effect.scoped( |
| | 61 | Effect.gen(function* () { |
| | 62 | const server = yield* McpServer.McpServer; |
| | 63 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| | 64 | const events = yield* broker.connect({ |
| | 65 | clientId: "mcp-failure-client", |
| | 66 | environmentId, |
| | 67 | }); |
| | 68 | yield* Stream.runForEach(events, (event) => |
| | 69 | event.type === "connected" |
| | 70 | ? Effect.void |
| | 71 | : broker.respond({ |
| | 72 | clientId: "mcp-failure-client", |
| | 73 | connectionId: event.connectionId, |
| | 74 | requestId: event.request.requestId, |
| | 75 | ok: false, |
| | 76 | error: { |
| | 77 | _tag: "PreviewAutomationExecutionError", |
| | 78 | message: "sensitive renderer failure", |
| | 79 | detail: { consoleOutput: "sensitive browser output" }, |
| | 80 | }, |
| | 81 | }), |
| | 82 | ).pipe(Effect.forkScoped); |
| | 83 | yield* Effect.yieldNow; |
| | 84 | |
| | 85 | const snapshot = yield* server |
| | 86 | .callTool({ name: "preview_snapshot", arguments: input }) |
| | 87 | .pipe( |
| | 88 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| | 89 | Effect.provideService(McpSchema.McpServerClient, client), |
| | 90 | ); |
| | 91 | |
| | 92 | expect(snapshot.isError).toBe(true); |
| | 93 | expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); |
| | 94 | expect(snapshot.structuredContent).toEqual({ |
| | 95 | error: { |
| | 96 | _tag: "PreviewAutomationExecutionError", |
| | 97 | operation: "snapshot", |
| | 98 | failureCount: 1, |
| | 99 | }, |
| | 100 | }); |
| | 101 | }), |
| | 102 | ).pipe(Effect.provide(TestLayer)), |
| | 103 | ); |
| | 104 | |
| | 105 | it.effect.each([ |
| | 106 | { mode: "default", input: {}, images: true }, |
| | 107 | { mode: "explicit image", input: { includeImage: true }, images: true }, |
| | 108 | { mode: "text only", input: { includeImage: false }, images: false }, |
| | 109 | ])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) => |
| 55 | Effect.scoped( | 110 | Effect.scoped( |
| 56 | Effect.gen(function* () { | 111 | Effect.gen(function* () { |
| 57 | const server = yield* McpServer.McpServer; | 112 | const server = yield* McpServer.McpServer; |
| 58 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 113 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 59 | const events = yield* broker.connect({ | 114 | const connected = yield* Deferred.make<void>(); |
| 60 | clientId: "mcp-failure-client", | 115 | const png = |
| 61 | environmentId, | 116 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="; |
| 62 | }); | 117 | const page = { |
| 63 | yield* Stream.runForEach(events, (event) => | 118 | url: "http://example.test/", |
| 64 | event.type === "connected" | 119 | loading: false, |
| 65 | ? Effect.void | 120 | visibleText: "Save your changes", |
| 66 | : broker.respond({ | 121 | interactiveElements: [ |
| 67 | clientId: "mcp-failure-client", | 122 | { |
| 68 | connectionId: event.connectionId, | 123 | tag: "button", |
| 69 | requestId: event.request.requestId, | 124 | role: "button", |
| 70 | ok: false, | 125 | name: "Save", |
| 71 | error: { | 126 | selector: "#save", |
| 72 | _tag: "PreviewAutomationExecutionError", | 127 | x: 0, |
| 73 | message: "sensitive renderer failure", | 128 | y: 0, |
| 74 | detail: { consoleOutput: "sensitive browser output" }, | 129 | width: 20, |
| 75 | }, | 130 | height: 10, |
| 76 | }), | 131 | }, |
| 77 | ).pipe(Effect.forkScoped); | 132 | ], |
| 78 | yield* Effect.yieldNow; | 133 | accessibilityTree: { role: "document", name: "Example" }, |
| | 134 | consoleEntries: [], |
| | 135 | networkEntries: [], |
| | 136 | actionTimeline: [], |
| | 137 | }; |
| | 138 | const screenshot = { mimeType: "image/png", width: 1, height: 1 }; |
| | 139 | let requests = 0; |
| | 140 | const events = yield* broker.connect({ clientId: "mcp-image-option-client", environmentId }); |
| | 141 | yield* Stream.runForEach(events, (event) => { |
| | 142 | if (event.type === "connected") return Deferred.succeed(connected, undefined); |
| | 143 | requests += 1; |
| | 144 | expect(event.request).toMatchObject({ |
| | 145 | operation: "snapshot", |
| | 146 | tabId: alternateTabId, |
| | 147 | threadId, |
| | 148 | }); |
| | 149 | expect(event.request.input).toEqual({}); |
| | 150 | return broker.respond({ |
| | 151 | clientId: "mcp-image-option-client", |
| | 152 | connectionId: event.connectionId, |
| | 153 | requestId: event.request.requestId, |
| | 154 | ok: true, |
| | 155 | result: { |
| | 156 | ...page, |
| | 157 | title: `Snapshot ${requests}`, |
| | 158 | screenshot: { ...screenshot, data: png }, |
| | 159 | }, |
| | 160 | }); |
| | 161 | }).pipe(Effect.forkScoped); |
| | 162 | yield* Deferred.await(connected); |
| 79 | | 163 | |
| 80 | const snapshot = yield* server | 164 | for (const call of [1, 2, 3, 4, 5, 6]) { |
| 81 | .callTool({ name: "preview_snapshot", arguments: {} }) | 165 | const snapshot = yield* server |
| | 166 | .callTool({ |
| | 167 | name: "preview_snapshot", |
| | 168 | arguments: { ...input, tabId: alternateTabId }, |
| | 169 | }) |
| | 170 | .pipe( |
| | 171 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| | 172 | Effect.provideService(McpSchema.McpServerClient, client), |
| | 173 | ); |
| | 174 | const metadata = { ...page, title: `Snapshot ${call}`, screenshot }; |
| | 175 | expect(snapshot.isError).toBe(false); |
| | 176 | expect(snapshot.structuredContent).toEqual(metadata); |
| | 177 | expect(snapshot.content).toEqual([ |
| | 178 | { type: "text", text: encodeJsonText(snapshot.structuredContent) }, |
| | 179 | ...(images |
| | 180 | ? [ |
| | 181 | { |
| | 182 | type: "image", |
| | 183 | mimeType: "image/png", |
| | 184 | data: new Uint8Array(Buffer.from(png, "base64")), |
| | 185 | }, |
| | 186 | ] |
| | 187 | : []), |
| | 188 | ]); |
| | 189 | } |
| | 190 | |
| | 191 | // Output selection belongs to this call, not the MCP session's history. |
| | 192 | const nextDefault = yield* server |
| | 193 | .callTool({ |
| | 194 | name: "preview_snapshot", |
| | 195 | arguments: { tabId: alternateTabId }, |
| | 196 | }) |
| 82 | .pipe( | 197 | .pipe( |
| 83 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | 198 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 84 | Effect.provideService(McpSchema.McpServerClient, client), | 199 | Effect.provideService(McpSchema.McpServerClient, client), |
| 85 | ); | 200 | ); |
| 86 | | 201 | expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "image"]); |
| 87 | expect(snapshot.isError).toBe(true); | 202 | expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); |
| 88 | expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); | 203 | expect(requests).toBe(7); |
| 89 | expect(snapshot.structuredContent).toEqual({ | | |
| 90 | error: { | | |
| 91 | _tag: "PreviewAutomationExecutionError", | | |
| 92 | operation: "snapshot", | | |
| 93 | failureCount: 1, | | |
| 94 | }, | | |
| 95 | }); | | |
| 96 | }), | 204 | }), |
| 97 | ).pipe(Effect.provide(TestLayer)), | 205 | ).pipe(Effect.provide(TestLayer)), |
| 98 | ); | 206 | ); |
| 99 | | 207 | |
| | 208 | it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => |
| | 209 | Effect.gen(function* () { |
| | 210 | const server = yield* McpServer.McpServer; |
| | 211 | for (const includeImage of ["false", 0, null]) { |
| | 212 | const result = yield* server |
| | 213 | .callTool({ |
| | 214 | name: "preview_snapshot", |
| | 215 | arguments: { includeImage }, |
| | 216 | }) |
| | 217 | .pipe( |
| | 218 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| | 219 | Effect.provideService(McpSchema.McpServerClient, client), |
| | 220 | ); |
| | 221 | expect(result.isError).toBe(true); |
| | 222 | expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); |
| | 223 | expect(result.structuredContent).toEqual({ |
| | 224 | error: { _tag: "AiError", operation: "snapshot", failureCount: 1 }, |
| | 225 | }); |
| | 226 | } |
| | 227 | }).pipe(Effect.provide(TestLayer)), |
| | 228 | ); |
| | 229 | |
| 100 | it.effect("terminates HTTP MCP sessions with DELETE", () => | 230 | it.effect("terminates HTTP MCP sessions with DELETE", () => |
| 101 | Effect.scoped( | 231 | Effect.scoped( |
| 102 | Effect.gen(function* () { | 232 | Effect.gen(function* () { |