Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
32 changes: 32 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ it.effect("atomically registers a connected host and correlates its response", (
),
);

it.effect("pins follow-up operations to the tab returned by open", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const openedTabId = PreviewTabId.make("tab-opened-by-agent");
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: openedTabId }
: { url: "http://localhost:3200" },
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

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

expect(routedRequests).toHaveLength(2);
expect(routedRequests[0]?.tabId).toBeUndefined();
expect(routedRequests[1]?.tabId).toBe(openedTabId);
}),
),
);

it.effect("announces a live replacement stream before delivering requests", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
40 changes: 37 additions & 3 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Comment thread
juliusmarminge marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import {
PreviewAutomationTargetNotEditableError,
PreviewAutomationTimeoutError,
PreviewAutomationUnsupportedClientError,
PreviewTabId,
type PreviewAutomationError,
type PreviewAutomationOperation,
type PreviewAutomationHost,
type PreviewAutomationHostFocus,
type PreviewAutomationResponse,
type PreviewAutomationStreamEvent,
type PreviewTabId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Clock from "effect/Clock";
Expand All @@ -29,6 +29,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SynchronizedRef from "effect/SynchronizedRef";

Expand Down Expand Up @@ -79,6 +80,7 @@ interface HostAssignment {
readonly connectionId: ClientConnection["connectionId"];
readonly queue: ClientConnection["queue"];
readonly expiresAt: number;
readonly tabId?: PreviewTabId;
}

interface PreviewAutomationRequestErrorContext {
Expand Down Expand Up @@ -144,6 +146,14 @@ const selectorDiagnosticsFromInput = (
const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
`${scope.environmentId}\u0000${scope.providerSessionId}`;

const isPreviewTabId = Schema.is(PreviewTabId);

const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
const tabId = result.tabId;
return tabId === null || isPreviewTabId(tabId) ? tabId : undefined;
};

const supportsOperation = (
connection: ClientConnection,
operation: PreviewAutomationOperation,
Expand Down Expand Up @@ -459,10 +469,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
connectionId: connection.connectionId,
queue: connection.queue,
expiresAt: input.scope.expiresAt,
...(assigned?.tabId === undefined ? {} : { tabId: assigned.tabId }),
});

const requestId = `preview-${current.requestSequence}`;
const tabId = input.tabId;
const tabId = input.tabId ?? assigned?.tabId;
const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
const context: PreviewAutomationRequestErrorContext = {
operation: input.operation,
Expand Down Expand Up @@ -526,7 +537,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
onSome: (value) => Effect.succeed(value as A),
});
});
return yield* awaitResponse().pipe(Effect.ensuring(removePending));
const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
const resultTabId = readResultTabId(result);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
if (resultTabId !== undefined) {
const assignmentKey = hostAssignmentKey(input.scope);
yield* SynchronizedRef.update(state, (current) => {
const assignment = current.assignments.get(assignmentKey);
if (
!assignment ||
assignment.connectionId !== connection.connectionId ||
assignment.queue !== connection.queue
) {
return current;
}
const assignments = new Map(current.assignments);
if (resultTabId === null) {
const { tabId: _tabId, ...withoutTabId } = assignment;
assignments.set(assignmentKey, withoutTabId);
} else {
assignments.set(assignmentKey, { ...assignment, tabId: resultTabId });
}
return { ...current, assignments };
});
}
return result;
});

return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/browser/BrowserSurfaceSlot.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useEffect, useRef } from "react";
import { useLayoutEffect, useRef } from "react";

import { useBrowserSurfaceStore } from "./browserSurfaceStore";
import { acquireBrowserSurface } from "./browserSurfaceStore";

