diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 90e7add6229e..980946cb874f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -59,6 +59,7 @@ export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; @@ -80,6 +81,7 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL = "desktop:preview-automation-set-viewport"; export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..9683914bdb52 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -2,6 +2,7 @@ import { DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationSetViewportInputSchema, DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, @@ -163,6 +164,18 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +export const setViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -299,6 +312,19 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({ }), }); +export const automationSetViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationSetViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationSetViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); + export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, @@ -383,6 +409,7 @@ export const methods = [ hardReload, setColorScheme, setAudioMuted, + setViewport, openDevTools, clearCookies, clearCache, @@ -397,6 +424,7 @@ export const methods = [ closePictureInPicture, automationStatus, automationSnapshot, + automationSetViewport, automationClick, automationType, automationPress, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3e181e2ca698..3c733a3919b6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -220,6 +220,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), @@ -269,6 +274,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), snapshot: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), click: (tabId, input) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), type: (tabId, input) => diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..7e99b0353a6f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -321,6 +321,38 @@ const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service" }; }); +const makeViewportWebContents = ( + id: number, + sendCommand: (method: string, params?: Record) => Promise, + isDevToolsOpened: () => boolean = () => false, +): Electron.WebContents => + ({ + id, + isDestroyed: () => false, + isDevToolsOpened, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }) as never; + const TEST_FAVICON = "data:image/png;base64,cG5n"; const makeSourcePng = (width = 1, height = 1): Buffer => { @@ -1698,6 +1730,817 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("applies logical viewport sizes without mobile emulation or a DPR override", () => + withManager((manager) => + Effect.gen(function* () { + const sendCommand = vi.fn(async () => undefined); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const controllers: Array = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + yield* manager.createTab("tab_viewport", { zoomFactor: 0.5 }); + yield* manager.registerWebview("tab_viewport", 42); + yield* manager.setViewport("tab_viewport", { width: 1024, height: 768 }); + yield* manager.setViewport("tab_viewport", { width: 844, height: 390 }); + yield* manager.setViewport("tab_viewport", { clear: true }); + + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 512, + height: 384, + deviceScaleFactor: 0, + mobile: false, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 422, + height: 195, + deviceScaleFactor: 0, + mobile: false, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); + expect(controllers).not.toContain("agent"); + }), + ), + ); + + effectIt.effect("does not retry or restore viewport intents rejected by the guest", () => + withManager((manager) => + Effect.gen(function* () { + const rejectedSendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.setDeviceMetricsOverride") { + throw new Error("guest refused viewport"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, rejectedSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_rejected"); + yield* manager.registerWebview("tab_viewport_rejected", 42); + yield* Effect.yieldNow; + + const humanExit = yield* manager + .setViewport("tab_viewport_rejected", { width: 1024, height: 768 }) + .pipe(Effect.exit); + const automationExit = yield* manager + .automationSetViewport("tab_viewport_rejected", { width: 844, height: 390 }) + .pipe(Effect.exit); + + expect(Exit.isFailure(humanExit)).toBe(true); + expect(Exit.isFailure(automationExit)).toBe(true); + expect( + rejectedSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ).toHaveLength(2); + + yield* manager.registerWebview("tab_viewport_rejected", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ); + expect( + replacementSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ).toHaveLength(0); + }), + ), + ); + + effectIt.effect("restores the prior viewport after a newer native apply fails", () => + withManager((manager) => + Effect.gen(function* () { + let rejectViewport = false; + const firstSendCommand = vi.fn( + async (method: string, _params?: Record) => { + if (rejectViewport && method === "Emulation.setDeviceMetricsOverride") { + throw new Error("guest refused viewport"); + } + }, + ); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_prior"); + yield* manager.registerWebview("tab_viewport_prior", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_prior", { width: 390, height: 844 }); + + rejectViewport = true; + const exit = yield* manager + .setViewport("tab_viewport_prior", { width: 1024, height: 768 }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_prior", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 390, height: 844 }), + ); + }), + ), + ); + + effectIt.effect("restores the prior viewport when automation control setup fails", () => + withManager((manager) => + Effect.gen(function* () { + const firstSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const blockedSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const finalSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const blocked = makeViewportWebContents(43, blockedSendCommand, () => true); + const final = makeViewportWebContents(44, finalSendCommand); + fromId.mockImplementation((id) => { + if (id === 42) return first as never; + if (id === 43) return blocked as never; + if (id === 44) return final as never; + return null; + }); + + yield* manager.createTab("tab_viewport_setup_failure"); + yield* manager.registerWebview("tab_viewport_setup_failure", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_setup_failure", { width: 390, height: 844 }); + yield* manager.registerWebview("tab_viewport_setup_failure", 43); + yield* Effect.yieldNow; + + const exit = yield* manager + .automationSetViewport("tab_viewport_setup_failure", { width: 1024, height: 768 }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_setup_failure", 44); + yield* settle(() => + finalSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect(finalSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 390, height: 844 }), + ); + }), + ), + ); + + effectIt.effect("keeps a newer viewport intent when an older apply fails", () => + withManager((manager) => + Effect.gen(function* () { + const olderApplyStarted = Promise.withResolvers(); + const releaseOlderApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + olderApplyStarted.resolve(); + await releaseOlderApply.promise; + throw new Error("older viewport failed"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_newer_intent"); + yield* manager.registerWebview("tab_viewport_newer_intent", 42); + yield* Effect.yieldNow; + + const older = yield* manager + .setViewport("tab_viewport_newer_intent", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => olderApplyStarted.promise); + yield* manager.setViewport("tab_viewport_newer_intent", { width: 1440, height: 900 }); + releaseOlderApply.resolve(); + + const olderExit = yield* Fiber.await(older); + expect(Exit.isFailure(olderExit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_newer_intent", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 1440, + ), + ); + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 1440, height: 900 }), + ); + }), + ), + ); + + effectIt.effect("skips rejected prior intents when overlapping viewport applies fail", () => + withManager((manager) => + Effect.gen(function* () { + const firstApplyStarted = Promise.withResolvers(); + const secondApplyStarted = Promise.withResolvers(); + const releaseFirstApply = Promise.withResolvers(); + const releaseSecondApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + firstApplyStarted.resolve(); + await releaseFirstApply.promise; + throw new Error("first viewport failed"); + } + if (params?.width === 1440) { + secondApplyStarted.resolve(); + await releaseSecondApply.promise; + throw new Error("second viewport failed"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_overlapping_failures"); + yield* manager.registerWebview("tab_viewport_overlapping_failures", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_overlapping_failures", { + width: 390, + height: 844, + }); + + const firstApply = yield* manager + .setViewport("tab_viewport_overlapping_failures", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => firstApplyStarted.promise); + const secondApply = yield* manager + .setViewport("tab_viewport_overlapping_failures", { width: 1440, height: 900 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => secondApplyStarted.promise); + + releaseFirstApply.resolve(); + const firstExit = yield* Fiber.await(firstApply); + expect(Exit.isFailure(firstExit)).toBe(true); + releaseSecondApply.resolve(); + const secondExit = yield* Fiber.await(secondApply); + expect(Exit.isFailure(secondExit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_overlapping_failures", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect( + replacementSendCommand.mock.calls + .filter(([method]) => method === "Emulation.setDeviceMetricsOverride") + .map(([, params]) => params?.width), + ).toEqual([390]); + }), + ), + ); + + effectIt.effect( + "restores a successful overlapping predecessor when the newer viewport fails", + () => + withManager((manager) => + Effect.gen(function* () { + const priorApplyStarted = Promise.withResolvers(); + const newerApplyStarted = Promise.withResolvers(); + const releasePriorApply = Promise.withResolvers(); + const releaseNewerApply = Promise.withResolvers(); + let newerApplyCount = 0; + const firstSendCommand = vi.fn( + async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + priorApplyStarted.resolve(); + await releasePriorApply.promise; + } + if (params?.width === 1440) { + newerApplyCount += 1; + if (newerApplyCount === 1) { + newerApplyStarted.resolve(); + await releaseNewerApply.promise; + throw new Error("newer viewport failed"); + } + } + }, + ); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_successful_predecessor"); + yield* manager.registerWebview("tab_viewport_successful_predecessor", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_successful_predecessor", { + width: 390, + height: 844, + }); + + const priorApply = yield* manager + .setViewport("tab_viewport_successful_predecessor", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => priorApplyStarted.promise); + const newerApply = yield* manager + .setViewport("tab_viewport_successful_predecessor", { width: 1440, height: 900 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => newerApplyStarted.promise); + + releasePriorApply.resolve(); + const priorExit = yield* Fiber.await(priorApply); + expect(Exit.isSuccess(priorExit)).toBe(true); + releaseNewerApply.resolve(); + const newerExit = yield* Fiber.await(newerApply); + expect(Exit.isFailure(newerExit)).toBe(true); + + const liveViewportCalls = firstSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ); + expect(liveViewportCalls.at(-1)?.[1]).toMatchObject({ width: 1024, height: 768 }); + + yield* manager.registerWebview("tab_viewport_successful_predecessor", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 1024, + ), + ); + expect( + replacementSendCommand.mock.calls + .filter(([method]) => method === "Emulation.setDeviceMetricsOverride") + .map(([, params]) => params?.width), + ).toEqual([1024]); + }), + ), + ); + + effectIt.effect("converts logical viewport sizes at each supported test zoom", () => + withManager((manager) => + Effect.gen(function* () { + const cases = [ + { tabId: "tab_zoom_50", webContentsId: 50, zoomFactor: 0.5, width: 512, height: 384 }, + { tabId: "tab_zoom_80", webContentsId: 80, zoomFactor: 0.8, width: 819, height: 614 }, + { tabId: "tab_zoom_100", webContentsId: 100, zoomFactor: 1, width: 1024, height: 768 }, + { + tabId: "tab_zoom_125", + webContentsId: 125, + zoomFactor: 1.25, + width: 1280, + height: 960, + }, + ] as const; + const commandsById = new Map( + cases.map( + ({ webContentsId }) => + [ + webContentsId, + vi.fn(async (_method: string, _params?: Record) => undefined), + ] as const, + ), + ); + const webContentsById = new Map(); + for (const { webContentsId } of cases) { + webContentsById.set(webContentsId, { + id: webContentsId, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: commandsById.get(webContentsId), + on: vi.fn(), + off: vi.fn(), + }, + }); + } + fromId.mockImplementation( + (id) => (id === undefined ? null : (webContentsById.get(id) ?? null)) as never, + ); + + for (const testCase of cases) { + yield* manager.createTab(testCase.tabId, { zoomFactor: testCase.zoomFactor }); + yield* manager.registerWebview(testCase.tabId, testCase.webContentsId); + yield* manager.automationSetViewport(testCase.tabId, { width: 1024, height: 768 }); + const viewportCalls = commandsById + .get(testCase.webContentsId) + ?.mock.calls.filter(([method]) => method.includes("DeviceMetrics")); + const expectedCall = [ + "Emulation.setDeviceMetricsOverride", + { + width: testCase.width, + height: testCase.height, + deviceScaleFactor: 0, + mobile: false, + }, + ]; + expect(viewportCalls?.length).toBeGreaterThanOrEqual(1); + for (const call of viewportCalls ?? []) { + expect(call).toEqual(expectedCall); + } + } + }), + ), + ); + + effectIt.effect("restores the latest viewport when a webview swap races a newer setting", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = ( + id: number, + sendCommand: ( + method: string, + params?: Record, + ) => Promise = vi.fn(async () => undefined), + ) => { + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + + yield* manager.createTab("tab_viewport_restore"); + yield* manager.registerWebview("tab_viewport_restore", 42); + yield* manager.setViewport("tab_viewport_restore", { width: 390, height: 844 }); + + const restoreStarted = Promise.withResolvers(); + const releaseRestore = Promise.withResolvers(); + const appliedWidths: number[] = []; + let holdRestore = true; + const replacementSendCommand = vi.fn( + async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (holdRestore) { + holdRestore = false; + restoreStarted.resolve(); + await releaseRestore.promise; + } + if (typeof params?.width === "number") appliedWidths.push(params.width); + }, + ); + const replacement = makeWebContents(43, replacementSendCommand); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_viewport_restore", 43); + yield* Effect.promise(() => restoreStarted.promise); + yield* manager.setViewport("tab_viewport_restore", { width: 1024, height: 768 }); + releaseRestore.resolve(); + yield* settle(() => appliedWidths.at(-1) === 1024 && appliedWidths.length >= 3); + + expect(appliedWidths).toEqual([1024, 390, 1024]); + }), + ), + ); + + effectIt.effect("retries a viewport intent when its captured webview is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const applyStarted = Promise.withResolvers(); + const releaseApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + applyStarted.resolve(); + await releaseApply.promise; + throw new Error("replaced guest rejected viewport"); + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_replaced_apply"); + yield* manager.registerWebview("tab_viewport_replaced_apply", 42); + yield* Effect.yieldNow; + + const setter = yield* manager + .setViewport("tab_viewport_replaced_apply", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => applyStarted.promise); + yield* manager.registerWebview("tab_viewport_replaced_apply", 43); + releaseApply.resolve(); + yield* Fiber.join(setter); + + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 1024, height: 768 }), + ); + }), + ), + ); + + effectIt.effect("clears a rejected viewport after a concurrent session restore", () => + withManager((manager) => + Effect.gen(function* () { + const initialApplyStarted = Promise.withResolvers(); + const releaseInitialApply = Promise.withResolvers(); + const restoreApplyStarted = Promise.withResolvers(); + const releaseRestoreApply = Promise.withResolvers(); + const firstClearApplied = Promise.withResolvers(); + const secondClearApplied = Promise.withResolvers(); + let viewportApplyCount = 0; + let viewportClearCount = 0; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.clearDeviceMetricsOverride") { + viewportClearCount += 1; + if (viewportClearCount === 1) firstClearApplied.resolve(); + if (viewportClearCount === 2) secondClearApplied.resolve(); + return; + } + if (method !== "Emulation.setDeviceMetricsOverride") return; + viewportApplyCount += 1; + if (viewportApplyCount === 1) { + initialApplyStarted.resolve(); + await releaseInitialApply.promise; + throw new Error("guest rejected viewport"); + } + if (viewportApplyCount === 2) { + restoreApplyStarted.resolve(); + await releaseRestoreApply.promise; + } + }); + let onDevToolsClosed: (() => void) | undefined; + const wc = Object.assign(makeViewportWebContents(42, sendCommand), { + once: vi.fn((event: string, listener: () => void) => { + if (event === "devtools-closed") onDevToolsClosed = listener; + }), + openDevTools: vi.fn(), + }); + fromId.mockReturnValue(wc); + + yield* manager.createTab("tab_viewport_rejected_restore"); + yield* manager.registerWebview("tab_viewport_rejected_restore", 42); + yield* Effect.yieldNow; + + const setter = yield* manager + .setViewport("tab_viewport_rejected_restore", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => initialApplyStarted.promise); + yield* manager.openDevTools("tab_viewport_rejected_restore"); + expect(onDevToolsClosed).toBeDefined(); + onDevToolsClosed?.(); + yield* Effect.promise(() => restoreApplyStarted.promise); + + releaseInitialApply.resolve(); + const setterExit = yield* Fiber.await(setter); + expect(Exit.isFailure(setterExit)).toBe(true); + yield* Effect.promise(() => firstClearApplied.promise); + + releaseRestoreApply.resolve(); + yield* Effect.promise(() => secondClearApplied.promise); + const viewportCalls = sendCommand.mock.calls.filter(([method]) => + method.includes("DeviceMetricsOverride"), + ); + expect(viewportCalls.at(-1)?.[0]).toBe("Emulation.clearDeviceMetricsOverride"); + }), + ), + ); + + effectIt.effect("restores a newer human viewport after a queued agent resize", () => + withManager((manager) => + Effect.gen(function* () { + const evaluationStarted = Promise.withResolvers(); + const releaseEvaluation = Promise.withResolvers(); + const sendCommand = vi.fn( + async (method: string, params?: Record): Promise => { + if (method === "Runtime.evaluate" && params?.expression === "holdViewportQueue") { + evaluationStarted.resolve(); + return releaseEvaluation.promise; + } + return { result: { value: null } }; + }, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_viewport_order"); + yield* manager.registerWebview("tab_viewport_order", 42); + const evaluation = yield* manager + .automationEvaluate("tab_viewport_order", { expression: "holdViewportQueue" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => evaluationStarted.promise); + const resize = yield* manager + .automationSetViewport("tab_viewport_order", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* manager.setViewport("tab_viewport_order", { width: 1440, height: 900 }); + releaseEvaluation.resolve({ result: { value: null } }); + yield* Fiber.join(evaluation); + yield* Fiber.join(resize); + + const viewportCalls = sendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ); + expect(viewportCalls.at(-1)?.[1]).toMatchObject({ width: 1440, height: 900 }); + }), + ), + ); + + effectIt.effect("drops a viewport intent when its tab closes during native apply", () => + withManager((manager) => + Effect.gen(function* () { + const applyStarted = Promise.withResolvers(); + const releaseApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.setDeviceMetricsOverride") { + applyStarted.resolve(); + await releaseApply.promise; + } + }); + const replacementSendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + sendCommand: (method: string, params?: Record) => Promise, + ) => + ({ + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }) as never; + const first = makeWebContents(42, firstSendCommand); + const replacement = makeWebContents(43, replacementSendCommand); + fromId.mockImplementation((id) => (id === 42 ? first : id === 43 ? replacement : null)); + + yield* manager.createTab("tab_viewport_close"); + yield* manager.registerWebview("tab_viewport_close", 42); + const setter = yield* manager + .setViewport("tab_viewport_close", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => applyStarted.promise); + yield* manager.closeTab("tab_viewport_close"); + releaseApply.resolve(); + + const exit = yield* Fiber.await(setter); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + }); + } + + yield* manager.createTab("tab_viewport_close"); + yield* manager.registerWebview("tab_viewport_close", 43); + yield* Effect.yieldNow; + expect(replacementSendCommand).not.toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.anything(), + ); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8ee312110d86..bd7e94f280a4 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -98,6 +98,14 @@ export interface PreviewTabState { updatedAt: string; } +type PreviewViewportOverride = + | { readonly width: number; readonly height: number } + | { readonly clear: true }; + +interface PreviewViewportIntent { + readonly input: PreviewViewportOverride; +} + /** Discrete zoom levels mirroring Chrome's preset list. */ const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, @@ -573,6 +581,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); + const viewportOverridesRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const viewportIntentPredecessors = new WeakMap< + PreviewViewportIntent, + PreviewViewportIntent | undefined + >(); + const rejectedViewportIntents = new WeakSet(); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); @@ -1991,6 +2007,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(tab)) return; + yield* SynchronizedRef.update(viewportOverridesRef, (overrides) => + replaceMap(overrides, (copy) => { + copy.delete(tabId); + }), + ); const closedTab = tab.value; if (closedTab.webContentsId != null) { yield* Effect.all( @@ -2527,10 +2548,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Re-establish the control session after a detach, restoring any - // color-scheme override the tab carries. The scheme is read after the - // session attaches so a concurrent setColorScheme is not overwritten with - // a stale snapshot. + // Re-establish the control session after a detach, restoring color-scheme + // and viewport overrides the tab carries. Both live on the CDP debugger + // session, so they are lost on webview swap and DevTools open/close. + // Values are read after attach so a concurrent setColorScheme/setViewport + // is not overwritten with a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -2553,6 +2575,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } + const viewportIntent = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (viewportIntent) { + yield* settleViewportIntent( + tabId, + wc, + viewportIntent, + tabLifecycleGenerations.get(tabId), + (input) => applyViewportOverride(tabId, wc, input), + ); + } }).pipe(Effect.ignore); const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( @@ -2611,6 +2643,228 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const deviceMetricsOverride = Effect.fnUntraced(function* ( + tabId: string, + input: { readonly width: number; readonly height: number }, + ) { + const zoomFactor = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.zoomFactor ?? 1; + return { + width: Math.max(1, Math.round(input.width * zoomFactor)), + height: Math.max(1, Math.round(input.height * zoomFactor)), + // Zero leaves Chromium's current device scale factor unchanged. + deviceScaleFactor: 0, + // A fixed CSS viewport is not full mobile-device emulation. Setting this + // true also changes Chromium's layout viewport when the page has no + // viewport meta tag. + mobile: false, + }; + }); + + const rememberViewportOverride = (tabId: string, input: PreviewViewportOverride) => + SynchronizedRef.modify(viewportOverridesRef, (overrides) => { + const intent: PreviewViewportIntent = { input }; + viewportIntentPredecessors.set(intent, overrides.get(tabId)); + return [ + intent, + replaceMap(overrides, (copy) => { + copy.set(tabId, intent); + }), + ] as const; + }); + + const restorePreviousViewportOverride = (tabId: string, intent: PreviewViewportIntent) => + SynchronizedRef.modify(viewportOverridesRef, (overrides) => { + rejectedViewportIntents.add(intent); + if (overrides.get(tabId) !== intent) return [false, overrides] as const; + let previousIntent = viewportIntentPredecessors.get(intent); + while (previousIntent && rejectedViewportIntents.has(previousIntent)) { + previousIntent = viewportIntentPredecessors.get(previousIntent); + } + return [ + true, + replaceMap(overrides, (copy) => { + if (previousIntent) copy.set(tabId, previousIntent); + else copy.delete(tabId); + }), + ] as const; + }); + + const acceptViewportOverride = (intent: PreviewViewportIntent) => + Effect.sync(() => { + viewportIntentPredecessors.delete(intent); + }); + + const prepareViewportIntent = (tabId: string, input: PreviewViewportOverride) => + withTabLifecycleLock( + tabId, + Effect.gen(function* () { + if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const wc = yield* requireWebContents(tabId); + const intent = yield* rememberViewportOverride(tabId, input); + return { + wc, + intent, + generation: tabLifecycleGenerations.get(tabId), + }; + }), + ); + + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( + tabId: string, + wc: Electron.WebContents, + input: PreviewViewportOverride, + ) { + yield* ensureControlSession(wc); + if ("clear" in input) { + yield* attemptPromise( + { operation: "applyViewportOverride", tabId, webContentsId: wc.id }, + () => wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride"), + ); + return; + } + const metrics = yield* deviceMetricsOverride(tabId, input); + yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", metrics), + ); + }); + + const reconcileViewportRollback = Effect.fnUntraced(function* ( + tabId: string, + generation: number | undefined, + ) { + let applied: PreviewViewportIntent | undefined; + let appliedWebContents: Electron.WebContents | undefined; + while (true) { + if ( + tabLifecycleGenerations.get(tabId) !== generation || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return; + } + const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!current || current.webContentsId == null) return; + const currentWebContents = webContents.fromId(current.webContentsId); + if (!currentWebContents || currentWebContents.isDestroyed()) return; + if (latest === applied && currentWebContents === appliedWebContents) return; + yield* applyViewportOverride(tabId, currentWebContents, latest?.input ?? { clear: true }); + applied = latest; + appliedWebContents = currentWebContents; + } + }); + + const rollbackViewportOverride = ( + tabId: string, + intent: PreviewViewportIntent, + generation: number | undefined, + ) => + Effect.gen(function* () { + const restored = yield* restorePreviousViewportOverride(tabId, intent); + if (restored) yield* reconcileViewportRollback(tabId, generation); + }).pipe(Effect.ignore); + + const currentViewportTarget = Effect.fnUntraced(function* ( + tabId: string, + generation: number | undefined, + ) { + if (tabLifecycleGenerations.get(tabId) !== generation) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!current) return yield* new PreviewTabNotFoundError({ tabId }); + if (current.webContentsId == null) { + return yield* new PreviewWebviewNotInitializedError({ tabId }); + } + const currentWebContents = webContents.fromId(current.webContentsId); + if (!currentWebContents || currentWebContents.isDestroyed()) { + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: current.webContentsId, + }); + } + return { + intent: (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId), + wc: currentWebContents, + }; + }); + + const settleViewportIntent = Effect.fnUntraced(function* ( + tabId: string, + wc: Electron.WebContents, + intent: PreviewViewportIntent, + generation: number | undefined, + applyInitial: (input: PreviewViewportOverride) => Effect.Effect, + ) { + const initialExit = yield* Effect.exit(applyInitial(intent.input)); + if (Exit.isFailure(initialExit)) { + const current = yield* currentViewportTarget(tabId, generation); + if (current.intent !== intent || current.wc === wc) { + return yield* Effect.failCause(initialExit.cause); + } + } + + let applied = Exit.isSuccess(initialExit) ? intent : undefined; + let appliedWebContents = Exit.isSuccess(initialExit) ? wc : undefined; + while (true) { + const current = yield* currentViewportTarget(tabId, generation); + if (current.intent === applied && current.wc === appliedWebContents) return; + + const applyExit = yield* Effect.exit( + applyViewportOverride(tabId, current.wc, current.intent?.input ?? { clear: true }), + ); + if (Exit.isFailure(applyExit)) { + const afterFailure = yield* currentViewportTarget(tabId, generation); + if (afterFailure.intent !== current.intent || afterFailure.wc === current.wc) { + return yield* Effect.failCause(applyExit.cause); + } + continue; + } + applied = current.intent; + appliedWebContents = current.wc; + } + }); + + // Human/toolbar path. Must not take agent control or write a resize action. + const setViewport = Effect.fn("PreviewManager.setViewport")(function* ( + tabId: string, + input: PreviewViewportOverride, + ) { + const prepared = yield* prepareViewportIntent(tabId, input); + yield* settleViewportIntent( + tabId, + prepared.wc, + prepared.intent, + prepared.generation, + (latest) => applyViewportOverride(tabId, prepared.wc, latest), + ).pipe( + Effect.tap(() => acceptViewportOverride(prepared.intent)), + Effect.onError(() => rollbackViewportOverride(tabId, prepared.intent, prepared.generation)), + ); + }); + + const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( + tabId: string, + input: PreviewViewportOverride, + ) { + const prepared = yield* prepareViewportIntent(tabId, input); + yield* withControlSession(tabId, prepared.wc, "resize", (send) => + settleViewportIntent(tabId, prepared.wc, prepared.intent, prepared.generation, (latest) => { + if ("clear" in latest) { + return send("Emulation.clearDeviceMetricsOverride").pipe(Effect.asVoid); + } + return deviceMetricsOverride(tabId, latest).pipe( + Effect.flatMap((metrics) => send("Emulation.setDeviceMetricsOverride", metrics)), + Effect.asVoid, + ); + }), + ).pipe( + Effect.tap(() => acceptViewportOverride(prepared.intent)), + Effect.onError(() => rollbackViewportOverride(tabId, prepared.intent, prepared.generation)), + ); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -4096,6 +4350,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + setViewport, + automationSetViewport, setMainWindow, startRecording, closePictureInPicture, @@ -4439,6 +4695,14 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; + readonly automationSetViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -4538,6 +4802,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, + setViewport: operations.setViewport, + automationSetViewport: operations.automationSetViewport, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 8b3dabfa3386..1f0403349cdd 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -232,6 +232,90 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("rejects a stale viewport rollback after the setting changes away and back", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const opened = yield* manager.open({ threadId }); + const requested = { _tag: "freeform" as const, width: 900, height: 600 }; + const intermediate = { _tag: "freeform" as const, width: 1024, height: 768 }; + + const firstWrite = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: requested, + }); + yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: intermediate, + }); + const latestWrite = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: requested, + }); + yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/after-resize", + }); + const firstVersion = firstWrite.stateVersion; + const latestVersion = latestWrite.stateVersion; + expect(firstVersion).toBeDefined(); + expect(latestVersion).toBeDefined(); + expect(firstWrite.previousViewport).toEqual({ _tag: "fill" }); + expect(latestWrite.previousViewport).toEqual(intermediate); + if (!firstVersion || !latestVersion) return; + + const writeEvents = (yield* collector.drain).filter((event) => event.type === "resized"); + expect(firstVersion.revision).toBe(writeEvents[0]?.revision); + expect(latestVersion.revision).toBe(writeEvents[2]?.revision); + const beforeConflict = yield* manager.list({ threadId }); + const error = yield* Effect.flip( + manager.resize({ + threadId, + tabId: opened.tabId, + viewport: { _tag: "fill" }, + expectedStateVersion: firstVersion, + }), + ); + + expect(error).toMatchObject({ + _tag: "PreviewResizeConflictError", + expectedStateVersion: firstVersion, + actualStateVersion: latestVersion, + }); + const afterConflict = yield* manager.list({ threadId }); + expect(afterConflict.revision).toBe(beforeConflict.revision); + expect(afterConflict.sessions[0]?.viewport).toEqual(requested); + expect(yield* collector.drain).toEqual([]); + + const epochError = yield* Effect.flip( + manager.resize({ + threadId, + tabId: opened.tabId, + viewport: { _tag: "fill" }, + expectedStateVersion: { ...latestVersion, serverEpoch: "restarted-server" }, + }), + ); + expect(epochError._tag).toBe("PreviewResizeConflictError"); + expect((yield* manager.list({ threadId })).revision).toBe(beforeConflict.revision); + expect((yield* manager.list({ threadId })).sessions[0]?.viewport).toEqual(requested); + expect(yield* collector.drain).toEqual([]); + + const rollback = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: latestWrite.previousViewport ?? { _tag: "fill" }, + expectedStateVersion: latestVersion, + }); + expect(rollback.viewport).toEqual(intermediate); + expect(rollback.stateVersion?.revision).toBe(beforeConflict.revision + 1); + }), + ); + it.effect("reportStatus emits failed for LoadFailed nav", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 09bbe0a41c76..a80aa9518b21 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -21,6 +21,8 @@ import { type PreviewRefreshInput, type PreviewReportStatusInput, type PreviewResizeInput, + PreviewResizeConflictError, + type PreviewResizeResult, FILL_PREVIEW_VIEWPORT, PreviewSessionLookupError, type PreviewSessionSnapshot, @@ -51,7 +53,7 @@ export class PreviewManager extends Context.Service< readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; readonly resize: ( input: PreviewResizeInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; readonly close: (input: PreviewCloseInput) => Effect.Effect; readonly list: (input: PreviewListInput) => Effect.Effect; @@ -64,6 +66,7 @@ interface PreviewSessionState { readonly threadId: string; readonly tabId: string; readonly snapshot: PreviewSessionSnapshot; + readonly viewportRevision: number; } interface ManagerState { @@ -173,6 +176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId: string, mutator: ( session: PreviewSessionState, + currentRevision: number, ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>, ): Effect.Effect => { type ModifyResult = @@ -187,7 +191,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { state, ] as readonly [ModifyResult, ManagerState]); } - return mutator(session).pipe( + return mutator(session, state.revision).pipe( Effect.flatMap( Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { const revision = emit ? state.revision + 1 : state.revision; @@ -240,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { threadId: input.threadId, tabId, snapshot, + viewportRevision: revision, }); yield* PubSub.publish(eventsPubSub, { type: "opened", @@ -343,15 +348,38 @@ export const make = Effect.gen(function* PreviewManagerMake() { return yield* mutateExistingSession( input.threadId, input.tabId, - Effect.fn("PreviewManager.resizeSession")(function* (session) { + Effect.fn("PreviewManager.resizeSession")(function* (session, currentRevision) { + const actualStateVersion = { + serverEpoch, + revision: session.viewportRevision, + } as const; + if ( + input.expectedStateVersion && + (input.expectedStateVersion.serverEpoch !== actualStateVersion.serverEpoch || + input.expectedStateVersion.revision !== actualStateVersion.revision) + ) { + return yield* new PreviewResizeConflictError({ + threadId: input.threadId, + tabId: input.tabId, + expectedStateVersion: input.expectedStateVersion, + actualStateVersion, + }); + } const updatedAt = yield* currentIsoTimestamp; + const revision = currentRevision + 1; + const previousViewport = session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; const snapshot: PreviewSessionSnapshot = { ...session.snapshot, viewport: input.viewport, updatedAt, }; + const result: PreviewResizeResult = { + ...snapshot, + stateVersion: { serverEpoch, revision }, + previousViewport, + }; return { - next: { ...session, snapshot }, + next: { ...session, snapshot, viewportRevision: revision }, emit: { type: "resized", threadId: session.threadId, @@ -359,7 +387,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { createdAt: snapshot.updatedAt, snapshot, }, - result: snapshot, + result, }; }), ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 77c65264aa94..5fac964d2b66 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -5,8 +5,10 @@ import { useShallow } from "zustand/react/shallow"; import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; +import { applyPreviewGuestViewport } from "~/components/preview/previewGuestViewport"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn, isMacPlatform } from "~/lib/utils"; +import { useThreadPreviewState } from "~/previewStateStore"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; @@ -58,6 +60,7 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); + const guestViewportRef = useRef(viewport); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -78,6 +81,8 @@ export function HostedBrowserWebview(props: { ); const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); usePreviewBridge({ threadRef, tabId, runtimeTabId }); + const hasWebContents = + useThreadPreviewState(threadRef).desktopByTabId[tabId]?.hasWebContents === true; useEffect(() => { crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; @@ -120,6 +125,15 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + if (disposed || webviewRef.current !== webview) return; + const setViewport = previewBridge?.setViewport; + if (setViewport) { + await applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + ).catch(() => undefined); + } } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. @@ -197,6 +211,18 @@ export function HostedBrowserWebview(props: { deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const guestViewportKey = browserViewportSettingKey(effectiveViewport); + guestViewportRef.current = effectiveViewport; + useEffect(() => { + const setViewport = previewBridge?.setViewport; + if (!setViewport || !hasWebContents) return; + const frame = window.requestAnimationFrame(() => { + void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( + () => undefined, + ); + }); + return () => window.cancelAnimationFrame(frame); + }, [guestViewportKey, hasWebContents, runtimeTabId, normalizedZoomFactor]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index a7633fd0963d..fb7731a01a39 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -99,7 +99,7 @@ describe("browserViewportActions", () => { unsubscribe(); }); - it("does not let a timed-out handler overtake a newer viewport commit", async () => { + it("releases a newer viewport commit when an earlier handler times out", async () => { vi.useFakeTimers(); try { let releaseFirst: (() => void) | undefined; @@ -117,24 +117,103 @@ describe("browserViewportActions", () => { const firstResult = expect(first).rejects.toThrow( "Timed out committing the browser viewport for tab tab-timeout", ); + + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS - 1); + expect(handler).toHaveBeenCalledTimes(1); + const second = commitBrowserViewportChange("tab-timeout", { _tag: "freeform", width: 900, height: 700, }); - - await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(1); await firstResult; - expect(handler).toHaveBeenCalledTimes(1); - - releaseFirst?.(); await second; expect(handler).toHaveBeenCalledTimes(2); expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); + releaseFirst?.(); + await Promise.resolve(); + + await commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 1_000, + height: 800, + }); + expect(handler).toHaveBeenCalledTimes(3); + expect(handler.mock.calls[2]?.[0]).toMatchObject({ width: 1_000, height: 800 }); + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + + it("expires a queued commit before its handler can write", async () => { + vi.useFakeTimers(); + try { + let releaseBackground: (() => void) | undefined; + const backgroundPending = new Promise((resolve) => { + releaseBackground = resolve; + }); + const background = runBrowserViewportMutation("tab-queued-timeout", () => backgroundPending); + const handler = vi.fn(async () => undefined); + const unsubscribe = subscribeBrowserViewportChange("tab-queued-timeout", handler); + const commit = commitBrowserViewportChange("tab-queued-timeout", { + _tag: "freeform", + width: 900, + height: 700, + }); + const result = expect(commit).rejects.toThrow( + "Timed out committing the browser viewport for tab tab-queued-timeout", + ); + + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); + await result; + releaseBackground?.(); + await background; + await vi.runAllTimersAsync(); + + expect(handler).not.toHaveBeenCalled(); unsubscribe(); } finally { vi.useRealTimers(); } }); + + it("releases the queue when an in-flight background mutation reaches its deadline", async () => { + vi.useFakeTimers(); + try { + let releaseFirst: (() => void) | undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const calls: string[] = []; + const timeoutError = () => new Error("background viewport mutation expired"); + const first = runBrowserViewportMutation( + "tab-background-deadline", + async () => { + calls.push("first"); + await firstPending; + }, + { + deadlineAt: Date.now() + 1_000, + timeoutError, + }, + ); + const firstResult = expect(first).rejects.toThrow("background viewport mutation expired"); + const second = runBrowserViewportMutation("tab-background-deadline", async () => { + calls.push("second"); + }); + + await vi.advanceTimersByTimeAsync(1_000); + await firstResult; + await second; + expect(calls).toEqual(["first", "second"]); + + releaseFirst?.(); + await vi.runAllTimersAsync(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..ceb7c4d4d66f 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -2,6 +2,11 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; +interface BrowserViewportMutationDeadline { + readonly deadlineAt: number; + readonly timeoutError: () => Error; +} + export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; export class BrowserViewportCommitTimeoutError extends Error { @@ -15,9 +20,26 @@ export class BrowserViewportCommitTimeoutError extends Error { const handlers = new Map(); const commitTails = new Map>(); +const runOperationBeforeDeadline = ( + operation: Promise, + deadline: BrowserViewportMutationDeadline, +): Promise => { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(deadline.timeoutError()), + Math.max(0, deadline.deadlineAt - Date.now()), + ); + }); + return Promise.race([operation, timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +}; + const queueBrowserViewportMutation = ( tabId: string, start: () => Promise, + deadline?: BrowserViewportMutationDeadline, ): { readonly started: Promise<{ readonly operation: Promise }>; readonly execution: Promise; @@ -26,10 +48,21 @@ const queueBrowserViewportMutation = ( const started = previous .catch(() => undefined) .then(() => ({ - operation: Promise.resolve().then(start), + operation: + deadline && Date.now() >= deadline.deadlineAt + ? Promise.reject(deadline.timeoutError()) + : Promise.resolve().then(start), })); - const execution = started.then(({ operation }) => operation); - const tail = execution.then(() => undefined); + const operation = started.then(({ operation: startedOperation }) => startedOperation); + const execution = deadline ? runOperationBeforeDeadline(operation, deadline) : operation; + // If this mutation reached the front, its deadline releases the queue even + // when the underlying request never settles. A mutation that expires while + // waiting still follows the prior tail and cannot overtake it. + const tail = started + .then(({ operation: startedOperation }) => + deadline ? runOperationBeforeDeadline(startedOperation, deadline) : startedOperation, + ) + .then(() => undefined); commitTails.set(tabId, tail); const clear = () => { if (commitTails.get(tabId) === tail) commitTails.delete(tabId); @@ -46,23 +79,11 @@ const queueBrowserViewportMutation = ( export function runBrowserViewportMutation( tabId: string, mutation: () => Promise, + deadline?: BrowserViewportMutationDeadline, ): Promise { - return queueBrowserViewportMutation(tabId, mutation).execution; + return queueBrowserViewportMutation(tabId, mutation, deadline).execution; } -const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout( - () => reject(new BrowserViewportCommitTimeoutError(tabId)), - BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, - ); - }); - return Promise.race([operation, timeout]).finally(() => { - if (timeoutId !== undefined) clearTimeout(timeoutId); - }); -}; - export function subscribeBrowserViewportChange( tabId: string, handler: BrowserViewportHandler, @@ -77,14 +98,19 @@ export function commitBrowserViewportChange( tabId: string, setting: PreviewViewportSetting, ): Promise { - const { started } = queueBrowserViewportMutation(tabId, () => { - const handler = handlers.get(tabId); - return handler - ? handler(setting) - : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); - }); - // The queue follows the real handler lifetime, while the caller-facing - // timeout starts only once this commit reaches the front of that queue. - const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); - return result; + const deadlineAt = Date.now() + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS; + const deadline = { + deadlineAt, + timeoutError: () => new BrowserViewportCommitTimeoutError(tabId), + }; + return queueBrowserViewportMutation( + tabId, + () => { + const handler = handlers.get(tabId); + return handler + ? handler(setting) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + }, + deadline, + ).execution; } diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..44956ef82fb1 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -31,6 +31,7 @@ import { } from "~/previewStateStore"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; import { readActiveBrowserRecordingTargets, startBrowserRecording, @@ -51,6 +52,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { PreviewAutomationOperationError, PreviewAutomationOverlayTimeoutError, @@ -59,8 +61,8 @@ import { PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { - previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + previewAutomationOpenViewport, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; import { @@ -75,7 +77,11 @@ import { resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; import { isPreviewViewportReady } from "./previewViewportReadiness"; -import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; +import { + applyPreviewViewportRollback, + createPreviewViewportRollbackState, + type PreviewViewportRollbackState, +} from "./previewViewportRollback"; const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; @@ -170,6 +176,7 @@ const waitForRenderedViewport = async ( tabId: string, runtimeTabId: string, setting: PreviewViewportSetting, + deadlineAt: number, timeoutMs: number, context: { readonly requestId: PreviewAutomationRequest["requestId"]; @@ -178,8 +185,7 @@ const waitForRenderedViewport = async ( readonly threadId: PreviewAutomationRequest["threadId"]; }, ): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { + while (Date.now() <= deadlineAt) { assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context); try { const webview = findPreviewWebview(runtimeTabId); @@ -210,6 +216,22 @@ const waitForRenderedViewport = async ( }); }; +const runBeforeDeadline = ( + deadlineAt: number, + operation: () => Promise, + timeoutError: () => Error, +): Promise => { + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) return Promise.reject(timeoutError()); + let timeoutId: number | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = window.setTimeout(() => reject(timeoutError()), remainingMs); + }); + return Promise.race([Promise.resolve().then(operation), timeout]).finally(() => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }); +}; + const currentStatus = async ( threadRef: ScopedThreadRef, requestedTabId: string | null, @@ -314,6 +336,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { + const requestDeadlineAt = Date.now() + request.timeoutMs; const threadRef: ScopedThreadRef = { environmentId, threadId: request.threadId, @@ -360,7 +383,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) readyTabId, runtimeTabId, request.operation, - request.timeoutMs, + Math.max(1, requestDeadlineAt - Date.now()), ); return { bridge, @@ -390,14 +413,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const configuredViewport = browserDefaultOpenViewport(await resolveBrowserDefaults()); const result = await open({ environmentId, input: { threadId: request.threadId, ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), - // An agent that didn't state a size gets the user's - // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: previewAutomationOpenViewport(configuredViewport), }, }); if (result._tag === "Failure") { @@ -414,38 +436,6 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) readThreadPreviewState(threadRef).serverEpoch, activeTabId, ); - if (activeSnapshot) { - const defaultViewport = previewAutomationDefaultViewport( - reusedExistingTab, - activeSnapshot, - ); - if (defaultViewport) { - const resizeResult = await runBrowserViewportMutation( - activeRuntimeTabId, - async () => { - assertPreviewRuntimeCurrent( - threadRef, - activeTabId, - activeRuntimeTabId, - request, - ); - return await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: activeTabId, - viewport: defaultViewport, - }, - }); - }, - ); - if (resizeResult._tag === "Failure") { - return raiseAtomCommandFailure(resizeResult); - } - activeSnapshot = resizeResult.value; - updatePreviewServerSnapshot(threadRef, resizeResult.value); - } - } const shouldPresentPreview = shouldOpenPreviewMiniPlayer( input, (await resolveBrowserDefaults()).autoShowFloatingPreview, @@ -504,40 +494,124 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); - const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { - const operationState = assertPreviewRuntimeCurrent( - threadRef, - ready.tabId, - ready.runtimeTabId, - request, - ); - const previousSetting = - operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - const result = await resize({ + const timeoutMs = input.timeoutMs ?? request.timeoutMs; + const deadlineAt = Math.min(requestDeadlineAt, Date.now() + timeoutMs); + const timeoutError = () => + new PreviewAutomationViewportTimeoutError({ + requestId: request.requestId, environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: setting, - }, + threadId: request.threadId, + tabId: ready.tabId, + timeoutMs, }); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); + const rollbackViewportIfCurrent = async ( + rollbackState: PreviewViewportRollbackState, + ) => { + const { previousSetting } = rollbackState; + try { + assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); + } catch { + return; } - updatePreviewServerSnapshot(threadRef, result.value); - return { - previousSetting, - serverEpoch: operationState.serverEpoch, - }; + await applyPreviewViewportRollback({ + previous: previousSetting, + // This direct path bypasses the agent-control semaphore and + // supersedes any stuck automation CDP command. + applyGuest: (viewport) => + applyPreviewGuestViewport(ready.bridge.setViewport, ready.runtimeTabId, viewport), + rollbackServer: async () => { + const rollback = await resize({ + environmentId, + input: rollbackState.input, + }); + if (rollback._tag === "Failure") return false; + updatePreviewServerSnapshot(threadRef, rollback.value); + const rollbackVersion = rollback.value.stateVersion; + const currentState = readThreadPreviewState(threadRef); + const currentViewport = + currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + return ( + rollbackVersion !== undefined && + currentState.serverEpoch === rollbackVersion.serverEpoch && + currentState.serverRevisionByTabId[ready.tabId] === rollbackVersion.revision && + browserViewportSettingKey(currentViewport) === + browserViewportSettingKey(previousSetting) + ); + }, + }); + }; + type RollbackState = PreviewViewportRollbackState | undefined; + let resolveRollbackState = (_state: RollbackState): void => undefined; + const rollbackStateReady = new Promise((resolve) => { + resolveRollbackState = resolve; }); - let viewport: PreviewRenderedViewportSize; + let persistenceStarted = false; + const persistViewport = async () => { + persistenceStarted = true; + try { + assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); + const result = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: setting, + }, + }); + if (result._tag === "Failure") { + resolveRollbackState(undefined); + return raiseAtomCommandFailure(result); + } + const applied = createPreviewViewportRollbackState({ + result: result.value, + threadId: request.threadId, + tabId: ready.tabId, + }); + resolveRollbackState(applied); + if (Date.now() >= deadlineAt) throw timeoutError(); + updatePreviewServerSnapshot(threadRef, result.value); + return applied; + } catch (error) { + resolveRollbackState(undefined); + throw error; + } + }; + const mutationDeadline = { deadlineAt, timeoutError }; try { - viewport = await waitForRenderedViewport( + await runBrowserViewportMutation( + ready.runtimeTabId, + persistViewport, + mutationDeadline, + ); + const currentState = assertPreviewRuntimeCurrent( + threadRef, + ready.tabId, + ready.runtimeTabId, + request, + ); + // The store contains either this response or a newer same-tab + // event. Always send that authoritative value to the guest. + const appliedSetting = + currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + // Native CDP can outlive the server request. Keep it outside the + // shared server-mutation queue so toolbar resizes can proceed. + await runBeforeDeadline( + deadlineAt, + () => + applyPreviewGuestViewport( + ready.bridge.automation.setViewport, + ready.runtimeTabId, + appliedSetting, + ), + timeoutError, + ); + const viewport = await waitForRenderedViewport( threadRef, ready.tabId, ready.runtimeTabId, - setting, - input.timeoutMs ?? request.timeoutMs, + appliedSetting, + deadlineAt, + timeoutMs, { requestId: request.requestId, operation: request.operation, @@ -545,40 +619,28 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) threadId: request.threadId, }, ); + return { + tabId: ready.tabId, + setting: appliedSetting, + viewport, + } satisfies PreviewAutomationResizeResult; } catch (cause) { - await runBrowserViewportMutation(ready.runtimeTabId, async () => { - const latestState = readThreadPreviewState(threadRef); - const latestSetting = - latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - if ( - shouldRollbackPreviewViewport( - applied.previousSetting, - setting, - latestSetting, - applied.serverEpoch, - latestState.serverEpoch, - ) - ) { - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: applied.previousSetting, + if (!persistenceStarted) resolveRollbackState(undefined); + void rollbackStateReady + .then((pendingRollback) => { + if (!pendingRollback) return; + return runBrowserViewportMutation( + ready.runtimeTabId, + () => rollbackViewportIfCurrent(pendingRollback), + { + deadlineAt: Date.now() + timeoutMs, + timeoutError, }, - }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - } - } - }); + ); + }) + .catch(() => undefined); throw cause; } - return { - tabId: ready.tabId, - setting, - viewport, - } satisfies PreviewAutomationResizeResult; } case "setColorScheme": { const ready = await requireReadyTab(); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts index dc8836c14492..00e9e687456c 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, - previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + previewAutomationOpenViewport, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; @@ -62,19 +62,14 @@ describe("preview automation open readiness", () => { }); it("gives newly-created automation tabs a stable desktop viewport", () => { - expect(previewAutomationDefaultViewport(false, snapshot({ _tag: "Idle" }))).toEqual( + expect(previewAutomationOpenViewport({ _tag: "fill" })).toEqual( DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, ); }); - it("preserves reused and already-fixed browser viewports", () => { - expect(previewAutomationDefaultViewport(true, snapshot({ _tag: "Idle" }))).toBeNull(); - expect( - previewAutomationDefaultViewport(false, { - ...snapshot({ _tag: "Idle" }), - viewport: { _tag: "freeform", width: 900, height: 600 }, - }), - ).toBeNull(); + it("preserves the configured fixed browser viewport", () => { + const configured = { _tag: "freeform", width: 900, height: 600 } as const; + expect(previewAutomationOpenViewport(configured)).toBe(configured); }); }); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts index 7f525b425501..cd083c5474e5 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -1,8 +1,7 @@ -import { - FILL_PREVIEW_VIEWPORT, - type PreviewAutomationOpenInput, - type PreviewSessionSnapshot, - type PreviewViewportSetting, +import type { + PreviewAutomationOpenInput, + PreviewSessionSnapshot, + PreviewViewportSetting, } from "@t3tools/contracts"; /** @@ -36,19 +35,11 @@ export function previewAutomationOpenNeedsOverlay( return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; } -/** - * Whether a freshly opened automation tab still needs a viewport applied. - * - * A configured browser default is sent with `preview.open`, so the snapshot - * already carries it and nothing is needed here. Fill means the user has no - * stated preference, which is where the agent fallback applies. - */ -export function previewAutomationDefaultViewport( - reusedExistingTab: boolean, - snapshot: PreviewSessionSnapshot, -): PreviewViewportSetting | null { - const viewport = snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; - return !reusedExistingTab && viewport._tag === "fill" +/** Select the viewport sent with a new automation tab's `preview.open`. */ +export function previewAutomationOpenViewport( + configuredViewport: PreviewViewportSetting, +): PreviewViewportSetting { + return configuredViewport._tag === "fill" ? DEFAULT_PREVIEW_AUTOMATION_VIEWPORT - : null; + : configuredViewport; } diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts index 11a70951b8f2..2580fbf34fd3 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.test.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts @@ -21,7 +21,7 @@ describe("preview automation target selection", () => { const active = snapshot("tab-active"); expect( needsPreviewAutomationSessionSync( - { snapshot: active, sessions: { [active.tabId]: active } }, + { snapshot: active, sessions: { [active.tabId]: active }, serverEpoch: "server-a" }, undefined, ), ).toBe(true); @@ -29,11 +29,29 @@ describe("preview automation target selection", () => { it("refreshes an explicit tab only when it is absent locally", () => { const active = snapshot("tab-active"); - const state = { snapshot: active, sessions: { [active.tabId]: active } }; + const state = { + snapshot: active, + sessions: { [active.tabId]: active }, + serverEpoch: "server-a", + }; expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false); expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true); }); + it("refreshes an explicit local tab when the server epoch is unknown", () => { + const active = snapshot("tab-active"); + expect( + needsPreviewAutomationSessionSync( + { + snapshot: active, + sessions: { [active.tabId]: active }, + serverEpoch: null, + }, + active.tabId, + ), + ).toBe(true); + }); + it("does not report the active tab under an unknown requested tab id", () => { const active = snapshot("tab-active"); expect( diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts index f1858f87d18c..28b4bc60b4ff 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.ts @@ -5,11 +5,16 @@ interface PreviewAutomationSessionIndex { readonly sessions: Readonly>; } +interface PreviewAutomationSyncState extends PreviewAutomationSessionIndex { + readonly serverEpoch: string | null; +} + export function needsPreviewAutomationSessionSync( - state: PreviewAutomationSessionIndex, + state: PreviewAutomationSyncState, requestedTabId: string | undefined, ): boolean { return ( + state.serverEpoch === null || Object.keys(state.sessions).length === 0 || requestedTabId === undefined || state.sessions[requestedTabId] === undefined diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts new file mode 100644 index 000000000000..169084953828 --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyPreviewGuestViewport, previewGuestViewportOverride } from "./previewGuestViewport"; + +describe("previewGuestViewportOverride", () => { + it("clears fill mode and uses explicit dimensions for fixed viewports", () => { + expect(previewGuestViewportOverride({ _tag: "fill" })).toEqual({ clear: true }); + expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ + width: 1024, + height: 768, + }); + expect( + previewGuestViewportOverride({ + _tag: "preset", + presetId: "iphone-12-pro", + width: 390, + height: 844, + }), + ).toEqual({ width: 390, height: 844 }); + }); + + it("leaves page-zoom conversion to the desktop manager", () => { + expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ + width: 1024, + height: 768, + }); + }); +}); + +describe("applyPreviewGuestViewport", () => { + it("skips older desktops and applies the mapped override otherwise", async () => { + await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); + + const setViewport = vi.fn(async () => undefined); + await applyPreviewGuestViewport(setViewport, "tab-1", { + _tag: "freeform", + width: 1024, + height: 768, + }); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 1024, height: 768 }); + }); +}); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts new file mode 100644 index 000000000000..ec4b31290efe --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -0,0 +1,31 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +export type PreviewGuestViewportOverride = + | { readonly clear: true } + | { readonly width: number; readonly height: number }; + +export type PreviewGuestViewportApplier = ( + tabId: string, + input: PreviewGuestViewportOverride, +) => Promise; + +/** Maps a stored CSS viewport setting onto the desktop bridge input. */ +export function previewGuestViewportOverride( + setting: PreviewViewportSetting, +): PreviewGuestViewportOverride { + if (setting._tag === "fill") return { clear: true }; + return { + width: setting.width, + height: setting.height, + }; +} + +/** Applies or clears the guest CDP metrics override. No-op on older desktops. */ +export async function applyPreviewGuestViewport( + setViewport: PreviewGuestViewportApplier | undefined, + tabId: string, + setting: PreviewViewportSetting, +): Promise { + if (!setViewport) return; + await setViewport(tabId, previewGuestViewportOverride(setting)); +} diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts index 287e2d87ce2a..95db4176b6f9 100644 --- a/apps/web/src/components/preview/previewViewportRollback.test.ts +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -1,36 +1,117 @@ -import { describe, expect, it } from "vite-plus/test"; +import { ThreadId, type PreviewResizeResult } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; +import { + applyPreviewViewportRollback, + createPreviewViewportRollbackState, +} from "./previewViewportRollback"; -describe("shouldRollbackPreviewViewport", () => { - const fill = { _tag: "fill" } as const; +describe("preview viewport rollback", () => { const requested = { _tag: "freeform", width: 900, height: 600 } as const; - it("rolls back a timed-out request that still owns the latest setting", () => { - expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-a")).toBe( - true, - ); + it("uses the server predecessor and write version for a guarded rollback", () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const stateVersion = { serverEpoch: "server-a", revision: 4 } as const; + const rollback = createPreviewViewportRollbackState({ + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + result: { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport: requested, + updatedAt: "2026-01-01T00:00:00.000Z", + stateVersion, + previousViewport: previous, + }, + }); + + expect(rollback).toEqual({ + previousSetting: previous, + stateVersion, + input: { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + viewport: previous, + expectedStateVersion: stateVersion, + }, + }); + expect(rollback?.input.expectedStateVersion).toBe(stateVersion); }); - it("does not overwrite a newer resize, replacement server, or repeated setting", () => { + it("skips rollback unless the server returns both write fields", () => { + const result = { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + navStatus: { _tag: "Idle" as const }, + canGoBack: false, + canGoForward: false, + viewport: requested, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const create = (resizeResult: PreviewResizeResult) => + createPreviewViewportRollbackState({ + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + result: resizeResult, + }); + + expect(create(result)).toBeUndefined(); expect( - shouldRollbackPreviewViewport( - fill, - requested, - { - _tag: "freeform", - width: 1024, - height: 768, + create({ ...result, stateVersion: { serverEpoch: "server-a", revision: 2 } }), + ).toBeUndefined(); + expect(create({ ...result, previousViewport: { _tag: "fill" } })).toBeUndefined(); + }); + + it("changes the guest only after the guarded server rollback succeeds", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const order: string[] = []; + const applyGuest = vi.fn(async () => { + order.push("guest"); + }); + + await applyPreviewViewportRollback({ + previous, + applyGuest, + rollbackServer: async () => { + order.push("server"); + return true; + }, + }); + + expect(order).toEqual(["server", "guest"]); + expect(applyGuest).toHaveBeenCalledOnce(); + expect(applyGuest).toHaveBeenCalledWith(previous); + }); + + it("leaves the guest unchanged when the guarded rollback conflicts", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const applyGuest = vi.fn(async () => undefined); + + await applyPreviewViewportRollback({ + previous, + applyGuest, + rollbackServer: async () => false, + }); + + expect(applyGuest).not.toHaveBeenCalled(); + }); + + it("leaves the guest unchanged when the server rollback throws", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const applyGuest = vi.fn(async () => undefined); + + await expect( + applyPreviewViewportRollback({ + previous, + applyGuest, + rollbackServer: async () => { + throw new Error("rollback unavailable"); }, - "server-a", - "server-a", - ), - ).toBe(false); - expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-b")).toBe( - false, - ); - expect( - shouldRollbackPreviewViewport(requested, requested, requested, "server-a", "server-a"), - ).toBe(false); + }), + ).rejects.toThrow("rollback unavailable"); + expect(applyGuest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts index c36bf52fd02c..40599ac50dce 100644 --- a/apps/web/src/components/preview/previewViewportRollback.ts +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -1,18 +1,41 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; +import type { + PreviewResizeInput, + PreviewResizeResult, + PreviewStateVersion, + PreviewViewportSetting, +} from "@t3tools/contracts"; -import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; +export interface PreviewViewportRollbackState { + readonly previousSetting: PreviewViewportSetting; + readonly stateVersion: PreviewStateVersion; + readonly input: PreviewResizeInput; +} + +export function createPreviewViewportRollbackState(options: { + readonly result: PreviewResizeResult; + readonly threadId: PreviewResizeInput["threadId"]; + readonly tabId: PreviewResizeInput["tabId"]; +}): PreviewViewportRollbackState | undefined { + const stateVersion = options.result.stateVersion; + const previousSetting = options.result.previousViewport; + if (!stateVersion || !previousSetting) return undefined; + return { + previousSetting, + stateVersion, + input: { + threadId: options.threadId, + tabId: options.tabId, + viewport: previousSetting, + expectedStateVersion: stateVersion, + }, + }; +} -export function shouldRollbackPreviewViewport( - previous: PreviewViewportSetting, - requested: PreviewViewportSetting, - latest: PreviewViewportSetting, - operationServerEpoch: string | null, - currentServerEpoch: string | null, -): boolean { - const requestedKey = browserViewportSettingKey(requested); - return ( - currentServerEpoch === operationServerEpoch && - browserViewportSettingKey(latest) === requestedKey && - browserViewportSettingKey(previous) !== requestedKey - ); +export async function applyPreviewViewportRollback(options: { + readonly previous: PreviewViewportSetting; + readonly applyGuest: (setting: PreviewViewportSetting) => Promise; + readonly rollbackServer: () => Promise; +}): Promise { + if (!(await options.rollbackServer())) return; + void options.applyGuest(options.previous).catch(() => undefined); } diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..625f926cccd1 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -412,6 +412,407 @@ describe("previewStateStore (single-tab)", () => { expect(state.sessions[background.tabId]).toEqual(resized); }); + it("orders resize responses with their server write version", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + const current = { + ...currentSnapshot, + stateVersion: { serverEpoch, revision: 3 }, + previousViewport: { _tag: "fill" as const }, + }; + updatePreviewServerSnapshot(ref, current); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverEpoch).toBe(serverEpoch); + expect(state.serverRevision).toBe(1); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + + it("applies an ordered resize response when the server clock moves backward", () => { + const snapshot = makeSnapshot({ updatedAt: "2026-01-01T00:00:02.000Z" }); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const resized = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:01.000Z", + }; + + updatePreviewServerSnapshot(ref, { + ...resized, + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(resized); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(2); + }); + + it("rejects a resize response superseded by a same-tab server event", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: currentSnapshot.updatedAt, + serverEpoch, + revision: 3, + snapshot: currentSnapshot, + }); + + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + // Make the stale response look newer by wall time. Its server revision + // must still lose to the event delivered by another connection. + updatedAt: "2026-01-01T00:00:03.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + + it("applies a same-tab server event that follows a resize response", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:02.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }; + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: currentSnapshot.updatedAt, + serverEpoch, + revision: 3, + snapshot: currentSnapshot, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + + it("does not let one tab's resize response skip earlier events for other tabs", () => { + const first = makeSnapshot({ tabId: "tab_a" }); + const second = makeSnapshot({ tabId: "tab_b" }); + reconcilePreviewServerSessions(ref, { + sessions: [first, second], + serverEpoch, + revision: 3, + }); + const resizedSecond = { + ...second, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + updatePreviewServerSnapshot(ref, { + ...resizedSecond, + stateVersion: { serverEpoch, revision: 6 }, + }); + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: second.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 4, + snapshot: { + ...second, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: first.tabId, + createdAt: "2026-01-01T00:00:04.000Z", + serverEpoch, + revision: 5, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({ [second.tabId]: resizedSecond }); + expect(state.serverRevision).toBe(5); + expect(state.serverRevisionByTabId).toEqual({ + [second.tabId]: 6, + }); + }); + + it("rejects a late resize response after pruning a closed tab revision", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 3, + }); + + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({}); + + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({}); + expect(state.serverRevisionByTabId).toEqual({}); + }); + + it("prunes tab revisions that an authoritative list removes", () => { + const closed = makeSnapshot({ tabId: "tab_closed" }); + const active = makeSnapshot({ tabId: "tab_active" }); + reconcilePreviewServerSessions(ref, { + sessions: [closed, active], + serverEpoch, + revision: 1, + }); + + reconcilePreviewServerSessions(ref, { + sessions: [active], + serverEpoch, + revision: 2, + }); + + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({ + [active.tabId]: 2, + }); + }); + + it("retains a pending-close revision until a failed close restores the tab", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + beginPreviewSessionClose(ref, snapshot.tabId); + + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 3, + }); + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(snapshot); + expect(state.serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + }); + + it("retains ignored event revisions until a failed close restores the tab", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + beginPreviewSessionClose(ref, snapshot.tabId); + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 3, + snapshot: { + ...snapshot, + viewport: { _tag: "freeform", width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:02.000Z", + serverEpoch, + revision: 2, + snapshot: { + ...snapshot, + viewport: { _tag: "freeform", width: 1000, height: 700 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(snapshot); + expect(state.serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + }); + + it("accepts a newer list that reuses a closed tab id", () => { + const original = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [original], + serverEpoch, + revision: 1, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: original.tabId, + createdAt: "2026-01-01T00:00:02.000Z", + serverEpoch, + revision: 2, + }); + reconcilePreviewServerSessions(ref, { + sessions: [original], + serverEpoch, + revision: 1, + }); + expect(readThreadPreviewState(ref).sessions).toEqual({}); + + reconcilePreviewServerSessions(ref, { + sessions: [ + makeSnapshot({ + navStatus: { _tag: "Success", url: "https://reopened.example", title: "Reopened" }, + updatedAt: "2026-01-01T00:00:03.000Z", + }), + ], + serverEpoch, + revision: 3, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[original.tabId]?.navStatus).toEqual({ + _tag: "Success", + url: "https://reopened.example", + title: "Reopened", + }); + expect(state.serverRevisionByTabId).toEqual({ [original.tabId]: 3 }); + }); + + it("keeps a tab-specific resize newer than an in-flight list response", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 3, + }); + const resized = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + updatePreviewServerSnapshot(ref, { + ...resized, + stateVersion: { serverEpoch, revision: 6 }, + }); + + reconcilePreviewServerSessions(ref, { + sessions: [ + { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + ], + serverEpoch, + revision: 5, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(resized); + expect(state.serverRevision).toBe(5); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(6); + }); + + it("applies a newer ordered list when the server clock moves backward", () => { + const snapshot = makeSnapshot({ updatedAt: "2026-01-01T00:00:02.000Z" }); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const listed = { + ...snapshot, + navStatus: { _tag: "Success" as const, url: "https://example.com", title: "Example" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }; + + reconcilePreviewServerSessions(ref, { + sessions: [listed], + serverEpoch, + revision: 2, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(listed); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(2); + }); + it("reconciles an authoritative session list without focusing a background tab", () => { const active = makeSnapshot({ tabId: "tab_a" }); const stale = makeSnapshot({ diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index eb1052feeb87..9c8670d0faae 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -12,6 +12,7 @@ import { type DesktopPreviewFavicon, type PreviewEvent, type PreviewListResult, + type PreviewResizeResult, type PreviewSessionSnapshot, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -47,6 +48,8 @@ export interface ThreadPreviewState { serverEpoch: string | null; /** Latest ordered server revision applied from a list response or event. */ serverRevision: number; + /** Latest server revision that established each live or pending-close tab's state. */ + serverRevisionByTabId: Readonly>; } const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ @@ -59,6 +62,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], serverEpoch: null, serverRevision: 0, + serverRevisionByTabId: {}, }); const emptyPreviewStateAtom = Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( @@ -190,65 +194,84 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; if (event.revision < current.serverRevision) return current; - const next = (() => { - switch (event.type) { - case "opened": - case "navigated": - case "resized": { - const snapshot = event.snapshot; - if (current.suppressedTabIds.has(snapshot.tabId)) return current; - const recentlySeenUrls = - snapshot.navStatus._tag === "Idle" - ? current.recentlySeenUrls - : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; - const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; - const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; - return { - ...current, - sessions, - activeTabId: activeTabId ?? snapshot.tabId, - snapshot: activeSnapshot, - desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, - recentlySeenUrls, - }; - } - case "failed": { - const existing = current.sessions[event.tabId]; - if (!existing) return current; - const failedSnapshot = { - ...existing, - navStatus: { - _tag: "LoadFailed" as const, - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - }; - const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; - return { - ...current, - sessions, - snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, - }; - } - case "closed": { - const closed = removeSession(current, event.tabId); - if (!closed.suppressedTabIds.has(event.tabId)) return closed; - const suppressedTabIds = new Set(closed.suppressedTabIds); - suppressedTabIds.delete(event.tabId); - return { ...closed, suppressedTabIds }; + const tabRevision = current.serverRevisionByTabId[event.tabId] ?? 0; + const next = + event.revision < tabRevision + ? current + : (() => { + switch (event.type) { + case "opened": + case "navigated": + case "resized": { + const snapshot = event.snapshot; + if (current.suppressedTabIds.has(snapshot.tabId)) return current; + const recentlySeenUrls = + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; + return { + ...current, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, + recentlySeenUrls, + }; + } + case "failed": { + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; + return { + ...current, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, + }; + } + case "closed": { + const closed = removeSession(current, event.tabId); + if (!closed.suppressedTabIds.has(event.tabId)) return closed; + const suppressedTabIds = new Set(closed.suppressedTabIds); + suppressedTabIds.delete(event.tabId); + return { ...closed, suppressedTabIds }; + } + } + })(); + const serverRevisionByTabId = (() => { + if (!next.sessions[event.tabId] && !next.suppressedTabIds.has(event.tabId)) { + if (current.serverRevisionByTabId[event.tabId] === undefined) { + return current.serverRevisionByTabId; } + const { [event.tabId]: _removed, ...remaining } = current.serverRevisionByTabId; + return remaining; } + return event.revision > tabRevision + ? { ...current.serverRevisionByTabId, [event.tabId]: event.revision } + : current.serverRevisionByTabId; })(); - return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch + return next.serverRevision === event.revision && + next.serverEpoch === event.serverEpoch && + next.serverRevisionByTabId === serverRevisionByTabId ? next : { ...next, serverEpoch: event.serverEpoch, serverRevision: event.revision, + serverRevisionByTabId, }; }); } @@ -292,23 +315,45 @@ export function applyPreviewServerSnapshot( */ export function updatePreviewServerSnapshot( ref: ScopedThreadRef, - snapshot: PreviewSessionSnapshot, + snapshot: PreviewResizeResult, ): void { updateThreadPreviewState(ref, (current) => { - if (current.suppressedTabIds.has(snapshot.tabId)) return current; - const existing = current.sessions[snapshot.tabId]; - if (existing && existing.updatedAt > snapshot.updatedAt) return current; - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const stateVersion = snapshot.stateVersion; + if (stateVersion) { + if (current.serverEpoch !== null && stateVersion.serverEpoch !== current.serverEpoch) { + return current; + } + const tabRevision = current.serverRevisionByTabId[snapshot.tabId] ?? 0; + if (stateVersion.revision < tabRevision) return current; + if (!current.sessions[snapshot.tabId] && stateVersion.revision <= current.serverRevision) { + return current; + } + } + const { + stateVersion: _stateVersion, + previousViewport: _previousViewport, + ...sessionSnapshot + } = snapshot; + if (current.suppressedTabIds.has(sessionSnapshot.tabId)) return current; + const existing = current.sessions[sessionSnapshot.tabId]; + if (!stateVersion && existing && existing.updatedAt > sessionSnapshot.updatedAt) return current; + const sessions = { ...current.sessions, [sessionSnapshot.tabId]: sessionSnapshot }; const activeTabId = - current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId; - const activeSnapshot = sessions[activeTabId] ?? snapshot; + current.activeTabId && sessions[current.activeTabId] + ? current.activeTabId + : sessionSnapshot.tabId; + const activeSnapshot = sessions[activeTabId] ?? sessionSnapshot; return { ...current, sessions, activeTabId, snapshot: activeSnapshot, desktopOverlay: current.desktopByTabId[activeTabId] ?? null, - recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot), + recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, sessionSnapshot), + serverEpoch: stateVersion?.serverEpoch ?? current.serverEpoch, + serverRevisionByTabId: stateVersion + ? { ...current.serverRevisionByTabId, [sessionSnapshot.tabId]: stateVersion.revision } + : current.serverRevisionByTabId, }; }); } @@ -328,14 +373,35 @@ export function reconcilePreviewServerSessions( const snapshots = result.sessions; const sessions: Record = {}; const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set(); + const listedTabIds = new Set(snapshots.map((snapshot) => snapshot.tabId)); let recentlySeenUrls = current.recentlySeenUrls; for (const snapshot of snapshots) { if (currentSuppressedTabIds.has(snapshot.tabId)) continue; const existing = sameServer ? current.sessions[snapshot.tabId] : undefined; - const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot; + const tabRevision = sameServer ? (current.serverRevisionByTabId[snapshot.tabId] ?? 0) : 0; + const hasNewerTabState = sameServer && tabRevision > result.revision; + if (hasNewerTabState && !existing) continue; + const next = + existing && + (hasNewerTabState || + (tabRevision === result.revision && existing.updatedAt > snapshot.updatedAt)) + ? existing + : snapshot; sessions[next.tabId] = next; recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next); } + if (sameServer) { + for (const [tabId, existing] of Object.entries(current.sessions)) { + if ( + !listedTabIds.has(tabId) && + !currentSuppressedTabIds.has(tabId) && + (current.serverRevisionByTabId[tabId] ?? 0) > result.revision + ) { + sessions[tabId] = existing; + recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, existing); + } + } + } const fallback = latestSnapshot(sessions); const activeTabId = @@ -353,6 +419,13 @@ export function reconcilePreviewServerSessions( snapshots.some((snapshot) => snapshot.tabId === tabId), ), ); + const revisionTabIds = new Set([...Object.keys(sessions), ...suppressedTabIds]); + const serverRevisionByTabId = Object.fromEntries( + [...revisionTabIds].map((tabId) => [ + tabId, + Math.max(sameServer ? (current.serverRevisionByTabId[tabId] ?? 0) : 0, result.revision), + ]), + ); return { ...current, sessions, @@ -364,6 +437,7 @@ export function reconcilePreviewServerSessions( recentlySeenUrls, serverEpoch: result.serverEpoch, serverRevision: result.revision, + serverRevisionByTabId, }; }); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88dc2b26b280..40f1a2e24a51 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,6 +60,7 @@ import type { PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -958,6 +959,18 @@ export interface DesktopPreviewTabDefaults { readonly colorScheme?: DesktopPreviewColorScheme | undefined; } +export const DesktopPreviewAutomationSetViewportInputSchema = Schema.Union([ + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + width: Schema.Int.check(Schema.isGreaterThan(0)), + height: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + clear: Schema.Literal(true), + }), +]); + export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), @@ -1151,6 +1164,15 @@ export interface DesktopPreviewBridge { * allowed; it simply takes effect once the page plays something. */ setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** + * Apply or clear a guest device-metrics override without taking agent + * control. Used by the toolbar and restore path so a human resize does + * not flash the agent-controlling badge. + */ + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ @@ -1194,6 +1216,10 @@ export interface DesktopPreviewBridge { automation: { status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; @@ -1349,7 +1375,7 @@ export interface EnvironmentApi { preview: { open: (input: typeof PreviewOpenInput.Encoded) => Promise; navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise; - resize: (input: typeof PreviewResizeInput.Encoded) => Promise; + resize: (input: typeof PreviewResizeInput.Encoded) => Promise; refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise; close: (input: typeof PreviewCloseInput.Encoded) => Promise; list: (input: typeof PreviewListInput.Encoded) => Promise; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef8..c11da581c1e0 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -6,11 +6,15 @@ import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, DiscoveredLocalServer, PREVIEW_URL_MAX_LENGTH, + PreviewError, PreviewEvent, PreviewNavStatus, + PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, PreviewViewportSetting, } from "./preview.ts"; +import { DesktopPreviewAutomationSetViewportInputSchema } from "./ipc.ts"; import { PreviewAutomationHost, PreviewAutomationError, @@ -22,6 +26,10 @@ import { const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); +const decodePreviewError = Schema.decodeUnknownSync(PreviewError); +const decodePreviewResizeInput = Schema.decodeUnknownSync(PreviewResizeInput); +const decodePreviewResizeResult = Schema.decodeUnknownSync(PreviewResizeResult); +const encodePreviewResizeResult = Schema.encodeSync(PreviewResizeResult); const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls); @@ -32,6 +40,9 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); +const decodeSetViewportInput = Schema.decodeUnknownSync( + DesktopPreviewAutomationSetViewportInputSchema, +); describe("PreviewAutomationOpenInput", () => { it("accepts the inline preview visibility flag", () => { @@ -122,6 +133,70 @@ describe("PreviewViewportSetting", () => { }); }); +describe("PreviewResizeInput and PreviewResizeResult", () => { + const snapshot = { + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { _tag: "Idle" as const }, + canGoBack: false, + canGoForward: false, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + + it("accepts old resize messages and guarded rollback messages", () => { + expect( + decodePreviewResizeInput({ + threadId: "thread-1", + tabId: "preview-thread-1", + viewport: { _tag: "fill" }, + }), + ).not.toHaveProperty("expectedStateVersion"); + expect( + decodePreviewResizeInput({ + threadId: "thread-1", + tabId: "preview-thread-1", + viewport: { _tag: "fill" }, + expectedStateVersion: { serverEpoch: "server-a", revision: 2 }, + }).expectedStateVersion, + ).toEqual({ serverEpoch: "server-a", revision: 2 }); + }); + + it("decodes old results and exposes the write version from new servers", () => { + expect(decodePreviewResizeResult(snapshot)).not.toHaveProperty("stateVersion"); + expect( + decodePreviewResizeResult({ + ...snapshot, + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }), + ).toMatchObject({ + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }); + }); + + it("keeps new resize results readable as old session snapshots", () => { + const encoded = encodePreviewResizeResult({ + ...snapshot, + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }); + expect(decodeSnapshot(encoded)).toMatchObject(snapshot); + }); + + it("decodes a typed viewport write conflict", () => { + const error = decodePreviewError({ + _tag: "PreviewResizeConflictError", + threadId: "thread-1", + tabId: "preview-thread-1", + expectedStateVersion: { serverEpoch: "server-a", revision: 2 }, + actualStateVersion: { serverEpoch: "server-a", revision: 4 }, + }); + expect(error._tag).toBe("PreviewResizeConflictError"); + }); +}); + describe("PreviewAutomationResizeInput", () => { it("requires fields that match the selected mode", () => { expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" }); @@ -223,6 +298,22 @@ describe("PreviewAutomationStatus", () => { }); }); +describe("DesktopPreviewAutomationSetViewportInputSchema", () => { + it("accepts a complete size or an explicit clear, and rejects a partial size", () => { + expect(decodeSetViewportInput({ tabId: "tab-1", width: 800, height: 600 })).toEqual({ + tabId: "tab-1", + width: 800, + height: 600, + }); + expect(decodeSetViewportInput({ tabId: "tab-1", clear: true })).toEqual({ + tabId: "tab-1", + clear: true, + }); + expect(() => decodeSetViewportInput({ tabId: "tab-1", width: 800 })).toThrow(); + expect(() => decodeSetViewportInput({ tabId: "tab-1" })).toThrow(); + }); +}); + describe("PreviewEvent", () => { it("decodes opened", () => { const event = decodePreviewEvent({ diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..fc23a6648787 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -173,6 +173,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; +export const PreviewStateVersion = Schema.Struct({ + serverEpoch: TrimmedNonEmptyString, + revision: PositiveInt, +}); +export type PreviewStateVersion = typeof PreviewStateVersion.Type; + export const PreviewOpenInput = Schema.Struct({ threadId: ThreadId, /** Omit to create an empty (Idle) tab the user can type into. */ @@ -214,9 +220,23 @@ export const PreviewResizeInput = Schema.Struct({ threadId: ThreadId, tabId: PreviewTabId, viewport: PreviewViewportSetting, + /** Apply only while the viewport write identified by this version is current. */ + expectedStateVersion: Schema.optional(PreviewStateVersion), }); export type PreviewResizeInput = typeof PreviewResizeInput.Type; +/** + * The version is optional so new clients can still resize through older + * servers. New servers always return it and use it to guard rollback writes. + */ +export const PreviewResizeResult = Schema.Struct({ + ...PreviewSessionSnapshot.fields, + stateVersion: Schema.optional(PreviewStateVersion), + /** The viewport replaced by this write. Missing when an older server responds. */ + previousViewport: Schema.optional(PreviewViewportSetting), +}); +export type PreviewResizeResult = typeof PreviewResizeResult.Type; + export const PreviewCloseInput = Schema.Struct({ threadId: ThreadId, tabId: Schema.optional(PreviewTabId), @@ -341,5 +361,23 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass()( + "PreviewResizeConflictError", + { + threadId: Schema.String, + tabId: Schema.String, + expectedStateVersion: PreviewStateVersion, + actualStateVersion: PreviewStateVersion, + }, +) { + override get message() { + return "The preview viewport changed before this resize could be applied."; + } +} + +export const PreviewError = Schema.Union([ + PreviewSessionLookupError, + PreviewInvalidUrlError, + PreviewResizeConflictError, +]); export type PreviewError = typeof PreviewError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7cd674485f94..41c7e8007e19 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -155,6 +155,7 @@ import { PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -842,7 +843,7 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { payload: PreviewResizeInput, - success: PreviewSessionSnapshot, + success: PreviewResizeResult, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), });