Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 48 additions & 40 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
const environmentId = EnvironmentId.make("environment-mcp-test");
const threadId = ThreadId.make("thread-mcp-test");
const tabId = PreviewTabId.make("tab-mcp-test");
const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
const invocation = {
environmentId,
threadId,
Expand Down Expand Up @@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const routedRequests: Array<{
readonly operation: string;
readonly tabId?: string | undefined;
}> = [];
const events = yield* broker.connect({
clientId: "mcp-test-client",
environmentId,
});
yield* Stream.runForEach(events, (event) =>
event.type === "connected"
? Effect.void
: broker.respond({
clientId: "mcp-test-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result:
event.request.operation === "snapshot"
? {
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
}
: event.request.operation === "press"
? undefined
: {
available: true,
visible: true,
tabId,
url: "http://example.test/",
title: "Example",
loading: false,
},
}),
).pipe(Effect.forkScoped);
yield* Stream.runForEach(events, (event) => {
if (event.type === "connected") return Effect.void;
routedRequests.push(event.request);
return broker.respond({
clientId: "mcp-test-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result:
event.request.operation === "snapshot"
? {
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
}
: event.request.operation === "press"
? undefined
: {
available: true,
visible: true,
tabId,
url: "http://example.test/",
title: "Example",
loading: false,
},
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
Expand Down Expand Up @@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context
expect(malformed.isError).toBe(true);

const snapshot = yield* server
.callTool({ name: "preview_snapshot", arguments: {} })
.callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
Expand All @@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context
expect(snapshot.structuredContent).toMatchObject({
screenshot: { mimeType: "image/png", width: 10, height: 5 },
});
expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe(
alternateTabId,
);

const press = yield* server
.callTool({ name: "preview_press", arguments: { key: "Enter" } })
Expand Down
182 changes: 182 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type PreviewAutomationStreamEvent,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as Result from "effect/Result";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -84,6 +85,187 @@ it.effect("atomically registers a connected host and correlates its response", (
),
);

it.effect("targets multiple tabs explicitly while retaining a default tab", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const appTabId = PreviewTabId.make("tab-web-app");
const simulatorTabId = PreviewTabId.make("tab-ios-simulator");
const openedTabIds = [appTabId, simulatorTabId];
let openIndex = 0;
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
return broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result:
request.operation === "open"
? { available: true, tabId: openedTabIds[openIndex++] }
: { url: "http://localhost:3200" },
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });
yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests).toHaveLength(5);
expect(routedRequests[0]?.tabId).toBeUndefined();
expect(routedRequests[1]?.tabId).toBe(appTabId);
expect(routedRequests[2]?.tabId).toBe(simulatorTabId);
expect(routedRequests[2]?.tabIdExplicit).toBe(false);
expect(routedRequests[3]?.tabId).toBe(appTabId);
expect(routedRequests[3]?.tabIdExplicit).toBe(true);
expect(routedRequests[4]?.tabId).toBe(appTabId);
}),
),
);

it.effect("does not let an older response replace a newer explicit tab target", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const olderTabId = PreviewTabId.make("tab-older-request");
const newerTabId = PreviewTabId.make("tab-newer-request");
const releaseOlderResponse = yield* Deferred.make<void>();
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
const response = Effect.gen(function* () {
if (request.tabId === olderTabId) {
yield* Deferred.await(releaseOlderResponse);
}
yield* broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: { url: "http://localhost:3200" },
});
if (request.tabId === newerTabId) {
yield* Deferred.succeed(releaseOlderResponse, undefined);
}
});
return response.pipe(Effect.forkScoped, Effect.asVoid);
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

const older = yield* broker
.invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId })
.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const newer = yield* broker
.invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId })
.pipe(Effect.forkScoped);
yield* Fiber.join(newer);
yield* Fiber.join(older);
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests.at(-1)?.tabId).toBe(newerTabId);
}),
),
);

it.effect("does not replace the default tab with a globally stopped recording tab", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const browsingTabId = PreviewTabId.make("tab-session-b");
const recordingTabId = PreviewTabId.make("tab-session-a-recording");
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
return broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result:
request.operation === "open"
? { available: true, tabId: browsingTabId }
: request.operation === "recordingStop"
? { id: "recording-1", tabId: recordingTabId }
: { url: "http://localhost:3200" },
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "open", input: {} });
yield* broker.invoke({ scope, operation: "recordingStop", input: {} });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId);
}),
),
);

it.effect("does not let a no-tab response suppress an earlier tab decision", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const initialTabId = PreviewTabId.make("tab-initial");
const openedTabId = PreviewTabId.make("tab-opened-late");
const releaseOpenResponse = yield* Deferred.make<void>();
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
const marker =
typeof request.input === "object" && request.input !== null && "marker" in request.input
? request.input.marker
: undefined;
const response = Effect.gen(function* () {
if (marker === "older") {
yield* Deferred.await(releaseOpenResponse);
}
yield* broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result:
request.operation === "open"
? { available: true, tabId: marker === "older" ? openedTabId : initialTabId }
: { url: "http://localhost:3200" },
});
if (marker === "newer") {
yield* Deferred.succeed(releaseOpenResponse, undefined);
}
});
return response.pipe(Effect.forkScoped, Effect.asVoid);
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "open", input: {} });
const older = yield* broker
.invoke({
scope,
operation: "open",
input: { marker: "older", reuseExistingTab: false },
})
.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const newer = yield* broker
.invoke({ scope, operation: "snapshot", input: { marker: "newer" } })
.pipe(Effect.forkScoped);
yield* Fiber.join(newer);
yield* Fiber.join(older);
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests.at(-1)?.tabId).toBe(openedTabId);
}),
),
);

it.effect("announces a live replacement stream before delivering requests", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading