@@ -17,12 +17,14 @@ import * as FileSystem from "effect/FileSystem";
1717import * as Layer from "effect/Layer";
1818import * as Option from "effect/Option";
1919import * as Queue from "effect/Queue";
20+ import * as Stream from "effect/Stream";
2021import type { BrowserContext, Page } from "playwright-core";
2122import { beforeEach, expect, vi } from "vite-plus/test";
2223
2324import * as ServerConfig from "../config.ts";
2425import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2526import * as Broker from "../mcp/PreviewAutomationBroker.ts";
27+ import * as DesktopChannel from "./DesktopBrowserChannel.ts";
2628import * as Manager from "./Manager.ts";
2729import * as ServerBrowser from "./ServerBrowser.ts";
2830import * as Toolchain from "./ServerBrowserToolchain.ts";
@@ -41,6 +43,14 @@ vi.mock("./ServerBrowserContexts.ts", () => ({
4143 contexts.push(context);
4244 return context as unknown as BrowserContext;
4345 }
46+ async scratchPage() {
47+ return makeContext().page as unknown as Page;
48+ }
49+ async connectDesktopPage(endpoint: string) {
50+ const context = makeContext();
51+ desktopConnections.push({ endpoint, context });
52+ return { browser: { close: async () => {} }, page: context.page as unknown as Page };
53+ }
4454 async close() {
4555 for (const context of contexts) await context.close();
4656 }
@@ -140,6 +150,20 @@ let contextGate: PromiseWithResolvers<void> | null = null;
140150type ClipboardBinding = (source: { page: unknown }, text: unknown) => void;
141151let clipboardBinding: ClipboardBinding | null = null;
142152let contextFailure: Error | null = null;
153+ /** Server tabs the fake desktop renders, and the endpoints the server connected to. */
154+ let desktopRendersNext = false;
155+ /** Pages the fake desktop takes back; the channel's detached stream emits them. */
156+ const desktopDetaches = new NodeEvents.EventEmitter();
157+ const desktopTabs = new Set<string>();
158+ const desktopRenders = (tabId: string) => {
159+ if (desktopRendersNext) {
160+ desktopRendersNext = false;
161+ desktopTabs.add(tabId);
162+ }
163+ return desktopTabs.has(tabId);
164+ };
165+ const releasedDesktopTabs: Array<string> = [];
166+ const desktopConnections: Array<{ endpoint: string; context: ReturnType<typeof makeContext> }> = [];
143167const scope = {
144168 environmentId: EnvironmentId.make("browser-test-environment"),
145169 threadId: ThreadId.make("browser-test-thread"),
@@ -158,6 +182,27 @@ const dependencies = Layer.mergeAll(
158182 Layer.succeed(Toolchain.ServerBrowserToolchain, {
159183 resolve: Effect.die("mock Chromium does not need an executable"),
160184 }),
185+ Layer.succeed(DesktopChannel.DesktopBrowserChannel, {
186+ // Only tabs a test marks render on the desktop; the rest stay headless.
187+ available: true,
188+ awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
189+ detached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
190+ Effect.acquireRelease(
191+ Effect.sync(() => {
192+ const onDetach = (key: { threadId: string; tabId: string }) =>
193+ Queue.offerUnsafe(queue, key);
194+ desktopDetaches.on("detach", onDetach);
195+ return onDetach;
196+ }),
197+ (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),
198+ ),
199+ ),
200+ isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
201+ endpoint: (key) =>
202+ Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>
203+ Effect.sync(() => releasedDesktopTabs.push(key.tabId)),
204+ ),
205+ }),
161206).pipe(
162207 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })),
163208 Layer.provideMerge(NodeServices.layer),
@@ -188,6 +233,10 @@ beforeEach(() => {
188233 contexts.length = 0;
189234 contextGate = null;
190235 contextFailure = null;
236+ desktopTabs.clear();
237+ desktopRendersNext = false;
238+ releasedDesktopTabs.length = 0;
239+ desktopConnections.length = 0;
191240});
192241
193242it.live("readiness none responds immediately but takeover input waits for navigation commit", () =>
@@ -865,3 +914,68 @@ it.live("viewers see the agent's pointer move to its target and click there", ()
865914 }),
866915 ).pipe(Effect.provide(layer)),
867916);
917+
918+ it.live("drives the desktop's own page for a tab the desktop renders", () =>
919+ Effect.scoped(
920+ Effect.gen(function* () {
921+ const browser = yield* ServerBrowser.ServerBrowser;
922+ const broker = yield* Broker.PreviewAutomationBroker;
923+ const manager = yield* Manager.PreviewManager;
924+ yield* Effect.yieldNow;
925+ desktopRendersNext = true;
926+ const opened = yield* manager.open({ threadId: scope.threadId, runtime: "server" });
927+ const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
928+ expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([
929+ `ws://desktop/${opened.tabId}`,
930+ ]);
931+ // No headless context was launched for it.
932+ expect(contexts).toEqual([]);
933+ const page = desktopConnections[0]!.context.page;
934+ // The desktop panel sizes its page, so a viewer resize leaves it alone.
935+ yield* viewer.input({ type: "takeControl" });
936+ yield* viewer.input({ type: "resize", width: 390, height: 844 });
937+ expect(page.setViewportSize).not.toHaveBeenCalled();
938+ yield* viewer.input({ type: "releaseControl" });
939+ // Agents reach it through the same engine as a headless tab.
940+ const evaluated = yield* broker.invoke({
941+ scope: { ...scope, providerSessionId: "agent-desktop" },
942+ operation: "status",
943+ input: {},
944+ tabId: PreviewTabId.make(opened.tabId),
945+ });
946+ expect(evaluated).toMatchObject({ tabId: opened.tabId });
947+ // Closing the session lets go of the desktop's page without closing it.
948+ yield* manager.close({ threadId: scope.threadId, tabId: opened.tabId });
949+ while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
950+ expect(releasedDesktopTabs).toEqual([opened.tabId]);
951+ expect(page.close).not.toHaveBeenCalled();
952+ }),
953+ ).pipe(Effect.provide(layer)),
954+ );
955+
956+ it.live("a desktop page the desktop takes back reconnects instead of closing", () =>
957+ Effect.scoped(
958+ Effect.gen(function* () {
959+ const browser = yield* ServerBrowser.ServerBrowser;
960+ const manager = yield* Manager.PreviewManager;
961+ yield* Effect.yieldNow;
962+ desktopRendersNext = true;
963+ const opened = yield* manager.open({ threadId: scope.threadId, runtime: "server" });
964+ const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false));
965+ // Devtools opened on the desktop, so it withdrew the page's debugger.
966+ // The server's listener subscribes in its own fiber; detach once it is there.
967+ while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
968+ desktopDetaches.emit("detach", { threadId: scope.threadId, tabId: opened.tabId });
969+ let end = yield* Queue.take(viewer.output);
970+ while (end._tag !== "reconnect" && end._tag !== "gone")
971+ end = yield* Queue.take(viewer.output);
972+ expect(end._tag).toBe("reconnect");
973+ expect(releasedDesktopTabs).toEqual([opened.tabId]);
974+ // The session survives, and the next viewer reaches the page again.
975+ const { sessions } = yield* manager.list({ threadId: scope.threadId });
976+ expect(sessions.map((session) => session.tabId)).toContain(opened.tabId);
977+ yield* browser.attachViewer(viewerInput(opened.tabId, false));
978+ expect(desktopConnections).toHaveLength(2);
979+ }),
980+ ).pipe(Effect.provide(layer)),
981+ );
0 commit comments