export function BrowserSurfaceSlot(props: {
readonly tabId: string;
Expand All @@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: {
const { tabId, visible, className } = props;
const elementRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
useLayoutEffect(() => {
const element = elementRef.current;
if (!element) return;
const lease = acquireBrowserSurface(tabId);
const update = () => {
const rect = element.getBoundingClientRect();
useBrowserSurfaceStore.getState().present(
tabId,
lease.present(
{
x: Math.round(rect.x),
y: Math.round(rect.y),
Expand All @@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: {
observer.disconnect();
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
useBrowserSurfaceStore.getState().hide(tabId);
lease.release();
};
}, [tabId, visible]);

Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { previewBridge } from "~/components/preview/previewBridge";
import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
import { cn } from "~/lib/utils";

import { useActiveBrowserRecordingTabId } from "./browserRecording";
import { useBrowserRecordingSurfaceTabId } from "./browserRecording";
import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
import { browserViewportSettingKey } from "./browserViewportLayout";
import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState";
Expand Down Expand Up @@ -56,7 +56,7 @@ export function HostedBrowserWebview(props: {
};
}),
);
const recording = useActiveBrowserRecordingTabId() === tabId;
const recording = useBrowserRecordingSurfaceTabId() === tabId;

usePreviewBridge({ threadRef, tabId });

Expand Down Expand Up @@ -181,12 +181,17 @@ export function HostedBrowserWebview(props: {
pointerEvents: "auto" as const,
}
: {
left: 0,
top: 0,
// Chromium must keep painting a background guest while recording
// so Page.startScreencast continues to emit frames. Every other
// inactive guest is both hidden and moved offscreen; z-index alone
// is not a reliable visibility boundary for Electron webviews.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
left: -100_000,
top: -100_000,
width: hiddenSize.width,
height: hiddenSize.height,
zIndex: recording ? 0 : -1,
pointerEvents: "none" as const,
visibility: recording ? ("visible" as const) : ("hidden" as const),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
};

return (
Expand Down
165 changes: 165 additions & 0 deletions apps/web/src/browser/browserRecording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const { events, onFrame, registrySet, save, startScreencast, stopScreencast } = vi.hoisted(() => {
const events: string[] = [];
return {
events,
onFrame: vi.fn(() => vi.fn()),
registrySet: vi.fn((_atom: unknown, value: string | null) => {
events.push(value === null ? "clear" : `publish:${value}`);
}),
save: vi.fn(async () => ({
id: "recording-test",
tabId: "recording-tab",
path: "/tmp/recording-test.webm",
mimeType: "video/webm" as const,
sizeBytes: 0,
createdAt: "2026-06-26T00:00:00.000Z",
})),
startScreencast: vi.fn(async () => {
events.push("start-screencast");
}),
stopScreencast: vi.fn(async () => undefined),
};
});

vi.mock("~/components/preview/previewBridge", () => ({
previewBridge: {
recording: { onFrame, save, startScreencast, stopScreencast },
},
}));

vi.mock("~/rpc/atomRegistry", () => ({
appAtomRegistry: { set: registrySet },
}));

vi.mock("./browserSurfaceStore", () => ({
useBrowserSurfaceStore: {
getState: () => ({ byTabId: {} }),
},
}));

import {
BrowserRecordingOperationError,
startBrowserRecording,
stopBrowserRecording,
} from "./browserRecording";

class FakeMediaRecorder {
static isTypeSupported(): boolean {
return true;
}

state: RecordingState = "inactive";
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();

addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}

start(): void {
this.state = "recording";
}

stop(): void {
this.state = "inactive";
for (const listener of this.listeners.get("stop") ?? []) {
if (typeof listener === "function") listener(new Event("stop"));
else listener.handleEvent(new Event("stop"));
}
}
}

describe("browser recording surface preparation", () => {
beforeEach(() => {
events.length = 0;
vi.clearAllMocks();
vi.stubGlobal("window", globalThis);
vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
vi.stubGlobal("document", {
createElement: () => ({
width: 0,
height: 0,
captureStream: () => ({}),
getContext: () => ({ drawImage: vi.fn() }),
}),
});
let frameId = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const id = ++frameId;
queueMicrotask(() => {
events.push(`paint:${id}`);
callback(id);
});
return id;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("makes an inactive guest paintable before starting its screencast", async () => {
await startBrowserRecording("recording-tab");

expect(events).toEqual([
"publish:recording-tab",
"paint:1",
"paint:2",
"start-screencast",
"publish:recording-tab",
]);

await stopBrowserRecording("recording-tab");
});

it("does not start a screencast after stopping during the paint wait", async () => {
const frameCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});

const startPromise = startBrowserRecording("recording-tab");
const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
BrowserRecordingOperationError,
);
await vi.waitFor(() => expect(frameCallbacks).toHaveLength(1));

await stopBrowserRecording("recording-tab");
frameCallbacks.shift()?.(1);
expect(frameCallbacks).toHaveLength(1);
frameCallbacks.shift()?.(2);

await rejectedStart;
expect(startScreencast).not.toHaveBeenCalled();
expect(events).toEqual(["publish:recording-tab", "clear", "clear"]);
});

it("stops a screencast that finishes starting after cancellation", async () => {
let finishStartingScreencast: (() => void) | undefined;
startScreencast.mockImplementationOnce(async () => {
events.push("start-screencast");
await new Promise<void>((resolve) => {
finishStartingScreencast = resolve;
});
});

const startPromise = startBrowserRecording("recording-tab");
const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
BrowserRecordingOperationError,
);
await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());

await stopBrowserRecording("recording-tab");
expect(stopScreencast).toHaveBeenCalledOnce();
finishStartingScreencast?.();

await rejectedStart;
expect(stopScreencast).toHaveBeenCalledTimes(2);
expect(events.at(-1)).toBe("clear");
});
});
Loading
Loading