diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 3b08e1c8f279a..0428dd5dfdc2d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -298,6 +298,114 @@ beforeEach(() => { desktopConnections.length = 0; }); +it.live.each(["desktop" as const, undefined])( + "explicit desktop targets ($0 runtime) fail without creating a server tab", + (runtime) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const desktop = yield* manager.open({ + threadId: scope.thread.threadId, + url: "https://example.com/original", + ...(runtime === undefined ? {} : { runtime }), + }); + const before = yield* manager.list({ threadId: scope.thread.threadId }); + const error = yield* broker + .invoke({ + scope, + tabId: PreviewTabId.make(desktop.tabId), + operation: "open", + input: { url: "https://example.com/replacement", open: true }, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PreviewAutomationExecutionError" }); + expect(error.message).toContain("desktop runtime"); + expect(error.message).toContain("cannot control"); + expect(yield* manager.list({ threadId: scope.thread.threadId })).toEqual(before); + expect(contexts).toHaveLength(1); + // A rejected explicit target must not replace this session's working tab. + const reused = yield* broker.invoke({ + scope, + operation: "open", + input: { open: false }, + }); + expect(reused.tabId).toBe(tabId); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an unknown explicit target fails without creating a server tab", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + const manager = yield* Manager.PreviewManager; + const before = yield* manager.list({ threadId: scope.thread.threadId }); + const error = yield* broker + .invoke({ + scope, + tabId: PreviewTabId.make("missing-tab"), + operation: "open", + input: { url: "https://example.com/replacement", open: true }, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "PreviewAutomationTabNotFoundError", + tabId: "missing-tab", + }); + expect(yield* manager.list({ threadId: scope.thread.threadId })).toEqual(before); + expect(contexts).toHaveLength(1); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an explicit server target reuses its ID and navigates without creating a tab", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const before = yield* manager.list({ threadId: scope.thread.threadId }); + const opened = yield* broker.invoke({ + scope, + tabId, + operation: "open", + input: { url: "https://example.com/reused", open: false }, + }); + expect(opened).toMatchObject({ tabId, url: "https://example.com/reused" }); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.map((session) => session.tabId)).toEqual( + before.sessions.map((session) => session.tabId), + ); + expect(contexts).toHaveLength(1); + expect(contexts[0]!.page.goto).toHaveBeenCalledWith( + "https://example.com/reused", + expect.anything(), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an unresolved implicit target still falls back to a new server tab", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + yield* manager.close({ threadId: scope.thread.threadId, tabId }); + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/fallback", open: false }, + }); + expect(opened.tabId).not.toBe(tabId); + expect(opened).toMatchObject({ available: true, url: "https://example.com/fallback" }); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions).toEqual([ + expect.objectContaining({ tabId: opened.tabId, runtime: "server" }), + ]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("readiness none responds immediately but takeover input waits for navigation commit", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 5157574f90048..76f930ac379ec 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1711,6 +1711,20 @@ const make = Effect.gen(function* () { ), ) : undefined; + if (reuse && request.tabIdExplicit && !found) { + const { sessions } = await Effect.runPromise( + manager.list({ threadId: ThreadId.make(request.threadId) }), + ); + const desktop = sessions.some( + (session) => session.tabId === request.tabId && session.runtime !== "server", + ); + throw new ServerBrowserPage.ServerBrowserOperationError( + desktop ? "PreviewAutomationExecutionError" : "PreviewAutomationTabNotFoundError", + desktop + ? "This tab belongs to the desktop runtime. This environment's server browser cannot control desktop tabs. Omit tabId to open a server tab instead." + : "The requested server preview tab does not exist.", + ); + } // Only an explicit tabId reuses a tab this session did not open; a tab // it last read (such as the user's) is not taken over implicitly. const existing =