| ... | ... | @@ -2,8 +2,10 @@ import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { NodeHttpServer } from "@effect/platform-node"; |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 5 | import * as Deferred from "effect/Deferred"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; |
| 7 | 9 | import * as Stream from "effect/Stream"; |
| 8 | 10 | import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; |
| 9 | 11 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| ... | ... | @@ -16,6 +18,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); |
| 16 | 18 | const threadId = ThreadId.make("thread-mcp-test"); |
| 17 | 19 | const tabId = PreviewTabId.make("tab-mcp-test"); |
| 18 | 20 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); |
| 21 | const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 19 | 22 | const invocation = { |
| 20 | 23 | environmentId, |
| 21 | 24 | threadId, |
| ... | ... | @@ -51,52 +54,179 @@ it("normalizes empty successful notification responses to accepted", () => { |
| 51 | 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 | 110 | Effect.scoped( |
| 56 | 111 | Effect.gen(function* () { |
| 57 | 112 | const server = yield* McpServer.McpServer; |
| 58 | 113 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 59 | | const events = yield* broker.connect({ |
| 60 | | clientId: "mcp-failure-client", |
| 61 | | environmentId, |
| 62 | | }); |
| 63 | | yield* Stream.runForEach(events, (event) => |
| 64 | | event.type === "connected" |
| 65 | | ? Effect.void |
| 66 | | : broker.respond({ |
| 67 | | clientId: "mcp-failure-client", |
| 68 | | connectionId: event.connectionId, |
| 69 | | requestId: event.request.requestId, |
| 70 | | ok: false, |
| 71 | | error: { |
| 72 | | _tag: "PreviewAutomationExecutionError", |
| 73 | | message: "sensitive renderer failure", |
| 74 | | detail: { consoleOutput: "sensitive browser output" }, |
| 75 | | }, |
| 76 | | }), |
| 77 | | ).pipe(Effect.forkScoped); |
| 78 | | yield* Effect.yieldNow; |
| 114 | const connected = yield* Deferred.make<void>(); |
| 115 | const png = |
| 116 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="; |
| 117 | const page = { |
| 118 | url: "http://example.test/", |
| 119 | loading: false, |
| 120 | visibleText: "Save your changes", |
| 121 | interactiveElements: [ |
| 122 | { |
| 123 | tag: "button", |
| 124 | role: "button", |
| 125 | name: "Save", |
| 126 | selector: "#save", |
| 127 | x: 0, |
| 128 | y: 0, |
| 129 | width: 20, |
| 130 | height: 10, |
| 131 | }, |
| 132 | ], |
| 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 |
| 81 | | .callTool({ name: "preview_snapshot", arguments: {} }) |
| 164 | for (const call of [1, 2, 3, 4, 5, 6]) { |
| 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 | 197 | .pipe( |
| 83 | 198 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 84 | 199 | Effect.provideService(McpSchema.McpServerClient, client), |
| 85 | 200 | ); |
| 86 | | |
| 87 | | expect(snapshot.isError).toBe(true); |
| 88 | | expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); |
| 89 | | expect(snapshot.structuredContent).toEqual({ |
| 90 | | error: { |
| 91 | | _tag: "PreviewAutomationExecutionError", |
| 92 | | operation: "snapshot", |
| 93 | | failureCount: 1, |
| 94 | | }, |
| 95 | | }); |
| 201 | expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "image"]); |
| 202 | expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); |
| 203 | expect(requests).toBe(7); |
| 96 | 204 | }), |
| 97 | 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 | 230 | it.effect("terminates HTTP MCP sessions with DELETE", () => |
| 101 | 231 | Effect.scoped( |
| 102 | 232 | Effect.gen(function* () { |