diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..caa8760baa053 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -85,7 +85,9 @@ const PNG_HEADER = (() => { function makeSession(frame?: ReturnType) { const events = new NodeEvents.EventEmitter(); + const metrics = { ratio: 2, width: 1280, height: 800 }; return { + metrics, on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), emit: (name: string) => events.emit(name), detach: vi.fn(async () => {}), @@ -100,13 +102,18 @@ function makeSession(frame?: ReturnType) { if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } }; if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { + const worldName = (input as { worldName?: string }).worldName; return { - executionContextId: (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, + executionContextId: + worldName === "t3-preview" ? 1 : worldName === "t3-preview-metrics" ? 9 : 7, }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { return { result: { value: [] } }; } + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) { + return { result: { value: metrics } }; + } if (method === "Page.addScriptToEvaluateOnNewDocument") frame?.recording.resolve(); if (method === "Input.dispatchKeyEvent" && (input as { commands?: unknown }).commands) { if (focusedFrame) focusedFrame.copy = keyCopy || focusedFrame.copy; @@ -159,7 +166,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { frames: () => [page], url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -627,6 +634,33 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); +it.live("sends desktop viewport dimensions when Playwright has no emulated viewport", () => + Effect.scoped( + Effect.gen(function* () { + desktopRendersNext = true; + const { browser, tabId } = yield* ready; + const { page } = desktopConnections[0]!.context; + page.viewportSize.mockReturnValue(null); + page.setViewportSize.mockClear(); + + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const outputs = yield* Queue.takeAll(viewer.output); + expect(outputs).toContainEqual({ _tag: "viewport", width: 1280, height: 800 }); + + const session = desktopConnections[0]!.context.sessions[0]!; + session.metrics.width = 960; + session.metrics.height = 600; + session.emit("Page.frameResized"); + expect(yield* Queue.take(viewer.output)).toEqual({ + _tag: "viewport", + width: 960, + height: 600, + }); + expect(page.setViewportSize).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index a2485b30e9e36..5792d552d769f 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -617,9 +617,12 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); - if (!size) return; + const broadcastViewport = async (tab: ServerTab) => { + let size = layoutSize(tab); + if (!size) { + const { width, height } = await ServerBrowserPage.readPageMetrics(tab.cdp); + size = { width, height }; + } for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -636,7 +639,7 @@ const make = Effect.gen(function* () { UNATTACHED_FILL_VIEWPORT; await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -649,7 +652,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -777,7 +780,12 @@ const make = Effect.gen(function* () { captureLock: Promise.resolve(), capturing: 0, }; - if (!desktop) { + if (desktop) { + cdp.on("Page.frameResized", () => { + if (tab.viewers.size > 0) void broadcastViewport(tab).catch(constVoid); + }); + await cdp.send("Page.enable"); + } else { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); @@ -2191,7 +2199,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); + await broadcastViewport(tab); return; } case "viewport": { @@ -2388,7 +2396,7 @@ const make = Effect.gen(function* () { ), }); }); - broadcastViewport(tab); + yield* Effect.promise(() => broadcastViewport(tab)); yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..934c0466f4f73 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -414,7 +414,7 @@ const pngSize = (base64: string) => { * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. */ -const readPageMetrics = async (cdp: CDPSession) => { +export const readPageMetrics = async (cdp: CDPSession) => { const { frameTree } = await cdp.send("Page.getFrameTree"); const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id,