| author | |
| committer | |
| log | ffae5410e8f8ff5ac72330e0b63535b89f9fd555 |
| tree | d510fca9a10be7667e3961b1ef5c3dfdaab12db9 |
| parent | 31dfe3596e429725d95d31ecff396caa02a47d12 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>68 files changed, 6109 insertions(+), 1067 deletions(-)
apps/desktop/scripts/electron-launcher.mjs+36-14| ... | ... | @@ -100,25 +100,41 @@ function shellSingleQuote(value) { |
| 100 | 100 | return `'${value.replaceAll("'", "'\\''")}'`; |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { | |
| 104 | const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); | |
| 103 | export function makeDevelopmentLauncherScript({ | |
| 104 | electronBinaryPath, | |
| 105 | mainEntryPath, | |
| 106 | desktopRoot, | |
| 107 | environment, | |
| 108 | }) { | |
| 105 | 109 | const envEntries = [ |
| 106 | ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], | |
| 107 | ["T3CODE_PORT", process.env.T3CODE_PORT], | |
| 108 | ["T3CODE_HOME", process.env.T3CODE_HOME], | |
| 109 | ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH], | |
| 110 | ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], | |
| 111 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], | |
| 110 | ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL], | |
| 111 | ["T3CODE_PORT", environment.T3CODE_PORT], | |
| 112 | ["T3CODE_HOME", environment.T3CODE_HOME], | |
| 113 | ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], | |
| 114 | ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], | |
| 115 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], | |
| 112 | 116 | ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], |
| 113 | 117 | ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); |
| 118 | return [ | |
| 119 | "#!/bin/sh", | |
| 120 | ...envEntries.map( | |
| 121 | ([name, value]) => | |
| 122 | `if [ -z "\${${name}:-}" ]; then export ${name}=${shellSingleQuote(value)}; fi`, | |
| 123 | ), | |
| 124 | `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopRoot)} ${shellSingleQuote(mainEntryPath)} "$@"`, | |
| 125 | "", | |
| 126 | ].join("\n"); | |
| 127 | } | |
| 128 | ||
| 129 | function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { | |
| 114 | 130 | NodeFS.writeFileSync( |
| 115 | 131 | targetBinaryPath, |
| 116 | [ | |
| 117 | "#!/bin/sh", | |
| 118 | ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), | |
| 119 | `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, | |
| 120 | "", | |
| 121 | ].join("\n"), | |
| 132 | makeDevelopmentLauncherScript({ | |
| 133 | electronBinaryPath, | |
| 134 | mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"), | |
| 135 | desktopRoot: desktopDir, | |
| 136 | environment: process.env, | |
| 137 | }), | |
| 122 | 138 | ); |
| 123 | 139 | NodeFS.chmodSync(targetBinaryPath, 0o755); |
| 124 | 140 | } |
| ... | ... | @@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) { |
| 286 | 302 | currentMetadata && |
| 287 | 303 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) |
| 288 | 304 | ) { |
| 305 | if (isDevelopment) { | |
| 306 | // The launcher also handles protocol activations outside the dev runner, | |
| 307 | // so refresh its fallback environment on every launch. Never let a value | |
| 308 | // captured by an older parent app override the live dev-runner environment. | |
| 309 | writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); | |
| 310 | } | |
| 289 | 311 | registerMacLauncherBundle(targetAppBundlePath); |
| 290 | 312 | return targetBinaryPath; |
| 291 | 313 | } |
apps/desktop/scripts/electron-launcher.test.mjs created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { assert, describe, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs"; | |
| 4 | ||
| 5 | describe("electron development launcher", () => { | |
| 6 | it("uses captured values only as fallbacks for a live runner environment", () => { | |
| 7 | const script = makeDevelopmentLauncherScript({ | |
| 8 | electronBinaryPath: "/repo/node_modules/electron/Electron", | |
| 9 | mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", | |
| 10 | desktopRoot: "/repo/apps/desktop", | |
| 11 | environment: { | |
| 12 | VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", | |
| 13 | T3CODE_PORT: "16566", | |
| 14 | T3CODE_HOME: "/tmp/t3", | |
| 15 | }, | |
| 16 | }); | |
| 17 | ||
| 18 | assert.include( | |
| 19 | script, | |
| 20 | "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", | |
| 21 | ); | |
| 22 | assert.notInclude(script, "\nexport VITE_DEV_SERVER_URL="); | |
| 23 | assert.include( | |
| 24 | script, | |
| 25 | "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"", | |
| 26 | ); | |
| 27 | }); | |
| 28 | }); |
apps/desktop/src/preview/Manager.test.ts+385-10| ... | ... | @@ -18,16 +18,25 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 18 | 18 | import * as BrowserSession from "./BrowserSession.ts"; |
| 19 | 19 | import * as PreviewManager from "./Manager.ts"; |
| 20 | 20 | |
| 21 | const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = | |
| 22 | vi.hoisted(() => ({ | |
| 23 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), | |
| 24 | fromId: vi.fn(() => null), | |
| 25 | mkdir: vi.fn((_path: string) => undefined), | |
| 26 | showItemInFolder: vi.fn(), | |
| 27 | webviewSend: vi.fn(), | |
| 28 | writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), | |
| 29 | writeImage: vi.fn(), | |
| 30 | })); | |
| 21 | const { | |
| 22 | createFromPath, | |
| 23 | fromId, | |
| 24 | getFocusedWebContents, | |
| 25 | mkdir, | |
| 26 | showItemInFolder, | |
| 27 | webviewSend, | |
| 28 | writeFile, | |
| 29 | writeImage, | |
| 30 | } = vi.hoisted(() => ({ | |
| 31 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), | |
| 32 | fromId: vi.fn(() => null), | |
| 33 | getFocusedWebContents: vi.fn(() => null), | |
| 34 | mkdir: vi.fn((_path: string) => undefined), | |
| 35 | showItemInFolder: vi.fn(), | |
| 36 | webviewSend: vi.fn(), | |
| 37 | writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), | |
| 38 | writeImage: vi.fn(), | |
| 39 | })); | |
| 31 | 40 | |
| 32 | 41 | vi.mock("electron", () => ({ |
| 33 | 42 | clipboard: { |
| ... | ... | @@ -44,6 +53,7 @@ vi.mock("electron", () => ({ |
| 44 | 53 | }, |
| 45 | 54 | webContents: { |
| 46 | 55 | fromId, |
| 56 | getFocusedWebContents, | |
| 47 | 57 | }, |
| 48 | 58 | })); |
| 49 | 59 | |
| ... | ... | @@ -97,6 +107,8 @@ const withManager = <A>( |
| 97 | 107 | describe("PreviewManager", () => { |
| 98 | 108 | beforeEach(() => { |
| 99 | 109 | fromId.mockClear(); |
| 110 | getFocusedWebContents.mockReset(); | |
| 111 | getFocusedWebContents.mockReturnValue(null); | |
| 100 | 112 | mkdir.mockClear(); |
| 101 | 113 | writeFile.mockClear(); |
| 102 | 114 | showItemInFolder.mockClear(); |
| ... | ... | @@ -250,6 +262,197 @@ describe("PreviewManager", () => { |
| 250 | 262 | ), |
| 251 | 263 | ); |
| 252 | 264 | |
| 265 | effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => | |
| 266 | withManager((manager) => | |
| 267 | Effect.gen(function* () { | |
| 268 | let effectiveZoom = 0.9; | |
| 269 | let zoomReadable = true; | |
| 270 | let url = "https://example.com"; | |
| 271 | const listeners = new Map<string, (...args: unknown[]) => void>(); | |
| 272 | const setZoomFactor = vi.fn(); | |
| 273 | fromId.mockReturnValue({ | |
| 274 | id: 42, | |
| 275 | isDestroyed: () => false, | |
| 276 | getType: () => "webview", | |
| 277 | getURL: () => url, | |
| 278 | getTitle: () => "Example", | |
| 279 | isLoading: () => false, | |
| 280 | getZoomFactor: () => { | |
| 281 | if (!zoomReadable) throw new Error("zoom unavailable"); | |
| 282 | return effectiveZoom; | |
| 283 | }, | |
| 284 | setZoomFactor, | |
| 285 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { | |
| 286 | listeners.set(event, listener); | |
| 287 | }), | |
| 288 | off: vi.fn(), | |
| 289 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 290 | send: webviewSend, | |
| 291 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 292 | setWindowOpenHandler: vi.fn(), | |
| 293 | debugger: { | |
| 294 | isAttached: () => false, | |
| 295 | attach: vi.fn(), | |
| 296 | sendCommand: vi.fn(async () => undefined), | |
| 297 | on: vi.fn(), | |
| 298 | off: vi.fn(), | |
| 299 | }, | |
| 300 | } as never); | |
| 301 | const states: PreviewManager.PreviewTabState[] = []; | |
| 302 | ||
| 303 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 304 | Effect.sync(() => { | |
| 305 | states.push(state); | |
| 306 | }), | |
| 307 | ); | |
| 308 | yield* manager.createTab("tab_zoom"); | |
| 309 | yield* manager.registerWebview("tab_zoom", 42); | |
| 310 | ||
| 311 | expect(states.at(-1)?.zoomFactor).toBe(0.9); | |
| 312 | expect(setZoomFactor).not.toHaveBeenCalled(); | |
| 313 | ||
| 314 | effectiveZoom = 1.25; | |
| 315 | listeners.get("did-navigate")?.(); | |
| 316 | yield* Effect.yieldNow; | |
| 317 | ||
| 318 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | |
| 319 | expect(setZoomFactor).not.toHaveBeenCalled(); | |
| 320 | ||
| 321 | zoomReadable = false; | |
| 322 | url = "https://example.com/after-zoom-read-failed"; | |
| 323 | listeners.get("did-navigate")?.(); | |
| 324 | yield* Effect.yieldNow; | |
| 325 | ||
| 326 | expect(states.at(-1)?.navStatus).toEqual({ | |
| 327 | kind: "Success", | |
| 328 | url, | |
| 329 | title: "Example", | |
| 330 | }); | |
| 331 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | |
| 332 | ||
| 333 | const replacementSetZoomFactor = vi.fn(); | |
| 334 | fromId.mockReturnValue({ | |
| 335 | id: 43, | |
| 336 | isDestroyed: () => false, | |
| 337 | getType: () => "webview", | |
| 338 | getURL: () => url, | |
| 339 | getTitle: () => "Example", | |
| 340 | isLoading: () => false, | |
| 341 | getZoomFactor: () => 1, | |
| 342 | setZoomFactor: replacementSetZoomFactor, | |
| 343 | on: vi.fn(), | |
| 344 | off: vi.fn(), | |
| 345 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 346 | send: webviewSend, | |
| 347 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 348 | setWindowOpenHandler: vi.fn(), | |
| 349 | debugger: { | |
| 350 | isAttached: () => false, | |
| 351 | attach: vi.fn(), | |
| 352 | sendCommand: vi.fn(async () => undefined), | |
| 353 | on: vi.fn(), | |
| 354 | off: vi.fn(), | |
| 355 | }, | |
| 356 | } as never); | |
| 357 | ||
| 358 | yield* manager.registerWebview("tab_zoom", 43); | |
| 359 | ||
| 360 | expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); | |
| 361 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | |
| 362 | }), | |
| 363 | ), | |
| 364 | ); | |
| 365 | ||
| 366 | effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => | |
| 367 | withManager((manager) => | |
| 368 | Effect.gen(function* () { | |
| 369 | const url = "http://localhost:5733/"; | |
| 370 | let loading = false; | |
| 371 | const listeners = new Map<string, (...args: unknown[]) => void>(); | |
| 372 | fromId.mockReturnValue({ | |
| 373 | id: 42, | |
| 374 | isDestroyed: () => false, | |
| 375 | getType: () => "webview", | |
| 376 | getURL: () => url, | |
| 377 | getTitle: () => "localhost:5733", | |
| 378 | isLoading: () => loading, | |
| 379 | getZoomFactor: () => 1, | |
| 380 | setZoomFactor: vi.fn(), | |
| 381 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { | |
| 382 | listeners.set(event, listener); | |
| 383 | }), | |
| 384 | off: vi.fn(), | |
| 385 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 386 | send: webviewSend, | |
| 387 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 388 | setWindowOpenHandler: vi.fn(), | |
| 389 | debugger: { | |
| 390 | isAttached: () => false, | |
| 391 | attach: vi.fn(), | |
| 392 | sendCommand: vi.fn(async () => undefined), | |
| 393 | on: vi.fn(), | |
| 394 | off: vi.fn(), | |
| 395 | }, | |
| 396 | } as never); | |
| 397 | const statuses: PreviewManager.PreviewNavStatus[] = []; | |
| 398 | ||
| 399 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 400 | Effect.sync(() => { | |
| 401 | statuses.push(state.navStatus); | |
| 402 | }), | |
| 403 | ); | |
| 404 | yield* manager.createTab("tab_failed"); | |
| 405 | yield* manager.registerWebview("tab_failed", 42); | |
| 406 | ||
| 407 | listeners.get("did-fail-load")?.( | |
| 408 | {}, | |
| 409 | -105, | |
| 410 | "ERR_NAME_NOT_RESOLVED", | |
| 411 | "https://missing-frame.example/", | |
| 412 | false, | |
| 413 | ); | |
| 414 | yield* Effect.yieldNow; | |
| 415 | expect(statuses.at(-1)?.kind).toBe("Success"); | |
| 416 | ||
| 417 | loading = true; | |
| 418 | listeners.get("did-start-loading")?.(); | |
| 419 | yield* Effect.yieldNow; | |
| 420 | expect(statuses.at(-1)?.kind).toBe("Loading"); | |
| 421 | ||
| 422 | loading = false; | |
| 423 | listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); | |
| 424 | listeners.get("did-stop-loading")?.(); | |
| 425 | listeners.get("page-title-updated")?.(); | |
| 426 | yield* Effect.yieldNow; | |
| 427 | expect(statuses.at(-1)).toEqual({ | |
| 428 | kind: "LoadFailed", | |
| 429 | url, | |
| 430 | title: "localhost:5733", | |
| 431 | code: -102, | |
| 432 | description: "ERR_CONNECTION_REFUSED", | |
| 433 | }); | |
| 434 | ||
| 435 | loading = true; | |
| 436 | listeners.get("did-start-loading")?.(); | |
| 437 | yield* Effect.yieldNow; | |
| 438 | expect(statuses.at(-1)?.kind).toBe("Loading"); | |
| 439 | ||
| 440 | loading = false; | |
| 441 | listeners.get("did-stop-loading")?.(); | |
| 442 | yield* Effect.yieldNow; | |
| 443 | expect(statuses.at(-1)?.kind).toBe("Success"); | |
| 444 | ||
| 445 | listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); | |
| 446 | yield* Effect.yieldNow; | |
| 447 | expect(statuses.at(-1)?.kind).toBe("LoadFailed"); | |
| 448 | ||
| 449 | listeners.get("did-navigate")?.(); | |
| 450 | yield* Effect.yieldNow; | |
| 451 | expect(statuses.at(-1)?.kind).toBe("Success"); | |
| 452 | }), | |
| 453 | ), | |
| 454 | ); | |
| 455 | ||
| 253 | 456 | effectIt.effect("captures a PNG screenshot into browser artifacts", () => |
| 254 | 457 | withManager((manager) => |
| 255 | 458 | Effect.gen(function* () { |
| ... | ... | @@ -511,6 +714,178 @@ describe("PreviewManager", () => { |
| 511 | 714 | ), |
| 512 | 715 | ); |
| 513 | 716 | |
| 717 | effectIt.effect("types in background webviews and enables native key input", () => | |
| 718 | withManager((manager) => | |
| 719 | Effect.gen(function* () { | |
| 720 | let failKeyDown = false; | |
| 721 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 722 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | |
| 723 | if ( | |
| 724 | failKeyDown && | |
| 725 | method === "Input.dispatchKeyEvent" && | |
| 726 | (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") | |
| 727 | ) { | |
| 728 | throw new Error("key dispatch failed"); | |
| 729 | } | |
| 730 | if ( | |
| 731 | method === "Input.dispatchKeyEvent" && | |
| 732 | (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") | |
| 733 | ) { | |
| 734 | humanInput?.( | |
| 735 | {}, | |
| 736 | { | |
| 737 | kind: "key", | |
| 738 | key: params["key"], | |
| 739 | code: params["code"] ?? "Digit1", | |
| 740 | }, | |
| 741 | ); | |
| 742 | } | |
| 743 | return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined; | |
| 744 | }); | |
| 745 | const restoreFocus = vi.fn(); | |
| 746 | const focus = vi.fn(); | |
| 747 | getFocusedWebContents.mockReturnValue({ | |
| 748 | id: 7, | |
| 749 | isDestroyed: () => false, | |
| 750 | focus: restoreFocus, | |
| 751 | } as never); | |
| 752 | fromId.mockReturnValue({ | |
| 753 | id: 42, | |
| 754 | isDestroyed: () => false, | |
| 755 | getType: () => "webview", | |
| 756 | getURL: () => "https://example.com", | |
| 757 | getTitle: () => "Example", | |
| 758 | isLoading: () => false, | |
| 759 | isDevToolsOpened: () => false, | |
| 760 | focus, | |
| 761 | getZoomFactor: () => 1, | |
| 762 | setZoomFactor: vi.fn(), | |
| 763 | on: vi.fn(), | |
| 764 | off: vi.fn(), | |
| 765 | ipc: { | |
| 766 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 767 | if (channel === "preview:human-input") humanInput = listener; | |
| 768 | }), | |
| 769 | off: vi.fn(), | |
| 770 | }, | |
| 771 | send: webviewSend, | |
| 772 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 773 | setWindowOpenHandler: vi.fn(), | |
| 774 | debugger: { | |
| 775 | isAttached: () => false, | |
| 776 | attach: vi.fn(), | |
| 777 | sendCommand, | |
| 778 | on: vi.fn(), | |
| 779 | off: vi.fn(), | |
| 780 | }, | |
| 781 | } as never); | |
| 782 | ||
| 783 | yield* manager.createTab("tab_input"); | |
| 784 | yield* manager.registerWebview("tab_input", 42); | |
| 785 | yield* manager.automationType("tab_input", { text: "hello", clear: true }); | |
| 786 | yield* manager.automationType("tab_input", { text: "", clear: true }); | |
| 787 | yield* manager.automationPress("tab_input", { key: "x" }); | |
| 788 | ||
| 789 | const calls = sendCommand.mock.calls; | |
| 790 | const methods = calls.map(([method]) => method); | |
| 791 | const enableIndex = methods.indexOf("Input.setIgnoreInputEvents"); | |
| 792 | const focusOnIndex = calls.findIndex( | |
| 793 | ([method, params]) => | |
| 794 | method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true, | |
| 795 | ); | |
| 796 | const keyDownIndex = calls.findIndex( | |
| 797 | ([method, params]) => | |
| 798 | method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown", | |
| 799 | ); | |
| 800 | const keyUpIndex = calls.findIndex( | |
| 801 | ([method, params]) => method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", | |
| 802 | ); | |
| 803 | const focusOffIndex = calls.findIndex( | |
| 804 | ([method, params]) => | |
| 805 | method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false, | |
| 806 | ); | |
| 807 | const typeEvaluation = sendCommand.mock.calls.find( | |
| 808 | ([method, params]) => | |
| 809 | method === "Runtime.evaluate" && | |
| 810 | typeof params === "object" && | |
| 811 | params !== null && | |
| 812 | "expression" in params && | |
| 813 | typeof params.expression === "string" && | |
| 814 | params.expression.includes('document.execCommand("insertText"'), | |
| 815 | ); | |
| 816 | expect(typeEvaluation).toBeDefined(); | |
| 817 | const clearOnlyEvaluation = sendCommand.mock.calls.find( | |
| 818 | ([method, params]) => | |
| 819 | method === "Runtime.evaluate" && | |
| 820 | typeof params === "object" && | |
| 821 | params !== null && | |
| 822 | "expression" in params && | |
| 823 | typeof params.expression === "string" && | |
| 824 | params.expression.includes('const text = ""') && | |
| 825 | params.expression.includes("Object.getOwnPropertyDescriptor"), | |
| 826 | ); | |
| 827 | expect(clearOnlyEvaluation).toBeDefined(); | |
| 828 | expect(methods).not.toContain("Input.insertText"); | |
| 829 | expect(enableIndex).toBeGreaterThanOrEqual(0); | |
| 830 | expect(focus).toHaveBeenCalledOnce(); | |
| 831 | expect(restoreFocus).toHaveBeenCalledOnce(); | |
| 832 | expect(methods).toContain("Page.bringToFront"); | |
| 833 | expect(enableIndex).toBeLessThan(focusOnIndex); | |
| 834 | expect(focusOnIndex).toBeLessThan(keyDownIndex); | |
| 835 | expect(keyDownIndex).toBeLessThan(keyUpIndex); | |
| 836 | expect(keyUpIndex).toBeLessThan(focusOffIndex); | |
| 837 | expect( | |
| 838 | calls.filter( | |
| 839 | ([method, params]) => | |
| 840 | method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", | |
| 841 | ), | |
| 842 | ).toHaveLength(1); | |
| 843 | expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); | |
| 844 | ||
| 845 | sendCommand.mockClear(); | |
| 846 | failKeyDown = true; | |
| 847 | const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" })); | |
| 848 | ||
| 849 | expect(Exit.isFailure(failedPress)).toBe(true); | |
| 850 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { | |
| 851 | type: "keyUp", | |
| 852 | key: "y", | |
| 853 | code: "KeyY", | |
| 854 | modifiers: 0, | |
| 855 | windowsVirtualKeyCode: 89, | |
| 856 | location: 0, | |
| 857 | isKeypad: false, | |
| 858 | }); | |
| 859 | expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { | |
| 860 | enabled: false, | |
| 861 | }); | |
| 862 | expect(restoreFocus).toHaveBeenCalledTimes(2); | |
| 863 | expect( | |
| 864 | sendCommand.mock.calls.filter( | |
| 865 | ([method, params]) => | |
| 866 | method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", | |
| 867 | ), | |
| 868 | ).toHaveLength(1); | |
| 869 | ||
| 870 | sendCommand.mockClear(); | |
| 871 | failKeyDown = false; | |
| 872 | yield* manager.automationPress("tab_input", { key: "!" }); | |
| 873 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { | |
| 874 | type: "keyDown", | |
| 875 | key: "!", | |
| 876 | code: "Digit1", | |
| 877 | modifiers: 0, | |
| 878 | windowsVirtualKeyCode: 49, | |
| 879 | location: 0, | |
| 880 | isKeypad: false, | |
| 881 | text: "!", | |
| 882 | unmodifiedText: "!", | |
| 883 | }); | |
| 884 | expect(restoreFocus).toHaveBeenCalledTimes(3); | |
| 885 | }), | |
| 886 | ), | |
| 887 | ); | |
| 888 | ||
| 514 | 889 | effectIt.effect("still interrupts agent control for a different human pointer event", () => |
| 515 | 890 | withManager((manager) => |
| 516 | 891 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+232-74| ... | ... | @@ -62,6 +62,7 @@ import { |
| 62 | 62 | } from "./GuestProtocol.ts"; |
| 63 | 63 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; |
| 64 | 64 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; |
| 65 | import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; | |
| 65 | 66 | |
| 66 | 67 | export type PreviewNavStatus = |
| 67 | 68 | | { kind: "Idle" } |
| ... | ... | @@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 854 | 855 | commandParams?: Record<string, unknown>, |
| 855 | 856 | ) => Effect.Effect<unknown, PreviewManagerError>; |
| 856 | 857 | |
| 858 | const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* ( | |
| 859 | send: SendCommand, | |
| 860 | enableRuntime: boolean, | |
| 861 | ) { | |
| 862 | yield* Effect.all( | |
| 863 | [ | |
| 864 | ...(enableRuntime ? [send("Runtime.enable")] : []), | |
| 865 | send("Input.setIgnoreInputEvents", { ignore: false }), | |
| 866 | ], | |
| 867 | { concurrency: 2, discard: true }, | |
| 868 | ); | |
| 869 | }); | |
| 870 | ||
| 857 | 871 | const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>( |
| 858 | 872 | tabId: string, |
| 859 | 873 | wc: Electron.WebContents, |
| 860 | 874 | action: string, |
| 861 | use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>, | |
| 875 | use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect<A, PreviewManagerError>, | |
| 862 | 876 | ) { |
| 863 | 877 | const sequence = yield* nextCounter(actionSequenceRef); |
| 864 | 878 | const startedAt = yield* currentIso; |
| ... | ... | @@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 899 | 913 | return result; |
| 900 | 914 | }, |
| 901 | 915 | ); |
| 902 | return yield* use(send); | |
| 916 | // Cleanup commands must still run after human input invalidates the action's | |
| 917 | // control epoch. Otherwise a partially dispatched input can leave Chromium | |
| 918 | // with a held key or focus emulation enabled for subsequent actions. | |
| 919 | const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")( | |
| 920 | function* (method, commandParams) { | |
| 921 | return yield* attemptPromise( | |
| 922 | { | |
| 923 | operation: `${action}.cleanup.${method}`, | |
| 924 | tabId, | |
| 925 | webContentsId: wc.id, | |
| 926 | }, | |
| 927 | () => wc.debugger.sendCommand(method, commandParams), | |
| 928 | ); | |
| 929 | }, | |
| 930 | ); | |
| 931 | return yield* use(send, sendCleanup); | |
| 903 | 932 | }); |
| 904 | 933 | const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( |
| 905 | 934 | exit: Exit.Exit<A, PreviewManagerError>, |
| ... | ... | @@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1091 | 1120 | wc: Electron.WebContents, |
| 1092 | 1121 | ) { |
| 1093 | 1122 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 1094 | const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { | |
| 1123 | const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( | |
| 1124 | preserveLoadFailure: boolean, | |
| 1125 | ) { | |
| 1095 | 1126 | if (wc.isDestroyed()) return; |
| 1096 | yield* update(tabId, { | |
| 1097 | navStatus: computeNavStatus(wc), | |
| 1098 | canGoBack: wc.navigationHistory.canGoBack(), | |
| 1099 | canGoForward: wc.navigationHistory.canGoForward(), | |
| 1127 | const zoomFactor = yield* attempt( | |
| 1128 | { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, | |
| 1129 | () => wc.getZoomFactor(), | |
| 1130 | ).pipe(Effect.option); | |
| 1131 | const computedNavStatus = computeNavStatus(wc); | |
| 1132 | const canGoBack = wc.navigationHistory.canGoBack(); | |
| 1133 | const canGoForward = wc.navigationHistory.canGoForward(); | |
| 1134 | const updatedAt = yield* currentIso; | |
| 1135 | const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | |
| 1136 | const current = tabs.get(tabId); | |
| 1137 | if (!current) return [Option.none<PreviewTabState>(), tabs] as const; | |
| 1138 | // Electron emits did-stop-loading after did-fail-load. At that point the | |
| 1139 | // failed guest is no longer "loading", but it has not successfully | |
| 1140 | // navigated anywhere. Keep the failure until a new load actually starts. | |
| 1141 | const navStatus = | |
| 1142 | preserveLoadFailure && | |
| 1143 | current.navStatus.kind === "LoadFailed" && | |
| 1144 | computedNavStatus.kind === "Success" | |
| 1145 | ? current.navStatus | |
| 1146 | : computedNavStatus; | |
| 1147 | const state: PreviewTabState = { | |
| 1148 | ...current, | |
| 1149 | navStatus, | |
| 1150 | canGoBack, | |
| 1151 | canGoForward, | |
| 1152 | ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), | |
| 1153 | updatedAt, | |
| 1154 | }; | |
| 1155 | return [ | |
| 1156 | Option.some(state), | |
| 1157 | replaceMap(tabs, (copy) => { | |
| 1158 | copy.set(tabId, state); | |
| 1159 | }), | |
| 1160 | ] as const; | |
| 1100 | 1161 | }); |
| 1162 | if (Option.isSome(next)) yield* emit(tabId, next.value); | |
| 1101 | 1163 | }); |
| 1102 | const sync = () => runFork(syncState()); | |
| 1103 | const failed = (_event: Event, code: number, description: string): void => { | |
| 1104 | if (code === -3) return; | |
| 1164 | const sync = () => runFork(syncState(true)); | |
| 1165 | const syncNavigation = () => runFork(syncState(false)); | |
| 1166 | const failed = ( | |
| 1167 | _event: Event, | |
| 1168 | code: number, | |
| 1169 | description: string, | |
| 1170 | validatedUrl: string, | |
| 1171 | isMainFrame: boolean, | |
| 1172 | ): void => { | |
| 1173 | if (code === -3 || !isMainFrame) return; | |
| 1105 | 1174 | runFork( |
| 1106 | 1175 | update(tabId, { |
| 1107 | 1176 | navStatus: { |
| 1108 | 1177 | kind: "LoadFailed", |
| 1109 | url: wc.getURL(), | |
| 1178 | url: validatedUrl || wc.getURL(), | |
| 1110 | 1179 | title: wc.getTitle(), |
| 1111 | 1180 | code, |
| 1112 | 1181 | description, |
| ... | ... | @@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1161 | 1230 | yield* Scope.addFinalizer( |
| 1162 | 1231 | scope, |
| 1163 | 1232 | attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { |
| 1164 | wc.off("did-navigate", sync); | |
| 1165 | wc.off("did-navigate-in-page", sync); | |
| 1233 | wc.off("did-navigate", syncNavigation); | |
| 1234 | wc.off("did-navigate-in-page", syncNavigation); | |
| 1166 | 1235 | wc.off("page-title-updated", sync); |
| 1167 | 1236 | wc.off("did-start-loading", sync); |
| 1168 | 1237 | wc.off("did-stop-loading", sync); |
| ... | ... | @@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1173 | 1242 | ); |
| 1174 | 1243 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| 1175 | 1244 | yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { |
| 1176 | wc.on("did-navigate", sync); | |
| 1177 | wc.on("did-navigate-in-page", sync); | |
| 1245 | wc.on("did-navigate", syncNavigation); | |
| 1246 | wc.on("did-navigate-in-page", syncNavigation); | |
| 1178 | 1247 | wc.on("page-title-updated", sync); |
| 1179 | 1248 | wc.on("did-start-loading", sync); |
| 1180 | 1249 | wc.on("did-stop-loading", sync); |
| ... | ... | @@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1280 | 1349 | const attached = yield* Ref.get(attachedRef); |
| 1281 | 1350 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1282 | 1351 | if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { |
| 1352 | const zoomFactor = yield* attempt( | |
| 1353 | { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, | |
| 1354 | () => wc.getZoomFactor(), | |
| 1355 | ); | |
| 1356 | yield* update(tabId, { zoomFactor }); | |
| 1283 | 1357 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1284 | 1358 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1285 | 1359 | ); |
| 1286 | 1360 | return; |
| 1287 | 1361 | } |
| 1288 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { | |
| 1362 | const replacedWebContentsId = | |
| 1363 | tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; | |
| 1364 | if (replacedWebContentsId !== null) { | |
| 1289 | 1365 | yield* Effect.all( |
| 1290 | 1366 | [ |
| 1291 | detachControlSession(tab.webContentsId), | |
| 1292 | detachListeners(tab.webContentsId), | |
| 1367 | detachControlSession(replacedWebContentsId), | |
| 1368 | detachListeners(replacedWebContentsId), | |
| 1293 | 1369 | cancelPickElement(tabId), |
| 1294 | 1370 | ], |
| 1295 | 1371 | { concurrency: 3, discard: true }, |
| 1296 | 1372 | ); |
| 1297 | 1373 | } |
| 1374 | const zoomFactor = | |
| 1375 | replacedWebContentsId !== null | |
| 1376 | ? yield* attempt( | |
| 1377 | { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, | |
| 1378 | () => { | |
| 1379 | wc.setZoomFactor(tab.zoomFactor); | |
| 1380 | return tab.zoomFactor; | |
| 1381 | }, | |
| 1382 | ) | |
| 1383 | : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => | |
| 1384 | wc.getZoomFactor(), | |
| 1385 | ); | |
| 1298 | 1386 | yield* attachListeners(tabId, wc); |
| 1299 | 1387 | runFork(ensureControlSession(wc).pipe(Effect.ignore)); |
| 1300 | 1388 | const registeredAt = yield* currentIso; |
| ... | ... | @@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1313 | 1401 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1314 | 1402 | canGoBack: wc.navigationHistory.canGoBack(), |
| 1315 | 1403 | canGoForward: wc.navigationHistory.canGoForward(), |
| 1404 | zoomFactor, | |
| 1316 | 1405 | updatedAt: registeredAt, |
| 1317 | 1406 | }; |
| 1318 | 1407 | return [ |
| ... | ... | @@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1330 | 1419 | } |
| 1331 | 1420 | const { state: registered, pendingUrl } = registration.value; |
| 1332 | 1421 | yield* emit(tabId, registered); |
| 1333 | if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { | |
| 1334 | yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () => | |
| 1335 | wc.setZoomFactor(registered.zoomFactor), | |
| 1336 | ).pipe(Effect.ignore); | |
| 1337 | } | |
| 1338 | 1422 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1339 | 1423 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1340 | 1424 | ); |
| ... | ... | @@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1946 | 2030 | input: PreviewAutomationClickInput, |
| 1947 | 2031 | send: SendCommand, |
| 1948 | 2032 | ) { |
| 1949 | yield* Effect.all( | |
| 1950 | [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], | |
| 1951 | { concurrency: 2, discard: true }, | |
| 1952 | ); | |
| 2033 | yield* prepareAutomationInput(send, true); | |
| 1953 | 2034 | const point = yield* resolveClickPoint(tabId, send, input); |
| 1954 | 2035 | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( |
| 1955 | 2036 | tabId, |
| ... | ... | @@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2011 | 2092 | ); |
| 2012 | 2093 | }); |
| 2013 | 2094 | |
| 2014 | const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( | |
| 2095 | const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* ( | |
| 2015 | 2096 | tabId: string, |
| 2016 | 2097 | send: SendCommand, |
| 2017 | 2098 | input: PreviewAutomationTypeInput, |
| ... | ... | @@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2021 | 2102 | const locatorJson = locator |
| 2022 | 2103 | ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) |
| 2023 | 2104 | : null; |
| 2105 | const textJson = yield* encodeJson( | |
| 2106 | { operation: "automationType.encodeText", tabId }, | |
| 2107 | input.text, | |
| 2108 | ); | |
| 2024 | 2109 | const result = yield* evaluateWithDebugger< |
| 2025 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | |
| 2110 | | { ok: true } | |
| 2111 | | { invalidSelector: true; message: string } | |
| 2112 | | { notEditable: true } | |
| 2113 | | { notFound: true } | |
| 2026 | 2114 | >( |
| 2027 | 2115 | tabId, |
| 2028 | 2116 | send, |
| ... | ... | @@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2030 | 2118 | try { |
| 2031 | 2119 | const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; |
| 2032 | 2120 | if (!element) return { notFound: true }; |
| 2121 | const textControl = | |
| 2122 | element instanceof HTMLTextAreaElement || | |
| 2123 | (element instanceof HTMLInputElement && | |
| 2124 | !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type)); | |
| 2125 | const editable = textControl || element.isContentEditable; | |
| 2126 | if (!editable || element.disabled || element.readOnly) return { notEditable: true }; | |
| 2033 | 2127 | element.focus(); |
| 2034 | if (${input.clear ?? false}) { | |
| 2035 | if ("value" in element) element.value = ""; | |
| 2036 | else if (element.isContentEditable) element.textContent = ""; | |
| 2037 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | |
| 2128 | if (document.activeElement !== element) return { notEditable: true }; | |
| 2129 | const clear = ${input.clear ?? false}; | |
| 2130 | if (clear) { | |
| 2131 | if (textControl) { | |
| 2132 | element.select(); | |
| 2133 | } else { | |
| 2134 | const range = document.createRange(); | |
| 2135 | range.selectNodeContents(element); | |
| 2136 | const selection = document.getSelection(); | |
| 2137 | selection?.removeAllRanges(); | |
| 2138 | selection?.addRange(range); | |
| 2139 | } | |
| 2140 | } | |
| 2141 | const text = ${textJson}; | |
| 2142 | let inserted = true; | |
| 2143 | if (text.length > 0) { | |
| 2144 | inserted = document.execCommand("insertText", false, text); | |
| 2145 | } else if (clear) { | |
| 2146 | document.execCommand("delete", false); | |
| 2147 | const cleared = textControl | |
| 2148 | ? element.value.length === 0 | |
| 2149 | : (element.textContent ?? "").length === 0; | |
| 2150 | if (!cleared) { | |
| 2151 | if (textControl) { | |
| 2152 | const prototype = element instanceof HTMLTextAreaElement | |
| 2153 | ? HTMLTextAreaElement.prototype | |
| 2154 | : HTMLInputElement.prototype; | |
| 2155 | const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; | |
| 2156 | if (valueSetter) valueSetter.call(element, ""); | |
| 2157 | else element.value = ""; | |
| 2158 | } else { | |
| 2159 | element.replaceChildren(); | |
| 2160 | } | |
| 2161 | element.dispatchEvent(new InputEvent("input", { | |
| 2162 | bubbles: true, | |
| 2163 | inputType: "deleteContentBackward", | |
| 2164 | })); | |
| 2165 | } | |
| 2038 | 2166 | } |
| 2167 | if (!inserted) return { notEditable: true }; | |
| 2168 | element.dispatchEvent(new Event("change", { bubbles: true })); | |
| 2039 | 2169 | return { ok: true }; |
| 2040 | 2170 | } catch (error) { |
| 2041 | 2171 | return { invalidSelector: true, message: String(error) }; |
| ... | ... | @@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2059 | 2189 | ...automationSelectorDiagnostics(input), |
| 2060 | 2190 | }); |
| 2061 | 2191 | } |
| 2192 | if ("notEditable" in result) { | |
| 2193 | return yield* new PreviewAutomationTargetNotEditableError({ | |
| 2194 | tabId, | |
| 2195 | ...automationSelectorDiagnostics(input), | |
| 2196 | }); | |
| 2197 | } | |
| 2062 | 2198 | }); |
| 2063 | 2199 | |
| 2064 | 2200 | const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( |
| ... | ... | @@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2066 | 2202 | input: PreviewAutomationTypeInput, |
| 2067 | 2203 | send: SendCommand, |
| 2068 | 2204 | ) { |
| 2069 | yield* send("Runtime.enable"); | |
| 2070 | yield* focusAutomationTarget(tabId, send, input); | |
| 2071 | yield* send("Input.insertText", { text: input.text }); | |
| 2072 | const textJson = yield* encodeJson( | |
| 2073 | { operation: "automationType.encodeText", tabId }, | |
| 2074 | input.text, | |
| 2075 | ); | |
| 2076 | yield* evaluateWithDebugger( | |
| 2077 | tabId, | |
| 2078 | send, | |
| 2079 | `(() => { | |
| 2080 | const element = document.activeElement; | |
| 2081 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); | |
| 2082 | element?.dispatchEvent(new Event("change", { bubbles: true })); | |
| 2083 | })()`, | |
| 2084 | false, | |
| 2085 | ); | |
| 2205 | // CDP Input.insertText silently drops text until Electron has activated a hidden | |
| 2206 | // guest WebContents with a pointer event. Editing in the page runtime keeps | |
| 2207 | // background automation deterministic without stealing foreground app focus. | |
| 2208 | yield* typeIntoAutomationTarget(tabId, send, input); | |
| 2086 | 2209 | }); |
| 2087 | 2210 | |
| 2088 | 2211 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( |
| ... | ... | @@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2097 | 2220 | |
| 2098 | 2221 | const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( |
| 2099 | 2222 | tabId: string, |
| 2223 | wc: Electron.WebContents, | |
| 2100 | 2224 | input: PreviewAutomationPressInput, |
| 2101 | 2225 | send: SendCommand, |
| 2226 | sendCleanup: SendCommand, | |
| 2102 | 2227 | ) { |
| 2103 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | |
| 2104 | switch (modifier) { | |
| 2105 | case "Alt": | |
| 2106 | return value | 1; | |
| 2107 | case "Control": | |
| 2108 | return value | 2; | |
| 2109 | case "Meta": | |
| 2110 | return value | 4; | |
| 2111 | case "Shift": | |
| 2112 | return value | 8; | |
| 2228 | yield* prepareAutomationInput(send, false); | |
| 2229 | const keySequence = makePreviewAutomationKeySequence(input); | |
| 2230 | const previouslyFocused = yield* attempt( | |
| 2231 | { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, | |
| 2232 | () => webContents.getFocusedWebContents(), | |
| 2233 | ); | |
| 2234 | let keyDownAttempted = false; | |
| 2235 | const releaseInput = Effect.gen(function* () { | |
| 2236 | if (keyDownAttempted) { | |
| 2237 | yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore); | |
| 2113 | 2238 | } |
| 2114 | }, 0); | |
| 2115 | const key = input.key; | |
| 2116 | const text = key.length === 1 ? key : undefined; | |
| 2117 | const params = { | |
| 2118 | key, | |
| 2119 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | |
| 2120 | modifiers, | |
| 2121 | ...(text ? { text, unmodifiedText: text } : {}), | |
| 2122 | }; | |
| 2123 | yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); | |
| 2124 | yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | |
| 2125 | yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | |
| 2239 | yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( | |
| 2240 | Effect.ignore, | |
| 2241 | ); | |
| 2242 | if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) { | |
| 2243 | yield* attempt( | |
| 2244 | { | |
| 2245 | operation: "automationPress.restoreFocusedWebContents", | |
| 2246 | tabId, | |
| 2247 | webContentsId: previouslyFocused.id, | |
| 2248 | }, | |
| 2249 | () => previouslyFocused.focus(), | |
| 2250 | ).pipe(Effect.ignore); | |
| 2251 | } | |
| 2252 | }); | |
| 2253 | ||
| 2254 | // Focus the guest WebContents itself, not its containing BrowserWindow. This | |
| 2255 | // activates native keyboard behavior for hidden/background previews without | |
| 2256 | // changing which thread is mounted in the UI. Restore the previous renderer | |
| 2257 | // after dispatch so automation never leaves the app's input focus behind. | |
| 2258 | yield* Effect.gen(function* () { | |
| 2259 | yield* attempt( | |
| 2260 | { operation: "automationPress.focusWebContents", tabId, webContentsId: wc.id }, | |
| 2261 | () => wc.focus(), | |
| 2262 | ); | |
| 2263 | yield* send("Page.bringToFront"); | |
| 2264 | yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }); | |
| 2265 | yield* expectAgentInput(tabId, keySequence.signal); | |
| 2266 | keyDownAttempted = true; | |
| 2267 | yield* send("Input.dispatchKeyEvent", keySequence.keyDown); | |
| 2268 | }).pipe(Effect.ensuring(releaseInput)); | |
| 2126 | 2269 | }); |
| 2127 | 2270 | |
| 2128 | 2271 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( |
| ... | ... | @@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2130 | 2273 | input: PreviewAutomationPressInput, |
| 2131 | 2274 | ) { |
| 2132 | 2275 | const wc = yield* requireWebContents(tabId); |
| 2133 | yield* withControlSession(tabId, wc, "press", (send) => | |
| 2134 | performAutomationPress(tabId, input, send), | |
| 2276 | yield* withControlSession(tabId, wc, "press", (send, sendCleanup) => | |
| 2277 | performAutomationPress(tabId, wc, input, send, sendCleanup), | |
| 2135 | 2278 | ); |
| 2136 | 2279 | }); |
| 2137 | 2280 | |
| ... | ... | @@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas |
| 2529 | 2672 | } |
| 2530 | 2673 | } |
| 2531 | 2674 | |
| 2675 | export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()( | |
| 2676 | "PreviewAutomationTargetNotEditableError", | |
| 2677 | { | |
| 2678 | tabId: Schema.String, | |
| 2679 | selectorKind: PreviewAutomationSelectorKind, | |
| 2680 | selectorLength: Schema.optionalKey(Schema.Number), | |
| 2681 | }, | |
| 2682 | ) { | |
| 2683 | override get message(): string { | |
| 2684 | const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); | |
| 2685 | return `Preview automation type found ${target}, but it is not editable in tab ${this.tabId}`; | |
| 2686 | } | |
| 2687 | } | |
| 2688 | ||
| 2532 | 2689 | export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()( |
| 2533 | 2690 | "PreviewAutomationCoordinatesOutsideViewportError", |
| 2534 | 2691 | { |
| ... | ... | @@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([ |
| 2631 | 2788 | PreviewAutomationDebuggerAttachedError, |
| 2632 | 2789 | PreviewAutomationEvaluationError, |
| 2633 | 2790 | PreviewAutomationTargetNotFoundError, |
| 2791 | PreviewAutomationTargetNotEditableError, | |
| 2634 | 2792 | PreviewAutomationCoordinatesOutsideViewportError, |
| 2635 | 2793 | PreviewAutomationInvalidSelectorError, |
| 2636 | 2794 | PreviewAutomationResultTooLargeError, |
apps/desktop/src/preview/PreviewKeyboard.test.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; | |
| 4 | ||
| 5 | describe("preview keyboard packets", () => { | |
| 6 | it("includes the Chromium virtual key code and Enter text", () => { | |
| 7 | expect(makePreviewAutomationKeySequence({ key: "Enter" })).toEqual({ | |
| 8 | keyDown: { | |
| 9 | type: "keyDown", | |
| 10 | key: "Enter", | |
| 11 | code: "Enter", | |
| 12 | modifiers: 0, | |
| 13 | windowsVirtualKeyCode: 13, | |
| 14 | location: 0, | |
| 15 | isKeypad: false, | |
| 16 | text: "\r", | |
| 17 | unmodifiedText: "\r", | |
| 18 | }, | |
| 19 | keyUp: { | |
| 20 | type: "keyUp", | |
| 21 | key: "Enter", | |
| 22 | code: "Enter", | |
| 23 | modifiers: 0, | |
| 24 | windowsVirtualKeyCode: 13, | |
| 25 | location: 0, | |
| 26 | isKeypad: false, | |
| 27 | }, | |
| 28 | signal: { kind: "key", key: "Enter", code: "Enter" }, | |
| 29 | }); | |
| 30 | }); | |
| 31 | ||
| 32 | it("dispatches printable keys as text key-down events", () => { | |
| 33 | const sequence = makePreviewAutomationKeySequence({ key: "z" }); | |
| 34 | expect(sequence.keyDown).toMatchObject({ | |
| 35 | type: "keyDown", | |
| 36 | key: "z", | |
| 37 | code: "KeyZ", | |
| 38 | windowsVirtualKeyCode: 90, | |
| 39 | text: "z", | |
| 40 | }); | |
| 41 | expect(sequence.keyUp).not.toHaveProperty("text"); | |
| 42 | }); | |
| 43 | ||
| 44 | it("suppresses text and uses raw key-down for shortcuts", () => { | |
| 45 | expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({ | |
| 46 | type: "rawKeyDown", | |
| 47 | key: "a", | |
| 48 | code: "KeyA", | |
| 49 | modifiers: 4, | |
| 50 | windowsVirtualKeyCode: 65, | |
| 51 | location: 0, | |
| 52 | isKeypad: false, | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | it("resolves shifted printable keys to their browser values", () => { | |
| 57 | const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] }); | |
| 58 | expect(sequence.keyDown).toMatchObject({ | |
| 59 | key: "!", | |
| 60 | code: "Digit1", | |
| 61 | modifiers: 8, | |
| 62 | windowsVirtualKeyCode: 49, | |
| 63 | text: "!", | |
| 64 | }); | |
| 65 | expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" }); | |
| 66 | }); | |
| 67 | ||
| 68 | it("keeps shifted key values while suppressing text for modified chords", () => { | |
| 69 | const sequence = makePreviewAutomationKeySequence({ | |
| 70 | key: "1", | |
| 71 | modifiers: ["Control", "Shift"], | |
| 72 | }); | |
| 73 | expect(sequence.keyDown).toEqual({ | |
| 74 | type: "rawKeyDown", | |
| 75 | key: "!", | |
| 76 | code: "Digit1", | |
| 77 | modifiers: 10, | |
| 78 | windowsVirtualKeyCode: 49, | |
| 79 | location: 0, | |
| 80 | isKeypad: false, | |
| 81 | }); | |
| 82 | expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" }); | |
| 83 | }); | |
| 84 | }); |
apps/desktop/src/preview/PreviewKeyboard.ts created+163| ... | ... | @@ -0,0 +1,163 @@ |
| 1 | import type { PreviewAutomationPressInput } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface KeyDefinition { | |
| 4 | readonly code: string; | |
| 5 | readonly key: string; | |
| 6 | readonly keyCode: number; | |
| 7 | readonly text?: string; | |
| 8 | readonly location?: number; | |
| 9 | readonly shiftedKey?: string; | |
| 10 | } | |
| 11 | ||
| 12 | export interface PreviewAutomationKeyEvent { | |
| 13 | readonly [key: string]: unknown; | |
| 14 | readonly type: "keyDown" | "rawKeyDown" | "keyUp"; | |
| 15 | readonly key: string; | |
| 16 | readonly code: string; | |
| 17 | readonly modifiers: number; | |
| 18 | readonly windowsVirtualKeyCode: number; | |
| 19 | readonly location: number; | |
| 20 | readonly isKeypad: boolean; | |
| 21 | readonly text?: string; | |
| 22 | readonly unmodifiedText?: string; | |
| 23 | } | |
| 24 | ||
| 25 | export interface PreviewAutomationKeySequence { | |
| 26 | readonly keyDown: PreviewAutomationKeyEvent; | |
| 27 | readonly keyUp: PreviewAutomationKeyEvent; | |
| 28 | readonly signal: { | |
| 29 | readonly kind: "key"; | |
| 30 | readonly key: string; | |
| 31 | readonly code: string; | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | const NAMED_KEYS: Readonly<Record<string, KeyDefinition>> = { | |
| 36 | Escape: { code: "Escape", key: "Escape", keyCode: 27 }, | |
| 37 | Backspace: { code: "Backspace", key: "Backspace", keyCode: 8 }, | |
| 38 | Tab: { code: "Tab", key: "Tab", keyCode: 9 }, | |
| 39 | Enter: { code: "Enter", key: "Enter", keyCode: 13, text: "\r" }, | |
| 40 | Shift: { code: "ShiftLeft", key: "Shift", keyCode: 16, location: 1 }, | |
| 41 | Control: { code: "ControlLeft", key: "Control", keyCode: 17, location: 1 }, | |
| 42 | Alt: { code: "AltLeft", key: "Alt", keyCode: 18, location: 1 }, | |
| 43 | Meta: { code: "MetaLeft", key: "Meta", keyCode: 91, location: 1 }, | |
| 44 | CapsLock: { code: "CapsLock", key: "CapsLock", keyCode: 20 }, | |
| 45 | Space: { code: "Space", key: " ", keyCode: 32, text: " " }, | |
| 46 | PageUp: { code: "PageUp", key: "PageUp", keyCode: 33 }, | |
| 47 | PageDown: { code: "PageDown", key: "PageDown", keyCode: 34 }, | |
| 48 | End: { code: "End", key: "End", keyCode: 35 }, | |
| 49 | Home: { code: "Home", key: "Home", keyCode: 36 }, | |
| 50 | ArrowLeft: { code: "ArrowLeft", key: "ArrowLeft", keyCode: 37 }, | |
| 51 | ArrowUp: { code: "ArrowUp", key: "ArrowUp", keyCode: 38 }, | |
| 52 | ArrowRight: { code: "ArrowRight", key: "ArrowRight", keyCode: 39 }, | |
| 53 | ArrowDown: { code: "ArrowDown", key: "ArrowDown", keyCode: 40 }, | |
| 54 | Insert: { code: "Insert", key: "Insert", keyCode: 45 }, | |
| 55 | Delete: { code: "Delete", key: "Delete", keyCode: 46 }, | |
| 56 | }; | |
| 57 | ||
| 58 | const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [ | |
| 59 | { code: "Backquote", key: "`", shiftedKey: "~", keyCode: 192 }, | |
| 60 | { code: "Digit1", key: "1", shiftedKey: "!", keyCode: 49 }, | |
| 61 | { code: "Digit2", key: "2", shiftedKey: "@", keyCode: 50 }, | |
| 62 | { code: "Digit3", key: "3", shiftedKey: "#", keyCode: 51 }, | |
| 63 | { code: "Digit4", key: "4", shiftedKey: "$", keyCode: 52 }, | |
| 64 | { code: "Digit5", key: "5", shiftedKey: "%", keyCode: 53 }, | |
| 65 | { code: "Digit6", key: "6", shiftedKey: "^", keyCode: 54 }, | |
| 66 | { code: "Digit7", key: "7", shiftedKey: "&", keyCode: 55 }, | |
| 67 | { code: "Digit8", key: "8", shiftedKey: "*", keyCode: 56 }, | |
| 68 | { code: "Digit9", key: "9", shiftedKey: "(", keyCode: 57 }, | |
| 69 | { code: "Digit0", key: "0", shiftedKey: ")", keyCode: 48 }, | |
| 70 | { code: "Minus", key: "-", shiftedKey: "_", keyCode: 189 }, | |
| 71 | { code: "Equal", key: "=", shiftedKey: "+", keyCode: 187 }, | |
| 72 | { code: "Backslash", key: "\\", shiftedKey: "|", keyCode: 220 }, | |
| 73 | { code: "BracketLeft", key: "[", shiftedKey: "{", keyCode: 219 }, | |
| 74 | { code: "BracketRight", key: "]", shiftedKey: "}", keyCode: 221 }, | |
| 75 | { code: "Semicolon", key: ";", shiftedKey: ":", keyCode: 186 }, | |
| 76 | { code: "Quote", key: "'", shiftedKey: '"', keyCode: 222 }, | |
| 77 | { code: "Comma", key: ",", shiftedKey: "<", keyCode: 188 }, | |
| 78 | { code: "Period", key: ".", shiftedKey: ">", keyCode: 190 }, | |
| 79 | { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 }, | |
| 80 | ]; | |
| 81 | ||
| 82 | const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number => | |
| 83 | (modifiers ?? []).reduce((value, modifier) => { | |
| 84 | switch (modifier) { | |
| 85 | case "Alt": | |
| 86 | return value | 1; | |
| 87 | case "Control": | |
| 88 | return value | 2; | |
| 89 | case "Meta": | |
| 90 | return value | 4; | |
| 91 | case "Shift": | |
| 92 | return value | 8; | |
| 93 | } | |
| 94 | }, 0); | |
| 95 | ||
| 96 | function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition { | |
| 97 | const named = NAMED_KEYS[input.key]; | |
| 98 | if (named) return named; | |
| 99 | ||
| 100 | const functionKey = /^F([1-9]|1[0-2])$/.exec(input.key); | |
| 101 | if (functionKey) { | |
| 102 | const number = Number(functionKey[1]); | |
| 103 | return { code: input.key, key: input.key, keyCode: 111 + number }; | |
| 104 | } | |
| 105 | ||
| 106 | if (/^[a-z]$/i.test(input.key)) { | |
| 107 | const upper = input.key.toUpperCase(); | |
| 108 | const shifted = input.modifiers?.includes("Shift") ?? false; | |
| 109 | const key = shifted || input.key === upper ? upper : input.key; | |
| 110 | return { code: `Key${upper}`, key, keyCode: upper.charCodeAt(0), text: key }; | |
| 111 | } | |
| 112 | ||
| 113 | const printable = PRINTABLE_KEYS.find( | |
| 114 | (definition) => definition.key === input.key || definition.shiftedKey === input.key, | |
| 115 | ); | |
| 116 | if (printable) { | |
| 117 | const shifted = input.modifiers?.includes("Shift") ?? false; | |
| 118 | const key = | |
| 119 | printable.shiftedKey && (shifted || input.key === printable.shiftedKey) | |
| 120 | ? printable.shiftedKey | |
| 121 | : printable.key; | |
| 122 | return { ...printable, key, text: key }; | |
| 123 | } | |
| 124 | ||
| 125 | return { | |
| 126 | code: input.key.length > 1 ? input.key : "", | |
| 127 | key: input.key, | |
| 128 | keyCode: 0, | |
| 129 | ...(input.key.length === 1 ? { text: input.key } : {}), | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Build Chromium CDP key packets using the same required fields and down-event | |
| 135 | * choice as Playwright's pinned Chromium keyboard implementation. | |
| 136 | */ | |
| 137 | export function makePreviewAutomationKeySequence( | |
| 138 | input: PreviewAutomationPressInput, | |
| 139 | ): PreviewAutomationKeySequence { | |
| 140 | const definition = resolveKeyDefinition(input); | |
| 141 | const modifiers = modifierMask(input.modifiers); | |
| 142 | const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false; | |
| 143 | const text = suppressText ? "" : (definition.text ?? ""); | |
| 144 | const location = definition.location ?? 0; | |
| 145 | const shared = { | |
| 146 | key: definition.key, | |
| 147 | code: definition.code, | |
| 148 | modifiers, | |
| 149 | windowsVirtualKeyCode: definition.keyCode, | |
| 150 | location, | |
| 151 | isKeypad: location === 3, | |
| 152 | }; | |
| 153 | ||
| 154 | return { | |
| 155 | keyDown: { | |
| 156 | type: text ? "keyDown" : "rawKeyDown", | |
| 157 | ...shared, | |
| 158 | ...(text ? { text, unmodifiedText: text } : {}), | |
| 159 | }, | |
| 160 | keyUp: { type: "keyUp", ...shared }, | |
| 161 | signal: { kind: "key", key: definition.key, code: definition.code }, | |
| 162 | }; | |
| 163 | } |
apps/desktop/src/window/DesktopWindow.test.ts+71| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | 8 | |
| 8 | 9 | import type * as Electron from "electron"; |
| 9 | 10 | import { vi } from "vite-plus/test"; |
| ... | ... | @@ -47,12 +48,14 @@ function makeFakeBrowserWindow() { |
| 47 | 48 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 48 | 49 | const webContents = { |
| 49 | 50 | copyImageAt: vi.fn(), |
| 51 | getURL: vi.fn(() => "t3code-dev://app/"), | |
| 50 | 52 | isLoadingMainFrame: vi.fn(() => false), |
| 51 | 53 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 52 | 54 | webContentsListeners.set(eventName, listener); |
| 53 | 55 | }), |
| 54 | 56 | once: vi.fn(), |
| 55 | 57 | openDevTools: vi.fn(), |
| 58 | reload: vi.fn(), | |
| 56 | 59 | replaceMisspelling: vi.fn(), |
| 57 | 60 | send: vi.fn(), |
| 58 | 61 | setWindowOpenHandler: vi.fn(), |
| ... | ... | @@ -79,6 +82,7 @@ function makeFakeBrowserWindow() { |
| 79 | 82 | window: window as unknown as Electron.BrowserWindow, |
| 80 | 83 | loadURL: window.loadURL, |
| 81 | 84 | openDevTools: webContents.openDevTools, |
| 85 | reload: webContents.reload, | |
| 82 | 86 | setAutoHideCursor: window.setAutoHideCursor, |
| 83 | 87 | webContentsListeners, |
| 84 | 88 | }; |
| ... | ... | @@ -237,6 +241,73 @@ describe("DesktopWindow", () => { |
| 237 | 241 | }), |
| 238 | 242 | ); |
| 239 | 243 | |
| 244 | it.effect("recovers when the development renderer is temporarily unreachable", () => | |
| 245 | Effect.gen(function* () { | |
| 246 | const fakeWindow = makeFakeBrowserWindow(); | |
| 247 | const createCount = yield* Ref.make(0); | |
| 248 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | |
| 249 | const layer = makeTestLayer({ | |
| 250 | window: fakeWindow.window, | |
| 251 | createCount, | |
| 252 | mainWindow, | |
| 253 | }); | |
| 254 | ||
| 255 | yield* Effect.gen(function* () { | |
| 256 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 257 | yield* desktopWindow.handleBackendReady; | |
| 258 | ||
| 259 | const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); | |
| 260 | const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); | |
| 261 | if (!didFailLoad || !didFinishLoad) { | |
| 262 | return yield* Effect.die("renderer load listeners were not registered"); | |
| 263 | } | |
| 264 | ||
| 265 | didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); | |
| 266 | assert.equal(fakeWindow.loadURL.mock.calls.length, 1); | |
| 267 | ||
| 268 | yield* TestClock.adjust(100); | |
| 269 | assert.deepEqual(fakeWindow.loadURL.mock.calls, [ | |
| 270 | ["t3code-dev://app/"], | |
| 271 | ["t3code-dev://app/"], | |
| 272 | ]); | |
| 273 | assert.equal(fakeWindow.reload.mock.calls.length, 0); | |
| 274 | ||
| 275 | didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); | |
| 276 | didFinishLoad(); | |
| 277 | yield* TestClock.adjust(250); | |
| 278 | assert.equal(fakeWindow.loadURL.mock.calls.length, 2); | |
| 279 | assert.equal(fakeWindow.reload.mock.calls.length, 0); | |
| 280 | }).pipe(Effect.provide(layer)); | |
| 281 | }), | |
| 282 | ); | |
| 283 | ||
| 284 | it("retries only transient failures for the development renderer", () => { | |
| 285 | assert.isTrue( | |
| 286 | DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ | |
| 287 | applicationUrl: "t3code-dev://app/", | |
| 288 | errorCode: -102, | |
| 289 | isMainFrame: true, | |
| 290 | validatedUrl: "t3code-dev://app/", | |
| 291 | }), | |
| 292 | ); | |
| 293 | assert.isFalse( | |
| 294 | DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ | |
| 295 | applicationUrl: "t3code-dev://app/", | |
| 296 | errorCode: -3, | |
| 297 | isMainFrame: true, | |
| 298 | validatedUrl: "t3code-dev://app/", | |
| 299 | }), | |
| 300 | ); | |
| 301 | assert.isFalse( | |
| 302 | DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ | |
| 303 | applicationUrl: "t3code-dev://app/", | |
| 304 | errorCode: -102, | |
| 305 | isMainFrame: true, | |
| 306 | validatedUrl: "https://example.com/", | |
| 307 | }), | |
| 308 | ); | |
| 309 | }); | |
| 310 | ||
| 240 | 311 | it.effect("opens safe off-origin renderer navigations in the system browser", () => |
| 241 | 312 | Effect.gen(function* () { |
| 242 | 313 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+95-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; | |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 4 | 5 | import * as Option from "effect/Option"; |
| 5 | 6 | import * as Ref from "effect/Ref"; |
| ... | ... | @@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40; |
| 22 | 23 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| 23 | 24 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; |
| 24 | 25 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; |
| 26 | const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; | |
| 27 | const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ | |
| 28 | -2, // ERR_FAILED | |
| 29 | -7, // ERR_TIMED_OUT | |
| 30 | -9, // ERR_UNEXPECTED (custom protocol handler rejected) | |
| 31 | -102, // ERR_CONNECTION_REFUSED | |
| 32 | -105, // ERR_NAME_NOT_RESOLVED | |
| 33 | -106, // ERR_INTERNET_DISCONNECTED | |
| 34 | -118, // ERR_CONNECTION_TIMED_OUT | |
| 35 | ]); | |
| 25 | 36 | |
| 26 | 37 | type WindowTitleBarOptions = Pick< |
| 27 | 38 | Electron.BrowserWindowConstructorOptions, |
| ... | ... | @@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: { |
| 86 | 97 | } |
| 87 | 98 | } |
| 88 | 99 | |
| 100 | export function isRetryableDevelopmentRendererLoadFailure(input: { | |
| 101 | readonly applicationUrl: string; | |
| 102 | readonly errorCode: number; | |
| 103 | readonly isMainFrame: boolean; | |
| 104 | readonly validatedUrl: string; | |
| 105 | }): boolean { | |
| 106 | return ( | |
| 107 | input.isMainFrame && | |
| 108 | DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES.has(input.errorCode) && | |
| 109 | isSameOriginRendererNavigation({ | |
| 110 | applicationUrl: input.applicationUrl, | |
| 111 | navigationUrl: input.validatedUrl, | |
| 112 | }) | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 89 | 116 | function getWindowTitleBarOptions( |
| 90 | 117 | shouldUseDarkColors: boolean, |
| 91 | 118 | platform: NodeJS.Platform, |
| ... | ... | @@ -152,6 +179,7 @@ export const make = Effect.gen(function* () { |
| 152 | 179 | const previewManager = yield* PreviewManager.PreviewManager; |
| 153 | 180 | const state = yield* DesktopState.DesktopState; |
| 154 | 181 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 182 | const runFork = Effect.runForkWith(context); | |
| 155 | 183 | const runPromise = Effect.runPromiseWith(context); |
| 156 | 184 | |
| 157 | 185 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< |
| ... | ... | @@ -277,7 +305,61 @@ export const make = Effect.gen(function* () { |
| 277 | 305 | event.preventDefault(); |
| 278 | 306 | window.setTitle(environment.displayName); |
| 279 | 307 | }); |
| 308 | ||
| 309 | let developmentLoadRetryIndex = 0; | |
| 310 | let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined; | |
| 311 | const clearDevelopmentLoadRetry = () => { | |
| 312 | if (developmentLoadRetryFiber === undefined) { | |
| 313 | return; | |
| 314 | } | |
| 315 | const retryFiber = developmentLoadRetryFiber; | |
| 316 | developmentLoadRetryFiber = undefined; | |
| 317 | runFork(Fiber.interrupt(retryFiber)); | |
| 318 | }; | |
| 319 | const loadApplication = () => { | |
| 320 | if (window.isDestroyed()) { | |
| 321 | return; | |
| 322 | } | |
| 323 | void window.loadURL(applicationUrl).catch(() => undefined); | |
| 324 | }; | |
| 325 | const scheduleDevelopmentLoadRetry = () => { | |
| 326 | if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { | |
| 327 | return undefined; | |
| 328 | } | |
| 329 | ||
| 330 | const retryIndex = Math.min( | |
| 331 | developmentLoadRetryIndex, | |
| 332 | DEVELOPMENT_LOAD_RETRY_DELAYS_MS.length - 1, | |
| 333 | ); | |
| 334 | const retryInMs = DEVELOPMENT_LOAD_RETRY_DELAYS_MS[retryIndex] ?? 2_000; | |
| 335 | developmentLoadRetryIndex += 1; | |
| 336 | developmentLoadRetryFiber = runFork( | |
| 337 | Effect.sleep(retryInMs).pipe( | |
| 338 | Effect.andThen( | |
| 339 | Effect.sync(() => { | |
| 340 | developmentLoadRetryFiber = undefined; | |
| 341 | if (!window.isDestroyed()) { | |
| 342 | loadApplication(); | |
| 343 | } | |
| 344 | }), | |
| 345 | ), | |
| 346 | ), | |
| 347 | ); | |
| 348 | return retryInMs; | |
| 349 | }; | |
| 350 | ||
| 280 | 351 | window.webContents.on("did-finish-load", () => { |
| 352 | if ( | |
| 353 | environment.isDevelopment && | |
| 354 | !isSameOriginRendererNavigation({ | |
| 355 | applicationUrl, | |
| 356 | navigationUrl: window.webContents.getURL(), | |
| 357 | }) | |
| 358 | ) { | |
| 359 | return; | |
| 360 | } | |
| 361 | clearDevelopmentLoadRetry(); | |
| 362 | developmentLoadRetryIndex = 0; | |
| 281 | 363 | window.setTitle(environment.displayName); |
| 282 | 364 | }); |
| 283 | 365 | window.webContents.on( |
| ... | ... | @@ -286,11 +368,22 @@ export const make = Effect.gen(function* () { |
| 286 | 368 | if (!isMainFrame) { |
| 287 | 369 | return; |
| 288 | 370 | } |
| 371 | const retryInMs = | |
| 372 | environment.isDevelopment && | |
| 373 | isRetryableDevelopmentRendererLoadFailure({ | |
| 374 | applicationUrl, | |
| 375 | errorCode, | |
| 376 | isMainFrame, | |
| 377 | validatedUrl: validatedURL, | |
| 378 | }) | |
| 379 | ? scheduleDevelopmentLoadRetry() | |
| 380 | : undefined; | |
| 289 | 381 | void runPromise( |
| 290 | 382 | logWindowWarning("main window failed to load", { |
| 291 | 383 | errorCode, |
| 292 | 384 | errorDescription, |
| 293 | 385 | url: validatedURL, |
| 386 | ...(retryInMs === undefined ? {} : { retryInMs }), | |
| 294 | 387 | }), |
| 295 | 388 | ); |
| 296 | 389 | }, |
| ... | ... | @@ -312,14 +405,13 @@ export const make = Effect.gen(function* () { |
| 312 | 405 | void runPromise(electronWindow.reveal(window)); |
| 313 | 406 | }); |
| 314 | 407 | |
| 408 | loadApplication(); | |
| 315 | 409 | if (environment.isDevelopment) { |
| 316 | void window.loadURL(applicationUrl); | |
| 317 | 410 | window.webContents.openDevTools({ mode: "detach" }); |
| 318 | } else { | |
| 319 | void window.loadURL(applicationUrl); | |
| 320 | 411 | } |
| 321 | 412 | |
| 322 | 413 | window.on("closed", () => { |
| 414 | clearDevelopmentLoadRetry(); | |
| 323 | 415 | void runPromise(electronWindow.clearMain(Option.some(window))); |
| 324 | 416 | }); |
| 325 | 417 |
apps/server/src/mcp/McpHttpServer.test.ts+56-75| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { NodeHttpServer } from "@effect/platform-node"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | 4 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({ |
| 34 | 35 | }); |
| 35 | 36 | const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( |
| 36 | 37 | Layer.provideMerge(McpServer.McpServer.layer), |
| 37 | Layer.provideMerge(PreviewAutomationBroker.layer), | |
| 38 | Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), | |
| 38 | 39 | ); |
| 39 | 40 | |
| 40 | 41 | it("normalizes empty successful notification responses to accepted", () => { |
| ... | ... | @@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () => |
| 54 | 55 | Effect.gen(function* () { |
| 55 | 56 | const server = yield* McpServer.McpServer; |
| 56 | 57 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 57 | const requests = yield* broker.connect({ | |
| 58 | const events = yield* broker.connect({ | |
| 58 | 59 | clientId: "mcp-failure-client", |
| 59 | 60 | environmentId, |
| 60 | threadId, | |
| 61 | tabId, | |
| 62 | visible: true, | |
| 63 | supportsAutomation: true, | |
| 64 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 65 | 61 | }); |
| 66 | yield* Stream.runForEach(requests, (request) => | |
| 67 | broker.respond({ | |
| 68 | requestId: request.requestId, | |
| 69 | ok: false, | |
| 70 | error: { | |
| 71 | _tag: "PreviewAutomationExecutionError", | |
| 72 | message: "sensitive renderer failure", | |
| 73 | detail: { consoleOutput: "sensitive browser output" }, | |
| 74 | }, | |
| 75 | }), | |
| 62 | yield* Stream.runForEach(events, (event) => | |
| 63 | event.type === "connected" | |
| 64 | ? Effect.void | |
| 65 | : broker.respond({ | |
| 66 | clientId: "mcp-failure-client", | |
| 67 | connectionId: event.connectionId, | |
| 68 | requestId: event.request.requestId, | |
| 69 | ok: false, | |
| 70 | error: { | |
| 71 | _tag: "PreviewAutomationExecutionError", | |
| 72 | message: "sensitive renderer failure", | |
| 73 | detail: { consoleOutput: "sensitive browser output" }, | |
| 74 | }, | |
| 75 | }), | |
| 76 | 76 | ).pipe(Effect.forkScoped); |
| 77 | 77 | yield* Effect.yieldNow; |
| 78 | yield* broker.reportOwner({ | |
| 79 | clientId: "mcp-failure-client", | |
| 80 | environmentId, | |
| 81 | threadId, | |
| 82 | tabId, | |
| 83 | visible: true, | |
| 84 | supportsAutomation: true, | |
| 85 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 86 | }); | |
| 87 | 78 | |
| 88 | 79 | const snapshot = yield* server |
| 89 | 80 | .callTool({ name: "preview_snapshot", arguments: {} }) |
| ... | ... | @@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 163 | 154 | Effect.gen(function* () { |
| 164 | 155 | const server = yield* McpServer.McpServer; |
| 165 | 156 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 166 | const requests = yield* broker.connect({ | |
| 157 | const events = yield* broker.connect({ | |
| 167 | 158 | clientId: "mcp-test-client", |
| 168 | 159 | environmentId, |
| 169 | threadId, | |
| 170 | tabId, | |
| 171 | visible: true, | |
| 172 | supportsAutomation: true, | |
| 173 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 174 | 160 | }); |
| 175 | yield* Stream.runForEach(requests, (request) => | |
| 176 | broker.respond({ | |
| 177 | requestId: request.requestId, | |
| 178 | ok: true, | |
| 179 | result: | |
| 180 | request.operation === "snapshot" | |
| 181 | ? { | |
| 182 | url: "http://example.test/", | |
| 183 | title: "Example", | |
| 184 | loading: false, | |
| 185 | visibleText: "Example", | |
| 186 | interactiveElements: [], | |
| 187 | accessibilityTree: {}, | |
| 188 | consoleEntries: [], | |
| 189 | networkEntries: [], | |
| 190 | actionTimeline: [], | |
| 191 | screenshot: { | |
| 192 | mimeType: "image/png", | |
| 193 | data: Buffer.from("png").toString("base64"), | |
| 194 | width: 10, | |
| 195 | height: 5, | |
| 196 | }, | |
| 197 | } | |
| 198 | : request.operation === "press" | |
| 199 | ? undefined | |
| 200 | : { | |
| 201 | available: true, | |
| 202 | visible: true, | |
| 203 | tabId, | |
| 204 | url: "http://example.test/", | |
| 205 | title: "Example", | |
| 206 | loading: false, | |
| 207 | }, | |
| 208 | }), | |
| 161 | yield* Stream.runForEach(events, (event) => | |
| 162 | event.type === "connected" | |
| 163 | ? Effect.void | |
| 164 | : broker.respond({ | |
| 165 | clientId: "mcp-test-client", | |
| 166 | connectionId: event.connectionId, | |
| 167 | requestId: event.request.requestId, | |
| 168 | ok: true, | |
| 169 | result: | |
| 170 | event.request.operation === "snapshot" | |
| 171 | ? { | |
| 172 | url: "http://example.test/", | |
| 173 | title: "Example", | |
| 174 | loading: false, | |
| 175 | visibleText: "Example", | |
| 176 | interactiveElements: [], | |
| 177 | accessibilityTree: {}, | |
| 178 | consoleEntries: [], | |
| 179 | networkEntries: [], | |
| 180 | actionTimeline: [], | |
| 181 | screenshot: { | |
| 182 | mimeType: "image/png", | |
| 183 | data: Buffer.from("png").toString("base64"), | |
| 184 | width: 10, | |
| 185 | height: 5, | |
| 186 | }, | |
| 187 | } | |
| 188 | : event.request.operation === "press" | |
| 189 | ? undefined | |
| 190 | : { | |
| 191 | available: true, | |
| 192 | visible: true, | |
| 193 | tabId, | |
| 194 | url: "http://example.test/", | |
| 195 | title: "Example", | |
| 196 | loading: false, | |
| 197 | }, | |
| 198 | }), | |
| 209 | 199 | ).pipe(Effect.forkScoped); |
| 210 | 200 | yield* Effect.yieldNow; |
| 211 | yield* broker.reportOwner({ | |
| 212 | clientId: "mcp-test-client", | |
| 213 | environmentId, | |
| 214 | threadId, | |
| 215 | tabId, | |
| 216 | visible: true, | |
| 217 | supportsAutomation: true, | |
| 218 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 219 | }); | |
| 220 | 201 | |
| 221 | 202 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); |
| 222 | 203 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); |
apps/server/src/mcp/McpHttpServer.ts+1-4| ... | ... | @@ -214,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({ |
| 214 | 214 | path: "/mcp", |
| 215 | 215 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); |
| 216 | 216 | |
| 217 | export const layer = PreviewToolkitRegistrationLive.pipe( | |
| 218 | Layer.provideMerge(McpTransportLive), | |
| 219 | Layer.provide(PreviewAutomationBroker.layer), | |
| 220 | ); | |
| 217 | export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+584-74| ... | ... | @@ -1,20 +1,28 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 1 | 2 | import { expect, it } from "@effect/vitest"; |
| 2 | 3 | import { |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | PreviewAutomationClientDisconnectedError, |
| 5 | 6 | PreviewAutomationInvalidSelectorError, |
| 6 | 7 | PreviewAutomationMalformedResponseError, |
| 7 | PreviewAutomationNoFocusedOwnerError, | |
| 8 | PreviewAutomationNoAvailableHostError, | |
| 9 | PreviewAutomationTargetNotEditableError, | |
| 10 | PreviewTabId, | |
| 8 | 11 | ProviderInstanceId, |
| 9 | 12 | ThreadId, |
| 10 | type PreviewAutomationOwner, | |
| 13 | type PreviewAutomationHost, | |
| 14 | type PreviewAutomationRequest, | |
| 15 | type PreviewAutomationStreamEvent, | |
| 11 | 16 | } from "@t3tools/contracts"; |
| 12 | 17 | import * as Effect from "effect/Effect"; |
| 13 | 18 | import * as Fiber from "effect/Fiber"; |
| 19 | import * as Result from "effect/Result"; | |
| 14 | 20 | import * as Stream from "effect/Stream"; |
| 15 | 21 | |
| 16 | 22 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 17 | 23 | |
| 24 | const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); | |
| 25 | ||
| 18 | 26 | const scope = { |
| 19 | 27 | environmentId: EnvironmentId.make("environment-1"), |
| 20 | 28 | threadId: ThreadId.make("thread-1"), |
| ... | ... | @@ -22,27 +30,42 @@ const scope = { |
| 22 | 30 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 23 | 31 | capabilities: new Set(["preview"] as const), |
| 24 | 32 | issuedAt: 1, |
| 25 | expiresAt: 2, | |
| 33 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 26 | 34 | }; |
| 27 | 35 | |
| 28 | const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAutomationOwner => ({ | |
| 36 | const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({ | |
| 29 | 37 | clientId: "client-1", |
| 30 | 38 | environmentId: scope.environmentId, |
| 31 | threadId: scope.threadId, | |
| 32 | tabId: null, | |
| 33 | visible: false, | |
| 34 | supportsAutomation: true, | |
| 35 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 36 | 39 | ...overrides, |
| 37 | 40 | }); |
| 38 | 41 | |
| 39 | it.effect("atomically registers a connected owner and correlates its response", () => | |
| 42 | type RoutedRequest = PreviewAutomationRequest & { | |
| 43 | readonly connectionId: PreviewAutomationStreamEvent["connectionId"]; | |
| 44 | }; | |
| 45 | ||
| 46 | const requestsFrom = ( | |
| 47 | events: Stream.Stream<PreviewAutomationStreamEvent>, | |
| 48 | onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {}, | |
| 49 | ): Stream.Stream<RoutedRequest> => | |
| 50 | events.pipe( | |
| 51 | Stream.filterMap((event) => { | |
| 52 | if (event.type === "connected") { | |
| 53 | onConnected(event.connectionId); | |
| 54 | return Result.failVoid; | |
| 55 | } | |
| 56 | return Result.succeed({ ...event.request, connectionId: event.connectionId }); | |
| 57 | }), | |
| 58 | ); | |
| 59 | ||
| 60 | it.effect("atomically registers a connected host and correlates its response", () => | |
| 40 | 61 | Effect.scoped( |
| 41 | 62 | Effect.gen(function* () { |
| 42 | const broker = yield* PreviewAutomationBroker.make; | |
| 43 | const requests = yield* broker.connect(makeOwner()); | |
| 63 | const broker = yield* makeBroker; | |
| 64 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 44 | 65 | yield* Stream.runForEach(requests, (request) => |
| 45 | 66 | broker.respond({ |
| 67 | clientId: "client-1", | |
| 68 | connectionId: request.connectionId, | |
| 46 | 69 | requestId: request.requestId, |
| 47 | 70 | ok: true, |
| 48 | 71 | result: { available: true }, |
| ... | ... | @@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response", |
| 61 | 84 | ), |
| 62 | 85 | ); |
| 63 | 86 | |
| 87 | it.effect("announces a live replacement stream before delivering requests", () => | |
| 88 | Effect.scoped( | |
| 89 | Effect.gen(function* () { | |
| 90 | const broker = yield* makeBroker; | |
| 91 | const events = yield* broker.connect(makeHost()); | |
| 92 | const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; | |
| 93 | const consumer = yield* events.pipe( | |
| 94 | Stream.take(2), | |
| 95 | Stream.runForEach((event) => { | |
| 96 | receivedTypes.push(event.type); | |
| 97 | return event.type === "connected" | |
| 98 | ? Effect.void | |
| 99 | : broker.respond({ | |
| 100 | clientId: "client-1", | |
| 101 | connectionId: event.connectionId, | |
| 102 | requestId: event.request.requestId, | |
| 103 | ok: true, | |
| 104 | result: "ready", | |
| 105 | }); | |
| 106 | }), | |
| 107 | Effect.forkScoped, | |
| 108 | ); | |
| 109 | yield* Effect.yieldNow; | |
| 110 | ||
| 111 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); | |
| 112 | yield* Fiber.join(consumer); | |
| 113 | ||
| 114 | expect(receivedTypes).toEqual(["connected", "request"]); | |
| 115 | expect(result).toBe("ready"); | |
| 116 | }), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 64 | 120 | it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 65 | 121 | const locator = "role=button[name='request-secret']"; |
| 66 | 122 | const remoteMessage = "Unexpected token near remote-secret."; |
| ... | ... | @@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 72 | 128 | |
| 73 | 129 | return Effect.scoped( |
| 74 | 130 | Effect.gen(function* () { |
| 75 | const broker = yield* PreviewAutomationBroker.make; | |
| 76 | const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); | |
| 131 | const broker = yield* makeBroker; | |
| 132 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 77 | 133 | yield* Stream.runForEach(requests, (request) => |
| 78 | 134 | broker.respond({ |
| 135 | clientId: "client-1", | |
| 136 | connectionId: request.connectionId, | |
| 79 | 137 | requestId: request.requestId, |
| 80 | 138 | ok: false, |
| 81 | 139 | error: remoteError, |
| ... | ... | @@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 88 | 146 | scope, |
| 89 | 147 | operation: "click", |
| 90 | 148 | input: { locator }, |
| 149 | tabId: PreviewTabId.make("tab-1"), | |
| 91 | 150 | timeoutMs: 1_234, |
| 92 | 151 | }) |
| 93 | 152 | .pipe(Effect.flip); |
| ... | ... | @@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 121 | 180 | ); |
| 122 | 181 | }); |
| 123 | 182 | |
| 183 | it.effect("classifies a remote non-editable target without collapsing it to execution", () => { | |
| 184 | const remoteError = { | |
| 185 | _tag: "PreviewAutomationTargetNotEditableError", | |
| 186 | message: "remote target details", | |
| 187 | detail: { selectorKind: "focused-element" }, | |
| 188 | } as const; | |
| 189 | ||
| 190 | return Effect.scoped( | |
| 191 | Effect.gen(function* () { | |
| 192 | const broker = yield* makeBroker; | |
| 193 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 194 | yield* Stream.runForEach(requests, (request) => | |
| 195 | broker.respond({ | |
| 196 | clientId: "client-1", | |
| 197 | connectionId: request.connectionId, | |
| 198 | requestId: request.requestId, | |
| 199 | ok: false, | |
| 200 | error: remoteError, | |
| 201 | }), | |
| 202 | ).pipe(Effect.forkScoped); | |
| 203 | yield* Effect.yieldNow; | |
| 204 | ||
| 205 | const error = yield* broker | |
| 206 | .invoke<void>({ | |
| 207 | scope, | |
| 208 | operation: "type", | |
| 209 | input: { text: "hello" }, | |
| 210 | tabId: PreviewTabId.make("tab-1"), | |
| 211 | }) | |
| 212 | .pipe(Effect.flip); | |
| 213 | ||
| 214 | expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError); | |
| 215 | expect(error).toMatchObject({ | |
| 216 | operation: "type", | |
| 217 | tabId: "tab-1", | |
| 218 | selectorKind: "focused-element", | |
| 219 | remoteTag: "PreviewAutomationTargetNotEditableError", | |
| 220 | }); | |
| 221 | expect(error.message).toBe("Preview automation type requires an editable focused element."); | |
| 222 | }), | |
| 223 | ); | |
| 224 | }); | |
| 225 | ||
| 124 | 226 | it.effect("distinguishes malformed remote failures", () => |
| 125 | 227 | Effect.scoped( |
| 126 | 228 | Effect.gen(function* () { |
| 127 | const broker = yield* PreviewAutomationBroker.make; | |
| 128 | const requests = yield* broker.connect(makeOwner()); | |
| 229 | const broker = yield* makeBroker; | |
| 230 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 129 | 231 | yield* Stream.runForEach(requests, (request) => |
| 130 | broker.respond({ requestId: request.requestId, ok: false }), | |
| 232 | broker.respond({ | |
| 233 | clientId: "client-1", | |
| 234 | connectionId: request.connectionId, | |
| 235 | requestId: request.requestId, | |
| 236 | ok: false, | |
| 237 | }), | |
| 131 | 238 | ).pipe(Effect.forkScoped); |
| 132 | 239 | yield* Effect.yieldNow; |
| 133 | 240 | |
| ... | ... | @@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () => |
| 150 | 257 | ), |
| 151 | 258 | ); |
| 152 | 259 | |
| 153 | it.effect("rejects calls when no focused owner exists", () => | |
| 260 | it.effect("rejects calls when no connected host exists", () => | |
| 154 | 261 | Effect.gen(function* () { |
| 155 | const broker = yield* PreviewAutomationBroker.make; | |
| 262 | const broker = yield* makeBroker; | |
| 156 | 263 | const error = yield* broker |
| 157 | 264 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 158 | 265 | .pipe(Effect.flip); |
| 159 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | |
| 266 | ||
| 267 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 160 | 268 | expect(error).toMatchObject({ |
| 161 | 269 | operation: "status", |
| 162 | 270 | environmentId: scope.environmentId, |
| ... | ... | @@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () => |
| 167 | 275 | }), |
| 168 | 276 | ); |
| 169 | 277 | |
| 170 | it.effect("routes interactive commands to a hidden durable browser host", () => | |
| 278 | it.effect("does not create host state from focus updates without a live stream", () => | |
| 279 | Effect.gen(function* () { | |
| 280 | const broker = yield* makeBroker; | |
| 281 | yield* broker.focusHost({ | |
| 282 | clientId: "client-1", | |
| 283 | environmentId: scope.environmentId, | |
| 284 | connectionId: "connection-missing", | |
| 285 | focused: true, | |
| 286 | }); | |
| 287 | ||
| 288 | const error = yield* broker | |
| 289 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 290 | .pipe(Effect.flip); | |
| 291 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 292 | }), | |
| 293 | ); | |
| 294 | ||
| 295 | it.effect("removes host availability when the authoritative request stream disconnects", () => | |
| 171 | 296 | Effect.scoped( |
| 172 | 297 | Effect.gen(function* () { |
| 173 | const broker = yield* PreviewAutomationBroker.make; | |
| 174 | const requests = yield* broker.connect( | |
| 175 | makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), | |
| 176 | ); | |
| 177 | yield* Stream.runForEach(requests, (request) => | |
| 178 | broker.respond({ requestId: request.requestId, ok: true }), | |
| 179 | ).pipe(Effect.forkScoped); | |
| 298 | const broker = yield* makeBroker; | |
| 299 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 300 | const beforeAcquisition = yield* broker | |
| 301 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 302 | .pipe(Effect.flip); | |
| 303 | expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 304 | ||
| 305 | const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); | |
| 180 | 306 | yield* Effect.yieldNow; |
| 307 | yield* Fiber.interrupt(consumer); | |
| 181 | 308 | |
| 182 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | |
| 309 | const error = yield* broker | |
| 310 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 311 | .pipe(Effect.flip); | |
| 312 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 183 | 313 | }), |
| 184 | 314 | ), |
| 185 | 315 | ); |
| 186 | 316 | |
| 187 | it.effect("lets the browser host resolve an active tab that has not been reported yet", () => | |
| 317 | it.effect("routes requests for background threads through an environment-level host", () => | |
| 188 | 318 | Effect.scoped( |
| 189 | 319 | Effect.gen(function* () { |
| 190 | const broker = yield* PreviewAutomationBroker.make; | |
| 191 | const requests = yield* broker.connect(makeOwner({ tabId: null })); | |
| 192 | let routedTabId: string | undefined; | |
| 320 | const broker = yield* makeBroker; | |
| 321 | const backgroundThreadId = ThreadId.make("thread-background"); | |
| 322 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 323 | let routedThreadId: string | undefined; | |
| 193 | 324 | yield* Stream.runForEach(requests, (request) => { |
| 194 | routedTabId = request.tabId; | |
| 195 | return broker.respond({ requestId: request.requestId, ok: true }); | |
| 325 | routedThreadId = request.threadId; | |
| 326 | return broker.respond({ | |
| 327 | clientId: "client-1", | |
| 328 | connectionId: request.connectionId, | |
| 329 | requestId: request.requestId, | |
| 330 | ok: true, | |
| 331 | result: "background", | |
| 332 | }); | |
| 196 | 333 | }).pipe(Effect.forkScoped); |
| 197 | 334 | yield* Effect.yieldNow; |
| 198 | 335 | |
| 199 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | |
| 336 | const result = yield* broker.invoke<string>({ | |
| 337 | scope: { | |
| 338 | ...scope, | |
| 339 | threadId: backgroundThreadId, | |
| 340 | providerSessionId: "provider-session-background", | |
| 341 | }, | |
| 342 | operation: "status", | |
| 343 | input: {}, | |
| 344 | }); | |
| 200 | 345 | |
| 201 | expect(routedTabId).toBeUndefined(); | |
| 346 | expect(result).toBe("background"); | |
| 347 | expect(routedThreadId).toBe(backgroundThreadId); | |
| 202 | 348 | }), |
| 203 | 349 | ), |
| 204 | 350 | ); |
| 205 | 351 | |
| 206 | it.effect("preserves current owner metadata when its request stream reconnects", () => | |
| 352 | it.effect("never routes a provider session to a host from another environment", () => | |
| 207 | 353 | Effect.scoped( |
| 208 | 354 | Effect.gen(function* () { |
| 209 | const broker = yield* PreviewAutomationBroker.make; | |
| 210 | const firstRequests = yield* broker.connect(makeOwner()); | |
| 211 | yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); | |
| 212 | yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true })); | |
| 355 | const broker = yield* makeBroker; | |
| 356 | const matchingRequests = requestsFrom( | |
| 357 | yield* broker.connect(makeHost({ clientId: "client-matching" })), | |
| 358 | ); | |
| 359 | const foreignRequests = requestsFrom( | |
| 360 | yield* broker.connect( | |
| 361 | makeHost({ | |
| 362 | clientId: "client-foreign", | |
| 363 | environmentId: EnvironmentId.make("environment-foreign"), | |
| 364 | }), | |
| 365 | ), | |
| 366 | ); | |
| 367 | yield* Stream.runForEach(matchingRequests, (request) => | |
| 368 | broker.respond({ | |
| 369 | clientId: "client-matching", | |
| 370 | connectionId: request.connectionId, | |
| 371 | requestId: request.requestId, | |
| 372 | ok: true, | |
| 373 | result: "matching", | |
| 374 | }), | |
| 375 | ).pipe(Effect.forkScoped); | |
| 376 | yield* Stream.runForEach(foreignRequests, (request) => | |
| 377 | broker.respond({ | |
| 378 | clientId: "client-foreign", | |
| 379 | connectionId: request.connectionId, | |
| 380 | requestId: request.requestId, | |
| 381 | ok: true, | |
| 382 | result: "foreign", | |
| 383 | }), | |
| 384 | ).pipe(Effect.forkScoped); | |
| 385 | yield* Effect.yieldNow; | |
| 386 | ||
| 387 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 388 | "matching", | |
| 389 | ); | |
| 390 | }), | |
| 391 | ), | |
| 392 | ); | |
| 213 | 393 | |
| 214 | const reconnectedRequests = yield* broker.connect(makeOwner()); | |
| 394 | it.effect("pins a provider session to its initial host despite later focus changes", () => | |
| 395 | Effect.scoped( | |
| 396 | Effect.gen(function* () { | |
| 397 | const broker = yield* makeBroker; | |
| 398 | let firstConnectionId = ""; | |
| 399 | let secondConnectionId = ""; | |
| 400 | const firstRequests = requestsFrom( | |
| 401 | yield* broker.connect(makeHost({ clientId: "client-first" })), | |
| 402 | (connectionId) => { | |
| 403 | firstConnectionId = connectionId; | |
| 404 | }, | |
| 405 | ); | |
| 406 | const secondRequests = requestsFrom( | |
| 407 | yield* broker.connect(makeHost({ clientId: "client-second" })), | |
| 408 | (connectionId) => { | |
| 409 | secondConnectionId = connectionId; | |
| 410 | }, | |
| 411 | ); | |
| 412 | yield* Stream.runForEach(firstRequests, (request) => | |
| 413 | broker.respond({ | |
| 414 | clientId: "client-first", | |
| 415 | connectionId: request.connectionId, | |
| 416 | requestId: request.requestId, | |
| 417 | ok: true, | |
| 418 | result: "first", | |
| 419 | }), | |
| 420 | ).pipe(Effect.forkScoped); | |
| 421 | yield* Stream.runForEach(secondRequests, (request) => | |
| 422 | broker.respond({ | |
| 423 | clientId: "client-second", | |
| 424 | connectionId: request.connectionId, | |
| 425 | requestId: request.requestId, | |
| 426 | ok: true, | |
| 427 | result: "second", | |
| 428 | }), | |
| 429 | ).pipe(Effect.forkScoped); | |
| 430 | yield* Effect.yieldNow; | |
| 431 | ||
| 432 | yield* broker.focusHost({ | |
| 433 | clientId: "client-first", | |
| 434 | environmentId: scope.environmentId, | |
| 435 | connectionId: "connection-stale", | |
| 436 | focused: true, | |
| 437 | }); | |
| 438 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 439 | "second", | |
| 440 | ); | |
| 441 | yield* broker.focusHost({ | |
| 442 | clientId: "client-first", | |
| 443 | environmentId: scope.environmentId, | |
| 444 | connectionId: firstConnectionId, | |
| 445 | focused: true, | |
| 446 | }); | |
| 447 | ||
| 448 | const firstPinnedScope = { | |
| 449 | ...scope, | |
| 450 | providerSessionId: "provider-session-first-pinned", | |
| 451 | }; | |
| 452 | expect( | |
| 453 | yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }), | |
| 454 | ).toBe("first"); | |
| 455 | ||
| 456 | yield* broker.focusHost({ | |
| 457 | clientId: "client-second", | |
| 458 | environmentId: scope.environmentId, | |
| 459 | connectionId: secondConnectionId, | |
| 460 | focused: true, | |
| 461 | }); | |
| 462 | ||
| 463 | expect( | |
| 464 | yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }), | |
| 465 | ).toBe("first"); | |
| 466 | expect( | |
| 467 | yield* broker.invoke<string>({ | |
| 468 | scope: { ...scope, providerSessionId: "provider-session-second-pinned" }, | |
| 469 | operation: "status", | |
| 470 | input: {}, | |
| 471 | }), | |
| 472 | ).toBe("second"); | |
| 473 | }), | |
| 474 | ), | |
| 475 | ); | |
| 476 | ||
| 477 | it.effect("does not route new operations to legacy hosts that did not advertise support", () => | |
| 478 | Effect.scoped( | |
| 479 | Effect.gen(function* () { | |
| 480 | const broker = yield* makeBroker; | |
| 481 | const legacyEvents = yield* broker.connect(makeHost()); | |
| 482 | yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped); | |
| 483 | yield* Effect.yieldNow; | |
| 484 | ||
| 485 | const error = yield* broker | |
| 486 | .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } }) | |
| 487 | .pipe(Effect.flip); | |
| 488 | ||
| 489 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 490 | expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId }); | |
| 491 | }), | |
| 492 | ), | |
| 493 | ); | |
| 494 | ||
| 495 | it.effect("routes resize to a capable host instead of a newer legacy connection", () => | |
| 496 | Effect.scoped( | |
| 497 | Effect.gen(function* () { | |
| 498 | const broker = yield* makeBroker; | |
| 499 | const capableRequests = requestsFrom( | |
| 500 | yield* broker.connect( | |
| 501 | makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), | |
| 502 | ), | |
| 503 | ); | |
| 504 | const legacyRequests = requestsFrom( | |
| 505 | yield* broker.connect(makeHost({ clientId: "client-legacy" })), | |
| 506 | ); | |
| 507 | yield* Stream.runForEach(capableRequests, (request) => | |
| 508 | broker.respond({ | |
| 509 | clientId: "client-capable", | |
| 510 | connectionId: request.connectionId, | |
| 511 | requestId: request.requestId, | |
| 512 | ok: true, | |
| 513 | result: "capable", | |
| 514 | }), | |
| 515 | ).pipe(Effect.forkScoped); | |
| 516 | yield* Stream.runForEach(legacyRequests, (request) => | |
| 517 | broker.respond({ | |
| 518 | clientId: "client-legacy", | |
| 519 | connectionId: request.connectionId, | |
| 520 | requestId: request.requestId, | |
| 521 | ok: true, | |
| 522 | result: "legacy", | |
| 523 | }), | |
| 524 | ).pipe(Effect.forkScoped); | |
| 525 | yield* Effect.yieldNow; | |
| 526 | ||
| 527 | expect( | |
| 528 | yield* broker.invoke<string>({ scope, operation: "resize", input: { mode: "fill" } }), | |
| 529 | ).toBe("capable"); | |
| 530 | }), | |
| 531 | ), | |
| 532 | ); | |
| 533 | ||
| 534 | it.effect("does not move a live legacy assignment to another runtime for resize", () => | |
| 535 | Effect.scoped( | |
| 536 | Effect.gen(function* () { | |
| 537 | const broker = yield* makeBroker; | |
| 538 | const legacyRequests = requestsFrom( | |
| 539 | yield* broker.connect(makeHost({ clientId: "client-legacy" })), | |
| 540 | ); | |
| 541 | yield* Stream.runForEach(legacyRequests, (request) => | |
| 542 | broker.respond({ | |
| 543 | clientId: "client-legacy", | |
| 544 | connectionId: request.connectionId, | |
| 545 | requestId: request.requestId, | |
| 546 | ok: true, | |
| 547 | result: "legacy", | |
| 548 | }), | |
| 549 | ).pipe(Effect.forkScoped); | |
| 550 | yield* Effect.yieldNow; | |
| 551 | ||
| 552 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 553 | "legacy", | |
| 554 | ); | |
| 555 | ||
| 556 | const capableRequests = requestsFrom( | |
| 557 | yield* broker.connect( | |
| 558 | makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), | |
| 559 | ), | |
| 560 | ); | |
| 561 | yield* Stream.runForEach(capableRequests, (request) => | |
| 562 | broker.respond({ | |
| 563 | clientId: "client-capable", | |
| 564 | connectionId: request.connectionId, | |
| 565 | requestId: request.requestId, | |
| 566 | ok: true, | |
| 567 | result: "capable", | |
| 568 | }), | |
| 569 | ).pipe(Effect.forkScoped); | |
| 570 | yield* Effect.yieldNow; | |
| 571 | ||
| 572 | const error = yield* broker | |
| 573 | .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } }) | |
| 574 | .pipe(Effect.flip); | |
| 575 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | |
| 576 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 577 | "legacy", | |
| 578 | ); | |
| 579 | }), | |
| 580 | ), | |
| 581 | ); | |
| 582 | ||
| 583 | it.effect("ignores stale focus updates for a different environment", () => | |
| 584 | Effect.scoped( | |
| 585 | Effect.gen(function* () { | |
| 586 | const broker = yield* makeBroker; | |
| 587 | let firstConnectionId = ""; | |
| 588 | const firstRequests = requestsFrom( | |
| 589 | yield* broker.connect(makeHost({ clientId: "client-first" })), | |
| 590 | (connectionId) => { | |
| 591 | firstConnectionId = connectionId; | |
| 592 | }, | |
| 593 | ); | |
| 594 | const secondRequests = requestsFrom( | |
| 595 | yield* broker.connect(makeHost({ clientId: "client-second" })), | |
| 596 | ); | |
| 597 | yield* Stream.runForEach(firstRequests, (request) => | |
| 598 | broker.respond({ | |
| 599 | clientId: "client-first", | |
| 600 | connectionId: request.connectionId, | |
| 601 | requestId: request.requestId, | |
| 602 | ok: true, | |
| 603 | result: "first", | |
| 604 | }), | |
| 605 | ).pipe(Effect.forkScoped); | |
| 606 | yield* Stream.runForEach(secondRequests, (request) => | |
| 607 | broker.respond({ | |
| 608 | clientId: "client-second", | |
| 609 | connectionId: request.connectionId, | |
| 610 | requestId: request.requestId, | |
| 611 | ok: true, | |
| 612 | result: "second", | |
| 613 | }), | |
| 614 | ).pipe(Effect.forkScoped); | |
| 615 | yield* Effect.yieldNow; | |
| 616 | ||
| 617 | yield* broker.focusHost({ | |
| 618 | clientId: "client-first", | |
| 619 | environmentId: EnvironmentId.make("environment-stale"), | |
| 620 | connectionId: firstConnectionId, | |
| 621 | focused: true, | |
| 622 | }); | |
| 623 | ||
| 624 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 625 | "second", | |
| 626 | ); | |
| 627 | }), | |
| 628 | ), | |
| 629 | ); | |
| 630 | ||
| 631 | it.effect("fails over a pinned provider session only after its host disconnects", () => | |
| 632 | Effect.scoped( | |
| 633 | Effect.gen(function* () { | |
| 634 | const broker = yield* makeBroker; | |
| 635 | let firstConnectionId = ""; | |
| 636 | const firstRequests = requestsFrom( | |
| 637 | yield* broker.connect(makeHost({ clientId: "client-first" })), | |
| 638 | (connectionId) => { | |
| 639 | firstConnectionId = connectionId; | |
| 640 | }, | |
| 641 | ); | |
| 642 | const secondRequests = requestsFrom( | |
| 643 | yield* broker.connect(makeHost({ clientId: "client-second" })), | |
| 644 | ); | |
| 645 | const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => | |
| 646 | broker.respond({ | |
| 647 | clientId: "client-first", | |
| 648 | connectionId: request.connectionId, | |
| 649 | requestId: request.requestId, | |
| 650 | ok: true, | |
| 651 | result: "first", | |
| 652 | }), | |
| 653 | ).pipe(Effect.forkScoped); | |
| 654 | yield* Stream.runForEach(secondRequests, (request) => | |
| 655 | broker.respond({ | |
| 656 | clientId: "client-second", | |
| 657 | connectionId: request.connectionId, | |
| 658 | requestId: request.requestId, | |
| 659 | ok: true, | |
| 660 | result: "second", | |
| 661 | }), | |
| 662 | ).pipe(Effect.forkScoped); | |
| 663 | yield* Effect.yieldNow; | |
| 664 | ||
| 665 | yield* broker.focusHost({ | |
| 666 | clientId: "client-first", | |
| 667 | environmentId: scope.environmentId, | |
| 668 | connectionId: firstConnectionId, | |
| 669 | focused: true, | |
| 670 | }); | |
| 671 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe("first"); | |
| 672 | ||
| 673 | yield* Fiber.interrupt(firstConsumer); | |
| 674 | yield* Effect.yieldNow; | |
| 675 | ||
| 676 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 677 | "second", | |
| 678 | ); | |
| 679 | }), | |
| 680 | ), | |
| 681 | ); | |
| 682 | ||
| 683 | it.effect("lets the browser host resolve an active tab locally", () => | |
| 684 | Effect.scoped( | |
| 685 | Effect.gen(function* () { | |
| 686 | const broker = yield* makeBroker; | |
| 687 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 215 | 688 | let routedTabId: string | undefined; |
| 216 | yield* Stream.runForEach(reconnectedRequests, (request) => { | |
| 689 | yield* Stream.runForEach(requests, (request) => { | |
| 217 | 690 | routedTabId = request.tabId; |
| 218 | return broker.respond({ requestId: request.requestId, ok: true }); | |
| 691 | return broker.respond({ | |
| 692 | clientId: "client-1", | |
| 693 | connectionId: request.connectionId, | |
| 694 | requestId: request.requestId, | |
| 695 | ok: true, | |
| 696 | }); | |
| 219 | 697 | }).pipe(Effect.forkScoped); |
| 220 | 698 | yield* Effect.yieldNow; |
| 221 | 699 | |
| 222 | 700 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); |
| 223 | 701 | |
| 224 | expect(routedTabId).toBe("tab-current"); | |
| 702 | expect(routedTabId).toBeUndefined(); | |
| 225 | 703 | }), |
| 226 | 704 | ), |
| 227 | 705 | ); |
| 228 | 706 | |
| 229 | it.effect("ignores stale owner cleanup after the client moves to another thread", () => | |
| 707 | it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => | |
| 230 | 708 | Effect.scoped( |
| 231 | 709 | Effect.gen(function* () { |
| 232 | const broker = yield* PreviewAutomationBroker.make; | |
| 233 | const requests = yield* broker.connect(makeOwner()); | |
| 234 | yield* Stream.runForEach(requests, (request) => | |
| 235 | broker.respond({ requestId: request.requestId, ok: true }), | |
| 236 | ).pipe(Effect.forkScoped); | |
| 710 | const broker = yield* makeBroker; | |
| 711 | let firstConnectionId = ""; | |
| 712 | let replacementConnectionId = ""; | |
| 713 | const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { | |
| 714 | firstConnectionId = connectionId; | |
| 715 | }); | |
| 716 | yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); | |
| 237 | 717 | yield* Effect.yieldNow; |
| 238 | 718 | |
| 239 | yield* broker.clearOwner({ | |
| 240 | clientId: "client-1", | |
| 241 | environmentId: scope.environmentId, | |
| 242 | threadId: ThreadId.make("thread-stale"), | |
| 243 | }); | |
| 719 | const replacementRequests = requestsFrom( | |
| 720 | yield* broker.connect(makeHost()), | |
| 721 | (connectionId) => { | |
| 722 | replacementConnectionId = connectionId; | |
| 723 | }, | |
| 724 | ); | |
| 725 | yield* Stream.runForEach(replacementRequests, (request) => | |
| 726 | broker.respond({ | |
| 727 | clientId: "client-1", | |
| 728 | connectionId: request.connectionId, | |
| 729 | requestId: request.requestId, | |
| 730 | ok: true, | |
| 731 | result: "replacement", | |
| 732 | }), | |
| 733 | ).pipe(Effect.forkScoped); | |
| 734 | yield* Effect.yieldNow; | |
| 244 | 735 | |
| 245 | yield* broker.invoke<void>({ scope, operation: "status", input: {} }); | |
| 736 | expect(replacementConnectionId).not.toBe(firstConnectionId); | |
| 737 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); | |
| 738 | expect(result).toBe("replacement"); | |
| 246 | 739 | }), |
| 247 | 740 | ), |
| 248 | 741 | ); |
| 249 | 742 | |
| 250 | it.effect("fails requests assigned to a browser stream when that stream reconnects", () => | |
| 743 | it.effect("fails requests assigned to the stream that is replaced", () => | |
| 251 | 744 | Effect.scoped( |
| 252 | 745 | Effect.gen(function* () { |
| 253 | const broker = yield* PreviewAutomationBroker.make; | |
| 254 | const _requests = yield* broker.connect(makeOwner()); | |
| 746 | const broker = yield* makeBroker; | |
| 747 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 748 | yield* Stream.runDrain(requests).pipe(Effect.forkScoped); | |
| 255 | 749 | const pending = yield* broker |
| 256 | 750 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 257 | 751 | .pipe(Effect.flip, Effect.forkScoped); |
| 258 | 752 | yield* Effect.yieldNow; |
| 259 | 753 | |
| 260 | const _replacementRequests = yield* broker.connect(makeOwner()); | |
| 754 | const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); | |
| 755 | yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); | |
| 261 | 756 | |
| 262 | 757 | const error = yield* Fiber.join(pending); |
| 263 | 758 | expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); |
| ... | ... | @@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec |
| 275 | 770 | ), |
| 276 | 771 | ); |
| 277 | 772 | |
| 278 | it.effect("falls back to an older connected owner when a newer report is not connected", () => | |
| 773 | it.effect("accepts responses only from the host that received the request", () => | |
| 279 | 774 | Effect.scoped( |
| 280 | 775 | Effect.gen(function* () { |
| 281 | const broker = yield* PreviewAutomationBroker.make; | |
| 282 | const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" })); | |
| 776 | const broker = yield* makeBroker; | |
| 777 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 283 | 778 | yield* Stream.runForEach(requests, (request) => |
| 284 | broker.respond({ requestId: request.requestId, ok: true, result: "connected" }), | |
| 779 | Effect.gen(function* () { | |
| 780 | yield* broker.respond({ | |
| 781 | clientId: "client-foreign", | |
| 782 | connectionId: request.connectionId, | |
| 783 | requestId: request.requestId, | |
| 784 | ok: true, | |
| 785 | result: "foreign", | |
| 786 | }); | |
| 787 | yield* broker.respond({ | |
| 788 | clientId: "client-1", | |
| 789 | connectionId: "connection-stale", | |
| 790 | requestId: request.requestId, | |
| 791 | ok: true, | |
| 792 | result: "stale", | |
| 793 | }); | |
| 794 | yield* broker.respond({ | |
| 795 | clientId: "client-1", | |
| 796 | connectionId: request.connectionId, | |
| 797 | requestId: request.requestId, | |
| 798 | ok: true, | |
| 799 | result: "owner", | |
| 800 | }); | |
| 801 | }), | |
| 285 | 802 | ).pipe(Effect.forkScoped); |
| 286 | 803 | yield* Effect.yieldNow; |
| 287 | yield* broker.reportOwner( | |
| 288 | makeOwner({ | |
| 289 | clientId: "client-report-only", | |
| 290 | focusedAt: "2026-06-11T00:00:01.000Z", | |
| 291 | }), | |
| 292 | ); | |
| 293 | 804 | |
| 294 | 805 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); |
| 295 | ||
| 296 | expect(result).toBe("connected"); | |
| 806 | expect(result).toBe("owner"); | |
| 297 | 807 | }), |
| 298 | 808 | ), |
| 299 | 809 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts+254-129| ... | ... | @@ -1,26 +1,29 @@ |
| 1 | 1 | import { |
| 2 | PREVIEW_AUTOMATION_V1_OPERATIONS, | |
| 2 | 3 | PreviewAutomationClientDisconnectedError, |
| 3 | 4 | PreviewAutomationControlInterruptedError, |
| 4 | 5 | PreviewAutomationExecutionError, |
| 5 | PreviewAutomationHostNotConnectedError, | |
| 6 | 6 | PreviewAutomationInvalidSelectorError, |
| 7 | 7 | PreviewAutomationMalformedResponseError, |
| 8 | PreviewAutomationNoFocusedOwnerError, | |
| 8 | PreviewAutomationNoAvailableHostError, | |
| 9 | 9 | PreviewAutomationRemoteUnavailableError, |
| 10 | 10 | PreviewAutomationRequestQueueClosedError, |
| 11 | 11 | PreviewAutomationResultTooLargeError, |
| 12 | 12 | PreviewAutomationTabNotFoundError, |
| 13 | PreviewAutomationTargetNotEditableError, | |
| 13 | 14 | PreviewAutomationTimeoutError, |
| 14 | 15 | PreviewAutomationUnsupportedClientError, |
| 15 | 16 | type PreviewAutomationError, |
| 16 | 17 | type PreviewAutomationOperation, |
| 17 | type PreviewAutomationOwner, | |
| 18 | type PreviewAutomationOwnerIdentity, | |
| 19 | type PreviewAutomationRequest, | |
| 18 | type PreviewAutomationHost, | |
| 19 | type PreviewAutomationHostFocus, | |
| 20 | 20 | type PreviewAutomationResponse, |
| 21 | type PreviewAutomationStreamEvent, | |
| 21 | 22 | type PreviewTabId, |
| 22 | 23 | } from "@t3tools/contracts"; |
| 23 | 24 | import * as Context from "effect/Context"; |
| 25 | import * as Clock from "effect/Clock"; | |
| 26 | import * as Crypto from "effect/Crypto"; | |
| 24 | 27 | import * as Deferred from "effect/Deferred"; |
| 25 | 28 | import * as Effect from "effect/Effect"; |
| 26 | 29 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service< |
| 43 | 46 | PreviewAutomationBroker, |
| 44 | 47 | { |
| 45 | 48 | readonly connect: ( |
| 46 | owner: PreviewAutomationOwner, | |
| 47 | ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | |
| 48 | readonly reportOwner: ( | |
| 49 | owner: PreviewAutomationOwner, | |
| 50 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 51 | readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>; | |
| 49 | host: PreviewAutomationHost, | |
| 50 | ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>; | |
| 51 | readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>; | |
| 52 | 52 | readonly respond: ( |
| 53 | 53 | response: PreviewAutomationResponse, |
| 54 | 54 | ) => Effect.Effect<void, PreviewAutomationError>; |
| ... | ... | @@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service< |
| 60 | 60 | |
| 61 | 61 | interface ClientConnection { |
| 62 | 62 | readonly clientId: string; |
| 63 | readonly queue: Queue.Queue<PreviewAutomationRequest>; | |
| 63 | readonly connectionId: string; | |
| 64 | readonly environmentId: PreviewAutomationHost["environmentId"]; | |
| 65 | readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>; | |
| 66 | readonly focused: boolean; | |
| 67 | readonly focusOrder: number; | |
| 68 | readonly queue: Queue.Queue<PreviewAutomationStreamEvent>; | |
| 64 | 69 | } |
| 65 | 70 | |
| 66 | 71 | interface PendingRequest { |
| ... | ... | @@ -69,6 +74,13 @@ interface PendingRequest { |
| 69 | 74 | readonly context: PreviewAutomationRequestErrorContext; |
| 70 | 75 | } |
| 71 | 76 | |
| 77 | interface HostAssignment { | |
| 78 | readonly clientId: ClientConnection["clientId"]; | |
| 79 | readonly connectionId: ClientConnection["connectionId"]; | |
| 80 | readonly queue: ClientConnection["queue"]; | |
| 81 | readonly expiresAt: number; | |
| 82 | } | |
| 83 | ||
| 72 | 84 | interface PreviewAutomationRequestErrorContext { |
| 73 | 85 | readonly operation: PreviewAutomationOperation; |
| 74 | 86 | readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; |
| ... | ... | @@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext { |
| 76 | 88 | readonly providerSessionId: string; |
| 77 | 89 | readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; |
| 78 | 90 | readonly clientId: string; |
| 91 | readonly connectionId: ClientConnection["connectionId"]; | |
| 79 | 92 | readonly requestId: string; |
| 80 | 93 | readonly tabId?: PreviewTabId; |
| 81 | 94 | readonly timeoutMs: number; |
| ... | ... | @@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext { |
| 85 | 98 | |
| 86 | 99 | interface BrokerState { |
| 87 | 100 | readonly clients: ReadonlyMap<string, ClientConnection>; |
| 88 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | |
| 101 | readonly assignments: ReadonlyMap<string, HostAssignment>; | |
| 89 | 102 | readonly pending: ReadonlyMap<string, PendingRequest>; |
| 90 | 103 | readonly requestSequence: number; |
| 104 | readonly focusSequence: number; | |
| 91 | 105 | } |
| 92 | 106 | |
| 107 | const removeConnectionFromState = ( | |
| 108 | current: BrokerState, | |
| 109 | clientId: string, | |
| 110 | queue: ClientConnection["queue"], | |
| 111 | ): { readonly state: BrokerState; readonly disconnected: ReadonlyArray<PendingRequest> } => { | |
| 112 | const clients = new Map(current.clients); | |
| 113 | const assignments = new Map(current.assignments); | |
| 114 | const pending = new Map(current.pending); | |
| 115 | const disconnected: PendingRequest[] = []; | |
| 116 | if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId); | |
| 117 | for (const [assignmentKey, assignment] of assignments) { | |
| 118 | if (assignment.queue === queue) assignments.delete(assignmentKey); | |
| 119 | } | |
| 120 | for (const [requestId, entry] of pending) { | |
| 121 | if (entry.queue !== queue) continue; | |
| 122 | pending.delete(requestId); | |
| 123 | disconnected.push(entry); | |
| 124 | } | |
| 125 | return { | |
| 126 | state: { ...current, clients, assignments, pending }, | |
| 127 | disconnected, | |
| 128 | }; | |
| 129 | }; | |
| 130 | ||
| 93 | 131 | const selectorDiagnosticsFromInput = ( |
| 94 | 132 | input: unknown, |
| 95 | 133 | ): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => { |
| ... | ... | @@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = ( |
| 103 | 141 | return {}; |
| 104 | 142 | }; |
| 105 | 143 | |
| 144 | const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => | |
| 145 | `${scope.environmentId}\u0000${scope.providerSessionId}`; | |
| 146 | ||
| 147 | const supportsOperation = ( | |
| 148 | connection: ClientConnection, | |
| 149 | operation: PreviewAutomationOperation, | |
| 150 | ): boolean => connection.supportedOperations.has(operation); | |
| 151 | ||
| 106 | 152 | type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; |
| 107 | 153 | |
| 108 | 154 | function remoteDetailKind(detail: unknown): RemoteDetailKind { |
| ... | ... | @@ -131,8 +177,8 @@ const classifyResponseError = ( |
| 131 | 177 | cause: error, |
| 132 | 178 | }; |
| 133 | 179 | switch (error._tag) { |
| 134 | case "PreviewAutomationNoFocusedOwnerError": | |
| 135 | return new PreviewAutomationNoFocusedOwnerError({ | |
| 180 | case "PreviewAutomationNoAvailableHostError": | |
| 181 | return new PreviewAutomationNoAvailableHostError({ | |
| 136 | 182 | ...context, |
| 137 | 183 | ...remoteDiagnostics, |
| 138 | 184 | }); |
| ... | ... | @@ -162,6 +208,36 @@ const classifyResponseError = ( |
| 162 | 208 | ...remoteDiagnostics, |
| 163 | 209 | }); |
| 164 | 210 | } |
| 211 | case "PreviewAutomationTargetNotEditableError": { | |
| 212 | const detail = | |
| 213 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 214 | const remoteSelectorKind = | |
| 215 | detail && | |
| 216 | "selectorKind" in detail && | |
| 217 | (detail.selectorKind === "focused-element" || | |
| 218 | detail.selectorKind === "locator" || | |
| 219 | detail.selectorKind === "selector") | |
| 220 | ? detail.selectorKind | |
| 221 | : undefined; | |
| 222 | const remoteSelectorLength = | |
| 223 | detail && | |
| 224 | "selectorLength" in detail && | |
| 225 | typeof detail.selectorLength === "number" && | |
| 226 | Number.isInteger(detail.selectorLength) && | |
| 227 | detail.selectorLength >= 0 | |
| 228 | ? detail.selectorLength | |
| 229 | : undefined; | |
| 230 | return new PreviewAutomationTargetNotEditableError({ | |
| 231 | ...context, | |
| 232 | ...remoteDiagnostics, | |
| 233 | ...(remoteSelectorKind === undefined && context.selectorKind === undefined | |
| 234 | ? {} | |
| 235 | : { selectorKind: remoteSelectorKind ?? context.selectorKind }), | |
| 236 | ...(remoteSelectorLength === undefined && context.selectorLength === undefined | |
| 237 | ? {} | |
| 238 | : { selectorLength: remoteSelectorLength ?? context.selectorLength }), | |
| 239 | }); | |
| 240 | } | |
| 165 | 241 | case "PreviewAutomationResultTooLargeError": { |
| 166 | 242 | const detail = |
| 167 | 243 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; |
| ... | ... | @@ -193,36 +269,21 @@ const classifyResponseError = ( |
| 193 | 269 | }; |
| 194 | 270 | |
| 195 | 271 | export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 272 | const crypto = yield* Crypto.Crypto; | |
| 196 | 273 | const state = yield* SynchronizedRef.make<BrokerState>({ |
| 197 | 274 | clients: new Map(), |
| 198 | owners: new Map(), | |
| 275 | assignments: new Map(), | |
| 199 | 276 | pending: new Map(), |
| 200 | 277 | requestSequence: 0, |
| 278 | focusSequence: 0, | |
| 201 | 279 | }); |
| 202 | 280 | |
| 203 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | |
| 204 | clientId: string, | |
| 281 | const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* ( | |
| 205 | 282 | queue: ClientConnection["queue"], |
| 283 | disconnected: ReadonlyArray<PendingRequest>, | |
| 206 | 284 | ) { |
| 207 | const toFail = yield* SynchronizedRef.modify(state, (current) => { | |
| 208 | const clients = new Map(current.clients); | |
| 209 | const owners = new Map(current.owners); | |
| 210 | const pending = new Map(current.pending); | |
| 211 | const disconnected: PendingRequest[] = []; | |
| 212 | if (current.clients.get(clientId)?.queue === queue) { | |
| 213 | clients.delete(clientId); | |
| 214 | owners.delete(clientId); | |
| 215 | } | |
| 216 | for (const [requestId, entry] of pending) { | |
| 217 | if (entry.queue === queue) { | |
| 218 | pending.delete(requestId); | |
| 219 | disconnected.push(entry); | |
| 220 | } | |
| 221 | } | |
| 222 | return [disconnected, { ...current, clients, owners, pending }] as const; | |
| 223 | }); | |
| 224 | 285 | yield* Effect.forEach( |
| 225 | toFail, | |
| 286 | disconnected, | |
| 226 | 287 | ({ deferred, context }) => |
| 227 | 288 | Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), |
| 228 | 289 | { discard: true }, |
| ... | ... | @@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 230 | 291 | yield* Queue.shutdown(queue); |
| 231 | 292 | }); |
| 232 | 293 | |
| 233 | const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( | |
| 234 | "PreviewAutomationBroker.connect", | |
| 235 | )(function* (owner) { | |
| 236 | const clientId = owner.clientId; | |
| 237 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | |
| 238 | const previous = yield* SynchronizedRef.modify(state, (current) => { | |
| 239 | const clients = new Map(current.clients); | |
| 240 | const owners = new Map(current.owners); | |
| 241 | const existingOwner = current.owners.get(clientId); | |
| 242 | clients.set(clientId, { clientId, queue }); | |
| 243 | owners.set( | |
| 244 | clientId, | |
| 245 | existingOwner?.environmentId === owner.environmentId && | |
| 246 | existingOwner.threadId === owner.threadId | |
| 247 | ? { ...existingOwner, supportsAutomation: owner.supportsAutomation } | |
| 248 | : owner, | |
| 249 | ); | |
| 250 | return [current.clients.get(clientId), { ...current, clients, owners }] as const; | |
| 294 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | |
| 295 | clientId: string, | |
| 296 | queue: ClientConnection["queue"], | |
| 297 | ) { | |
| 298 | const disconnected = yield* SynchronizedRef.modify(state, (current) => { | |
| 299 | const removed = removeConnectionFromState(current, clientId, queue); | |
| 300 | return [removed.disconnected, removed.state] as const; | |
| 251 | 301 | }); |
| 252 | if (previous) yield* disconnect(clientId, previous.queue); | |
| 253 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | |
| 302 | yield* closeConnection(queue, disconnected); | |
| 254 | 303 | }); |
| 255 | 304 | |
| 256 | const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( | |
| 257 | "PreviewAutomationBroker.reportOwner", | |
| 258 | )(function* (owner) { | |
| 259 | yield* SynchronizedRef.update(state, (current) => { | |
| 260 | const owners = new Map(current.owners); | |
| 261 | owners.set(owner.clientId, owner); | |
| 262 | return { ...current, owners }; | |
| 305 | const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( | |
| 306 | host: PreviewAutomationHost, | |
| 307 | ) { | |
| 308 | const clientId = host.clientId; | |
| 309 | const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent>(); | |
| 310 | const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | |
| 311 | yield* Queue.offer(queue, { type: "connected", connectionId }); | |
| 312 | const connection: ClientConnection = { | |
| 313 | clientId, | |
| 314 | connectionId, | |
| 315 | environmentId: host.environmentId, | |
| 316 | supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), | |
| 317 | focused: false, | |
| 318 | focusOrder: 0, | |
| 319 | queue, | |
| 320 | }; | |
| 321 | const registration = yield* SynchronizedRef.modify(state, (current) => { | |
| 322 | const previousConnection = current.clients.get(clientId); | |
| 323 | const removed = previousConnection | |
| 324 | ? removeConnectionFromState(current, clientId, previousConnection.queue) | |
| 325 | : { state: current, disconnected: [] }; | |
| 326 | const clients = new Map(removed.state.clients); | |
| 327 | const focusSequence = removed.state.focusSequence + 1; | |
| 328 | const registeredConnection = { ...connection, focusOrder: focusSequence }; | |
| 329 | clients.set(clientId, registeredConnection); | |
| 330 | return [ | |
| 331 | { | |
| 332 | previousConnection, | |
| 333 | disconnected: removed.disconnected, | |
| 334 | registeredConnection, | |
| 335 | }, | |
| 336 | { ...removed.state, clients, focusSequence }, | |
| 337 | ] as const; | |
| 263 | 338 | }); |
| 339 | if (registration.previousConnection) { | |
| 340 | yield* closeConnection(registration.previousConnection.queue, registration.disconnected); | |
| 341 | } | |
| 342 | return registration.registeredConnection; | |
| 264 | 343 | }); |
| 265 | 344 | |
| 266 | const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( | |
| 267 | "PreviewAutomationBroker.clearOwner", | |
| 268 | )(function* (owner) { | |
| 345 | const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( | |
| 346 | "PreviewAutomationBroker.connect", | |
| 347 | )((host) => | |
| 348 | Effect.succeed( | |
| 349 | Stream.unwrap( | |
| 350 | Effect.acquireRelease(acquireConnection(host), (connection) => | |
| 351 | disconnect(connection.clientId, connection.queue), | |
| 352 | ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), | |
| 353 | ), | |
| 354 | ), | |
| 355 | ); | |
| 356 | ||
| 357 | const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn( | |
| 358 | "PreviewAutomationBroker.focusHost", | |
| 359 | )(function* (host) { | |
| 269 | 360 | yield* SynchronizedRef.update(state, (current) => { |
| 270 | const currentOwner = current.owners.get(owner.clientId); | |
| 361 | const currentHost = current.clients.get(host.clientId); | |
| 271 | 362 | if ( |
| 272 | !currentOwner || | |
| 273 | currentOwner.environmentId !== owner.environmentId || | |
| 274 | currentOwner.threadId !== owner.threadId | |
| 363 | !currentHost || | |
| 364 | currentHost.environmentId !== host.environmentId || | |
| 365 | currentHost.connectionId !== host.connectionId | |
| 275 | 366 | ) { |
| 276 | 367 | return current; |
| 277 | 368 | } |
| 278 | const owners = new Map(current.owners); | |
| 279 | owners.delete(owner.clientId); | |
| 280 | return { ...current, owners }; | |
| 369 | const clients = new Map(current.clients); | |
| 370 | const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence; | |
| 371 | clients.set(host.clientId, { | |
| 372 | ...currentHost, | |
| 373 | focused: host.focused, | |
| 374 | focusOrder: host.focused ? focusSequence : currentHost.focusOrder, | |
| 375 | }); | |
| 376 | return { ...current, clients, focusSequence }; | |
| 281 | 377 | }); |
| 282 | 378 | }); |
| 283 | 379 | |
| ... | ... | @@ -286,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 286 | 382 | )(function* (response) { |
| 287 | 383 | const pending = yield* SynchronizedRef.modify(state, (current) => { |
| 288 | 384 | const entry = current.pending.get(response.requestId); |
| 289 | if (!entry) return [undefined, current] as const; | |
| 385 | if ( | |
| 386 | !entry || | |
| 387 | entry.context.clientId !== response.clientId || | |
| 388 | entry.context.connectionId !== response.connectionId | |
| 389 | ) { | |
| 390 | return [undefined, current] as const; | |
| 391 | } | |
| 290 | 392 | const next = new Map(current.pending); |
| 291 | 393 | next.delete(response.requestId); |
| 292 | 394 | return [entry, { ...current, pending: next }] as const; |
| ... | ... | @@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 307 | 409 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( |
| 308 | 410 | input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0], |
| 309 | 411 | ): Effect.fn.Return<A, PreviewAutomationError> { |
| 310 | const current = yield* SynchronizedRef.get(state); | |
| 311 | const candidates = Array.from(current.owners.values()) | |
| 312 | .filter( | |
| 313 | (owner) => | |
| 314 | owner.environmentId === input.scope.environmentId && | |
| 315 | owner.threadId === input.scope.threadId && | |
| 316 | owner.supportsAutomation, | |
| 317 | ) | |
| 318 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | |
| 319 | const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); | |
| 320 | if (!owner) { | |
| 321 | const disconnectedOwner = candidates[0]; | |
| 322 | if (disconnectedOwner) { | |
| 323 | return yield* new PreviewAutomationHostNotConnectedError({ | |
| 324 | operation: input.operation, | |
| 325 | environmentId: input.scope.environmentId, | |
| 326 | threadId: input.scope.threadId, | |
| 327 | providerSessionId: input.scope.providerSessionId, | |
| 328 | providerInstanceId: input.scope.providerInstanceId, | |
| 329 | clientId: disconnectedOwner.clientId, | |
| 330 | }); | |
| 331 | } | |
| 332 | return yield* new PreviewAutomationNoFocusedOwnerError({ | |
| 333 | operation: input.operation, | |
| 334 | environmentId: input.scope.environmentId, | |
| 335 | threadId: input.scope.threadId, | |
| 336 | providerSessionId: input.scope.providerSessionId, | |
| 337 | providerInstanceId: input.scope.providerInstanceId, | |
| 338 | }); | |
| 339 | } | |
| 340 | const connection = current.clients.get(owner.clientId); | |
| 341 | if (!connection) { | |
| 342 | return yield* new PreviewAutomationHostNotConnectedError({ | |
| 343 | operation: input.operation, | |
| 344 | environmentId: input.scope.environmentId, | |
| 345 | threadId: input.scope.threadId, | |
| 346 | providerSessionId: input.scope.providerSessionId, | |
| 347 | providerInstanceId: input.scope.providerInstanceId, | |
| 348 | clientId: owner.clientId, | |
| 349 | }); | |
| 350 | } | |
| 351 | 412 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 352 | 413 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 353 | const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { | |
| 354 | const requestId = `preview-${next.requestSequence}`; | |
| 355 | const tabId = input.tabId ?? owner.tabId ?? undefined; | |
| 414 | const now = yield* Clock.currentTimeMillis; | |
| 415 | const route = yield* SynchronizedRef.modify(state, (current) => { | |
| 416 | const assignments = new Map( | |
| 417 | Array.from(current.assignments).filter(([, assignment]) => { | |
| 418 | const connection = current.clients.get(assignment.clientId); | |
| 419 | return ( | |
| 420 | assignment.expiresAt > now && | |
| 421 | connection?.connectionId === assignment.connectionId && | |
| 422 | connection.queue === assignment.queue | |
| 423 | ); | |
| 424 | }), | |
| 425 | ); | |
| 426 | const assignmentKey = hostAssignmentKey(input.scope); | |
| 427 | const assigned = assignments.get(assignmentKey); | |
| 428 | const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; | |
| 429 | const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; | |
| 430 | // Keep one provider session on one physical desktop runtime so a | |
| 431 | // multi-step browser interaction cannot jump between independent | |
| 432 | // Electron cookie/DOM state. A live assignment that predates an | |
| 433 | // operation is not silently moved to a newer client: the caller gets a | |
| 434 | // capability failure and can deliberately start a fresh provider | |
| 435 | // session. A dead lease is pruned above and may fail over. | |
| 436 | const connection = | |
| 437 | hasLiveAssignment && supportsOperation(assignedConnection, input.operation) | |
| 438 | ? assignedConnection | |
| 439 | : hasLiveAssignment | |
| 440 | ? undefined | |
| 441 | : Array.from(current.clients.values()) | |
| 442 | .filter( | |
| 443 | (host) => | |
| 444 | host.environmentId === input.scope.environmentId && | |
| 445 | supportsOperation(host, input.operation), | |
| 446 | ) | |
| 447 | .sort( | |
| 448 | (left, right) => | |
| 449 | right.supportedOperations.size - left.supportedOperations.size || | |
| 450 | Number(right.focused) - Number(left.focused) || | |
| 451 | right.focusOrder - left.focusOrder, | |
| 452 | )[0]; | |
| 453 | if (!connection) { | |
| 454 | if (!hasLiveAssignment) assignments.delete(assignmentKey); | |
| 455 | return [undefined, { ...current, assignments }] as const; | |
| 456 | } | |
| 457 | assignments.set(assignmentKey, { | |
| 458 | clientId: connection.clientId, | |
| 459 | connectionId: connection.connectionId, | |
| 460 | queue: connection.queue, | |
| 461 | expiresAt: input.scope.expiresAt, | |
| 462 | }); | |
| 463 | ||
| 464 | const requestId = `preview-${current.requestSequence}`; | |
| 465 | const tabId = input.tabId; | |
| 356 | 466 | const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); |
| 357 | 467 | const context: PreviewAutomationRequestErrorContext = { |
| 358 | 468 | operation: input.operation, |
| ... | ... | @@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 360 | 470 | threadId: input.scope.threadId, |
| 361 | 471 | providerSessionId: input.scope.providerSessionId, |
| 362 | 472 | providerInstanceId: input.scope.providerInstanceId, |
| 363 | clientId: owner.clientId, | |
| 473 | clientId: connection.clientId, | |
| 474 | connectionId: connection.connectionId, | |
| 364 | 475 | requestId, |
| 365 | 476 | ...(tabId === undefined ? {} : { tabId }), |
| 366 | 477 | timeoutMs, |
| 367 | 478 | ...selectorDiagnostics, |
| 368 | 479 | }; |
| 369 | const pending = new Map(next.pending); | |
| 480 | const pending = new Map(current.pending); | |
| 370 | 481 | pending.set(requestId, { queue: connection.queue, deferred, context }); |
| 371 | 482 | return [ |
| 372 | [requestId, context] as const, | |
| 373 | { ...next, pending, requestSequence: next.requestSequence + 1 }, | |
| 483 | { connection, requestId, requestContext: context }, | |
| 484 | { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, | |
| 374 | 485 | ] as const; |
| 375 | 486 | }); |
| 487 | if (!route) { | |
| 488 | return yield* new PreviewAutomationNoAvailableHostError({ | |
| 489 | operation: input.operation, | |
| 490 | environmentId: input.scope.environmentId, | |
| 491 | threadId: input.scope.threadId, | |
| 492 | providerSessionId: input.scope.providerSessionId, | |
| 493 | providerInstanceId: input.scope.providerInstanceId, | |
| 494 | }); | |
| 495 | } | |
| 496 | const { connection, requestId, requestContext } = route; | |
| 376 | 497 | const removePending = SynchronizedRef.update(state, (next) => { |
| 377 | 498 | if (!next.pending.has(requestId)) return next; |
| 378 | 499 | const pending = new Map(next.pending); |
| ... | ... | @@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 381 | 502 | }); |
| 382 | 503 | const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { |
| 383 | 504 | const offered = yield* Queue.offer(connection.queue, { |
| 384 | requestId, | |
| 385 | threadId: input.scope.threadId, | |
| 386 | tabId: requestContext.tabId, | |
| 387 | operation: input.operation, | |
| 388 | input: input.input, | |
| 389 | timeoutMs, | |
| 505 | type: "request", | |
| 506 | connectionId: connection.connectionId, | |
| 507 | request: { | |
| 508 | requestId, | |
| 509 | threadId: input.scope.threadId, | |
| 510 | tabId: requestContext.tabId, | |
| 511 | operation: input.operation, | |
| 512 | input: input.input, | |
| 513 | timeoutMs, | |
| 514 | }, | |
| 390 | 515 | }); |
| 391 | 516 | if (!offered) { |
| 392 | 517 | const completion = yield* Deferred.poll(deferred); |
| ... | ... | @@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 404 | 529 | return yield* awaitResponse().pipe(Effect.ensuring(removePending)); |
| 405 | 530 | }); |
| 406 | 531 | |
| 407 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | |
| 532 | return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke }); | |
| 408 | 533 | }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); |
| 409 | 534 | |
| 410 | 535 | export const layer = Layer.effect(PreviewAutomationBroker, make); |
apps/server/src/mcp/toolkits/preview/handlers.ts+3| ... | ... | @@ -3,6 +3,7 @@ import type { |
| 3 | 3 | PreviewAutomationOperation, |
| 4 | 4 | PreviewAutomationRecordingArtifact, |
| 5 | 5 | PreviewAutomationRecordingStatus, |
| 6 | PreviewAutomationResizeResult, | |
| 6 | 7 | PreviewAutomationSnapshot, |
| 7 | 8 | PreviewAutomationStatus, |
| 8 | 9 | } from "@t3tools/contracts"; |
| ... | ... | @@ -39,6 +40,8 @@ const handlers = { |
| 39 | 40 | reuseExistingTab: input.reuseExistingTab ?? true, |
| 40 | 41 | }), |
| 41 | 42 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 43 | preview_resize: (input) => | |
| 44 | invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), | |
| 42 | 45 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), |
| 43 | 46 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), |
| 44 | 47 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), |
apps/server/src/mcp/toolkits/preview/tools.ts+18-1| ... | ... | @@ -7,6 +7,8 @@ import { |
| 7 | 7 | PreviewAutomationPressInput, |
| 8 | 8 | PreviewAutomationRecordingArtifact, |
| 9 | 9 | PreviewAutomationRecordingStatus, |
| 10 | PreviewAutomationResizeInput, | |
| 11 | PreviewAutomationResizeResult, | |
| 10 | 12 | PreviewAutomationScrollInput, |
| 11 | 13 | PreviewAutomationSnapshot, |
| 12 | 14 | PreviewAutomationStatus, |
| ... | ... | @@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => |
| 35 | 37 | |
| 36 | 38 | export const PreviewStatusTool = Tool.make("preview_status", { |
| 37 | 39 | description: |
| 38 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", | |
| 40 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size.", | |
| 39 | 41 | success: PreviewAutomationStatus, |
| 40 | 42 | failure: PreviewAutomationError, |
| 41 | 43 | dependencies, |
| ... | ... | @@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool( |
| 69 | 71 | }).annotate(Tool.Title, "Navigate browser preview"), |
| 70 | 72 | ); |
| 71 | 73 | |
| 74 | export const PreviewResizeTool = safeBrowserTool( | |
| 75 | Tool.make("preview_resize", { | |
| 76 | description: | |
| 77 | "Set the active collaborative browser tab to fill-panel sizing, an independently resizable freeform size, or a Chrome-standard device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", | |
| 78 | parameters: PreviewAutomationResizeInput, | |
| 79 | success: PreviewAutomationResizeResult, | |
| 80 | failure: PreviewAutomationError, | |
| 81 | dependencies, | |
| 82 | }) | |
| 83 | .annotate(Tool.Title, "Resize browser viewport") | |
| 84 | .annotate(Tool.Idempotent, true), | |
| 85 | ); | |
| 86 | ||
| 72 | 87 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 73 | 88 | Tool.make("preview_snapshot", { |
| 74 | 89 | description: |
| ... | ... | @@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make( |
| 168 | 183 | PreviewStatusTool, |
| 169 | 184 | PreviewOpenTool, |
| 170 | 185 | PreviewNavigateTool, |
| 186 | PreviewResizeTool, | |
| 171 | 187 | PreviewSnapshotTool, |
| 172 | 188 | PreviewClickTool, |
| 173 | 189 | PreviewTypeTool, |
| ... | ... | @@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make( |
| 183 | 199 | PreviewStatusTool, |
| 184 | 200 | PreviewOpenTool, |
| 185 | 201 | PreviewNavigateTool, |
| 202 | PreviewResizeTool, | |
| 186 | 203 | PreviewClickTool, |
| 187 | 204 | PreviewTypeTool, |
| 188 | 205 | PreviewPressTool, |
apps/server/src/preview/Manager.test.ts+55| ... | ... | @@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { |
| 151 | 151 | }), |
| 152 | 152 | ); |
| 153 | 153 | |
| 154 | it.effect("resizes a tab and preserves its viewport across navigation reports", () => | |
| 155 | Effect.gen(function* () { | |
| 156 | const threadId = freshThreadId(); | |
| 157 | const manager = yield* PreviewManager.PreviewManager; | |
| 158 | const collector = yield* collectEvents; | |
| 159 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 160 | ||
| 161 | const resized = yield* manager.resize({ | |
| 162 | threadId, | |
| 163 | tabId: opened.tabId, | |
| 164 | viewport: { _tag: "freeform", width: 1024, height: 768 }, | |
| 165 | }); | |
| 166 | expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 }); | |
| 167 | ||
| 168 | const navigated = yield* manager.navigate({ | |
| 169 | threadId, | |
| 170 | tabId: opened.tabId, | |
| 171 | url: "http://localhost:5173/resized", | |
| 172 | }); | |
| 173 | expect(navigated.viewport).toEqual(resized.viewport); | |
| 174 | ||
| 175 | yield* manager.reportStatus({ | |
| 176 | threadId, | |
| 177 | tabId: opened.tabId, | |
| 178 | navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" }, | |
| 179 | canGoBack: true, | |
| 180 | canGoForward: false, | |
| 181 | }); | |
| 182 | const listed = yield* manager.list({ threadId }); | |
| 183 | expect(listed.sessions[0]?.viewport).toEqual(resized.viewport); | |
| 184 | ||
| 185 | const events = yield* collector.drain; | |
| 186 | expect(events.map((event) => event.type)).toEqual([ | |
| 187 | "opened", | |
| 188 | "resized", | |
| 189 | "navigated", | |
| 190 | "navigated", | |
| 191 | ]); | |
| 192 | }), | |
| 193 | ); | |
| 194 | ||
| 195 | it.effect("rejects resize for an unknown tab", () => | |
| 196 | Effect.gen(function* () { | |
| 197 | const manager = yield* PreviewManager.PreviewManager; | |
| 198 | const error = yield* Effect.flip( | |
| 199 | manager.resize({ | |
| 200 | threadId: freshThreadId(), | |
| 201 | tabId: "tab_missing", | |
| 202 | viewport: { _tag: "fill" }, | |
| 203 | }), | |
| 204 | ); | |
| 205 | expect(error._tag).toBe("PreviewSessionLookupError"); | |
| 206 | }), | |
| 207 | ); | |
| 208 | ||
| 154 | 209 | it.effect("reportStatus emits failed for LoadFailed nav", () => |
| 155 | 210 | Effect.gen(function* () { |
| 156 | 211 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+38| ... | ... | @@ -20,6 +20,8 @@ import { |
| 20 | 20 | type PreviewOpenInput, |
| 21 | 21 | type PreviewRefreshInput, |
| 22 | 22 | type PreviewReportStatusInput, |
| 23 | type PreviewResizeInput, | |
| 24 | FILL_PREVIEW_VIEWPORT, | |
| 23 | 25 | PreviewSessionLookupError, |
| 24 | 26 | type PreviewSessionSnapshot, |
| 25 | 27 | } from "@t3tools/contracts"; |
| ... | ... | @@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service< |
| 45 | 47 | input: PreviewNavigateInput, |
| 46 | 48 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 47 | 49 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; |
| 50 | readonly resize: ( | |
| 51 | input: PreviewResizeInput, | |
| 52 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 48 | 53 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; |
| 49 | 54 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; |
| 50 | 55 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; |
| ... | ... | @@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: { |
| 114 | 119 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, |
| 115 | 120 | canGoBack: false, |
| 116 | 121 | canGoForward: false, |
| 122 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 117 | 123 | updatedAt: input.updatedAt, |
| 118 | 124 | }); |
| 119 | 125 | |
| ... | ... | @@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: { |
| 127 | 133 | navStatus: { _tag: "Idle" }, |
| 128 | 134 | canGoBack: false, |
| 129 | 135 | canGoForward: false, |
| 136 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 130 | 137 | updatedAt: input.updatedAt, |
| 131 | 138 | }); |
| 132 | 139 | |
| ... | ... | @@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 237 | 244 | navStatus: { _tag: "Success", url, title: resolvedTitle }, |
| 238 | 245 | canGoBack: session.snapshot.canGoBack, |
| 239 | 246 | canGoForward: session.snapshot.canGoForward, |
| 247 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | |
| 240 | 248 | updatedAt, |
| 241 | 249 | }; |
| 242 | 250 | return { |
| ... | ... | @@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 269 | 277 | navStatus: input.navStatus, |
| 270 | 278 | canGoBack: input.canGoBack, |
| 271 | 279 | canGoForward: input.canGoForward, |
| 280 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | |
| 272 | 281 | updatedAt, |
| 273 | 282 | }; |
| 274 | 283 | const emit: PreviewEvent = |
| ... | ... | @@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 299 | 308 | ); |
| 300 | 309 | }); |
| 301 | 310 | |
| 311 | const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")( | |
| 312 | function* (input) { | |
| 313 | return yield* mutateExistingSession( | |
| 314 | input.threadId, | |
| 315 | input.tabId, | |
| 316 | Effect.fn("PreviewManager.resizeSession")(function* (session) { | |
| 317 | const updatedAt = yield* currentIsoTimestamp; | |
| 318 | const snapshot: PreviewSessionSnapshot = { | |
| 319 | ...session.snapshot, | |
| 320 | viewport: input.viewport, | |
| 321 | updatedAt, | |
| 322 | }; | |
| 323 | return { | |
| 324 | next: { ...session, snapshot }, | |
| 325 | emit: { | |
| 326 | type: "resized", | |
| 327 | threadId: session.threadId, | |
| 328 | tabId: session.tabId, | |
| 329 | createdAt: snapshot.updatedAt, | |
| 330 | snapshot, | |
| 331 | }, | |
| 332 | result: snapshot, | |
| 333 | }; | |
| 334 | }), | |
| 335 | ); | |
| 336 | }, | |
| 337 | ); | |
| 338 | ||
| 302 | 339 | const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( |
| 303 | 340 | function* (input) { |
| 304 | 341 | // Verify the session exists; the desktop bridge handles the actual reload |
| ... | ... | @@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 360 | 397 | open, |
| 361 | 398 | navigate, |
| 362 | 399 | reportStatus, |
| 400 | resize, | |
| 363 | 401 | refresh, |
| 364 | 402 | close, |
| 365 | 403 | list, |
apps/server/src/server.test.ts+41| ... | ... | @@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: { |
| 653 | 653 | Layer.mock(PreviewManager.PreviewManager)({ |
| 654 | 654 | open: () => Effect.die("PreviewManager not stubbed in this test"), |
| 655 | 655 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), |
| 656 | resize: () => Effect.die("PreviewManager not stubbed in this test"), | |
| 656 | 657 | reportStatus: () => Effect.void, |
| 657 | 658 | refresh: () => Effect.void, |
| 658 | 659 | close: () => Effect.void, |
| ... | ... | @@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4125 | 4126 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4126 | 4127 | ); |
| 4127 | 4128 | |
| 4129 | it.effect("shares one preview automation broker across websocket sessions", () => | |
| 4130 | Effect.scoped( | |
| 4131 | Effect.gen(function* () { | |
| 4132 | yield* buildAppUnderTest(); | |
| 4133 | ||
| 4134 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 4135 | const firstConnected = yield* Deferred.make<string>(); | |
| 4136 | const firstClosed = yield* Deferred.make<void>(); | |
| 4137 | const host = { | |
| 4138 | clientId: "shared-preview-host", | |
| 4139 | environmentId: testEnvironmentDescriptor.environmentId, | |
| 4140 | } as const; | |
| 4141 | ||
| 4142 | yield* withWsRpcClient(wsUrl, (client) => | |
| 4143 | client[WS_METHODS.previewAutomationConnect](host).pipe( | |
| 4144 | Stream.tap((event) => | |
| 4145 | event.type === "connected" | |
| 4146 | ? Deferred.succeed(firstConnected, event.connectionId) | |
| 4147 | : Effect.void, | |
| 4148 | ), | |
| 4149 | Stream.runDrain, | |
| 4150 | Effect.ensuring(Deferred.succeed(firstClosed, undefined)), | |
| 4151 | ), | |
| 4152 | ).pipe(Effect.forkScoped); | |
| 4153 | ||
| 4154 | const firstConnectionId = yield* Deferred.await(firstConnected); | |
| 4155 | const replacementEvent = yield* withWsRpcClient(wsUrl, (client) => | |
| 4156 | client[WS_METHODS.previewAutomationConnect](host).pipe(Stream.runHead), | |
| 4157 | ).pipe(Effect.map(Option.getOrThrow)); | |
| 4158 | const firstStreamClosed = yield* Deferred.await(firstClosed).pipe( | |
| 4159 | Effect.timeoutOption("2 seconds"), | |
| 4160 | ); | |
| 4161 | ||
| 4162 | assert.equal(replacementEvent.type, "connected"); | |
| 4163 | assert.notEqual(replacementEvent.connectionId, firstConnectionId); | |
| 4164 | assert.isTrue(Option.isSome(firstStreamClosed)); | |
| 4165 | }), | |
| 4166 | ).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4167 | ); | |
| 4168 | ||
| 4128 | 4169 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => |
| 4129 | 4170 | Effect.gen(function* () { |
| 4130 | 4171 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/server.ts+2-1| ... | ... | @@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide |
| 36 | 36 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 37 | 37 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 38 | 38 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| 39 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | |
| 39 | 40 | import * as PreviewManager from "./preview/Manager.ts"; |
| 40 | 41 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 41 | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| ... | ... | @@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 357 | 358 | websocketRpcRouteLayer, |
| 358 | 359 | ), |
| 359 | 360 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), |
| 360 | ).pipe(Layer.provide(browserApiCorsLayer)); | |
| 361 | ).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); | |
| 361 | 362 | |
| 362 | 363 | export const makeServerLayer = Layer.unwrap( |
| 363 | 364 | Effect.gen(function* () { |
apps/server/src/ws.ts+19-19| ... | ... | @@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 330 | 330 | [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], |
| 331 | 331 | [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], |
| 332 | 332 | [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], |
| 333 | [WS_METHODS.previewResize, AuthOrchestrationOperateScope], | |
| 333 | 334 | [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], |
| 334 | 335 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], |
| 335 | 336 | [WS_METHODS.previewList, AuthOrchestrationReadScope], |
| 336 | 337 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], |
| 337 | 338 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], |
| 338 | 339 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], |
| 339 | [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], | |
| 340 | [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], | |
| 340 | [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], | |
| 341 | 341 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], |
| 342 | 342 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], |
| 343 | 343 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], |
| ... | ... | @@ -385,7 +385,10 @@ function toAuthAccessStreamEvent( |
| 385 | 385 | } |
| 386 | 386 | } |
| 387 | 387 | |
| 388 | const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | |
| 388 | const makeWsRpcLayer = ( | |
| 389 | currentSession: EnvironmentAuth.AuthenticatedSession, | |
| 390 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], | |
| 391 | ) => | |
| 389 | 392 | WsRpcGroup.toLayer( |
| 390 | 393 | Effect.gen(function* () { |
| 391 | 394 | const currentSessionId = currentSession.sessionId; |
| ... | ... | @@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 400 | 403 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| 401 | 404 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 402 | 405 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 403 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 404 | 406 | const previewManager = yield* PreviewManager.PreviewManager; |
| 405 | 407 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 406 | 408 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| ... | ... | @@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1622 | 1624 | observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { |
| 1623 | 1625 | "rpc.aggregate": "preview", |
| 1624 | 1626 | }), |
| 1627 | [WS_METHODS.previewResize]: (input) => | |
| 1628 | observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), { | |
| 1629 | "rpc.aggregate": "preview", | |
| 1630 | }), | |
| 1625 | 1631 | [WS_METHODS.previewRefresh]: (input) => |
| 1626 | 1632 | observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { |
| 1627 | 1633 | "rpc.aggregate": "preview", |
| ... | ... | @@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1650 | 1656 | previewAutomationBroker.respond(input), |
| 1651 | 1657 | { "rpc.aggregate": "preview-automation" }, |
| 1652 | 1658 | ), |
| 1653 | [WS_METHODS.previewAutomationReportOwner]: (input) => | |
| 1654 | observeRpcEffect( | |
| 1655 | WS_METHODS.previewAutomationReportOwner, | |
| 1656 | previewAutomationBroker.reportOwner(input), | |
| 1657 | { "rpc.aggregate": "preview-automation" }, | |
| 1658 | ), | |
| 1659 | [WS_METHODS.previewAutomationClearOwner]: (input) => | |
| 1659 | [WS_METHODS.previewAutomationFocusHost]: (input) => | |
| 1660 | 1660 | observeRpcEffect( |
| 1661 | WS_METHODS.previewAutomationClearOwner, | |
| 1662 | previewAutomationBroker.clearOwner(input), | |
| 1661 | WS_METHODS.previewAutomationFocusHost, | |
| 1662 | previewAutomationBroker.focusHost(input), | |
| 1663 | 1663 | { "rpc.aggregate": "preview-automation" }, |
| 1664 | 1664 | ), |
| 1665 | 1665 | [WS_METHODS.subscribePreviewEvents]: (_input) => |
| ... | ... | @@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1791 | 1791 | ); |
| 1792 | 1792 | |
| 1793 | 1793 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 1794 | Effect.succeed( | |
| 1795 | HttpRouter.add( | |
| 1794 | Effect.gen(function* () { | |
| 1795 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 1796 | return HttpRouter.add( | |
| 1796 | 1797 | "GET", |
| 1797 | 1798 | "/ws", |
| 1798 | 1799 | Effect.gen(function* () { |
| ... | ... | @@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1811 | 1812 | disableTracing: true, |
| 1812 | 1813 | }).pipe( |
| 1813 | 1814 | Effect.provide( |
| 1814 | makeWsRpcLayer(session).pipe( | |
| 1815 | makeWsRpcLayer(session, previewAutomationBroker).pipe( | |
| 1815 | 1816 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1816 | Layer.provide(PreviewAutomationBroker.layer), | |
| 1817 | 1817 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1818 | 1818 | Layer.provide( |
| 1819 | 1819 | SourceControlDiscovery.layer.pipe( |
| ... | ... | @@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1850 | 1850 | EnvironmentInternalError: HttpServerRespondable.toResponse, |
| 1851 | 1851 | }), |
| 1852 | 1852 | ), |
| 1853 | ), | |
| 1854 | ), | |
| 1853 | ); | |
| 1854 | }), | |
| 1855 | 1855 | ); |
apps/web/src/AppRoot.test.tsx+5-3| ... | ... | @@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 6 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; | |
| 6 | 7 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 7 | 8 | import type { AppRouter } from "./router"; |
| 8 | 9 | import { AppRoot } from "./AppRoot"; |
| 9 | 10 | |
| 10 | 11 | describe("AppRoot", () => { |
| 11 | it("shares the application atom registry with routed UI and the Electron browser host", () => { | |
| 12 | it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { | |
| 12 | 13 | const root = AppRoot({ router: {} as AppRouter }); |
| 13 | 14 | |
| 14 | 15 | expect(root.type).toBe(AppAtomRegistryProvider); |
| 15 | 16 | const children = Children.toArray( |
| 16 | 17 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, |
| 17 | 18 | ); |
| 18 | expect(children).toHaveLength(2); | |
| 19 | expect(children).toHaveLength(3); | |
| 19 | 20 | expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); |
| 20 | expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); | |
| 21 | expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); | |
| 22 | expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); | |
| 21 | 23 | }); |
| 22 | 24 | }); |
apps/web/src/AppRoot.tsx+2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { RouterProvider } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 4 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; | |
| 4 | 5 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 5 | 6 | import type { AppRouter } from "./router"; |
| 6 | 7 | |
| ... | ... | @@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { |
| 13 | 14 | return ( |
| 14 | 15 | <AppAtomRegistryProvider> |
| 15 | 16 | <RouterProvider router={router} /> |
| 17 | <PreviewAutomationHosts /> | |
| 16 | 18 | <ElectronBrowserHost /> |
| 17 | 19 | </AppAtomRegistryProvider> |
| 18 | 20 | ); |
apps/web/src/browser/BrowserDeviceToolbar.test.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | commitViewportAndAspectRatio, | |
| 6 | reconcileLockedAspectRatio, | |
| 7 | } from "./browserDeviceToolbarState"; | |
| 8 | ||
| 9 | describe("commitViewportAndAspectRatio", () => { | |
| 10 | it("commits the aspect ratio only after the viewport succeeds", async () => { | |
| 11 | let resolveChange: (() => void) | undefined; | |
| 12 | const onChange = vi.fn( | |
| 13 | () => | |
| 14 | new Promise<void>((resolve) => { | |
| 15 | resolveChange = resolve; | |
| 16 | }), | |
| 17 | ); | |
| 18 | const onAspectRatioChange = vi.fn(); | |
| 19 | const setting: PreviewViewportSetting = { _tag: "freeform", width: 900, height: 600 }; | |
| 20 | ||
| 21 | const commit = commitViewportAndAspectRatio(setting, 1.5, onChange, onAspectRatioChange); | |
| 22 | expect(onAspectRatioChange).not.toHaveBeenCalled(); | |
| 23 | ||
| 24 | resolveChange?.(); | |
| 25 | await commit; | |
| 26 | expect(onAspectRatioChange).toHaveBeenCalledWith(1.5); | |
| 27 | }); | |
| 28 | ||
| 29 | it("keeps the previous aspect ratio when the viewport commit fails", async () => { | |
| 30 | const onAspectRatioChange = vi.fn(); | |
| 31 | await expect( | |
| 32 | commitViewportAndAspectRatio( | |
| 33 | { _tag: "fill" }, | |
| 34 | null, | |
| 35 | async () => Promise.reject(new Error("resize failed")), | |
| 36 | onAspectRatioChange, | |
| 37 | ), | |
| 38 | ).rejects.toThrow("resize failed"); | |
| 39 | expect(onAspectRatioChange).not.toHaveBeenCalled(); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("reconcileLockedAspectRatio", () => { | |
| 44 | it("tracks external viewport ratios only while the lock remains active", () => { | |
| 45 | expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); | |
| 46 | expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); | |
| 47 | expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); | |
| 48 | }); | |
| 49 | }); |
apps/web/src/browser/BrowserDeviceToolbar.tsx created+340| ... | ... | @@ -0,0 +1,340 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { | |
| 4 | PREVIEW_VIEWPORT_MAX_AREA, | |
| 5 | PREVIEW_VIEWPORT_MAX_DIMENSION, | |
| 6 | PREVIEW_VIEWPORT_MIN_DIMENSION, | |
| 7 | type PreviewViewportSetting, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | |
| 10 | import { Link2, X } from "lucide-react"; | |
| 11 | import { useState } from "react"; | |
| 12 | ||
| 13 | import { Button } from "~/components/ui/button"; | |
| 14 | import { Input } from "~/components/ui/input"; | |
| 15 | import { | |
| 16 | Select, | |
| 17 | SelectGroup, | |
| 18 | SelectGroupLabel, | |
| 19 | SelectItem, | |
| 20 | SelectPopup, | |
| 21 | SelectTrigger, | |
| 22 | SelectValue, | |
| 23 | } from "~/components/ui/select"; | |
| 24 | import { cn } from "~/lib/utils"; | |
| 25 | ||
| 26 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; | |
| 27 | import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; | |
| 28 | ||
| 29 | const RESPONSIVE_VALUE = "responsive"; | |
| 30 | const SELECT_ITEMS = [ | |
| 31 | { value: RESPONSIVE_VALUE, label: "Responsive" }, | |
| 32 | ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), | |
| 33 | ]; | |
| 34 | ||
| 35 | function ScreenRotationIcon() { | |
| 36 | return ( | |
| 37 | <svg | |
| 38 | viewBox="0 0 24 24" | |
| 39 | fill="none" | |
| 40 | stroke="currentColor" | |
| 41 | strokeWidth="2" | |
| 42 | strokeLinecap="round" | |
| 43 | strokeLinejoin="round" | |
| 44 | aria-hidden="true" | |
| 45 | > | |
| 46 | <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" /> | |
| 47 | <path d="M12.5 2a10 10 0 0 1 8.4 5.4" /> | |
| 48 | <path d="M20.8 3.5v4h-4" /> | |
| 49 | <path d="M11.5 22a10 10 0 0 1-8.4-5.4" /> | |
| 50 | <path d="M3.2 20.5v-4h4" /> | |
| 51 | </svg> | |
| 52 | ); | |
| 53 | } | |
| 54 | ||
| 55 | interface Props { | |
| 56 | readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>; | |
| 57 | readonly width: number; | |
| 58 | readonly aspectRatio: number | null; | |
| 59 | readonly onAspectRatioChange: (aspectRatio: number | null) => void; | |
| 60 | readonly onChange: (setting: PreviewViewportSetting) => Promise<void>; | |
| 61 | } | |
| 62 | ||
| 63 | export function BrowserDeviceToolbar({ | |
| 64 | setting, | |
| 65 | width, | |
| 66 | aspectRatio, | |
| 67 | onAspectRatioChange, | |
| 68 | onChange, | |
| 69 | }: Props) { | |
| 70 | const [pending, setPending] = useState(false); | |
| 71 | const [customSize, setCustomSize] = useState<{ | |
| 72 | readonly width: string; | |
| 73 | readonly height: string; | |
| 74 | } | null>(null); | |
| 75 | const presentedSize = customSize ?? { | |
| 76 | width: String(setting.width), | |
| 77 | height: String(setting.height), | |
| 78 | }; | |
| 79 | const selectedValue = | |
| 80 | setting._tag === "preset" && | |
| 81 | PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) | |
| 82 | ? setting.presetId | |
| 83 | : RESPONSIVE_VALUE; | |
| 84 | const customWidth = Number(presentedSize.width); | |
| 85 | const customHeight = Number(presentedSize.height); | |
| 86 | const customValid = | |
| 87 | Number.isInteger(customWidth) && | |
| 88 | Number.isInteger(customHeight) && | |
| 89 | customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && | |
| 90 | customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && | |
| 91 | customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && | |
| 92 | customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && | |
| 93 | customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; | |
| 94 | ||
| 95 | const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { | |
| 96 | setPending(true); | |
| 97 | void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then( | |
| 98 | () => { | |
| 99 | setPending(false); | |
| 100 | setCustomSize(null); | |
| 101 | }, | |
| 102 | () => setPending(false), | |
| 103 | ); | |
| 104 | }; | |
| 105 | ||
| 106 | const applyCustomSize = () => { | |
| 107 | if (!customValid || (customWidth === setting.width && customHeight === setting.height)) { | |
| 108 | setCustomSize(null); | |
| 109 | return; | |
| 110 | } | |
| 111 | apply({ _tag: "freeform", width: customWidth, height: customHeight }); | |
| 112 | }; | |
| 113 | ||
| 114 | const updateCustomDimension = (axis: "width" | "height", value: string) => { | |
| 115 | setCustomSize((current) => { | |
| 116 | const next = { | |
| 117 | width: axis === "width" ? value : (current?.width ?? String(setting.width)), | |
| 118 | height: axis === "height" ? value : (current?.height ?? String(setting.height)), | |
| 119 | }; | |
| 120 | const numeric = Number(value); | |
| 121 | if ( | |
| 122 | aspectRatio === null || | |
| 123 | !Number.isInteger(numeric) || | |
| 124 | numeric < PREVIEW_VIEWPORT_MIN_DIMENSION || | |
| 125 | numeric > PREVIEW_VIEWPORT_MAX_DIMENSION | |
| 126 | ) { | |
| 127 | return next; | |
| 128 | } | |
| 129 | const resized = resizeFreeformViewport( | |
| 130 | setting, | |
| 131 | axis === "width" | |
| 132 | ? { x: numeric - setting.width, y: 0 } | |
| 133 | : { x: 0, y: numeric - setting.height }, | |
| 134 | 1, | |
| 135 | axis === "width" ? "east" : "south", | |
| 136 | aspectRatio, | |
| 137 | ); | |
| 138 | return { width: String(resized.width), height: String(resized.height) }; | |
| 139 | }); | |
| 140 | }; | |
| 141 | ||
| 142 | const selectViewport = (value: string | null) => { | |
| 143 | if (!value) return; | |
| 144 | if (value === RESPONSIVE_VALUE) { | |
| 145 | if (setting._tag === "freeform") return; | |
| 146 | apply({ _tag: "freeform", width: setting.width, height: setting.height }); | |
| 147 | return; | |
| 148 | } | |
| 149 | const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); | |
| 150 | if (!preset) return; | |
| 151 | apply( | |
| 152 | resolvePreviewViewport({ mode: "preset", preset: preset.id }), | |
| 153 | aspectRatio === null ? null : preset.width / preset.height, | |
| 154 | ); | |
| 155 | }; | |
| 156 | ||
| 157 | const rotate = () => { | |
| 158 | const hasCustomSize = | |
| 159 | customValid && (customWidth !== setting.width || customHeight !== setting.height); | |
| 160 | const source = hasCustomSize | |
| 161 | ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const) | |
| 162 | : setting; | |
| 163 | apply( | |
| 164 | { ...source, width: source.height, height: source.width }, | |
| 165 | aspectRatio === null ? null : 1 / aspectRatio, | |
| 166 | ); | |
| 167 | }; | |
| 168 | ||
| 169 | const toggleAspectRatio = () => { | |
| 170 | onAspectRatioChange(aspectRatio === null ? customWidth / customHeight : null); | |
| 171 | }; | |
| 172 | ||
| 173 | return ( | |
| 174 | <div | |
| 175 | className="sticky left-0 top-0 z-50 flex items-center gap-0.5 overflow-x-auto border-b border-border/70 bg-background/95 px-1.5 shadow-xs backdrop-blur-md [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 176 | style={{ width, height: BROWSER_DEVICE_TOOLBAR_HEIGHT }} | |
| 177 | role="toolbar" | |
| 178 | aria-label="Browser device toolbar" | |
| 179 | data-browser-device-toolbar | |
| 180 | onBlur={(event) => { | |
| 181 | const nextTarget = event.relatedTarget; | |
| 182 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; | |
| 183 | const eventTarget = event.target; | |
| 184 | if ( | |
| 185 | (nextTarget instanceof HTMLElement && | |
| 186 | nextTarget.closest('[data-slot="select-positioner"]')) || | |
| 187 | (eventTarget instanceof HTMLElement && | |
| 188 | eventTarget.closest('[data-slot="select-positioner"]')) | |
| 189 | ) { | |
| 190 | return; | |
| 191 | } | |
| 192 | applyCustomSize(); | |
| 193 | }} | |
| 194 | > | |
| 195 | {width >= 560 ? ( | |
| 196 | <span className="mr-0.5 shrink-0 text-[11px] font-medium text-muted-foreground"> | |
| 197 | Dimensions | |
| 198 | </span> | |
| 199 | ) : null} | |
| 200 | <Select | |
| 201 | modal={false} | |
| 202 | value={selectedValue} | |
| 203 | onValueChange={selectViewport} | |
| 204 | items={SELECT_ITEMS} | |
| 205 | disabled={pending} | |
| 206 | > | |
| 207 | <SelectTrigger | |
| 208 | variant="ghost" | |
| 209 | size="xs" | |
| 210 | className={cn( | |
| 211 | "shrink-0 justify-between px-1.5 font-medium", | |
| 212 | width >= 440 ? "w-36" : "w-24", | |
| 213 | )} | |
| 214 | aria-label="Browser device preset" | |
| 215 | > | |
| 216 | <SelectValue /> | |
| 217 | </SelectTrigger> | |
| 218 | <SelectPopup align="start" alignItemWithTrigger={false} className="min-w-64"> | |
| 219 | <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem> | |
| 220 | <SelectGroup> | |
| 221 | <SelectGroupLabel>Standard</SelectGroupLabel> | |
| 222 | {PREVIEW_VIEWPORT_PRESETS.map((preset) => ( | |
| 223 | <SelectItem key={preset.id} value={preset.id}> | |
| 224 | <span className="flex w-full items-center justify-between gap-5"> | |
| 225 | <span>{preset.label}</span> | |
| 226 | <span className="text-xs tabular-nums text-muted-foreground"> | |
| 227 | {preset.detail} | |
| 228 | </span> | |
| 229 | </span> | |
| 230 | </SelectItem> | |
| 231 | ))} | |
| 232 | </SelectGroup> | |
| 233 | </SelectPopup> | |
| 234 | </Select> | |
| 235 | ||
| 236 | <form | |
| 237 | className="m-0 flex min-w-0 shrink-0 items-center gap-0.5 border-0 p-0" | |
| 238 | aria-label="Viewport dimensions" | |
| 239 | onSubmit={(event) => { | |
| 240 | event.preventDefault(); | |
| 241 | applyCustomSize(); | |
| 242 | }} | |
| 243 | > | |
| 244 | <Input | |
| 245 | nativeInput | |
| 246 | type="number" | |
| 247 | inputMode="numeric" | |
| 248 | size="sm" | |
| 249 | min={PREVIEW_VIEWPORT_MIN_DIMENSION} | |
| 250 | max={PREVIEW_VIEWPORT_MAX_DIMENSION} | |
| 251 | value={presentedSize.width} | |
| 252 | disabled={pending} | |
| 253 | onFocus={() => | |
| 254 | setCustomSize( | |
| 255 | (current) => | |
| 256 | current ?? { | |
| 257 | width: String(setting.width), | |
| 258 | height: String(setting.height), | |
| 259 | }, | |
| 260 | ) | |
| 261 | } | |
| 262 | onChange={(event) => updateCustomDimension("width", event.target.value)} | |
| 263 | aria-label="Viewport width" | |
| 264 | aria-invalid={!customValid} | |
| 265 | className={cn( | |
| 266 | "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", | |
| 267 | width >= 360 ? "w-14" : "w-11", | |
| 268 | )} | |
| 269 | /> | |
| 270 | <span className="text-xs text-muted-foreground">×</span> | |
| 271 | <Input | |
| 272 | nativeInput | |
| 273 | type="number" | |
| 274 | inputMode="numeric" | |
| 275 | size="sm" | |
| 276 | min={PREVIEW_VIEWPORT_MIN_DIMENSION} | |
| 277 | max={PREVIEW_VIEWPORT_MAX_DIMENSION} | |
| 278 | value={presentedSize.height} | |
| 279 | disabled={pending} | |
| 280 | onFocus={() => | |
| 281 | setCustomSize( | |
| 282 | (current) => | |
| 283 | current ?? { | |
| 284 | width: String(setting.width), | |
| 285 | height: String(setting.height), | |
| 286 | }, | |
| 287 | ) | |
| 288 | } | |
| 289 | onChange={(event) => updateCustomDimension("height", event.target.value)} | |
| 290 | aria-label="Viewport height" | |
| 291 | aria-invalid={!customValid} | |
| 292 | className={cn( | |
| 293 | "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", | |
| 294 | width >= 360 ? "w-14" : "w-11", | |
| 295 | )} | |
| 296 | /> | |
| 297 | </form> | |
| 298 | ||
| 299 | <Button | |
| 300 | variant="ghost" | |
| 301 | size="icon-xs" | |
| 302 | type="button" | |
| 303 | aria-label={ | |
| 304 | aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio" | |
| 305 | } | |
| 306 | aria-pressed={aspectRatio !== null} | |
| 307 | title={aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"} | |
| 308 | className={cn(aspectRatio !== null && "bg-accent text-foreground")} | |
| 309 | disabled={pending || !customValid} | |
| 310 | onPointerDown={(event) => event.preventDefault()} | |
| 311 | onClick={toggleAspectRatio} | |
| 312 | > | |
| 313 | <Link2 className={cn(aspectRatio !== null && "text-foreground")} /> | |
| 314 | </Button> | |
| 315 | <Button | |
| 316 | variant="ghost" | |
| 317 | size="icon-xs" | |
| 318 | type="button" | |
| 319 | aria-label="Rotate viewport" | |
| 320 | disabled={pending} | |
| 321 | onClick={rotate} | |
| 322 | > | |
| 323 | <ScreenRotationIcon /> | |
| 324 | </Button> | |
| 325 | <Button | |
| 326 | variant="ghost" | |
| 327 | size="icon-xs" | |
| 328 | type="button" | |
| 329 | aria-label="Close device toolbar" | |
| 330 | className="sticky right-0 ml-auto bg-background/95" | |
| 331 | disabled={pending} | |
| 332 | onClick={() => { | |
| 333 | apply({ _tag: "fill" }, null); | |
| 334 | }} | |
| 335 | > | |
| 336 | <X /> | |
| 337 | </Button> | |
| 338 | </div> | |
| 339 | ); | |
| 340 | } |
apps/web/src/browser/BrowserViewportResizeHandles.tsx created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import type { | |
| 4 | CSSProperties, | |
| 5 | KeyboardEvent as ReactKeyboardEvent, | |
| 6 | PointerEvent as ReactPointerEvent, | |
| 7 | } from "react"; | |
| 8 | ||
| 9 | import { cn } from "~/lib/utils"; | |
| 10 | ||
| 11 | import { | |
| 12 | BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, | |
| 13 | type BrowserViewportLayout, | |
| 14 | type BrowserViewportResizeDirection, | |
| 15 | } from "./browserViewportLayout"; | |
| 16 | ||
| 17 | interface Props { | |
| 18 | readonly layout: BrowserViewportLayout; | |
| 19 | readonly activeDirection: BrowserViewportResizeDirection | null; | |
| 20 | readonly onPointerDown: ( | |
| 21 | direction: BrowserViewportResizeDirection, | |
| 22 | event: ReactPointerEvent<HTMLButtonElement>, | |
| 23 | ) => void; | |
| 24 | readonly onKeyDown: ( | |
| 25 | direction: BrowserViewportResizeDirection, | |
| 26 | event: ReactKeyboardEvent<HTMLButtonElement>, | |
| 27 | ) => void; | |
| 28 | } | |
| 29 | ||
| 30 | type HandleKind = "horizontal" | "vertical" | "corner"; | |
| 31 | ||
| 32 | const EDGE_BUTTON_CLASS = | |
| 33 | "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]"; | |
| 34 | const EDGE_GRIP_CLASS = | |
| 35 | "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground"; | |
| 36 | ||
| 37 | function ResizeHandle(props: { | |
| 38 | readonly direction: BrowserViewportResizeDirection; | |
| 39 | readonly label: string; | |
| 40 | readonly kind: HandleKind; | |
| 41 | readonly cursorClassName: string; | |
| 42 | readonly style: CSSProperties; | |
| 43 | readonly active: boolean; | |
| 44 | readonly mirrorCorner?: boolean; | |
| 45 | readonly onPointerDown: Props["onPointerDown"]; | |
| 46 | readonly onKeyDown: Props["onKeyDown"]; | |
| 47 | }) { | |
| 48 | const { | |
| 49 | direction, | |
| 50 | label, | |
| 51 | kind, | |
| 52 | cursorClassName, | |
| 53 | style, | |
| 54 | active, | |
| 55 | mirrorCorner = false, | |
| 56 | onPointerDown, | |
| 57 | onKeyDown, | |
| 58 | } = props; | |
| 59 | return ( | |
| 60 | <button | |
| 61 | type="button" | |
| 62 | aria-label={`${label}. Use arrow keys to resize.`} | |
| 63 | className={cn(EDGE_BUTTON_CLASS, kind === "corner" && "z-30", cursorClassName)} | |
| 64 | style={style} | |
| 65 | onPointerDown={(event) => onPointerDown(direction, event)} | |
| 66 | onKeyDown={(event) => onKeyDown(direction, event)} | |
| 67 | > | |
| 68 | <span | |
| 69 | className={cn( | |
| 70 | EDGE_GRIP_CLASS, | |
| 71 | kind === "vertical" && "h-8 w-1.5", | |
| 72 | kind === "horizontal" && "h-1.5 w-8", | |
| 73 | kind === "corner" && "size-3", | |
| 74 | active && "text-foreground", | |
| 75 | )} | |
| 76 | > | |
| 77 | {kind === "vertical" ? ( | |
| 78 | <span className="flex gap-px" aria-hidden="true"> | |
| 79 | <span className="h-6 w-px rounded-full bg-current" /> | |
| 80 | <span className="h-6 w-px rounded-full bg-current" /> | |
| 81 | </span> | |
| 82 | ) : kind === "horizontal" ? ( | |
| 83 | <span className="flex flex-col gap-px" aria-hidden="true"> | |
| 84 | <span className="h-px w-6 rounded-full bg-current" /> | |
| 85 | <span className="h-px w-6 rounded-full bg-current" /> | |
| 86 | </span> | |
| 87 | ) : ( | |
| 88 | <span | |
| 89 | className={cn("relative block size-3", mirrorCorner && "-scale-x-100")} | |
| 90 | aria-hidden="true" | |
| 91 | > | |
| 92 | <span className="absolute bottom-[3px] left-0 h-px w-3 -rotate-45 rounded-full bg-current" /> | |
| 93 | <span className="absolute bottom-0 left-[5px] h-px w-2 -rotate-45 rounded-full bg-current" /> | |
| 94 | </span> | |
| 95 | )} | |
| 96 | </span> | |
| 97 | </button> | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | export function BrowserViewportResizeHandles({ | |
| 102 | layout, | |
| 103 | activeDirection, | |
| 104 | onPointerDown, | |
| 105 | onKeyDown, | |
| 106 | }: Props) { | |
| 107 | const left = layout.viewportX; | |
| 108 | const top = layout.viewportY; | |
| 109 | const right = left + layout.viewportWidth; | |
| 110 | const bottom = top + layout.viewportHeight; | |
| 111 | const railSize = BROWSER_VIEWPORT_RESIZE_RAIL_SIZE; | |
| 112 | ||
| 113 | const shared = { activeDirection, onPointerDown, onKeyDown }; | |
| 114 | return ( | |
| 115 | <> | |
| 116 | <ResizeHandle | |
| 117 | direction="west" | |
| 118 | label="Resize browser viewport from left edge" | |
| 119 | kind="vertical" | |
| 120 | cursorClassName="cursor-ew-resize" | |
| 121 | style={{ left: left - railSize, top, width: railSize, height: layout.viewportHeight }} | |
| 122 | active={shared.activeDirection === "west"} | |
| 123 | onPointerDown={shared.onPointerDown} | |
| 124 | onKeyDown={shared.onKeyDown} | |
| 125 | /> | |
| 126 | <ResizeHandle | |
| 127 | direction="east" | |
| 128 | label="Resize browser viewport from right edge" | |
| 129 | kind="vertical" | |
| 130 | cursorClassName="cursor-ew-resize" | |
| 131 | style={{ left: right, top, width: railSize, height: layout.viewportHeight }} | |
| 132 | active={shared.activeDirection === "east"} | |
| 133 | onPointerDown={shared.onPointerDown} | |
| 134 | onKeyDown={shared.onKeyDown} | |
| 135 | /> | |
| 136 | <ResizeHandle | |
| 137 | direction="south" | |
| 138 | label="Resize browser viewport from bottom edge" | |
| 139 | kind="horizontal" | |
| 140 | cursorClassName="cursor-ns-resize" | |
| 141 | style={{ left, top: bottom, width: layout.viewportWidth, height: railSize }} | |
| 142 | active={shared.activeDirection === "south"} | |
| 143 | onPointerDown={shared.onPointerDown} | |
| 144 | onKeyDown={shared.onKeyDown} | |
| 145 | /> | |
| 146 | <ResizeHandle | |
| 147 | direction="southwest" | |
| 148 | label="Resize browser viewport from bottom-left corner" | |
| 149 | kind="corner" | |
| 150 | cursorClassName="cursor-nesw-resize" | |
| 151 | style={{ left: left - railSize, top: bottom, width: railSize, height: railSize }} | |
| 152 | active={shared.activeDirection === "southwest"} | |
| 153 | mirrorCorner | |
| 154 | onPointerDown={shared.onPointerDown} | |
| 155 | onKeyDown={shared.onKeyDown} | |
| 156 | /> | |
| 157 | <ResizeHandle | |
| 158 | direction="southeast" | |
| 159 | label="Resize browser viewport from bottom-right corner" | |
| 160 | kind="corner" | |
| 161 | cursorClassName="cursor-nwse-resize" | |
| 162 | style={{ left: right, top: bottom, width: railSize, height: railSize }} | |
| 163 | active={shared.activeDirection === "southeast"} | |
| 164 | onPointerDown={shared.onPointerDown} | |
| 165 | onKeyDown={shared.onKeyDown} | |
| 166 | /> | |
| 167 | </> | |
| 168 | ); | |
| 169 | } |
apps/web/src/browser/ElectronBrowserHost.tsx+5-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; | |
| 4 | 5 | import { useEffect, useMemo } from "react"; |
| 5 | 6 | |
| 6 | 7 | import { isElectron } from "~/env"; |
| ... | ... | @@ -22,7 +23,7 @@ export function ElectronBrowserHost() { |
| 22 | 23 | ? Object.values(previewState.sessions).map((snapshot) => ({ |
| 23 | 24 | threadRef, |
| 24 | 25 | snapshot, |
| 25 | active: previewState.activeTabId === snapshot.tabId, | |
| 26 | zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, | |
| 26 | 27 | })) |
| 27 | 28 | : []; |
| 28 | 29 | }), |
| ... | ... | @@ -73,7 +74,7 @@ export function ElectronBrowserHost() { |
| 73 | 74 | if (!isElectron) return null; |
| 74 | 75 | return ( |
| 75 | 76 | <div className="contents" data-electron-browser-host> |
| 76 | {sessions.map(({ threadRef, snapshot }) => { | |
| 77 | {sessions.map(({ threadRef, snapshot, zoomFactor }) => { | |
| 77 | 78 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; |
| 78 | 79 | return ( |
| 79 | 80 | <HostedBrowserWebview |
| ... | ... | @@ -81,6 +82,8 @@ export function ElectronBrowserHost() { |
| 81 | 82 | threadRef={threadRef} |
| 82 | 83 | tabId={snapshot.tabId} |
| 83 | 84 | initialUrl={url} |
| 85 | viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} | |
| 86 | zoomFactor={zoomFactor} | |
| 84 | 87 | /> |
| 85 | 88 | ); |
| 86 | 89 | })} |
apps/web/src/browser/HostedBrowserWebview.tsx+163-22| ... | ... | @@ -1,16 +1,22 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | 4 | import { useShallow } from "zustand/react/shallow"; |
| 5 | import { useCallback, useEffect, useRef } from "react"; | |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 6 | 6 | |
| 7 | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | |
| 9 | 10 | |
| 10 | 11 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; |
| 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | |
| 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | |
| 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; | |
| 14 | import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; | |
| 15 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; | |
| 16 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; | |
| 12 | 17 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 13 | 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; | |
| 14 | 20 | |
| 15 | 21 | interface ElectronWebview extends HTMLElement { |
| 16 | 22 | src: string; |
| ... | ... | @@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement { |
| 18 | 24 | preload?: string; |
| 19 | 25 | webpreferences?: string; |
| 20 | 26 | getWebContentsId: () => number; |
| 27 | executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>; | |
| 21 | 28 | } |
| 22 | 29 | |
| 23 | 30 | declare global { |
| ... | ... | @@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: { |
| 30 | 37 | readonly threadRef: ScopedThreadRef; |
| 31 | 38 | readonly tabId: string; |
| 32 | 39 | readonly initialUrl: string | null; |
| 40 | readonly viewport: PreviewViewportSetting; | |
| 41 | readonly zoomFactor: number; | |
| 33 | 42 | }) { |
| 34 | const { threadRef, tabId, initialUrl } = props; | |
| 43 | const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; | |
| 35 | 44 | const config = usePreviewWebviewConfig(threadRef.environmentId); |
| 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); | |
| 45 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); | |
| 37 | 46 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 47 | const wrapperRef = useRef<HTMLDivElement | null>(null); | |
| 38 | 48 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 39 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); | |
| 49 | const [lockedAspectRatio, setLockedAspectRatio] = useState<number | null>(null); | |
| 50 | const presentation = useBrowserSurfaceStore( | |
| 51 | useShallow((state) => { | |
| 52 | const current = state.byTabId[tabId]; | |
| 53 | return { | |
| 54 | rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), | |
| 55 | visible: current?.visible ?? false, | |
| 56 | }; | |
| 57 | }), | |
| 58 | ); | |
| 40 | 59 | const recording = useActiveBrowserRecordingTabId() === tabId; |
| 41 | 60 | |
| 42 | 61 | usePreviewBridge({ threadRef, tabId }); |
| ... | ... | @@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: { |
| 89 | 108 | }; |
| 90 | 109 | }, [config, tabId]); |
| 91 | 110 | |
| 111 | const active = presentation.visible && presentation.rect !== null; | |
| 112 | const lastRect = presentation.rect; | |
| 113 | const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; | |
| 114 | const viewportWidth = viewport._tag === "fill" ? null : viewport.width; | |
| 115 | const viewportHeight = viewport._tag === "fill" ? null : viewport.height; | |
| 116 | const viewportAspectRatio = | |
| 117 | viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; | |
| 118 | useEffect(() => { | |
| 119 | setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); | |
| 120 | }, [viewportAspectRatio]); | |
| 121 | const hiddenSize = | |
| 122 | viewport._tag !== "fill" | |
| 123 | ? { | |
| 124 | width: viewport.width * normalizedZoomFactor, | |
| 125 | height: viewport.height * normalizedZoomFactor, | |
| 126 | } | |
| 127 | : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; | |
| 128 | const containerSize = active && lastRect ? lastRect : hiddenSize; | |
| 129 | const deviceToolbarVisible = active && viewport._tag !== "fill"; | |
| 130 | const { | |
| 131 | activeDrag, | |
| 132 | commitViewportChange, | |
| 133 | effectiveViewport, | |
| 134 | handleResizeKeyDown, | |
| 135 | handleResizePointerDown, | |
| 136 | layout, | |
| 137 | } = useBrowserViewportResize({ | |
| 138 | tabId, | |
| 139 | viewport, | |
| 140 | zoomFactor, | |
| 141 | containerSize, | |
| 142 | deviceToolbarVisible, | |
| 143 | aspectRatio: lockedAspectRatio, | |
| 144 | }); | |
| 145 | ||
| 146 | const syncContentPresentation = useCallback(() => { | |
| 147 | const wrapper = wrapperRef.current; | |
| 148 | if (!wrapper) return; | |
| 149 | useBrowserSurfaceStore.getState().presentContent(tabId, { | |
| 150 | x: layout.viewportX, | |
| 151 | y: layout.viewportY, | |
| 152 | width: layout.viewportWidth, | |
| 153 | height: layout.viewportHeight, | |
| 154 | scale: layout.viewportScale, | |
| 155 | scrollLeft: wrapper.scrollLeft, | |
| 156 | scrollTop: wrapper.scrollTop, | |
| 157 | }); | |
| 158 | }, [layout, tabId]); | |
| 159 | ||
| 160 | useEffect(() => { | |
| 161 | const frameId = window.requestAnimationFrame(syncContentPresentation); | |
| 162 | return () => window.cancelAnimationFrame(frameId); | |
| 163 | }, [syncContentPresentation]); | |
| 164 | ||
| 165 | useEffect(() => { | |
| 166 | const wrapper = wrapperRef.current; | |
| 167 | if (!wrapper) return; | |
| 168 | wrapper.scrollTo({ left: 0, top: 0 }); | |
| 169 | }, [tabId, viewport._tag, viewportHeight, viewportWidth]); | |
| 170 | ||
| 92 | 171 | if (!config) return null; |
| 93 | const active = presentation?.visible === true && presentation.rect !== null; | |
| 94 | const lastRect = presentation?.rect; | |
| 95 | const style = | |
| 172 | ||
| 173 | const wrapperStyle = | |
| 96 | 174 | active && lastRect |
| 97 | 175 | ? { |
| 98 | 176 | left: lastRect.x, |
| ... | ... | @@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: { |
| 105 | 183 | : { |
| 106 | 184 | left: 0, |
| 107 | 185 | top: 0, |
| 108 | width: lastRect?.width ?? 1280, | |
| 109 | height: lastRect?.height ?? 800, | |
| 186 | width: hiddenSize.width, | |
| 187 | height: hiddenSize.height, | |
| 110 | 188 | zIndex: recording ? 0 : -1, |
| 111 | 189 | pointerEvents: "none" as const, |
| 112 | 190 | }; |
| 113 | 191 | |
| 114 | 192 | return ( |
| 115 | <webview | |
| 116 | ref={setWebviewRef} | |
| 117 | src={initialSrcRef.current} | |
| 118 | partition={config.partition} | |
| 119 | webpreferences={config.webPreferences} | |
| 120 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | |
| 121 | data-preview-tab={tabId} | |
| 122 | aria-hidden={active ? undefined : true} | |
| 123 | className="fixed flex overflow-hidden bg-background" | |
| 124 | style={style} | |
| 125 | /> | |
| 193 | <div | |
| 194 | ref={wrapperRef} | |
| 195 | className="fixed overflow-hidden bg-muted/35" | |
| 196 | style={{ ...wrapperStyle, overscrollBehavior: "contain" }} | |
| 197 | onScroll={syncContentPresentation} | |
| 198 | data-preview-viewport={tabId} | |
| 199 | > | |
| 200 | <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}> | |
| 201 | {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( | |
| 202 | <BrowserDeviceToolbar | |
| 203 | setting={effectiveViewport} | |
| 204 | width={Math.max(1, Math.round(containerSize.width))} | |
| 205 | aspectRatio={lockedAspectRatio} | |
| 206 | onAspectRatioChange={setLockedAspectRatio} | |
| 207 | onChange={commitViewportChange} | |
| 208 | /> | |
| 209 | ) : null} | |
| 210 | <webview | |
| 211 | ref={setWebviewRef} | |
| 212 | src={initialSrc} | |
| 213 | partition={config.partition} | |
| 214 | webpreferences={config.webPreferences} | |
| 215 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | |
| 216 | data-preview-tab={tabId} | |
| 217 | data-preview-viewport-mode={effectiveViewport._tag} | |
| 218 | data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} | |
| 219 | data-preview-css-width={ | |
| 220 | effectiveViewport._tag === "fill" | |
| 221 | ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) | |
| 222 | : effectiveViewport.width | |
| 223 | } | |
| 224 | data-preview-css-height={ | |
| 225 | effectiveViewport._tag === "fill" | |
| 226 | ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) | |
| 227 | : effectiveViewport.height | |
| 228 | } | |
| 229 | aria-hidden={active ? undefined : true} | |
| 230 | className={cn( | |
| 231 | "absolute flex overflow-hidden bg-background", | |
| 232 | active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", | |
| 233 | )} | |
| 234 | style={{ | |
| 235 | left: layout.viewportX, | |
| 236 | top: layout.viewportY, | |
| 237 | width: layout.viewportWidth / layout.viewportScale, | |
| 238 | height: layout.viewportHeight / layout.viewportScale, | |
| 239 | transform: layout.viewportScale < 1 ? `scale(${layout.viewportScale})` : undefined, | |
| 240 | transformOrigin: "top left", | |
| 241 | }} | |
| 242 | /> | |
| 243 | {active && effectiveViewport._tag !== "fill" ? ( | |
| 244 | <> | |
| 245 | <BrowserViewportResizeHandles | |
| 246 | layout={layout} | |
| 247 | activeDirection={activeDrag?.direction ?? null} | |
| 248 | onPointerDown={handleResizePointerDown} | |
| 249 | onKeyDown={handleResizeKeyDown} | |
| 250 | /> | |
| 251 | {activeDrag ? ( | |
| 252 | <div | |
| 253 | className="pointer-events-none absolute z-40 -translate-x-1/2 rounded-md border border-border/80 bg-background/95 px-2 py-1 text-[11px] font-medium tabular-nums text-foreground shadow-md backdrop-blur-sm" | |
| 254 | style={{ | |
| 255 | left: layout.viewportX + layout.viewportWidth / 2, | |
| 256 | top: layout.viewportY + 10, | |
| 257 | }} | |
| 258 | aria-hidden="true" | |
| 259 | > | |
| 260 | {activeDrag.width} × {activeDrag.height} | |
| 261 | </div> | |
| 262 | ) : null} | |
| 263 | </> | |
| 264 | ) : null} | |
| 265 | </div> | |
| 266 | </div> | |
| 126 | 267 | ); |
| 127 | 268 | } |
apps/web/src/browser/browserDeviceToolbarState.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export function reconcileLockedAspectRatio( | |
| 4 | current: number | null, | |
| 5 | viewportAspectRatio: number | null, | |
| 6 | ): number | null { | |
| 7 | return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; | |
| 8 | } | |
| 9 | ||
| 10 | export async function commitViewportAndAspectRatio( | |
| 11 | setting: PreviewViewportSetting, | |
| 12 | aspectRatio: number | null, | |
| 13 | onChange: (setting: PreviewViewportSetting) => Promise<void>, | |
| 14 | onAspectRatioChange: (aspectRatio: number | null) => void, | |
| 15 | ): Promise<void> { | |
| 16 | await onChange(setting); | |
| 17 | onAspectRatioChange(aspectRatio); | |
| 18 | } |
apps/web/src/browser/browserRecording.ts+8-3| ... | ... | @@ -90,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null { |
| 90 | 90 | let active: ActiveRecording | null = null; |
| 91 | 91 | let unsubscribeFrames: (() => void) | null = null; |
| 92 | 92 | |
| 93 | export function readActiveBrowserRecordingTabId(): string | null { | |
| 94 | return active?.tabId ?? null; | |
| 95 | } | |
| 96 | ||
| 93 | 97 | const preferredMimeType = (): string => { |
| 94 | 98 | const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; |
| 95 | 99 | return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; |
| ... | ... | @@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 137 | 141 | activeTabId: active.tabId, |
| 138 | 142 | }); |
| 139 | 143 | } |
| 140 | const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; | |
| 144 | const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; | |
| 145 | const recordingSize = surface?.content ?? surface?.rect; | |
| 141 | 146 | const canvas = document.createElement("canvas"); |
| 142 | canvas.width = Math.max(1, rect?.width ?? 1280); | |
| 143 | canvas.height = Math.max(1, rect?.height ?? 800); | |
| 147 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); | |
| 148 | canvas.height = Math.max(1, recordingSize?.height ?? 800); | |
| 144 | 149 | const context = canvas.getContext("2d", { alpha: false }); |
| 145 | 150 | if (!context) { |
| 146 | 151 | throw new BrowserRecordingCanvasUnavailableError({ |
apps/web/src/browser/browserRecordingScope.test.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; | |
| 4 | ||
| 5 | describe("resolveBrowserRecordingStopTarget", () => { | |
| 6 | it("stops the active recording even after the requested tab changes", () => { | |
| 7 | expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); | |
| 8 | expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); | |
| 9 | expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); | |
| 10 | }); | |
| 11 | }); |
apps/web/src/browser/browserRecordingScope.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { | |
| 2 | return activeTabId; | |
| 3 | } |
apps/web/src/browser/browserSurfaceStore.test.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | |
| 4 | ||
| 5 | describe("browserSurfaceStore", () => { | |
| 6 | it("tracks content dimensions for a browser that has never been visible", () => { | |
| 7 | const tabId = "hidden-browser-surface-content-test"; | |
| 8 | useBrowserSurfaceStore.getState().presentContent(tabId, { | |
| 9 | x: 0, | |
| 10 | y: 0, | |
| 11 | width: 393, | |
| 12 | height: 852, | |
| 13 | scale: 1, | |
| 14 | scrollLeft: 0, | |
| 15 | scrollTop: 0, | |
| 16 | }); | |
| 17 | ||
| 18 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | |
| 19 | rect: null, | |
| 20 | visible: false, | |
| 21 | content: { width: 393, height: 852 }, | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | it("uses the live panel rect for a hidden background tab", () => { | |
| 26 | const staleRect = { x: 0, y: 0, width: 500, height: 700 }; | |
| 27 | const liveRect = { x: 10, y: 20, width: 900, height: 640 }; | |
| 28 | expect( | |
| 29 | resolveBrowserSurfacePanelRect( | |
| 30 | { | |
| 31 | hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 }, | |
| 32 | active: { rect: liveRect, visible: true, content: null, updatedAt: 2 }, | |
| 33 | }, | |
| 34 | "hidden", | |
| 35 | ), | |
| 36 | ).toEqual(liveRect); | |
| 37 | }); | |
| 38 | }); |
apps/web/src/browser/browserSurfaceStore.ts+69-1| ... | ... | @@ -10,15 +10,47 @@ export interface BrowserSurfaceRect { |
| 10 | 10 | export interface BrowserSurfacePresentation { |
| 11 | 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | 12 | readonly visible: boolean; |
| 13 | readonly content: BrowserSurfaceContentPresentation | null; | |
| 13 | 14 | readonly updatedAt: number; |
| 14 | 15 | } |
| 15 | 16 | |
| 17 | export interface BrowserSurfaceContentPresentation { | |
| 18 | readonly x: number; | |
| 19 | readonly y: number; | |
| 20 | readonly width: number; | |
| 21 | readonly height: number; | |
| 22 | readonly scale: number; | |
| 23 | readonly scrollLeft: number; | |
| 24 | readonly scrollTop: number; | |
| 25 | } | |
| 26 | ||
| 16 | 27 | interface BrowserSurfaceStoreState { |
| 17 | 28 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 18 | 29 | readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; |
| 30 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; | |
| 19 | 31 | readonly hide: (tabId: string) => void; |
| 20 | 32 | } |
| 21 | 33 | |
| 34 | export function resolveBrowserSurfacePanelRect( | |
| 35 | byTabId: Readonly<Record<string, BrowserSurfacePresentation>>, | |
| 36 | tabId: string, | |
| 37 | ): BrowserSurfaceRect | null { | |
| 38 | const current = byTabId[tabId]; | |
| 39 | if (current?.visible && current.rect) return current.rect; | |
| 40 | ||
| 41 | let latestVisible: BrowserSurfacePresentation | undefined; | |
| 42 | for (const presentation of Object.values(byTabId)) { | |
| 43 | if ( | |
| 44 | presentation.visible && | |
| 45 | presentation.rect && | |
| 46 | (!latestVisible || presentation.updatedAt > latestVisible.updatedAt) | |
| 47 | ) { | |
| 48 | latestVisible = presentation; | |
| 49 | } | |
| 50 | } | |
| 51 | return latestVisible?.rect ?? current?.rect ?? null; | |
| 52 | } | |
| 53 | ||
| 22 | 54 | const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => |
| 23 | 55 | left !== null && |
| 24 | 56 | left.x === right.x && |
| ... | ... | @@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 35 | 67 | return { |
| 36 | 68 | byTabId: { |
| 37 | 69 | ...state.byTabId, |
| 38 | [tabId]: { rect, visible, updatedAt: Date.now() }, | |
| 70 | [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() }, | |
| 71 | }, | |
| 72 | }; | |
| 73 | }), | |
| 74 | presentContent: (tabId, content) => | |
| 75 | set((state) => { | |
| 76 | const current = state.byTabId[tabId]; | |
| 77 | if (!current) { | |
| 78 | return { | |
| 79 | byTabId: { | |
| 80 | ...state.byTabId, | |
| 81 | [tabId]: { | |
| 82 | rect: null, | |
| 83 | visible: false, | |
| 84 | content, | |
| 85 | updatedAt: Date.now(), | |
| 86 | }, | |
| 87 | }, | |
| 88 | }; | |
| 89 | } | |
| 90 | const previous = current.content; | |
| 91 | if ( | |
| 92 | previous && | |
| 93 | previous.x === content.x && | |
| 94 | previous.y === content.y && | |
| 95 | previous.width === content.width && | |
| 96 | previous.height === content.height && | |
| 97 | previous.scale === content.scale && | |
| 98 | previous.scrollLeft === content.scrollLeft && | |
| 99 | previous.scrollTop === content.scrollTop | |
| 100 | ) { | |
| 101 | return state; | |
| 102 | } | |
| 103 | return { | |
| 104 | byTabId: { | |
| 105 | ...state.byTabId, | |
| 106 | [tabId]: { ...current, content, updatedAt: Date.now() }, | |
| 39 | 107 | }, |
| 40 | 108 | }; |
| 41 | 109 | }), |
apps/web/src/browser/browserViewportActions.test.ts created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, | |
| 6 | commitBrowserViewportChange, | |
| 7 | subscribeBrowserViewportChange, | |
| 8 | } from "./browserViewportActions"; | |
| 9 | ||
| 10 | describe("browserViewportActions", () => { | |
| 11 | it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => { | |
| 12 | const first = vi.fn(async () => undefined); | |
| 13 | const second = vi.fn(async () => undefined); | |
| 14 | const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first); | |
| 15 | const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second); | |
| 16 | ||
| 17 | unsubscribeFirst(); | |
| 18 | await commitBrowserViewportChange("tab-1", { | |
| 19 | _tag: "freeform", | |
| 20 | width: 900, | |
| 21 | height: 700, | |
| 22 | }); | |
| 23 | expect(first).not.toHaveBeenCalled(); | |
| 24 | expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 }); | |
| 25 | ||
| 26 | unsubscribeSecond(); | |
| 27 | await expect( | |
| 28 | commitBrowserViewportChange("tab-1", { | |
| 29 | _tag: "freeform", | |
| 30 | width: 800, | |
| 31 | height: 600, | |
| 32 | }), | |
| 33 | ).rejects.toThrow("No visible browser viewport handler"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("commits viewport changes in order for each tab", async () => { | |
| 37 | let releaseFirst: (() => void) | undefined; | |
| 38 | let markFirstStarted: (() => void) | undefined; | |
| 39 | const firstPending = new Promise<void>((resolve) => { | |
| 40 | releaseFirst = resolve; | |
| 41 | }); | |
| 42 | const firstStarted = new Promise<void>((resolve) => { | |
| 43 | markFirstStarted = resolve; | |
| 44 | }); | |
| 45 | const calls: Array<number> = []; | |
| 46 | const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => { | |
| 47 | if (setting._tag === "fill") return; | |
| 48 | calls.push(setting.width); | |
| 49 | if (setting.width === 800) { | |
| 50 | markFirstStarted?.(); | |
| 51 | await firstPending; | |
| 52 | } | |
| 53 | }); | |
| 54 | ||
| 55 | const first = commitBrowserViewportChange("tab-serial", { | |
| 56 | _tag: "freeform", | |
| 57 | width: 800, | |
| 58 | height: 600, | |
| 59 | }); | |
| 60 | const second = commitBrowserViewportChange("tab-serial", { | |
| 61 | _tag: "freeform", | |
| 62 | width: 900, | |
| 63 | height: 700, | |
| 64 | }); | |
| 65 | await firstStarted; | |
| 66 | expect(calls).toEqual([800]); | |
| 67 | ||
| 68 | releaseFirst?.(); | |
| 69 | await Promise.all([first, second]); | |
| 70 | expect(calls).toEqual([800, 900]); | |
| 71 | unsubscribe(); | |
| 72 | }); | |
| 73 | ||
| 74 | it("does not let a timed-out handler overtake a newer viewport commit", async () => { | |
| 75 | vi.useFakeTimers(); | |
| 76 | try { | |
| 77 | let releaseFirst: (() => void) | undefined; | |
| 78 | const delayed = new Promise<void>((resolve) => { | |
| 79 | releaseFirst = resolve; | |
| 80 | }); | |
| 81 | const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise<void> => undefined); | |
| 82 | handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); | |
| 83 | const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); | |
| 84 | const first = commitBrowserViewportChange("tab-timeout", { | |
| 85 | _tag: "freeform", | |
| 86 | width: 800, | |
| 87 | height: 600, | |
| 88 | }); | |
| 89 | const firstResult = expect(first).rejects.toThrow( | |
| 90 | "Timed out committing the browser viewport for tab tab-timeout", | |
| 91 | ); | |
| 92 | const second = commitBrowserViewportChange("tab-timeout", { | |
| 93 | _tag: "freeform", | |
| 94 | width: 900, | |
| 95 | height: 700, | |
| 96 | }); | |
| 97 | ||
| 98 | await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); | |
| 99 | await firstResult; | |
| 100 | expect(handler).toHaveBeenCalledTimes(1); | |
| 101 | ||
| 102 | releaseFirst?.(); | |
| 103 | await second; | |
| 104 | ||
| 105 | expect(handler).toHaveBeenCalledTimes(2); | |
| 106 | expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); | |
| 107 | unsubscribe(); | |
| 108 | } finally { | |
| 109 | vi.useRealTimers(); | |
| 110 | } | |
| 111 | }); | |
| 112 | }); |
apps/web/src/browser/browserViewportActions.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>; | |
| 4 | ||
| 5 | export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; | |
| 6 | ||
| 7 | export class BrowserViewportCommitTimeoutError extends Error { | |
| 8 | override readonly name = "BrowserViewportCommitTimeoutError"; | |
| 9 | ||
| 10 | constructor(readonly tabId: string) { | |
| 11 | super(`Timed out committing the browser viewport for tab ${tabId}`); | |
| 12 | } | |
| 13 | } | |
| 14 | ||
| 15 | const handlers = new Map<string, BrowserViewportHandler>(); | |
| 16 | const commitTails = new Map<string, Promise<void>>(); | |
| 17 | ||
| 18 | const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => { | |
| 19 | let timeoutId: ReturnType<typeof setTimeout> | undefined; | |
| 20 | const timeout = new Promise<never>((_resolve, reject) => { | |
| 21 | timeoutId = setTimeout( | |
| 22 | () => reject(new BrowserViewportCommitTimeoutError(tabId)), | |
| 23 | BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, | |
| 24 | ); | |
| 25 | }); | |
| 26 | return Promise.race([operation, timeout]).finally(() => { | |
| 27 | if (timeoutId !== undefined) clearTimeout(timeoutId); | |
| 28 | }); | |
| 29 | }; | |
| 30 | ||
| 31 | export function subscribeBrowserViewportChange( | |
| 32 | tabId: string, | |
| 33 | handler: BrowserViewportHandler, | |
| 34 | ): () => void { | |
| 35 | handlers.set(tabId, handler); | |
| 36 | return () => { | |
| 37 | if (handlers.get(tabId) === handler) handlers.delete(tabId); | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | export function commitBrowserViewportChange( | |
| 42 | tabId: string, | |
| 43 | setting: PreviewViewportSetting, | |
| 44 | ): Promise<void> { | |
| 45 | const previous = commitTails.get(tabId) ?? Promise.resolve(); | |
| 46 | const started = previous | |
| 47 | .catch(() => undefined) | |
| 48 | .then(() => { | |
| 49 | const handler = handlers.get(tabId); | |
| 50 | const operation = handler | |
| 51 | ? Promise.resolve().then(() => handler(setting)) | |
| 52 | : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); | |
| 53 | return { operation }; | |
| 54 | }); | |
| 55 | // The queue follows the real handler lifetime, not the caller-facing timeout. | |
| 56 | // A slow commit therefore cannot time out, release the queue, and overwrite a | |
| 57 | // newer viewport after that newer request has already completed. | |
| 58 | const execution = started.then(({ operation }) => operation); | |
| 59 | const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); | |
| 60 | commitTails.set(tabId, execution); | |
| 61 | const clear = () => { | |
| 62 | if (commitTails.get(tabId) === execution) commitTails.delete(tabId); | |
| 63 | }; | |
| 64 | void execution.then(clear, clear); | |
| 65 | return result; | |
| 66 | } |
apps/web/src/browser/browserViewportLayout.test.ts created+165| ... | ... | @@ -0,0 +1,165 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | resizeBrowserViewportFromRail, | |
| 5 | resizeFreeformViewport, | |
| 6 | resolveBrowserDeviceViewportLayout, | |
| 7 | resolveBrowserViewportLayout, | |
| 8 | resolveResponsiveBrowserViewportSize, | |
| 9 | } from "./browserViewportLayout"; | |
| 10 | ||
| 11 | describe("resolveBrowserViewportLayout", () => { | |
| 12 | it("fills the available surface in fill mode", () => { | |
| 13 | expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ | |
| 14 | canvasWidth: 700, | |
| 15 | canvasHeight: 500, | |
| 16 | viewportX: 0, | |
| 17 | viewportY: 0, | |
| 18 | viewportWidth: 700, | |
| 19 | viewportHeight: 500, | |
| 20 | viewportScale: 1, | |
| 21 | fillsPanel: true, | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | it("centers a smaller fixed viewport", () => { | |
| 26 | expect( | |
| 27 | resolveBrowserViewportLayout( | |
| 28 | { width: 700, height: 1000 }, | |
| 29 | { _tag: "freeform", width: 393, height: 852 }, | |
| 30 | ), | |
| 31 | ).toMatchObject({ | |
| 32 | canvasWidth: 700, | |
| 33 | canvasHeight: 1000, | |
| 34 | viewportX: 154, | |
| 35 | viewportY: 74, | |
| 36 | viewportWidth: 393, | |
| 37 | viewportHeight: 852, | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("scales a larger fixed viewport down to fit without creating overflow", () => { | |
| 42 | const layout = resolveBrowserViewportLayout( | |
| 43 | { width: 600, height: 700 }, | |
| 44 | { _tag: "freeform", width: 1440, height: 900 }, | |
| 45 | ); | |
| 46 | expect(layout).toMatchObject({ | |
| 47 | canvasWidth: 600, | |
| 48 | canvasHeight: 700, | |
| 49 | viewportX: 0, | |
| 50 | viewportY: 163, | |
| 51 | viewportWidth: 600, | |
| 52 | viewportHeight: 375, | |
| 53 | }); | |
| 54 | expect(layout.viewportScale).toBeCloseTo(5 / 12); | |
| 55 | }); | |
| 56 | ||
| 57 | it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { | |
| 58 | expect( | |
| 59 | resolveBrowserViewportLayout( | |
| 60 | { width: 800, height: 700 }, | |
| 61 | { _tag: "freeform", width: 400, height: 300 }, | |
| 62 | 1.5, | |
| 63 | ), | |
| 64 | ).toMatchObject({ | |
| 65 | viewportX: 100, | |
| 66 | viewportY: 125, | |
| 67 | viewportWidth: 600, | |
| 68 | viewportHeight: 450, | |
| 69 | }); | |
| 70 | expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({ | |
| 71 | width: 500, | |
| 72 | height: 350, | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | it("bounds freeform drag sizes and total render area", () => { | |
| 77 | expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({ | |
| 78 | width: 240, | |
| 79 | height: 240, | |
| 80 | }); | |
| 81 | const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 }); | |
| 82 | expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160); | |
| 83 | }); | |
| 84 | ||
| 85 | it("resizes only the axes controlled by each edge", () => { | |
| 86 | expect( | |
| 87 | resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"), | |
| 88 | ).toEqual({ width: 900, height: 600 }); | |
| 89 | expect( | |
| 90 | resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"), | |
| 91 | ).toEqual({ width: 800, height: 500 }); | |
| 92 | expect( | |
| 93 | resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"), | |
| 94 | ).toEqual({ width: 900, height: 650 }); | |
| 95 | }); | |
| 96 | ||
| 97 | it("preserves a locked aspect ratio from either axis", () => { | |
| 98 | expect( | |
| 99 | resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3), | |
| 100 | ).toEqual({ width: 1000, height: 750 }); | |
| 101 | expect( | |
| 102 | resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3), | |
| 103 | ).toEqual({ width: 1000, height: 750 }); | |
| 104 | }); | |
| 105 | ||
| 106 | it("reserves persistent device-toolbar rails around the guest viewport", () => { | |
| 107 | expect( | |
| 108 | resolveBrowserDeviceViewportLayout( | |
| 109 | { width: 1200, height: 900 }, | |
| 110 | { _tag: "freeform", width: 1180, height: 858 }, | |
| 111 | ), | |
| 112 | ).toEqual({ | |
| 113 | canvasWidth: 1200, | |
| 114 | canvasHeight: 900, | |
| 115 | viewportX: 10, | |
| 116 | viewportY: 32, | |
| 117 | viewportWidth: 1180, | |
| 118 | viewportHeight: 858, | |
| 119 | viewportScale: 1, | |
| 120 | fillsPanel: false, | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | it("captures the available framed area when responsive mode is enabled", () => { | |
| 125 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ | |
| 126 | width: 1180, | |
| 127 | height: 858, | |
| 128 | }); | |
| 129 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ | |
| 130 | width: 590, | |
| 131 | height: 429, | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | it("keeps the grabbed rail under the pointer across centered layout boundaries", () => { | |
| 136 | const available = { width: 1120, height: 818 }; | |
| 137 | expect( | |
| 138 | resizeBrowserViewportFromRail( | |
| 139 | { width: 1120, height: 818 }, | |
| 140 | { x: -100, y: -50 }, | |
| 141 | available, | |
| 142 | 1, | |
| 143 | "southeast", | |
| 144 | ), | |
| 145 | ).toEqual({ width: 920, height: 718 }); | |
| 146 | expect( | |
| 147 | resizeBrowserViewportFromRail( | |
| 148 | { width: 800, height: 600 }, | |
| 149 | { x: 300, y: 0 }, | |
| 150 | { width: 1200, height: 800 }, | |
| 151 | 1, | |
| 152 | "east", | |
| 153 | ), | |
| 154 | ).toEqual({ width: 1300, height: 600 }); | |
| 155 | expect( | |
| 156 | resizeBrowserViewportFromRail( | |
| 157 | { width: 560, height: 409 }, | |
| 158 | { x: -100, y: 0 }, | |
| 159 | available, | |
| 160 | 2, | |
| 161 | "east", | |
| 162 | ), | |
| 163 | ).toEqual({ width: 460, height: 409 }); | |
| 164 | }); | |
| 165 | }); |
apps/web/src/browser/browserViewportLayout.ts created+286| ... | ... | @@ -0,0 +1,286 @@ |
| 1 | import { | |
| 2 | PREVIEW_VIEWPORT_MAX_AREA, | |
| 3 | PREVIEW_VIEWPORT_MAX_DIMENSION, | |
| 4 | PREVIEW_VIEWPORT_MIN_DIMENSION, | |
| 5 | type PreviewViewportSetting, | |
| 6 | type PreviewViewportSize, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | export interface BrowserViewportLayout { | |
| 10 | readonly canvasWidth: number; | |
| 11 | readonly canvasHeight: number; | |
| 12 | readonly viewportX: number; | |
| 13 | readonly viewportY: number; | |
| 14 | /** Visible footprint inside the preview panel after fit-to-panel scaling. */ | |
| 15 | readonly viewportWidth: number; | |
| 16 | readonly viewportHeight: number; | |
| 17 | /** Presentation-only scale; the guest keeps its requested CSS viewport. */ | |
| 18 | readonly viewportScale: number; | |
| 19 | readonly fillsPanel: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; | |
| 23 | export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; | |
| 24 | ||
| 25 | export type BrowserViewportResizeDirection = | |
| 26 | | "north" | |
| 27 | | "northeast" | |
| 28 | | "east" | |
| 29 | | "southeast" | |
| 30 | | "south" | |
| 31 | | "southwest" | |
| 32 | | "west" | |
| 33 | | "northwest"; | |
| 34 | ||
| 35 | export const browserViewportSettingKey = (setting: PreviewViewportSetting): string => | |
| 36 | setting._tag === "fill" | |
| 37 | ? "fill" | |
| 38 | : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`; | |
| 39 | ||
| 40 | const normalizeZoomFactor = (zoomFactor: number): number => | |
| 41 | Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; | |
| 42 | ||
| 43 | export function resolveBrowserDeviceViewportArea(container: { | |
| 44 | readonly width: number; | |
| 45 | readonly height: number; | |
| 46 | }): PreviewViewportSize { | |
| 47 | return { | |
| 48 | width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2), | |
| 49 | height: Math.max( | |
| 50 | 1, | |
| 51 | container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, | |
| 52 | ), | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | export function resolveBrowserViewportLayout( | |
| 57 | container: { readonly width: number; readonly height: number }, | |
| 58 | setting: PreviewViewportSetting, | |
| 59 | zoomFactor = 1, | |
| 60 | ): BrowserViewportLayout { | |
| 61 | const containerWidth = Math.max(1, Math.round(container.width)); | |
| 62 | const containerHeight = Math.max(1, Math.round(container.height)); | |
| 63 | if (setting._tag === "fill") { | |
| 64 | return { | |
| 65 | canvasWidth: containerWidth, | |
| 66 | canvasHeight: containerHeight, | |
| 67 | viewportX: 0, | |
| 68 | viewportY: 0, | |
| 69 | viewportWidth: containerWidth, | |
| 70 | viewportHeight: containerHeight, | |
| 71 | viewportScale: 1, | |
| 72 | fillsPanel: true, | |
| 73 | }; | |
| 74 | } | |
| 75 | const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); | |
| 76 | const renderedWidth = setting.width * normalizedZoomFactor; | |
| 77 | const renderedHeight = setting.height * normalizedZoomFactor; | |
| 78 | const viewportScale = Math.min( | |
| 79 | 1, | |
| 80 | containerWidth / renderedWidth, | |
| 81 | containerHeight / renderedHeight, | |
| 82 | ); | |
| 83 | const viewportWidth = renderedWidth * viewportScale; | |
| 84 | const viewportHeight = renderedHeight * viewportScale; | |
| 85 | return { | |
| 86 | canvasWidth: containerWidth, | |
| 87 | canvasHeight: containerHeight, | |
| 88 | viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)), | |
| 89 | viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)), | |
| 90 | viewportWidth, | |
| 91 | viewportHeight, | |
| 92 | viewportScale, | |
| 93 | fillsPanel: false, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | export function resolveBrowserDeviceViewportLayout( | |
| 98 | container: { readonly width: number; readonly height: number }, | |
| 99 | setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>, | |
| 100 | zoomFactor = 1, | |
| 101 | ): BrowserViewportLayout { | |
| 102 | const layout = resolveBrowserViewportLayout( | |
| 103 | resolveBrowserDeviceViewportArea(container), | |
| 104 | setting, | |
| 105 | zoomFactor, | |
| 106 | ); | |
| 107 | return { | |
| 108 | ...layout, | |
| 109 | canvasWidth: Math.max(1, Math.round(container.width)), | |
| 110 | canvasHeight: Math.max(1, Math.round(container.height)), | |
| 111 | viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, | |
| 112 | viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT, | |
| 113 | }; | |
| 114 | } | |
| 115 | ||
| 116 | const clampViewportDimension = (value: number): number => | |
| 117 | Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value)); | |
| 118 | ||
| 119 | const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number => | |
| 120 | aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0; | |
| 121 | ||
| 122 | function resizeAtAspectRatio( | |
| 123 | desired: number, | |
| 124 | aspectRatio: number, | |
| 125 | primaryAxis: "width" | "height", | |
| 126 | ): PreviewViewportSize { | |
| 127 | if (primaryAxis === "width") { | |
| 128 | const minimum = Math.ceil( | |
| 129 | Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio), | |
| 130 | ); | |
| 131 | const maximum = Math.floor( | |
| 132 | Math.min( | |
| 133 | PREVIEW_VIEWPORT_MAX_DIMENSION, | |
| 134 | PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio, | |
| 135 | Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio), | |
| 136 | ), | |
| 137 | ); | |
| 138 | let width = Math.min(maximum, Math.max(minimum, Math.round(desired))); | |
| 139 | let height = Math.round(width / aspectRatio); | |
| 140 | while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) { | |
| 141 | width -= 1; | |
| 142 | height = Math.round(width / aspectRatio); | |
| 143 | } | |
| 144 | return { width, height }; | |
| 145 | } | |
| 146 | ||
| 147 | const minimum = Math.ceil( | |
| 148 | Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio), | |
| 149 | ); | |
| 150 | const maximum = Math.floor( | |
| 151 | Math.min( | |
| 152 | PREVIEW_VIEWPORT_MAX_DIMENSION, | |
| 153 | PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio, | |
| 154 | Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio), | |
| 155 | ), | |
| 156 | ); | |
| 157 | let height = Math.min(maximum, Math.max(minimum, Math.round(desired))); | |
| 158 | let width = Math.round(height * aspectRatio); | |
| 159 | while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) { | |
| 160 | height -= 1; | |
| 161 | width = Math.round(height * aspectRatio); | |
| 162 | } | |
| 163 | return { width, height }; | |
| 164 | } | |
| 165 | ||
| 166 | export function resizeFreeformViewport( | |
| 167 | start: PreviewViewportSize, | |
| 168 | delta: { readonly x: number; readonly y: number }, | |
| 169 | zoomFactor = 1, | |
| 170 | direction: BrowserViewportResizeDirection = "southeast", | |
| 171 | aspectRatio?: number, | |
| 172 | ): PreviewViewportSize { | |
| 173 | const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); | |
| 174 | const horizontalDelta = direction.includes("east") | |
| 175 | ? delta.x | |
| 176 | : direction.includes("west") | |
| 177 | ? -delta.x | |
| 178 | : 0; | |
| 179 | const verticalDelta = direction.includes("south") | |
| 180 | ? delta.y | |
| 181 | : direction.includes("north") | |
| 182 | ? -delta.y | |
| 183 | : 0; | |
| 184 | const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor; | |
| 185 | const desiredHeight = start.height + verticalDelta / normalizedZoomFactor; | |
| 186 | if (validAspectRatio(aspectRatio)) { | |
| 187 | const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west"; | |
| 188 | const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south"; | |
| 189 | const primaryAxis = | |
| 190 | controlsWidth && !controlsHeight | |
| 191 | ? "width" | |
| 192 | : controlsHeight && !controlsWidth | |
| 193 | ? "height" | |
| 194 | : Math.abs(desiredWidth - start.width) / start.width >= | |
| 195 | Math.abs(desiredHeight - start.height) / start.height | |
| 196 | ? "width" | |
| 197 | : "height"; | |
| 198 | return resizeAtAspectRatio( | |
| 199 | primaryAxis === "width" ? desiredWidth : desiredHeight, | |
| 200 | aspectRatio, | |
| 201 | primaryAxis, | |
| 202 | ); | |
| 203 | } | |
| 204 | let width = clampViewportDimension(Math.round(desiredWidth)); | |
| 205 | let height = clampViewportDimension(Math.round(desiredHeight)); | |
| 206 | if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; | |
| 207 | if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) { | |
| 208 | width = Math.max( | |
| 209 | PREVIEW_VIEWPORT_MIN_DIMENSION, | |
| 210 | Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height), | |
| 211 | ); | |
| 212 | } else { | |
| 213 | height = Math.max( | |
| 214 | PREVIEW_VIEWPORT_MIN_DIMENSION, | |
| 215 | Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width), | |
| 216 | ); | |
| 217 | } | |
| 218 | return { width, height }; | |
| 219 | } | |
| 220 | ||
| 221 | const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => { | |
| 222 | const startEdge = start < available ? (available + start) / 2 : start; | |
| 223 | const targetEdge = startEdge + pointerDelta; | |
| 224 | return targetEdge <= available ? targetEdge * 2 - available : targetEdge; | |
| 225 | }; | |
| 226 | ||
| 227 | const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => { | |
| 228 | if (start > available) { | |
| 229 | const distanceToFit = start - available; | |
| 230 | return pointerDelta <= distanceToFit | |
| 231 | ? start - pointerDelta | |
| 232 | : available - (pointerDelta - distanceToFit) * 2; | |
| 233 | } | |
| 234 | const targetEdge = (available - start) / 2 + pointerDelta; | |
| 235 | return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge; | |
| 236 | }; | |
| 237 | ||
| 238 | export function resizeBrowserViewportFromRail( | |
| 239 | start: PreviewViewportSize, | |
| 240 | pointerDelta: { readonly x: number; readonly y: number }, | |
| 241 | available: PreviewViewportSize, | |
| 242 | zoomFactor = 1, | |
| 243 | direction: BrowserViewportResizeDirection = "southeast", | |
| 244 | aspectRatio?: number, | |
| 245 | ): PreviewViewportSize { | |
| 246 | const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); | |
| 247 | const startWidth = start.width * normalizedZoomFactor; | |
| 248 | const startHeight = start.height * normalizedZoomFactor; | |
| 249 | const desiredWidth = direction.includes("east") | |
| 250 | ? resizeFromEndRail(startWidth, pointerDelta.x, available.width) | |
| 251 | : direction.includes("west") | |
| 252 | ? resizeFromStartRail(startWidth, pointerDelta.x, available.width) | |
| 253 | : startWidth; | |
| 254 | const desiredHeight = direction.includes("south") | |
| 255 | ? resizeFromEndRail(startHeight, pointerDelta.y, available.height) | |
| 256 | : direction.includes("north") | |
| 257 | ? resizeFromStartRail(startHeight, pointerDelta.y, available.height) | |
| 258 | : startHeight; | |
| 259 | const widthDelta = desiredWidth - startWidth; | |
| 260 | const heightDelta = desiredHeight - startHeight; | |
| 261 | return resizeFreeformViewport( | |
| 262 | start, | |
| 263 | { | |
| 264 | x: direction.includes("west") ? -widthDelta : widthDelta, | |
| 265 | y: direction.includes("north") ? -heightDelta : heightDelta, | |
| 266 | }, | |
| 267 | normalizedZoomFactor, | |
| 268 | direction, | |
| 269 | aspectRatio, | |
| 270 | ); | |
| 271 | } | |
| 272 | ||
| 273 | export function resolveResponsiveBrowserViewportSize( | |
| 274 | container: { readonly width: number; readonly height: number }, | |
| 275 | zoomFactor = 1, | |
| 276 | ): PreviewViewportSize { | |
| 277 | const area = resolveBrowserDeviceViewportArea(container); | |
| 278 | const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); | |
| 279 | return resizeFreeformViewport( | |
| 280 | { | |
| 281 | width: area.width / normalizedZoomFactor, | |
| 282 | height: area.height / normalizedZoomFactor, | |
| 283 | }, | |
| 284 | { x: 0, y: 0 }, | |
| 285 | ); | |
| 286 | } |
apps/web/src/browser/useBrowserViewportResize.ts created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | useCallback, | |
| 6 | useEffect, | |
| 7 | useRef, | |
| 8 | useState, | |
| 9 | type KeyboardEvent as ReactKeyboardEvent, | |
| 10 | type PointerEvent as ReactPointerEvent, | |
| 11 | } from "react"; | |
| 12 | ||
| 13 | import { commitBrowserViewportChange } from "./browserViewportActions"; | |
| 14 | import { | |
| 15 | browserViewportSettingKey, | |
| 16 | resizeBrowserViewportFromRail, | |
| 17 | resizeFreeformViewport, | |
| 18 | resolveBrowserDeviceViewportArea, | |
| 19 | resolveBrowserDeviceViewportLayout, | |
| 20 | resolveBrowserViewportLayout, | |
| 21 | type BrowserViewportResizeDirection, | |
| 22 | } from "./browserViewportLayout"; | |
| 23 | ||
| 24 | interface ViewportDrag extends PreviewViewportSize { | |
| 25 | readonly sourceKey: string; | |
| 26 | readonly direction: BrowserViewportResizeDirection; | |
| 27 | } | |
| 28 | ||
| 29 | const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; | |
| 30 | ||
| 31 | export function useBrowserViewportResize(options: { | |
| 32 | readonly tabId: string; | |
| 33 | readonly viewport: PreviewViewportSetting; | |
| 34 | readonly zoomFactor: number; | |
| 35 | readonly containerSize: PreviewViewportSize; | |
| 36 | readonly deviceToolbarVisible: boolean; | |
| 37 | readonly aspectRatio: number | null; | |
| 38 | }) { | |
| 39 | const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options; | |
| 40 | const dragCleanupRef = useRef<(() => void) | null>(null); | |
| 41 | const dragVersionRef = useRef(0); | |
| 42 | const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 43 | const keyboardViewportRef = useRef<ViewportDrag | null>(null); | |
| 44 | const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null); | |
| 45 | const sourceViewportKey = browserViewportSettingKey(viewport); | |
| 46 | const sourceViewportKeyRef = useRef(sourceViewportKey); | |
| 47 | sourceViewportKeyRef.current = sourceViewportKey; | |
| 48 | const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; | |
| 49 | const effectiveViewport = activeDrag | |
| 50 | ? ({ | |
| 51 | _tag: "freeform", | |
| 52 | width: activeDrag.width, | |
| 53 | height: activeDrag.height, | |
| 54 | } as const satisfies PreviewViewportSetting) | |
| 55 | : viewport; | |
| 56 | const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; | |
| 57 | const viewportContainerSize = deviceToolbarVisible | |
| 58 | ? resolveBrowserDeviceViewportArea(containerSize) | |
| 59 | : containerSize; | |
| 60 | const layout = | |
| 61 | deviceToolbarVisible && effectiveViewport._tag !== "fill" | |
| 62 | ? resolveBrowserDeviceViewportLayout(containerSize, effectiveViewport, zoomFactor) | |
| 63 | : resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor); | |
| 64 | ||
| 65 | useEffect( | |
| 66 | () => () => { | |
| 67 | dragVersionRef.current += 1; | |
| 68 | dragCleanupRef.current?.(); | |
| 69 | if (keyboardCommitTimerRef.current !== null) { | |
| 70 | clearTimeout(keyboardCommitTimerRef.current); | |
| 71 | } | |
| 72 | keyboardCommitTimerRef.current = null; | |
| 73 | keyboardViewportRef.current = null; | |
| 74 | }, | |
| 75 | [], | |
| 76 | ); | |
| 77 | ||
| 78 | useEffect(() => { | |
| 79 | const pending = keyboardViewportRef.current; | |
| 80 | if (!pending || pending.sourceKey === sourceViewportKey) return; | |
| 81 | if (keyboardCommitTimerRef.current !== null) { | |
| 82 | clearTimeout(keyboardCommitTimerRef.current); | |
| 83 | keyboardCommitTimerRef.current = null; | |
| 84 | } | |
| 85 | keyboardViewportRef.current = null; | |
| 86 | }, [sourceViewportKey]); | |
| 87 | ||
| 88 | const commitViewportChange = useCallback( | |
| 89 | (next: PreviewViewportSetting) => { | |
| 90 | dragVersionRef.current += 1; | |
| 91 | dragCleanupRef.current?.(); | |
| 92 | if (keyboardCommitTimerRef.current !== null) { | |
| 93 | clearTimeout(keyboardCommitTimerRef.current); | |
| 94 | keyboardCommitTimerRef.current = null; | |
| 95 | } | |
| 96 | keyboardViewportRef.current = null; | |
| 97 | setDragViewport(null); | |
| 98 | return commitBrowserViewportChange(tabId, next); | |
| 99 | }, | |
| 100 | [tabId], | |
| 101 | ); | |
| 102 | ||
| 103 | const clearDrag = () => setDragViewport(null); | |
| 104 | const commitDrag = (next: PreviewViewportSetting) => { | |
| 105 | const version = ++dragVersionRef.current; | |
| 106 | const clearIfCurrent = () => { | |
| 107 | if (dragVersionRef.current === version) clearDrag(); | |
| 108 | }; | |
| 109 | void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent); | |
| 110 | }; | |
| 111 | ||
| 112 | const handleResizeKeyDown = ( | |
| 113 | direction: BrowserViewportResizeDirection, | |
| 114 | event: ReactKeyboardEvent<HTMLButtonElement>, | |
| 115 | ) => { | |
| 116 | if (effectiveViewport._tag === "fill") return; | |
| 117 | const controlsWidth = direction.includes("east") || direction.includes("west"); | |
| 118 | const controlsHeight = direction.includes("north") || direction.includes("south"); | |
| 119 | const step = (event.shiftKey ? 50 : 10) * normalizedZoomFactor; | |
| 120 | const delta = | |
| 121 | event.key === "ArrowLeft" && controlsWidth | |
| 122 | ? { x: -step, y: 0 } | |
| 123 | : event.key === "ArrowRight" && controlsWidth | |
| 124 | ? { x: step, y: 0 } | |
| 125 | : event.key === "ArrowUp" && controlsHeight | |
| 126 | ? { x: 0, y: -step } | |
| 127 | : event.key === "ArrowDown" && controlsHeight | |
| 128 | ? { x: 0, y: step } | |
| 129 | : null; | |
| 130 | if (!delta) return; | |
| 131 | event.preventDefault(); | |
| 132 | event.stopPropagation(); | |
| 133 | const pending = keyboardViewportRef.current; | |
| 134 | const base = pending?.sourceKey === sourceViewportKey ? pending : effectiveViewport; | |
| 135 | const next = resizeFreeformViewport( | |
| 136 | base, | |
| 137 | delta, | |
| 138 | zoomFactor, | |
| 139 | direction, | |
| 140 | aspectRatio ?? undefined, | |
| 141 | ); | |
| 142 | if (next.width === base.width && next.height === base.height) return; | |
| 143 | const keyboardViewport = { sourceKey: sourceViewportKey, ...next, direction }; | |
| 144 | keyboardViewportRef.current = keyboardViewport; | |
| 145 | setDragViewport(keyboardViewport); | |
| 146 | if (keyboardCommitTimerRef.current !== null) { | |
| 147 | clearTimeout(keyboardCommitTimerRef.current); | |
| 148 | } | |
| 149 | keyboardCommitTimerRef.current = setTimeout(() => { | |
| 150 | keyboardCommitTimerRef.current = null; | |
| 151 | const latest = keyboardViewportRef.current; | |
| 152 | if (!latest || latest.sourceKey !== sourceViewportKeyRef.current) return; | |
| 153 | keyboardViewportRef.current = null; | |
| 154 | commitDrag({ _tag: "freeform", width: latest.width, height: latest.height }); | |
| 155 | }, KEYBOARD_RESIZE_COMMIT_DELAY_MS); | |
| 156 | }; | |
| 157 | ||
| 158 | const handleResizePointerDown = ( | |
| 159 | direction: BrowserViewportResizeDirection, | |
| 160 | event: ReactPointerEvent<HTMLButtonElement>, | |
| 161 | ) => { | |
| 162 | if (effectiveViewport._tag === "fill") return; | |
| 163 | event.preventDefault(); | |
| 164 | event.stopPropagation(); | |
| 165 | if (keyboardCommitTimerRef.current !== null) { | |
| 166 | clearTimeout(keyboardCommitTimerRef.current); | |
| 167 | keyboardCommitTimerRef.current = null; | |
| 168 | } | |
| 169 | keyboardViewportRef.current = null; | |
| 170 | dragCleanupRef.current?.(); | |
| 171 | dragVersionRef.current += 1; | |
| 172 | const pointerId = event.pointerId; | |
| 173 | const target = event.currentTarget; | |
| 174 | const startX = event.clientX; | |
| 175 | const startY = event.clientY; | |
| 176 | const startWidth = effectiveViewport.width; | |
| 177 | const startHeight = effectiveViewport.height; | |
| 178 | const dragZoomFactor = normalizedZoomFactor * layout.viewportScale; | |
| 179 | let latest = { width: startWidth, height: startHeight }; | |
| 180 | setDragViewport({ | |
| 181 | sourceKey: sourceViewportKey, | |
| 182 | width: startWidth, | |
| 183 | height: startHeight, | |
| 184 | direction, | |
| 185 | }); | |
| 186 | try { | |
| 187 | target.setPointerCapture(pointerId); | |
| 188 | } catch { | |
| 189 | // Window listeners below keep the drag functional when capture is unavailable. | |
| 190 | } | |
| 191 | ||
| 192 | const sourceChanged = () => sourceViewportKeyRef.current !== sourceViewportKey; | |
| 193 | const move = (moveEvent: PointerEvent) => { | |
| 194 | if (moveEvent.pointerId !== pointerId) return; | |
| 195 | if (sourceChanged()) { | |
| 196 | cleanup(); | |
| 197 | dragVersionRef.current += 1; | |
| 198 | clearDrag(); | |
| 199 | return; | |
| 200 | } | |
| 201 | moveEvent.preventDefault(); | |
| 202 | const { width, height } = resizeBrowserViewportFromRail( | |
| 203 | { width: startWidth, height: startHeight }, | |
| 204 | { | |
| 205 | x: moveEvent.clientX - startX, | |
| 206 | y: moveEvent.clientY - startY, | |
| 207 | }, | |
| 208 | viewportContainerSize, | |
| 209 | dragZoomFactor, | |
| 210 | direction, | |
| 211 | aspectRatio ?? undefined, | |
| 212 | ); | |
| 213 | latest = { width, height }; | |
| 214 | setDragViewport({ sourceKey: sourceViewportKey, width, height, direction }); | |
| 215 | }; | |
| 216 | function cleanup() { | |
| 217 | window.removeEventListener("pointermove", move); | |
| 218 | window.removeEventListener("pointerup", finish); | |
| 219 | window.removeEventListener("pointercancel", cancel); | |
| 220 | dragCleanupRef.current = null; | |
| 221 | try { | |
| 222 | target.releasePointerCapture(pointerId); | |
| 223 | } catch { | |
| 224 | // The browser may already have released capture on pointerup. | |
| 225 | } | |
| 226 | } | |
| 227 | function finish(upEvent: PointerEvent) { | |
| 228 | if (upEvent.pointerId !== pointerId) return; | |
| 229 | cleanup(); | |
| 230 | if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { | |
| 231 | clearDrag(); | |
| 232 | return; | |
| 233 | } | |
| 234 | commitDrag({ | |
| 235 | _tag: "freeform", | |
| 236 | width: latest.width, | |
| 237 | height: latest.height, | |
| 238 | }); | |
| 239 | } | |
| 240 | function cancel(cancelEvent: PointerEvent) { | |
| 241 | if (cancelEvent.pointerId !== pointerId) return; | |
| 242 | cleanup(); | |
| 243 | dragVersionRef.current += 1; | |
| 244 | clearDrag(); | |
| 245 | } | |
| 246 | dragCleanupRef.current = cleanup; | |
| 247 | window.addEventListener("pointermove", move, { passive: false }); | |
| 248 | window.addEventListener("pointerup", finish); | |
| 249 | window.addEventListener("pointercancel", cancel); | |
| 250 | }; | |
| 251 | ||
| 252 | return { | |
| 253 | activeDrag, | |
| 254 | commitViewportChange, | |
| 255 | effectiveViewport, | |
| 256 | handleResizeKeyDown, | |
| 257 | handleResizePointerDown, | |
| 258 | layout, | |
| 259 | }; | |
| 260 | } |
apps/web/src/components/ChatView.tsx-4| ... | ... | @@ -129,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 129 | 129 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 130 | 130 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 131 | 131 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 132 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | |
| 133 | 132 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 134 | 133 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 135 | 134 | import { BranchToolbar } from "./BranchToolbar"; |
| ... | ... | @@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 4704 | 4703 | |
| 4705 | 4704 | return ( |
| 4706 | 4705 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> |
| 4707 | {isElectron && activeThreadRef ? ( | |
| 4708 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> | |
| 4709 | ) : null} | |
| 4710 | 4706 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} |
| 4711 | 4707 | <div |
| 4712 | 4708 | className={cn( |
apps/web/src/components/preview/AgentBrowserCursor.tsx+12-2| ... | ... | @@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react"; |
| 5 | 5 | import { useEffect, useState } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; |
| 8 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | |
| 8 | 9 | |
| 9 | 10 | import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; |
| 10 | 11 | |
| ... | ... | @@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: { |
| 17 | 18 | }) { |
| 18 | 19 | const { tabId, zoomFactor, controller } = props; |
| 19 | 20 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); |
| 21 | const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null); | |
| 20 | 22 | |
| 21 | 23 | if (!event) return null; |
| 22 | 24 | |
| ... | ... | @@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: { |
| 24 | 26 | <AgentBrowserCursorEvent |
| 25 | 27 | key={event.sequence} |
| 26 | 28 | event={event} |
| 29 | content={content} | |
| 27 | 30 | zoomFactor={zoomFactor} |
| 28 | 31 | controller={controller} |
| 29 | 32 | /> |
| ... | ... | @@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: { |
| 32 | 35 | |
| 33 | 36 | function AgentBrowserCursorEvent(props: { |
| 34 | 37 | readonly event: DesktopPreviewPointerEvent; |
| 38 | readonly content: { | |
| 39 | readonly x: number; | |
| 40 | readonly y: number; | |
| 41 | readonly scale: number; | |
| 42 | readonly scrollLeft: number; | |
| 43 | readonly scrollTop: number; | |
| 44 | } | null; | |
| 35 | 45 | readonly zoomFactor: number; |
| 36 | 46 | readonly controller: BrowserController; |
| 37 | 47 | }) { |
| 38 | const { event, zoomFactor, controller } = props; | |
| 48 | const { event, content, zoomFactor, controller } = props; | |
| 39 | 49 | const [active, setActive] = useState(true); |
| 40 | 50 | |
| 41 | 51 | useEffect(() => { |
| ... | ... | @@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: { |
| 48 | 58 | className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" |
| 49 | 59 | style={{ |
| 50 | 60 | opacity: agentBrowserCursorOpacity(active, controller), |
| 51 | transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`, | |
| 61 | transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`, | |
| 52 | 62 | }} |
| 53 | 63 | aria-hidden="true" |
| 54 | 64 | data-agent-browser-cursor |
apps/web/src/components/preview/PreviewAutomationHosts.tsx created+569| ... | ... | @@ -0,0 +1,569 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 5 | import { | |
| 6 | FILL_PREVIEW_VIEWPORT, | |
| 7 | PREVIEW_AUTOMATION_OPERATIONS, | |
| 8 | type EnvironmentId, | |
| 9 | type PreviewAutomationNavigateInput, | |
| 10 | type PreviewAutomationOpenInput, | |
| 11 | type PreviewAutomationResizeInput, | |
| 12 | type PreviewAutomationResizeResult, | |
| 13 | type PreviewAutomationHost as PreviewAutomationHostState, | |
| 14 | type PreviewAutomationRequest, | |
| 15 | type PreviewAutomationStatus, | |
| 16 | type PreviewRenderedViewportSize, | |
| 17 | type PreviewViewportSetting, | |
| 18 | type ScopedThreadRef, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | |
| 21 | import { useCallback, useContext, useEffect, useMemo, useState } from "react"; | |
| 22 | import { Atom } from "effect/unstable/reactivity"; | |
| 23 | ||
| 24 | import { | |
| 25 | applyPreviewServerSnapshot, | |
| 26 | readThreadPreviewState, | |
| 27 | reconcilePreviewServerSessions, | |
| 28 | updatePreviewServerSnapshot, | |
| 29 | } from "~/previewStateStore"; | |
| 30 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 31 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | |
| 32 | import { | |
| 33 | readActiveBrowserRecordingTabId, | |
| 34 | startBrowserRecording, | |
| 35 | stopBrowserRecording, | |
| 36 | } from "~/browser/browserRecording"; | |
| 37 | import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; | |
| 38 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | |
| 39 | import { isElectron } from "~/env"; | |
| 40 | import { useEnvironments } from "~/state/environments"; | |
| 41 | import { previewEnvironment } from "~/state/preview"; | |
| 42 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | |
| 43 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 44 | ||
| 45 | import { previewBridge } from "./previewBridge"; | |
| 46 | import { | |
| 47 | PreviewAutomationNavigationTimeoutError, | |
| 48 | PreviewAutomationOperationError, | |
| 49 | PreviewAutomationOverlayTimeoutError, | |
| 50 | PreviewAutomationRecordingNotActiveError, | |
| 51 | PreviewAutomationTargetUnavailableError, | |
| 52 | PreviewAutomationViewportTimeoutError, | |
| 53 | } from "./previewAutomationErrors"; | |
| 54 | import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; | |
| 55 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; | |
| 56 | import { | |
| 57 | needsPreviewAutomationSessionSync, | |
| 58 | resolvePreviewAutomationTarget, | |
| 59 | } from "./previewAutomationTarget"; | |
| 60 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | |
| 61 | ||
| 62 | const waitForDesktopOverlay = async ( | |
| 63 | threadRef: ScopedThreadRef, | |
| 64 | requestId: string, | |
| 65 | tabId: string, | |
| 66 | timeoutMs: number, | |
| 67 | ): Promise<void> => { | |
| 68 | const deadline = Date.now() + timeoutMs; | |
| 69 | while (Date.now() <= deadline) { | |
| 70 | const state = readThreadPreviewState(threadRef); | |
| 71 | if (state.desktopByTabId[tabId] && previewBridge) { | |
| 72 | const status = await previewBridge.automation.status(tabId); | |
| 73 | if (status.available) return; | |
| 74 | } | |
| 75 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 76 | } | |
| 77 | throw new PreviewAutomationOverlayTimeoutError({ | |
| 78 | requestId, | |
| 79 | environmentId: threadRef.environmentId, | |
| 80 | threadId: threadRef.threadId, | |
| 81 | timeoutMs, | |
| 82 | }); | |
| 83 | }; | |
| 84 | ||
| 85 | const waitForNavigationReadiness = async ( | |
| 86 | threadRef: ScopedThreadRef, | |
| 87 | requestId: string, | |
| 88 | tabId: string, | |
| 89 | readiness: PreviewAutomationNavigateInput["readiness"], | |
| 90 | timeoutMs: number, | |
| 91 | ): Promise<void> => { | |
| 92 | const targetReadiness = readiness ?? "load"; | |
| 93 | if (!previewBridge || targetReadiness === "none") return; | |
| 94 | const deadline = Date.now() + timeoutMs; | |
| 95 | while (Date.now() <= deadline) { | |
| 96 | if (targetReadiness === "domContentLoaded") { | |
| 97 | const readyState = await previewBridge.automation.evaluate(tabId, { | |
| 98 | expression: "document.readyState", | |
| 99 | }); | |
| 100 | if (readyState === "interactive" || readyState === "complete") return; | |
| 101 | } else { | |
| 102 | const status = await previewBridge.automation.status(tabId); | |
| 103 | if (!status.loading) return; | |
| 104 | } | |
| 105 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 106 | } | |
| 107 | throw new PreviewAutomationNavigationTimeoutError({ | |
| 108 | requestId, | |
| 109 | environmentId: threadRef.environmentId, | |
| 110 | threadId: threadRef.threadId, | |
| 111 | tabId, | |
| 112 | readiness: targetReadiness, | |
| 113 | timeoutMs, | |
| 114 | }); | |
| 115 | }; | |
| 116 | ||
| 117 | interface ExecutablePreviewWebview extends Element { | |
| 118 | readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>; | |
| 119 | } | |
| 120 | ||
| 121 | const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => | |
| 122 | Array.from(document.querySelectorAll<ExecutablePreviewWebview>("webview[data-preview-tab]")).find( | |
| 123 | (candidate) => candidate.getAttribute("data-preview-tab") === tabId, | |
| 124 | ) ?? null; | |
| 125 | ||
| 126 | const readWebviewViewport = async ( | |
| 127 | webview: ExecutablePreviewWebview, | |
| 128 | ): Promise<PreviewRenderedViewportSize | null> => { | |
| 129 | const value = await webview.executeJavaScript( | |
| 130 | "({ width: window.innerWidth, height: window.innerHeight })", | |
| 131 | ); | |
| 132 | if (typeof value !== "object" || value === null) return null; | |
| 133 | const { width, height } = value as { readonly width?: unknown; readonly height?: unknown }; | |
| 134 | return typeof width === "number" && | |
| 135 | Number.isInteger(width) && | |
| 136 | width > 0 && | |
| 137 | typeof height === "number" && | |
| 138 | Number.isInteger(height) && | |
| 139 | height > 0 | |
| 140 | ? { width, height } | |
| 141 | : null; | |
| 142 | }; | |
| 143 | ||
| 144 | const readRenderedViewport = async (tabId: string): Promise<PreviewRenderedViewportSize | null> => { | |
| 145 | const webview = findPreviewWebview(tabId); | |
| 146 | if (!webview) return null; | |
| 147 | return await readWebviewViewport(webview); | |
| 148 | }; | |
| 149 | ||
| 150 | const readDeclaredViewport = ( | |
| 151 | webview: ExecutablePreviewWebview | null, | |
| 152 | ): PreviewRenderedViewportSize | null => { | |
| 153 | const width = Number(webview?.getAttribute("data-preview-css-width")); | |
| 154 | const height = Number(webview?.getAttribute("data-preview-css-height")); | |
| 155 | return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0 | |
| 156 | ? { width, height } | |
| 157 | : null; | |
| 158 | }; | |
| 159 | ||
| 160 | const waitForRenderedViewport = async ( | |
| 161 | tabId: string, | |
| 162 | setting: PreviewViewportSetting, | |
| 163 | timeoutMs: number, | |
| 164 | context: { | |
| 165 | readonly requestId: PreviewAutomationRequest["requestId"]; | |
| 166 | readonly environmentId: EnvironmentId; | |
| 167 | readonly threadId: PreviewAutomationRequest["threadId"]; | |
| 168 | }, | |
| 169 | ): Promise<PreviewRenderedViewportSize> => { | |
| 170 | const deadline = Date.now() + timeoutMs; | |
| 171 | while (Date.now() <= deadline) { | |
| 172 | try { | |
| 173 | const webview = findPreviewWebview(tabId); | |
| 174 | const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; | |
| 175 | const declaredViewport = readDeclaredViewport(webview); | |
| 176 | const renderedViewport = webview ? await readWebviewViewport(webview) : null; | |
| 177 | if ( | |
| 178 | renderedViewport && | |
| 179 | isPreviewViewportReady({ | |
| 180 | setting, | |
| 181 | appliedSettingKey, | |
| 182 | declaredViewport, | |
| 183 | renderedViewport, | |
| 184 | }) | |
| 185 | ) { | |
| 186 | return renderedViewport; | |
| 187 | } | |
| 188 | } catch { | |
| 189 | // Registration and navigation can transiently replace the guest while | |
| 190 | // React applies the server snapshot. Retry until the operation deadline. | |
| 191 | } | |
| 192 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 193 | } | |
| 194 | throw new PreviewAutomationViewportTimeoutError({ | |
| 195 | ...context, | |
| 196 | tabId, | |
| 197 | timeoutMs, | |
| 198 | }); | |
| 199 | }; | |
| 200 | ||
| 201 | const currentStatus = async ( | |
| 202 | threadRef: ScopedThreadRef, | |
| 203 | requestedTabId: string | null, | |
| 204 | ): Promise<PreviewAutomationStatus> => { | |
| 205 | const state = readThreadPreviewState(threadRef); | |
| 206 | const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); | |
| 207 | const visible = tabId | |
| 208 | ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) | |
| 209 | : false; | |
| 210 | const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; | |
| 211 | const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null; | |
| 212 | const viewportStatus = { | |
| 213 | ...(viewportSetting === undefined ? {} : { viewportSetting }), | |
| 214 | ...(viewport === null ? {} : { viewport }), | |
| 215 | }; | |
| 216 | if (tabId && previewBridge && state.desktopByTabId[tabId]) { | |
| 217 | const status = await previewBridge.automation.status(tabId); | |
| 218 | return { ...status, visible, ...viewportStatus }; | |
| 219 | } | |
| 220 | const navStatus = snapshot?.navStatus; | |
| 221 | return { | |
| 222 | available: Boolean(previewBridge?.automation), | |
| 223 | visible, | |
| 224 | tabId, | |
| 225 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, | |
| 226 | title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, | |
| 227 | loading: navStatus?._tag === "Loading", | |
| 228 | ...viewportStatus, | |
| 229 | }; | |
| 230 | }; | |
| 231 | ||
| 232 | export function PreviewAutomationHosts() { | |
| 233 | const { environments } = useEnvironments(); | |
| 234 | if (!isElectron || !previewBridge?.automation) return null; | |
| 235 | return ( | |
| 236 | <> | |
| 237 | {/* | |
| 238 | * Host lifetime follows the desktop runtime's environment connections, | |
| 239 | * not the routed thread. This keeps background threads automatable and | |
| 240 | * lets the subscription runtime own reconnects for every saved target. | |
| 241 | */} | |
| 242 | {environments.map((environment) => ( | |
| 243 | <PreviewAutomationHost | |
| 244 | key={environment.environmentId} | |
| 245 | environmentId={environment.environmentId} | |
| 246 | /> | |
| 247 | ))} | |
| 248 | </> | |
| 249 | ); | |
| 250 | } | |
| 251 | ||
| 252 | function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { | |
| 253 | const { environmentId } = props; | |
| 254 | const registry = useContext(RegistryContext); | |
| 255 | const [automationClientId] = useState(createPreviewAutomationClientId); | |
| 256 | const initialAutomationHost = useMemo<PreviewAutomationHostState>( | |
| 257 | () => ({ | |
| 258 | clientId: automationClientId, | |
| 259 | environmentId, | |
| 260 | supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], | |
| 261 | }), | |
| 262 | [automationClientId, environmentId], | |
| 263 | ); | |
| 264 | const automationRequestsAtom = previewEnvironment.automationRequests({ | |
| 265 | environmentId, | |
| 266 | input: initialAutomationHost, | |
| 267 | }); | |
| 268 | const listPreviews = useAtomQueryRunner(previewEnvironment.list, { | |
| 269 | reportFailure: false, | |
| 270 | }); | |
| 271 | const open = useAtomCommand(previewEnvironment.open, { | |
| 272 | reportFailure: false, | |
| 273 | }); | |
| 274 | const resize = useAtomCommand(previewEnvironment.resize, { | |
| 275 | reportFailure: false, | |
| 276 | }); | |
| 277 | const respondToAutomation = useAtomCommand( | |
| 278 | previewEnvironment.respondToAutomation, | |
| 279 | "preview automation response", | |
| 280 | ); | |
| 281 | const focusAutomationHost = useAtomCommand( | |
| 282 | previewEnvironment.focusAutomationHost, | |
| 283 | "preview automation host focus", | |
| 284 | ); | |
| 285 | const [automationConnectionAtom] = useState(() => Atom.make<string | null>(null)); | |
| 286 | const automationConnectionId = useAtomValue(automationConnectionAtom); | |
| 287 | ||
| 288 | const handleRequest = useCallback( | |
| 289 | async (request: PreviewAutomationRequest): Promise<unknown> => { | |
| 290 | const threadRef: ScopedThreadRef = { | |
| 291 | environmentId, | |
| 292 | threadId: request.threadId, | |
| 293 | }; | |
| 294 | let tabId = request.tabId ?? null; | |
| 295 | try { | |
| 296 | let state = readThreadPreviewState(threadRef); | |
| 297 | const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); | |
| 298 | if (needsSessionSync) { | |
| 299 | const listTarget = { | |
| 300 | environmentId, | |
| 301 | input: { threadId: request.threadId }, | |
| 302 | } as const; | |
| 303 | registry.refresh(previewEnvironment.list(listTarget)); | |
| 304 | const result = await listPreviews(listTarget); | |
| 305 | if (result._tag === "Failure") { | |
| 306 | throw squashAtomCommandFailure(result); | |
| 307 | } | |
| 308 | reconcilePreviewServerSessions(threadRef, result.value.sessions); | |
| 309 | state = readThreadPreviewState(threadRef); | |
| 310 | } | |
| 311 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 312 | const unavailableTarget = { | |
| 313 | requestId: request.requestId, | |
| 314 | operation: request.operation, | |
| 315 | environmentId, | |
| 316 | threadId: request.threadId, | |
| 317 | tabId, | |
| 318 | bridgeAvailable: Boolean(previewBridge), | |
| 319 | }; | |
| 320 | const requireReadyTab = async () => { | |
| 321 | const bridge = previewBridge; | |
| 322 | const readyTabId = tabId; | |
| 323 | if (!bridge || !readyTabId) { | |
| 324 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 325 | } | |
| 326 | await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs); | |
| 327 | return { bridge, tabId: readyTabId }; | |
| 328 | }; | |
| 329 | switch (request.operation) { | |
| 330 | case "status": | |
| 331 | return await currentStatus(threadRef, tabId); | |
| 332 | case "open": { | |
| 333 | const input = request.input as PreviewAutomationOpenInput; | |
| 334 | let activeTabId = | |
| 335 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 336 | const reusedExistingTab = activeTabId !== null; | |
| 337 | tabId = activeTabId; | |
| 338 | if (!activeTabId) { | |
| 339 | const result = await open({ | |
| 340 | environmentId, | |
| 341 | input: { | |
| 342 | threadId: request.threadId, | |
| 343 | ...(input.url ? { url: input.url } : {}), | |
| 344 | }, | |
| 345 | }); | |
| 346 | if (result._tag === "Failure") { | |
| 347 | throw squashAtomCommandFailure(result); | |
| 348 | } | |
| 349 | const snapshot = result.value; | |
| 350 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 351 | activeTabId = snapshot.tabId; | |
| 352 | tabId = activeTabId; | |
| 353 | } | |
| 354 | if (input.show ?? true) { | |
| 355 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | |
| 356 | } | |
| 357 | await waitForDesktopOverlay( | |
| 358 | threadRef, | |
| 359 | request.requestId, | |
| 360 | activeTabId, | |
| 361 | request.timeoutMs, | |
| 362 | ); | |
| 363 | if (reusedExistingTab && input.url && previewBridge) { | |
| 364 | const resolution = resolveBrowserNavigationTarget(environmentId, { | |
| 365 | kind: "url", | |
| 366 | url: input.url, | |
| 367 | }); | |
| 368 | await previewBridge.navigate(activeTabId, resolution.resolvedUrl); | |
| 369 | await waitForNavigationReadiness( | |
| 370 | threadRef, | |
| 371 | request.requestId, | |
| 372 | activeTabId, | |
| 373 | "load", | |
| 374 | request.timeoutMs, | |
| 375 | ); | |
| 376 | } | |
| 377 | return await currentStatus(threadRef, activeTabId); | |
| 378 | } | |
| 379 | case "navigate": { | |
| 380 | const ready = await requireReadyTab(); | |
| 381 | const input = request.input as PreviewAutomationNavigateInput; | |
| 382 | const resolution = resolveBrowserNavigationTarget( | |
| 383 | environmentId, | |
| 384 | input.target ?? { | |
| 385 | kind: "url", | |
| 386 | url: input.url!, | |
| 387 | }, | |
| 388 | ); | |
| 389 | await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); | |
| 390 | await waitForNavigationReadiness( | |
| 391 | threadRef, | |
| 392 | request.requestId, | |
| 393 | ready.tabId, | |
| 394 | input.readiness ?? "load", | |
| 395 | input.timeoutMs ?? request.timeoutMs, | |
| 396 | ); | |
| 397 | return await currentStatus(threadRef, ready.tabId); | |
| 398 | } | |
| 399 | case "resize": { | |
| 400 | const ready = await requireReadyTab(); | |
| 401 | const input = request.input as PreviewAutomationResizeInput; | |
| 402 | const setting = resolvePreviewViewport(input); | |
| 403 | const result = await resize({ | |
| 404 | environmentId, | |
| 405 | input: { | |
| 406 | threadId: request.threadId, | |
| 407 | tabId: ready.tabId, | |
| 408 | viewport: setting, | |
| 409 | }, | |
| 410 | }); | |
| 411 | if (result._tag === "Failure") { | |
| 412 | throw squashAtomCommandFailure(result); | |
| 413 | } | |
| 414 | updatePreviewServerSnapshot(threadRef, result.value); | |
| 415 | const viewport = await waitForRenderedViewport( | |
| 416 | ready.tabId, | |
| 417 | setting, | |
| 418 | input.timeoutMs ?? request.timeoutMs, | |
| 419 | { | |
| 420 | requestId: request.requestId, | |
| 421 | environmentId, | |
| 422 | threadId: request.threadId, | |
| 423 | }, | |
| 424 | ); | |
| 425 | return { | |
| 426 | tabId: ready.tabId, | |
| 427 | setting, | |
| 428 | viewport, | |
| 429 | } satisfies PreviewAutomationResizeResult; | |
| 430 | } | |
| 431 | case "snapshot": { | |
| 432 | const ready = await requireReadyTab(); | |
| 433 | return await ready.bridge.automation.snapshot(ready.tabId); | |
| 434 | } | |
| 435 | case "click": { | |
| 436 | const ready = await requireReadyTab(); | |
| 437 | return await ready.bridge.automation.click( | |
| 438 | ready.tabId, | |
| 439 | request.input as Parameters<typeof ready.bridge.automation.click>[1], | |
| 440 | ); | |
| 441 | } | |
| 442 | case "type": { | |
| 443 | const ready = await requireReadyTab(); | |
| 444 | return await ready.bridge.automation.type( | |
| 445 | ready.tabId, | |
| 446 | request.input as Parameters<typeof ready.bridge.automation.type>[1], | |
| 447 | ); | |
| 448 | } | |
| 449 | case "press": { | |
| 450 | const ready = await requireReadyTab(); | |
| 451 | return await ready.bridge.automation.press( | |
| 452 | ready.tabId, | |
| 453 | request.input as Parameters<typeof ready.bridge.automation.press>[1], | |
| 454 | ); | |
| 455 | } | |
| 456 | case "scroll": { | |
| 457 | const ready = await requireReadyTab(); | |
| 458 | return await ready.bridge.automation.scroll( | |
| 459 | ready.tabId, | |
| 460 | request.input as Parameters<typeof ready.bridge.automation.scroll>[1], | |
| 461 | ); | |
| 462 | } | |
| 463 | case "evaluate": { | |
| 464 | const ready = await requireReadyTab(); | |
| 465 | return await ready.bridge.automation.evaluate( | |
| 466 | ready.tabId, | |
| 467 | request.input as Parameters<typeof ready.bridge.automation.evaluate>[1], | |
| 468 | ); | |
| 469 | } | |
| 470 | case "waitFor": { | |
| 471 | const ready = await requireReadyTab(); | |
| 472 | return await ready.bridge.automation.waitFor( | |
| 473 | ready.tabId, | |
| 474 | request.input as Parameters<typeof ready.bridge.automation.waitFor>[1], | |
| 475 | ); | |
| 476 | } | |
| 477 | case "recordingStart": { | |
| 478 | const ready = await requireReadyTab(); | |
| 479 | const startedAt = await startBrowserRecording(ready.tabId); | |
| 480 | return { | |
| 481 | tabId: ready.tabId, | |
| 482 | recording: true, | |
| 483 | startedAt, | |
| 484 | }; | |
| 485 | } | |
| 486 | case "recordingStop": { | |
| 487 | const recordingTabId = readActiveBrowserRecordingTabId(); | |
| 488 | const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId); | |
| 489 | const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; | |
| 490 | if (!artifact) { | |
| 491 | throw new PreviewAutomationRecordingNotActiveError({ | |
| 492 | requestId: request.requestId, | |
| 493 | environmentId, | |
| 494 | threadId: request.threadId, | |
| 495 | tabId, | |
| 496 | }); | |
| 497 | } | |
| 498 | return artifact; | |
| 499 | } | |
| 500 | } | |
| 501 | } catch (cause) { | |
| 502 | throw PreviewAutomationOperationError.fromCause({ | |
| 503 | requestId: request.requestId, | |
| 504 | operation: request.operation, | |
| 505 | environmentId, | |
| 506 | threadId: request.threadId, | |
| 507 | tabId, | |
| 508 | cause, | |
| 509 | }); | |
| 510 | } | |
| 511 | }, | |
| 512 | [environmentId, listPreviews, open, registry, resize], | |
| 513 | ); | |
| 514 | const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); | |
| 515 | const setRequestHandler = useAtomSet(requestHandlerAtom); | |
| 516 | useEffect(() => { | |
| 517 | setRequestHandler({ handle: handleRequest }); | |
| 518 | }, [handleRequest, setRequestHandler]); | |
| 519 | ||
| 520 | const automationRequestConsumerAtom = useMemo( | |
| 521 | () => | |
| 522 | createPreviewAutomationRequestConsumerAtom({ | |
| 523 | requestsAtom: automationRequestsAtom, | |
| 524 | clientId: automationClientId, | |
| 525 | connectionAtom: automationConnectionAtom, | |
| 526 | environmentId, | |
| 527 | requestHandlerAtom, | |
| 528 | respond: (response) => | |
| 529 | respondToAutomation({ | |
| 530 | environmentId, | |
| 531 | input: response, | |
| 532 | }), | |
| 533 | label: `preview:automation-host:${environmentId}:${automationClientId}`, | |
| 534 | }), | |
| 535 | [ | |
| 536 | automationClientId, | |
| 537 | automationConnectionAtom, | |
| 538 | automationRequestsAtom, | |
| 539 | requestHandlerAtom, | |
| 540 | respondToAutomation, | |
| 541 | environmentId, | |
| 542 | ], | |
| 543 | ); | |
| 544 | useAtomValue(automationRequestConsumerAtom); | |
| 545 | ||
| 546 | useEffect(() => { | |
| 547 | const report = () => { | |
| 548 | if (!automationConnectionId) return; | |
| 549 | void focusAutomationHost({ | |
| 550 | environmentId, | |
| 551 | input: { | |
| 552 | clientId: automationClientId, | |
| 553 | environmentId, | |
| 554 | connectionId: automationConnectionId, | |
| 555 | focused: document.hasFocus(), | |
| 556 | }, | |
| 557 | }); | |
| 558 | }; | |
| 559 | report(); | |
| 560 | window.addEventListener("focus", report); | |
| 561 | window.addEventListener("blur", report); | |
| 562 | return () => { | |
| 563 | window.removeEventListener("focus", report); | |
| 564 | window.removeEventListener("blur", report); | |
| 565 | }; | |
| 566 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); | |
| 567 | ||
| 568 | return null; | |
| 569 | } |
apps/web/src/components/preview/PreviewAutomationOwner.test.ts deleted-31| ... | ... | @@ -1,31 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; | |
| 4 | ||
| 5 | describe("observeAutomationOwnerConnectedGeneration", () => { | |
| 6 | it("reports ownership when the initial transport generation connects", () => { | |
| 7 | const initial = observeAutomationOwnerConnectedGeneration(null, 1); | |
| 8 | expect(initial).toEqual({ | |
| 9 | nextGeneration: 1, | |
| 10 | shouldReport: true, | |
| 11 | }); | |
| 12 | ||
| 13 | const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); | |
| 14 | expect(disconnected).toEqual({ | |
| 15 | nextGeneration: 1, | |
| 16 | shouldReport: false, | |
| 17 | }); | |
| 18 | ||
| 19 | expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({ | |
| 20 | nextGeneration: 2, | |
| 21 | shouldReport: true, | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | it("does not re-report for repeated connected state from the same generation", () => { | |
| 26 | expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({ | |
| 27 | nextGeneration: 3, | |
| 28 | shouldReport: false, | |
| 29 | }); | |
| 30 | }); | |
| 31 | }); |
apps/web/src/components/preview/PreviewAutomationOwner.tsx deleted-426| ... | ... | @@ -1,426 +0,0 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 5 | import { | |
| 6 | type PreviewAutomationNavigateInput, | |
| 7 | type PreviewAutomationOpenInput, | |
| 8 | type PreviewAutomationOwner as PreviewAutomationOwnerState, | |
| 9 | type PreviewAutomationRequest, | |
| 10 | type PreviewAutomationStatus, | |
| 11 | type ScopedThreadRef, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; | |
| 14 | ||
| 15 | import { | |
| 16 | applyPreviewServerSnapshot, | |
| 17 | readThreadPreviewState, | |
| 18 | subscribeThreadPreviewState, | |
| 19 | } from "~/previewStateStore"; | |
| 20 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 21 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | |
| 22 | import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; | |
| 23 | import { previewEnvironment } from "~/state/preview"; | |
| 24 | import { useEnvironmentConnectionState } from "~/state/environments"; | |
| 25 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 26 | ||
| 27 | import { previewBridge } from "./previewBridge"; | |
| 28 | import { | |
| 29 | PreviewAutomationNavigationTimeoutError, | |
| 30 | PreviewAutomationOperationError, | |
| 31 | PreviewAutomationOverlayTimeoutError, | |
| 32 | PreviewAutomationRecordingNotActiveError, | |
| 33 | PreviewAutomationStaleOwnerError, | |
| 34 | PreviewAutomationTargetUnavailableError, | |
| 35 | } from "./previewAutomationErrors"; | |
| 36 | import { | |
| 37 | createLatestPreviewAutomationRequestHandler, | |
| 38 | createPreviewAutomationRequestConsumerAtom, | |
| 39 | } from "./previewAutomationRequestConsumer"; | |
| 40 | ||
| 41 | export function observeAutomationOwnerConnectedGeneration( | |
| 42 | previousGeneration: number | null, | |
| 43 | connectedGeneration: number | null, | |
| 44 | ): { | |
| 45 | readonly nextGeneration: number | null; | |
| 46 | readonly shouldReport: boolean; | |
| 47 | } { | |
| 48 | if (connectedGeneration === null) { | |
| 49 | return { | |
| 50 | nextGeneration: previousGeneration, | |
| 51 | shouldReport: false, | |
| 52 | }; | |
| 53 | } | |
| 54 | return { | |
| 55 | nextGeneration: connectedGeneration, | |
| 56 | shouldReport: previousGeneration !== connectedGeneration, | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 60 | const waitForDesktopOverlay = async ( | |
| 61 | threadRef: ScopedThreadRef, | |
| 62 | requestId: string, | |
| 63 | timeoutMs: number, | |
| 64 | ): Promise<void> => { | |
| 65 | const deadline = Date.now() + timeoutMs; | |
| 66 | while (Date.now() <= deadline) { | |
| 67 | const state = readThreadPreviewState(threadRef); | |
| 68 | const tabId = state.snapshot?.tabId; | |
| 69 | if (tabId && state.desktopOverlay && previewBridge) { | |
| 70 | const status = await previewBridge.automation.status(tabId); | |
| 71 | if (status.available) return; | |
| 72 | } | |
| 73 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 74 | } | |
| 75 | throw new PreviewAutomationOverlayTimeoutError({ | |
| 76 | requestId, | |
| 77 | environmentId: threadRef.environmentId, | |
| 78 | threadId: threadRef.threadId, | |
| 79 | timeoutMs, | |
| 80 | }); | |
| 81 | }; | |
| 82 | ||
| 83 | const waitForNavigationReadiness = async ( | |
| 84 | threadRef: ScopedThreadRef, | |
| 85 | requestId: string, | |
| 86 | tabId: string, | |
| 87 | readiness: PreviewAutomationNavigateInput["readiness"], | |
| 88 | timeoutMs: number, | |
| 89 | ): Promise<void> => { | |
| 90 | const targetReadiness = readiness ?? "load"; | |
| 91 | if (!previewBridge || targetReadiness === "none") return; | |
| 92 | const deadline = Date.now() + timeoutMs; | |
| 93 | while (Date.now() <= deadline) { | |
| 94 | if (targetReadiness === "domContentLoaded") { | |
| 95 | const readyState = await previewBridge.automation.evaluate(tabId, { | |
| 96 | expression: "document.readyState", | |
| 97 | }); | |
| 98 | if (readyState === "interactive" || readyState === "complete") return; | |
| 99 | } else { | |
| 100 | const status = await previewBridge.automation.status(tabId); | |
| 101 | if (!status.loading) return; | |
| 102 | } | |
| 103 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 104 | } | |
| 105 | throw new PreviewAutomationNavigationTimeoutError({ | |
| 106 | requestId, | |
| 107 | environmentId: threadRef.environmentId, | |
| 108 | threadId: threadRef.threadId, | |
| 109 | tabId, | |
| 110 | readiness: targetReadiness, | |
| 111 | timeoutMs, | |
| 112 | }); | |
| 113 | }; | |
| 114 | ||
| 115 | const currentStatus = async ( | |
| 116 | threadRef: ScopedThreadRef, | |
| 117 | visible: boolean, | |
| 118 | ): Promise<PreviewAutomationStatus> => { | |
| 119 | const state = readThreadPreviewState(threadRef); | |
| 120 | const tabId = state.snapshot?.tabId ?? null; | |
| 121 | if (tabId && previewBridge && state.desktopOverlay) { | |
| 122 | const status = await previewBridge.automation.status(tabId); | |
| 123 | return { ...status, visible }; | |
| 124 | } | |
| 125 | const navStatus = state.snapshot?.navStatus; | |
| 126 | return { | |
| 127 | available: Boolean(previewBridge?.automation), | |
| 128 | visible, | |
| 129 | tabId, | |
| 130 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, | |
| 131 | title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, | |
| 132 | loading: navStatus?._tag === "Loading", | |
| 133 | }; | |
| 134 | }; | |
| 135 | ||
| 136 | export function PreviewAutomationOwner(props: { | |
| 137 | readonly threadRef: ScopedThreadRef; | |
| 138 | readonly visible: boolean; | |
| 139 | }) { | |
| 140 | const { threadRef, visible } = props; | |
| 141 | const automationClientId = useId(); | |
| 142 | const initialAutomationOwner = useMemo<PreviewAutomationOwnerState>( | |
| 143 | () => ({ | |
| 144 | clientId: automationClientId, | |
| 145 | environmentId: threadRef.environmentId, | |
| 146 | threadId: threadRef.threadId, | |
| 147 | tabId: null, | |
| 148 | visible: false, | |
| 149 | supportsAutomation: Boolean(previewBridge?.automation), | |
| 150 | focusedAt: new Date().toISOString(), | |
| 151 | }), | |
| 152 | [automationClientId, threadRef.environmentId, threadRef.threadId], | |
| 153 | ); | |
| 154 | const automationRequestsAtom = previewEnvironment.automationRequests({ | |
| 155 | environmentId: threadRef.environmentId, | |
| 156 | input: initialAutomationOwner, | |
| 157 | }); | |
| 158 | const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data; | |
| 159 | const connectedGeneration = | |
| 160 | connectionState?.phase === "connected" ? connectionState.generation : null; | |
| 161 | const open = useAtomCommand(previewEnvironment.open, { | |
| 162 | reportFailure: false, | |
| 163 | }); | |
| 164 | const respondToAutomation = useAtomCommand( | |
| 165 | previewEnvironment.respondToAutomation, | |
| 166 | "preview automation response", | |
| 167 | ); | |
| 168 | const reportAutomationOwner = useAtomCommand( | |
| 169 | previewEnvironment.reportAutomationOwner, | |
| 170 | "preview automation owner report", | |
| 171 | ); | |
| 172 | const clearAutomationOwner = useAtomCommand( | |
| 173 | previewEnvironment.clearAutomationOwner, | |
| 174 | "preview automation owner clear", | |
| 175 | ); | |
| 176 | const connectedGenerationRef = useRef<number | null>(null); | |
| 177 | const reportCurrentAutomationOwner = useEffectEvent(() => { | |
| 178 | const state = readThreadPreviewState(threadRef); | |
| 179 | return reportAutomationOwner({ | |
| 180 | environmentId: threadRef.environmentId, | |
| 181 | input: { | |
| 182 | clientId: automationClientId, | |
| 183 | environmentId: threadRef.environmentId, | |
| 184 | threadId: threadRef.threadId, | |
| 185 | tabId: state.snapshot?.tabId ?? null, | |
| 186 | visible, | |
| 187 | supportsAutomation: Boolean(previewBridge?.automation), | |
| 188 | focusedAt: new Date().toISOString(), | |
| 189 | }, | |
| 190 | }); | |
| 191 | }); | |
| 192 | useEffect(() => { | |
| 193 | void reportCurrentAutomationOwner(); | |
| 194 | }, [threadRef, visible]); | |
| 195 | ||
| 196 | const handleRequest = useCallback( | |
| 197 | async (request: PreviewAutomationRequest): Promise<unknown> => { | |
| 198 | let tabId = request.tabId ?? null; | |
| 199 | try { | |
| 200 | if (request.threadId !== threadRef.threadId) { | |
| 201 | throw new PreviewAutomationStaleOwnerError({ | |
| 202 | requestId: request.requestId, | |
| 203 | environmentId: threadRef.environmentId, | |
| 204 | expectedThreadId: threadRef.threadId, | |
| 205 | requestedThreadId: request.threadId, | |
| 206 | }); | |
| 207 | } | |
| 208 | const state = readThreadPreviewState(threadRef); | |
| 209 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 210 | const unavailableTarget = { | |
| 211 | requestId: request.requestId, | |
| 212 | operation: request.operation, | |
| 213 | environmentId: threadRef.environmentId, | |
| 214 | threadId: threadRef.threadId, | |
| 215 | tabId, | |
| 216 | bridgeAvailable: Boolean(previewBridge), | |
| 217 | }; | |
| 218 | switch (request.operation) { | |
| 219 | case "status": | |
| 220 | return await currentStatus(threadRef, visible); | |
| 221 | case "open": { | |
| 222 | const input = request.input as PreviewAutomationOpenInput; | |
| 223 | let activeTabId = | |
| 224 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 225 | tabId = activeTabId; | |
| 226 | if (!activeTabId) { | |
| 227 | const result = await open({ | |
| 228 | environmentId: threadRef.environmentId, | |
| 229 | input: { | |
| 230 | threadId: threadRef.threadId, | |
| 231 | ...(input.url ? { url: input.url } : {}), | |
| 232 | }, | |
| 233 | }); | |
| 234 | if (result._tag === "Failure") { | |
| 235 | throw squashAtomCommandFailure(result); | |
| 236 | } | |
| 237 | const snapshot = result.value; | |
| 238 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 239 | activeTabId = snapshot.tabId; | |
| 240 | tabId = activeTabId; | |
| 241 | } else if (input.url && previewBridge) { | |
| 242 | await previewBridge.navigate(activeTabId, input.url); | |
| 243 | } | |
| 244 | if (input.show ?? true) { | |
| 245 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | |
| 246 | } | |
| 247 | await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); | |
| 248 | return await currentStatus(threadRef, input.show ?? true); | |
| 249 | } | |
| 250 | case "navigate": { | |
| 251 | if (!previewBridge || !tabId) { | |
| 252 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 253 | } | |
| 254 | const input = request.input as PreviewAutomationNavigateInput; | |
| 255 | const resolution = resolveBrowserNavigationTarget( | |
| 256 | threadRef.environmentId, | |
| 257 | input.target ?? { kind: "url", url: input.url! }, | |
| 258 | ); | |
| 259 | await previewBridge.navigate(tabId, resolution.resolvedUrl); | |
| 260 | await waitForNavigationReadiness( | |
| 261 | threadRef, | |
| 262 | request.requestId, | |
| 263 | tabId, | |
| 264 | input.readiness ?? "load", | |
| 265 | input.timeoutMs ?? request.timeoutMs, | |
| 266 | ); | |
| 267 | return await currentStatus(threadRef, visible); | |
| 268 | } | |
| 269 | case "snapshot": | |
| 270 | if (!previewBridge || !tabId) { | |
| 271 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 272 | } | |
| 273 | return await previewBridge.automation.snapshot(tabId); | |
| 274 | case "click": | |
| 275 | if (!previewBridge || !tabId) { | |
| 276 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 277 | } | |
| 278 | return await previewBridge.automation.click( | |
| 279 | tabId, | |
| 280 | request.input as Parameters<typeof previewBridge.automation.click>[1], | |
| 281 | ); | |
| 282 | case "type": | |
| 283 | if (!previewBridge || !tabId) { | |
| 284 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 285 | } | |
| 286 | return await previewBridge.automation.type( | |
| 287 | tabId, | |
| 288 | request.input as Parameters<typeof previewBridge.automation.type>[1], | |
| 289 | ); | |
| 290 | case "press": | |
| 291 | if (!previewBridge || !tabId) { | |
| 292 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 293 | } | |
| 294 | return await previewBridge.automation.press( | |
| 295 | tabId, | |
| 296 | request.input as Parameters<typeof previewBridge.automation.press>[1], | |
| 297 | ); | |
| 298 | case "scroll": | |
| 299 | if (!previewBridge || !tabId) { | |
| 300 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 301 | } | |
| 302 | return await previewBridge.automation.scroll( | |
| 303 | tabId, | |
| 304 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], | |
| 305 | ); | |
| 306 | case "evaluate": | |
| 307 | if (!previewBridge || !tabId) { | |
| 308 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 309 | } | |
| 310 | return await previewBridge.automation.evaluate( | |
| 311 | tabId, | |
| 312 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], | |
| 313 | ); | |
| 314 | case "waitFor": | |
| 315 | if (!previewBridge || !tabId) { | |
| 316 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 317 | } | |
| 318 | return await previewBridge.automation.waitFor( | |
| 319 | tabId, | |
| 320 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | |
| 321 | ); | |
| 322 | case "recordingStart": { | |
| 323 | if (!tabId) { | |
| 324 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 325 | } | |
| 326 | const startedAt = await startBrowserRecording(tabId); | |
| 327 | return { | |
| 328 | tabId, | |
| 329 | recording: true, | |
| 330 | startedAt, | |
| 331 | }; | |
| 332 | } | |
| 333 | case "recordingStop": { | |
| 334 | if (!tabId) { | |
| 335 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 336 | } | |
| 337 | const artifact = await stopBrowserRecording(tabId); | |
| 338 | if (!artifact) { | |
| 339 | throw new PreviewAutomationRecordingNotActiveError({ | |
| 340 | requestId: request.requestId, | |
| 341 | environmentId: threadRef.environmentId, | |
| 342 | threadId: threadRef.threadId, | |
| 343 | tabId, | |
| 344 | }); | |
| 345 | } | |
| 346 | return artifact; | |
| 347 | } | |
| 348 | } | |
| 349 | } catch (cause) { | |
| 350 | throw PreviewAutomationOperationError.fromCause({ | |
| 351 | requestId: request.requestId, | |
| 352 | operation: request.operation, | |
| 353 | environmentId: threadRef.environmentId, | |
| 354 | threadId: threadRef.threadId, | |
| 355 | tabId, | |
| 356 | cause, | |
| 357 | }); | |
| 358 | } | |
| 359 | }, | |
| 360 | [open, threadRef, visible], | |
| 361 | ); | |
| 362 | const [requestHandler] = useState(() => | |
| 363 | createLatestPreviewAutomationRequestHandler(handleRequest), | |
| 364 | ); | |
| 365 | useEffect(() => { | |
| 366 | requestHandler.set(handleRequest); | |
| 367 | }, [handleRequest, requestHandler]); | |
| 368 | ||
| 369 | const automationRequestConsumerAtom = useMemo( | |
| 370 | () => | |
| 371 | createPreviewAutomationRequestConsumerAtom({ | |
| 372 | requestsAtom: automationRequestsAtom, | |
| 373 | environmentId: threadRef.environmentId, | |
| 374 | handleRequest: requestHandler.handle, | |
| 375 | respond: (response) => | |
| 376 | respondToAutomation({ | |
| 377 | environmentId: threadRef.environmentId, | |
| 378 | input: response, | |
| 379 | }), | |
| 380 | label: `preview:automation-request-consumer:${automationClientId}`, | |
| 381 | }), | |
| 382 | [ | |
| 383 | automationClientId, | |
| 384 | automationRequestsAtom, | |
| 385 | requestHandler, | |
| 386 | respondToAutomation, | |
| 387 | threadRef.environmentId, | |
| 388 | ], | |
| 389 | ); | |
| 390 | useAtomValue(automationRequestConsumerAtom); | |
| 391 | ||
| 392 | useEffect(() => { | |
| 393 | const observation = observeAutomationOwnerConnectedGeneration( | |
| 394 | connectedGenerationRef.current, | |
| 395 | connectedGeneration, | |
| 396 | ); | |
| 397 | connectedGenerationRef.current = observation.nextGeneration; | |
| 398 | if (!observation.shouldReport) return; | |
| 399 | ||
| 400 | void reportCurrentAutomationOwner(); | |
| 401 | }, [connectedGeneration]); | |
| 402 | ||
| 403 | useEffect(() => { | |
| 404 | const report = () => void reportCurrentAutomationOwner(); | |
| 405 | window.addEventListener("focus", report); | |
| 406 | const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { | |
| 407 | if (state.snapshot?.tabId !== previous.snapshot?.tabId) { | |
| 408 | report(); | |
| 409 | } | |
| 410 | }); | |
| 411 | return () => { | |
| 412 | window.removeEventListener("focus", report); | |
| 413 | unsubscribe(); | |
| 414 | void clearAutomationOwner({ | |
| 415 | environmentId: threadRef.environmentId, | |
| 416 | input: { | |
| 417 | clientId: automationClientId, | |
| 418 | environmentId: threadRef.environmentId, | |
| 419 | threadId: threadRef.threadId, | |
| 420 | }, | |
| 421 | }); | |
| 422 | }; | |
| 423 | }, [automationClientId, clearAutomationOwner, threadRef]); | |
| 424 | ||
| 425 | return null; | |
| 426 | } |
apps/web/src/components/preview/PreviewMoreMenu.tsx+15-1| ... | ... | @@ -19,6 +19,10 @@ interface Props { |
| 19 | 19 | hasWebContents: boolean; |
| 20 | 20 | /** Current zoom factor as a number (1.0 = 100%). */ |
| 21 | 21 | zoomFactor: number; |
| 22 | /** Fixed viewport modes expose the device toolbar and resize rails. */ | |
| 23 | deviceToolbarVisible: boolean; | |
| 24 | /** Switches between fill-panel mode and a fixed responsive viewport. */ | |
| 25 | onToggleDeviceToolbar: () => void; | |
| 22 | 26 | } |
| 23 | 27 | |
| 24 | 28 | /** |
| ... | ... | @@ -26,7 +30,13 @@ interface Props { |
| 26 | 30 | * controls, and storage-clearing actions. Only mounted by `PreviewView` |
| 27 | 31 | * when the desktop bridge is present, so we can call it unconditionally. |
| 28 | 32 | */ |
| 29 | export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { | |
| 33 | export function PreviewMoreMenu({ | |
| 34 | tabId, | |
| 35 | hasWebContents, | |
| 36 | zoomFactor, | |
| 37 | deviceToolbarVisible, | |
| 38 | onToggleDeviceToolbar, | |
| 39 | }: Props) { | |
| 30 | 40 | if (!previewBridge) return null; |
| 31 | 41 | const bridge = previewBridge; |
| 32 | 42 | const tabDisabled = !tabId || !hasWebContents; |
| ... | ... | @@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { |
| 59 | 69 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> |
| 60 | 70 | Open DevTools |
| 61 | 71 | </MenuItem> |
| 72 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> | |
| 73 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} | |
| 74 | </MenuItem> | |
| 75 | <MenuSeparator /> | |
| 62 | 76 | {/* |
| 63 | 77 | Zoom row: label + inline control cluster. `closeOnClick=false` |
| 64 | 78 | keeps the menu open while the user clicks the +/− buttons. |
apps/web/src/components/preview/PreviewView.tsx+69-2| ... | ... | @@ -1,13 +1,22 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { type ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 5 | import { | |
| 6 | FILL_PREVIEW_VIEWPORT, | |
| 7 | type PreviewViewportSetting, | |
| 8 | type ScopedThreadRef, | |
| 9 | } from "@t3tools/contracts"; | |
| 5 | 10 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 11 | |
| 7 | 12 | import { useComposerDraftStore } from "~/composerDraftStore"; |
| 8 | 13 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 9 | 14 | import { ensureLocalApi } from "~/localApi"; |
| 10 | import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; | |
| 15 | import { | |
| 16 | rememberPreviewUrl, | |
| 17 | updatePreviewServerSnapshot, | |
| 18 | useThreadPreviewState, | |
| 19 | } from "~/previewStateStore"; | |
| 11 | 20 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 12 | 21 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 13 | 22 | import { previewEnvironment } from "~/state/preview"; |
| ... | ... | @@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 20 | 29 | import { formatPreviewUrl } from "./previewUrlPresentation"; |
| 21 | 30 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 22 | 31 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 32 | import { | |
| 33 | commitBrowserViewportChange, | |
| 34 | subscribeBrowserViewportChange, | |
| 35 | } from "~/browser/browserViewportActions"; | |
| 36 | import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; | |
| 23 | 37 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 24 | 38 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 25 | 39 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 26 | 40 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 41 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | |
| 27 | 42 | import { useLoadingProgress } from "./useLoadingProgress"; |
| 28 | 43 | import { usePreviewSession } from "./usePreviewSession"; |
| 29 | 44 | import { ZoomIndicator } from "./ZoomIndicator"; |
| ... | ... | @@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 60 | 75 | const environment = useEnvironment(threadRef.environmentId); |
| 61 | 76 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); |
| 62 | 77 | const open = useAtomCommand(previewEnvironment.open); |
| 78 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); | |
| 63 | 79 | |
| 64 | 80 | usePreviewSession(threadRef); |
| 65 | 81 | |
| ... | ... | @@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 91 | 107 | environmentHttpBaseUrl, |
| 92 | 108 | }) ?? undefined) |
| 93 | 109 | : undefined; |
| 110 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; | |
| 111 | const panelRect = useBrowserSurfaceStore((state) => | |
| 112 | tabId ? (state.byTabId[tabId]?.rect ?? null) : null, | |
| 113 | ); | |
| 94 | 114 | |
| 95 | 115 | const handleSubmitUrl = useCallback( |
| 96 | 116 | async (next: string) => { |
| ... | ... | @@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 131 | 151 | if (previewBridge && tabId) void previewBridge.resetZoom(tabId); |
| 132 | 152 | }, [tabId]); |
| 133 | 153 | |
| 154 | const handleViewportChange = useCallback( | |
| 155 | async (nextViewport: PreviewViewportSetting) => { | |
| 156 | if (!tabId) return; | |
| 157 | const result = await resize({ | |
| 158 | environmentId: threadRef.environmentId, | |
| 159 | input: { | |
| 160 | threadId: threadRef.threadId, | |
| 161 | tabId, | |
| 162 | viewport: nextViewport, | |
| 163 | }, | |
| 164 | }); | |
| 165 | if (result._tag === "Failure") { | |
| 166 | const error = squashAtomCommandFailure(result); | |
| 167 | toastManager.add({ | |
| 168 | type: "error", | |
| 169 | title: "Unable to resize browser viewport", | |
| 170 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 171 | }); | |
| 172 | throw error; | |
| 173 | } | |
| 174 | updatePreviewServerSnapshot(threadRef, result.value); | |
| 175 | }, | |
| 176 | [resize, tabId, threadRef], | |
| 177 | ); | |
| 178 | ||
| 179 | const handleToggleDeviceToolbar = () => { | |
| 180 | if (!tabId) return; | |
| 181 | if (viewport._tag !== "fill") { | |
| 182 | void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); | |
| 183 | return; | |
| 184 | } | |
| 185 | ||
| 186 | const responsiveSize = panelRect | |
| 187 | ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) | |
| 188 | : { width: 1024, height: 768 }; | |
| 189 | void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch( | |
| 190 | () => undefined, | |
| 191 | ); | |
| 192 | }; | |
| 193 | ||
| 194 | useEffect(() => { | |
| 195 | if (!tabId) return; | |
| 196 | return subscribeBrowserViewportChange(tabId, handleViewportChange); | |
| 197 | }, [handleViewportChange, tabId]); | |
| 198 | ||
| 134 | 199 | const handleBack = useCallback(() => { |
| 135 | 200 | if (previewBridge && tabId) void previewBridge.goBack(tabId); |
| 136 | 201 | }, [tabId]); |
| ... | ... | @@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 527 | 592 | tabId={tabId} |
| 528 | 593 | hasWebContents={desktopOverlay !== null} |
| 529 | 594 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
| 595 | deviceToolbarVisible={viewport._tag !== "fill"} | |
| 596 | onToggleDeviceToolbar={handleToggleDeviceToolbar} | |
| 530 | 597 | /> |
| 531 | 598 | ) : null |
| 532 | 599 | } |
apps/web/src/components/preview/previewAutomationClientId.test.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; | |
| 4 | ||
| 5 | describe("createPreviewAutomationClientId", () => { | |
| 6 | it("creates bounded cryptographically random identities for independent host lifetimes", () => { | |
| 7 | const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); | |
| 8 | ||
| 9 | expect(new Set(clientIds).size).toBe(clientIds.length); | |
| 10 | expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true); | |
| 11 | expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true); | |
| 12 | }); | |
| 13 | }); |
apps/web/src/components/preview/previewAutomationClientId.ts created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | export function createPreviewAutomationClientId(): string { | |
| 2 | const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); | |
| 3 | return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; | |
| 4 | } |
apps/web/src/components/preview/previewAutomationErrors.ts+85-19| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | type PreviewAutomationOwner, | |
| 3 | type PreviewAutomationHost, | |
| 4 | 4 | PreviewAutomationOperation, |
| 5 | 5 | type PreviewAutomationRequest, |
| 6 | 6 | type PreviewAutomationResponse, |
| ... | ... | @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; |
| 13 | 13 | export interface PreviewAutomationOperationContext { |
| 14 | 14 | readonly requestId: PreviewAutomationRequest["requestId"]; |
| 15 | 15 | readonly operation: PreviewAutomationRequest["operation"]; |
| 16 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | |
| 16 | readonly environmentId: PreviewAutomationHost["environmentId"]; | |
| 17 | 17 | readonly threadId: PreviewAutomationRequest["threadId"]; |
| 18 | 18 | readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null; |
| 19 | 19 | } |
| ... | ... | @@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC |
| 56 | 56 | } |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()( | |
| 60 | "PreviewAutomationStaleOwnerError", | |
| 59 | export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationViewportTimeoutError>()( | |
| 60 | "PreviewAutomationViewportTimeoutError", | |
| 61 | 61 | { |
| 62 | 62 | requestId: TrimmedNonEmptyString, |
| 63 | 63 | environmentId: EnvironmentId, |
| 64 | expectedThreadId: ThreadId, | |
| 65 | requestedThreadId: ThreadId, | |
| 64 | threadId: ThreadId, | |
| 65 | tabId: PreviewTabId, | |
| 66 | timeoutMs: Schema.Int, | |
| 66 | 67 | }, |
| 67 | 68 | ) { |
| 68 | 69 | get responseTag() { |
| 69 | return "PreviewAutomationUnavailableError" as const; | |
| 70 | return "PreviewAutomationTimeoutError" as const; | |
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | override get message(): string { |
| 73 | return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; | |
| 74 | return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`; | |
| 74 | 75 | } |
| 75 | 76 | } |
| 76 | 77 | |
| ... | ... | @@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError |
| 100 | 101 | requestId: TrimmedNonEmptyString, |
| 101 | 102 | environmentId: EnvironmentId, |
| 102 | 103 | threadId: ThreadId, |
| 103 | tabId: PreviewTabId, | |
| 104 | tabId: Schema.NullOr(PreviewTabId), | |
| 104 | 105 | }, |
| 105 | 106 | ) { |
| 106 | 107 | get responseTag() { |
| ... | ... | @@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError |
| 108 | 109 | } |
| 109 | 110 | |
| 110 | 111 | override get message(): string { |
| 111 | return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; | |
| 112 | return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`; | |
| 112 | 113 | } |
| 113 | 114 | } |
| 114 | 115 | |
| 116 | export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableHostError>()( | |
| 117 | "PreviewAutomationTargetNotEditableHostError", | |
| 118 | { | |
| 119 | requestId: TrimmedNonEmptyString, | |
| 120 | operation: PreviewAutomationOperation, | |
| 121 | environmentId: EnvironmentId, | |
| 122 | threadId: ThreadId, | |
| 123 | tabId: Schema.NullOr(PreviewTabId), | |
| 124 | selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), | |
| 125 | selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | |
| 126 | }, | |
| 127 | ) { | |
| 128 | get responseTag() { | |
| 129 | return "PreviewAutomationTargetNotEditableError" as const; | |
| 130 | } | |
| 131 | ||
| 132 | override get message(): string { | |
| 133 | return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`; | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | const targetNotEditableDiagnostics = ( | |
| 138 | cause: unknown, | |
| 139 | ): { | |
| 140 | readonly selectorKind?: "focused-element" | "locator" | "selector"; | |
| 141 | readonly selectorLength?: number; | |
| 142 | } | null => { | |
| 143 | if ( | |
| 144 | typeof cause !== "object" || | |
| 145 | cause === null || | |
| 146 | !("_tag" in cause) || | |
| 147 | cause._tag !== "PreviewAutomationTargetNotEditableError" | |
| 148 | ) { | |
| 149 | return null; | |
| 150 | } | |
| 151 | const selectorKind = | |
| 152 | "selectorKind" in cause && | |
| 153 | (cause.selectorKind === "focused-element" || | |
| 154 | cause.selectorKind === "locator" || | |
| 155 | cause.selectorKind === "selector") | |
| 156 | ? cause.selectorKind | |
| 157 | : undefined; | |
| 158 | const selectorLength = | |
| 159 | "selectorLength" in cause && | |
| 160 | typeof cause.selectorLength === "number" && | |
| 161 | Number.isInteger(cause.selectorLength) && | |
| 162 | cause.selectorLength >= 0 | |
| 163 | ? cause.selectorLength | |
| 164 | : undefined; | |
| 165 | return { | |
| 166 | ...(selectorKind === undefined ? {} : { selectorKind }), | |
| 167 | ...(selectorLength === undefined ? {} : { selectorLength }), | |
| 168 | }; | |
| 169 | }; | |
| 170 | ||
| 115 | 171 | export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()( |
| 116 | 172 | "PreviewAutomationOperationError", |
| 117 | 173 | { |
| ... | ... | @@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre |
| 125 | 181 | ) { |
| 126 | 182 | static fromCause( |
| 127 | 183 | input: PreviewAutomationOperationContext & { readonly cause: unknown }, |
| 128 | ): PreviewAutomationOwnerError { | |
| 129 | return isPreviewAutomationOwnerError(input.cause) | |
| 130 | ? input.cause | |
| 184 | ): PreviewAutomationHostError { | |
| 185 | if (isPreviewAutomationHostError(input.cause)) return input.cause; | |
| 186 | const diagnostics = targetNotEditableDiagnostics(input.cause); | |
| 187 | return diagnostics | |
| 188 | ? new PreviewAutomationTargetNotEditableHostError({ | |
| 189 | requestId: input.requestId, | |
| 190 | operation: input.operation, | |
| 191 | environmentId: input.environmentId, | |
| 192 | threadId: input.threadId, | |
| 193 | tabId: input.tabId, | |
| 194 | ...diagnostics, | |
| 195 | }) | |
| 131 | 196 | : new PreviewAutomationOperationError(input); |
| 132 | 197 | } |
| 133 | 198 | |
| ... | ... | @@ -140,20 +205,21 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre |
| 140 | 205 | } |
| 141 | 206 | } |
| 142 | 207 | |
| 143 | export const PreviewAutomationOwnerError = Schema.Union([ | |
| 208 | export const PreviewAutomationHostError = Schema.Union([ | |
| 144 | 209 | PreviewAutomationOverlayTimeoutError, |
| 145 | 210 | PreviewAutomationNavigationTimeoutError, |
| 146 | PreviewAutomationStaleOwnerError, | |
| 211 | PreviewAutomationViewportTimeoutError, | |
| 147 | 212 | PreviewAutomationTargetUnavailableError, |
| 148 | 213 | PreviewAutomationRecordingNotActiveError, |
| 214 | PreviewAutomationTargetNotEditableHostError, | |
| 149 | 215 | PreviewAutomationOperationError, |
| 150 | 216 | ]); |
| 151 | export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; | |
| 217 | export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; | |
| 152 | 218 | |
| 153 | export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); | |
| 219 | export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); | |
| 154 | 220 | |
| 155 | export function serializePreviewAutomationOwnerError( | |
| 156 | error: PreviewAutomationOwnerError, | |
| 221 | export function serializePreviewAutomationHostError( | |
| 222 | error: PreviewAutomationHostError, | |
| 157 | 223 | ): NonNullable<PreviewAutomationResponse["error"]> { |
| 158 | 224 | const detail = Object.fromEntries( |
| 159 | 225 | Object.entries(error).filter( |
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+226-15| ... | ... | @@ -2,13 +2,18 @@ import { |
| 2 | 2 | EnvironmentId, |
| 3 | 3 | type PreviewAutomationRequest, |
| 4 | 4 | type PreviewAutomationResponse, |
| 5 | type PreviewAutomationStreamEvent, | |
| 5 | 6 | PreviewTabId, |
| 6 | 7 | ThreadId, |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 9 | 10 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 11 | |
| 11 | import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; | |
| 12 | import { | |
| 13 | PreviewAutomationRecordingNotActiveError, | |
| 14 | PreviewAutomationTargetUnavailableError, | |
| 15 | PreviewAutomationViewportTimeoutError, | |
| 16 | } from "./previewAutomationErrors"; | |
| 12 | 17 | import { |
| 13 | 18 | createPreviewAutomationRequestConsumerAtom, |
| 14 | 19 | serializePreviewAutomationError, |
| ... | ... | @@ -17,6 +22,8 @@ import { |
| 17 | 22 | const environmentId = EnvironmentId.make("environment-1"); |
| 18 | 23 | const threadId = ThreadId.make("thread-1"); |
| 19 | 24 | const tabId = PreviewTabId.make("tab-1"); |
| 25 | const clientId = "client-1"; | |
| 26 | const connectionId = "connection-1"; | |
| 20 | 27 | |
| 21 | 28 | const request = ( |
| 22 | 29 | requestId: string, |
| ... | ... | @@ -30,10 +37,88 @@ const request = ( |
| 30 | 37 | ...overrides, |
| 31 | 38 | }); |
| 32 | 39 | |
| 40 | const requestEvent = ( | |
| 41 | requestId: string, | |
| 42 | overrides: Partial<PreviewAutomationRequest> = {}, | |
| 43 | eventConnectionId = connectionId, | |
| 44 | ): PreviewAutomationStreamEvent => ({ | |
| 45 | type: "request", | |
| 46 | connectionId: eventConnectionId, | |
| 47 | request: request(requestId, overrides), | |
| 48 | }); | |
| 49 | ||
| 50 | const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>) => ({ | |
| 51 | connectionAtom: Atom.make<string | null>(null), | |
| 52 | requestHandlerAtom: Atom.make({ handle: handleRequest }), | |
| 53 | }); | |
| 54 | ||
| 33 | 55 | describe("previewAutomationRequestConsumer", () => { |
| 56 | it("acknowledges a replacement stream before consuming requests from it", async () => { | |
| 57 | const requestsAtom = Atom.make( | |
| 58 | AsyncResult.success<PreviewAutomationStreamEvent, Error>({ | |
| 59 | type: "connected", | |
| 60 | connectionId, | |
| 61 | }), | |
| 62 | ); | |
| 63 | const handleRequest = vi.fn(async () => undefined); | |
| 64 | const respond = vi.fn(async () => undefined); | |
| 65 | const state = consumerState(handleRequest); | |
| 66 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | |
| 67 | requestsAtom, | |
| 68 | clientId, | |
| 69 | connectionAtom: state.connectionAtom, | |
| 70 | environmentId, | |
| 71 | requestHandlerAtom: state.requestHandlerAtom, | |
| 72 | respond, | |
| 73 | label: "test:preview-automation-connected", | |
| 74 | }); | |
| 75 | const registry = AtomRegistry.make(); | |
| 76 | ||
| 77 | registry.mount(consumerAtom); | |
| 78 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect"))); | |
| 79 | ||
| 80 | await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId)); | |
| 81 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); | |
| 82 | expect(handleRequest).toHaveBeenCalledTimes(1); | |
| 83 | registry.dispose(); | |
| 84 | }); | |
| 85 | ||
| 86 | it("drops late requests from an older stream generation", async () => { | |
| 87 | const requestsAtom = Atom.make( | |
| 88 | AsyncResult.success<PreviewAutomationStreamEvent, Error>({ | |
| 89 | type: "connected", | |
| 90 | connectionId: "connection-2", | |
| 91 | }), | |
| 92 | ); | |
| 93 | const handleRequest = vi.fn(async () => undefined); | |
| 94 | const respond = vi.fn(async () => undefined); | |
| 95 | const state = consumerState(handleRequest); | |
| 96 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | |
| 97 | requestsAtom, | |
| 98 | clientId, | |
| 99 | connectionAtom: state.connectionAtom, | |
| 100 | environmentId, | |
| 101 | requestHandlerAtom: state.requestHandlerAtom, | |
| 102 | respond, | |
| 103 | label: "test:preview-automation-stale-generation", | |
| 104 | }); | |
| 105 | const registry = AtomRegistry.make(); | |
| 106 | ||
| 107 | registry.mount(consumerAtom); | |
| 108 | registry.set( | |
| 109 | requestsAtom, | |
| 110 | AsyncResult.success(requestEvent("request-stale", {}, "connection-1")), | |
| 111 | ); | |
| 112 | ||
| 113 | await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2")); | |
| 114 | expect(handleRequest).not.toHaveBeenCalled(); | |
| 115 | expect(respond).not.toHaveBeenCalled(); | |
| 116 | registry.dispose(); | |
| 117 | }); | |
| 118 | ||
| 34 | 119 | it("consumes every request emitted before React can render", async () => { |
| 35 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>( | |
| 36 | AsyncResult.initial<PreviewAutomationRequest, Error>(false), | |
| 120 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( | |
| 121 | AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false), | |
| 37 | 122 | ); |
| 38 | 123 | const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({ |
| 39 | 124 | requestId: value.requestId, |
| ... | ... | @@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => { |
| 42 | 127 | const respond = vi.fn(async (response: PreviewAutomationResponse) => { |
| 43 | 128 | responses.push(response); |
| 44 | 129 | }); |
| 130 | const state = consumerState(handleRequest); | |
| 45 | 131 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 46 | 132 | requestsAtom, |
| 133 | clientId, | |
| 134 | connectionAtom: state.connectionAtom, | |
| 47 | 135 | environmentId, |
| 48 | handleRequest, | |
| 136 | requestHandlerAtom: state.requestHandlerAtom, | |
| 49 | 137 | respond, |
| 50 | 138 | label: "test:preview-automation-consumer", |
| 51 | 139 | }); |
| 52 | 140 | const registry = AtomRegistry.make(); |
| 53 | 141 | registry.mount(consumerAtom); |
| 54 | 142 | |
| 55 | registry.set(requestsAtom, AsyncResult.success(request("request-1"))); | |
| 56 | registry.set(requestsAtom, AsyncResult.success(request("request-2"))); | |
| 143 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1"))); | |
| 144 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2"))); | |
| 57 | 145 | |
| 58 | 146 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); |
| 59 | 147 | expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([ |
| ... | ... | @@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => { |
| 64 | 152 | registry.dispose(); |
| 65 | 153 | }); |
| 66 | 154 | |
| 155 | it("uses the latest request handler without rebuilding the stream consumer", async () => { | |
| 156 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( | |
| 157 | AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false), | |
| 158 | ); | |
| 159 | const firstHandler = vi.fn(async () => "first"); | |
| 160 | const secondHandler = vi.fn(async () => "second"); | |
| 161 | const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); | |
| 162 | const state = consumerState(firstHandler); | |
| 163 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | |
| 164 | requestsAtom, | |
| 165 | clientId, | |
| 166 | connectionAtom: state.connectionAtom, | |
| 167 | environmentId, | |
| 168 | requestHandlerAtom: state.requestHandlerAtom, | |
| 169 | respond, | |
| 170 | label: "test:preview-automation-latest-handler", | |
| 171 | }); | |
| 172 | const registry = AtomRegistry.make(); | |
| 173 | registry.mount(consumerAtom); | |
| 174 | ||
| 175 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first"))); | |
| 176 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); | |
| 177 | registry.set(state.requestHandlerAtom, { handle: secondHandler }); | |
| 178 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second"))); | |
| 179 | ||
| 180 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); | |
| 181 | expect(firstHandler).toHaveBeenCalledTimes(1); | |
| 182 | expect(secondHandler).toHaveBeenCalledTimes(1); | |
| 183 | expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]); | |
| 184 | registry.dispose(); | |
| 185 | }); | |
| 186 | ||
| 67 | 187 | it("consumes a request that arrived immediately before the consumer mounted", async () => { |
| 68 | 188 | const requestsAtom = Atom.make( |
| 69 | AsyncResult.success<PreviewAutomationRequest, Error>(request("request-ready")), | |
| 189 | AsyncResult.success<PreviewAutomationStreamEvent, Error>(requestEvent("request-ready")), | |
| 70 | 190 | ); |
| 71 | 191 | const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); |
| 192 | const state = consumerState(async () => undefined); | |
| 72 | 193 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 73 | 194 | requestsAtom, |
| 195 | clientId, | |
| 196 | connectionAtom: state.connectionAtom, | |
| 74 | 197 | environmentId, |
| 75 | handleRequest: async () => undefined, | |
| 198 | requestHandlerAtom: state.requestHandlerAtom, | |
| 76 | 199 | respond, |
| 77 | 200 | label: "test:preview-automation-initial-request", |
| 78 | 201 | }); |
| ... | ... | @@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => { |
| 81 | 204 | registry.mount(consumerAtom); |
| 82 | 205 | |
| 83 | 206 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); |
| 84 | expect(respond).toHaveBeenCalledWith({ requestId: "request-ready", ok: true }); | |
| 207 | expect(respond).toHaveBeenCalledWith({ | |
| 208 | clientId, | |
| 209 | connectionId, | |
| 210 | requestId: "request-ready", | |
| 211 | ok: true, | |
| 212 | }); | |
| 85 | 213 | registry.dispose(); |
| 86 | 214 | }); |
| 87 | 215 | |
| ... | ... | @@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => { |
| 118 | 246 | }); |
| 119 | 247 | }); |
| 120 | 248 | |
| 249 | it("reports a missing recording even when no preview tab remains", () => { | |
| 250 | const error = new PreviewAutomationRecordingNotActiveError({ | |
| 251 | requestId: "request-recording-stop", | |
| 252 | environmentId, | |
| 253 | threadId, | |
| 254 | tabId: null, | |
| 255 | }); | |
| 256 | ||
| 257 | expect( | |
| 258 | serializePreviewAutomationError(error, { | |
| 259 | requestId: "request-recording-stop", | |
| 260 | operation: "recordingStop", | |
| 261 | environmentId, | |
| 262 | threadId, | |
| 263 | tabId: null, | |
| 264 | }), | |
| 265 | ).toMatchObject({ | |
| 266 | _tag: "PreviewAutomationExecutionError", | |
| 267 | detail: { tabId: null }, | |
| 268 | }); | |
| 269 | }); | |
| 270 | ||
| 271 | it("preserves viewport render timeouts as timeout responses", () => { | |
| 272 | const error = new PreviewAutomationViewportTimeoutError({ | |
| 273 | requestId: "request-resize", | |
| 274 | environmentId, | |
| 275 | threadId, | |
| 276 | tabId, | |
| 277 | timeoutMs: 2_500, | |
| 278 | }); | |
| 279 | ||
| 280 | expect( | |
| 281 | serializePreviewAutomationError(error, { | |
| 282 | requestId: "request-resize", | |
| 283 | operation: "resize", | |
| 284 | environmentId, | |
| 285 | threadId, | |
| 286 | tabId, | |
| 287 | }), | |
| 288 | ).toMatchObject({ | |
| 289 | _tag: "PreviewAutomationTimeoutError", | |
| 290 | detail: { tabId: "tab-1", timeoutMs: 2_500 }, | |
| 291 | }); | |
| 292 | }); | |
| 293 | ||
| 294 | it("maps desktop non-editable targets to the public typed response", () => { | |
| 295 | expect( | |
| 296 | serializePreviewAutomationError( | |
| 297 | { | |
| 298 | _tag: "PreviewAutomationTargetNotEditableError", | |
| 299 | tabId: "tab-1", | |
| 300 | selectorKind: "selector", | |
| 301 | selectorLength: 6, | |
| 302 | }, | |
| 303 | { | |
| 304 | requestId: "request-type", | |
| 305 | operation: "type", | |
| 306 | environmentId, | |
| 307 | threadId, | |
| 308 | tabId, | |
| 309 | }, | |
| 310 | ), | |
| 311 | ).toEqual({ | |
| 312 | _tag: "PreviewAutomationTargetNotEditableError", | |
| 313 | message: | |
| 314 | "Preview automation type request request-type requires an editable target in tab tab-1.", | |
| 315 | detail: { | |
| 316 | requestId: "request-type", | |
| 317 | operation: "type", | |
| 318 | environmentId: "environment-1", | |
| 319 | threadId: "thread-1", | |
| 320 | tabId: "tab-1", | |
| 321 | selectorKind: "selector", | |
| 322 | selectorLength: 6, | |
| 323 | }, | |
| 324 | }); | |
| 325 | }); | |
| 326 | ||
| 121 | 327 | it("correlates unexpected failures without exposing cause details", () => { |
| 122 | 328 | const cause = new Error("private bridge token: preview-secret"); |
| 123 | 329 | const context = { |
| ... | ... | @@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => { |
| 145 | 351 | }); |
| 146 | 352 | |
| 147 | 353 | it("sanitizes unexpected handler failures at the response boundary", async () => { |
| 148 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>( | |
| 149 | AsyncResult.initial<PreviewAutomationRequest, Error>(false), | |
| 354 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( | |
| 355 | AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false), | |
| 150 | 356 | ); |
| 151 | 357 | const responses: PreviewAutomationResponse[] = []; |
| 358 | const state = consumerState(async () => { | |
| 359 | throw new Error("desktop IPC secret: do-not-return"); | |
| 360 | }); | |
| 152 | 361 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 153 | 362 | requestsAtom, |
| 363 | clientId, | |
| 364 | connectionAtom: state.connectionAtom, | |
| 154 | 365 | environmentId, |
| 155 | handleRequest: async () => { | |
| 156 | throw new Error("desktop IPC secret: do-not-return"); | |
| 157 | }, | |
| 366 | requestHandlerAtom: state.requestHandlerAtom, | |
| 158 | 367 | respond: async (response) => { |
| 159 | 368 | responses.push(response); |
| 160 | 369 | }, |
| ... | ... | @@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => { |
| 166 | 375 | registry.set( |
| 167 | 376 | requestsAtom, |
| 168 | 377 | AsyncResult.success( |
| 169 | request("request-failed", { | |
| 378 | requestEvent("request-failed", { | |
| 170 | 379 | operation: "click", |
| 171 | 380 | tabId, |
| 172 | 381 | }), |
| ... | ... | @@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => { |
| 175 | 384 | |
| 176 | 385 | await vi.waitFor(() => expect(responses).toHaveLength(1)); |
| 177 | 386 | expect(responses[0]).toEqual({ |
| 387 | clientId, | |
| 388 | connectionId, | |
| 178 | 389 | requestId: "request-failed", |
| 179 | 390 | ok: false, |
| 180 | 391 | error: { |
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+76-42| ... | ... | @@ -1,87 +1,121 @@ |
| 1 | 1 | import type { |
| 2 | PreviewAutomationOwner, | |
| 2 | PreviewAutomationHost, | |
| 3 | 3 | PreviewAutomationRequest, |
| 4 | 4 | PreviewAutomationResponse, |
| 5 | PreviewAutomationStreamEvent, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | 8 | |
| 8 | 9 | import { |
| 9 | 10 | PreviewAutomationOperationError, |
| 10 | 11 | type PreviewAutomationOperationContext, |
| 11 | serializePreviewAutomationOwnerError, | |
| 12 | serializePreviewAutomationHostError, | |
| 12 | 13 | } from "./previewAutomationErrors"; |
| 13 | 14 | |
| 14 | type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>; | |
| 15 | type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>; | |
| 16 | ||
| 17 | export function createLatestPreviewAutomationRequestHandler(initial: AutomationRequestHandler): { | |
| 18 | readonly set: (handler: AutomationRequestHandler) => void; | |
| 19 | readonly handle: AutomationRequestHandler; | |
| 20 | } { | |
| 21 | let current = initial; | |
| 22 | return { | |
| 23 | set: (handler) => { | |
| 24 | current = handler; | |
| 25 | }, | |
| 26 | handle: (request) => current(request), | |
| 27 | }; | |
| 28 | } | |
| 15 | type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, E>; | |
| 29 | 16 | |
| 30 | 17 | export function serializePreviewAutomationError( |
| 31 | 18 | error: unknown, |
| 32 | 19 | context: PreviewAutomationOperationContext, |
| 33 | 20 | ): NonNullable<PreviewAutomationResponse["error"]> { |
| 34 | return serializePreviewAutomationOwnerError( | |
| 21 | return serializePreviewAutomationHostError( | |
| 35 | 22 | PreviewAutomationOperationError.fromCause({ ...context, cause: error }), |
| 36 | 23 | ); |
| 37 | 24 | } |
| 38 | 25 | |
| 39 | 26 | export function createPreviewAutomationRequestConsumerAtom<E>(options: { |
| 40 | readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>; | |
| 41 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | |
| 42 | readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>; | |
| 27 | readonly requestsAtom: Atom.Atom<AutomationStreamResult<E>>; | |
| 28 | readonly clientId: PreviewAutomationHost["clientId"]; | |
| 29 | readonly connectionAtom: Atom.Writable<PreviewAutomationStreamEvent["connectionId"] | null>; | |
| 30 | readonly environmentId: PreviewAutomationHost["environmentId"]; | |
| 31 | readonly requestHandlerAtom: Atom.Atom<{ | |
| 32 | readonly handle: (request: PreviewAutomationRequest) => Promise<unknown>; | |
| 33 | }>; | |
| 43 | 34 | readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>; |
| 44 | 35 | readonly label: string; |
| 45 | 36 | }): Atom.Atom<void> { |
| 46 | 37 | return Atom.make((get) => { |
| 38 | get.mount(options.connectionAtom); | |
| 39 | get.mount(options.requestHandlerAtom); | |
| 47 | 40 | let disposed = false; |
| 41 | let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; | |
| 42 | let connectionExplicitlyAnnounced = false; | |
| 43 | let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; | |
| 48 | 44 | let requestsVersion = 0; |
| 49 | 45 | |
| 50 | const consume = (result: AutomationRequestResult<E>) => { | |
| 46 | const consume = (result: AutomationStreamResult<E>) => { | |
| 51 | 47 | if (!AsyncResult.isSuccess(result)) return; |
| 52 | const request = result.value; | |
| 53 | void options.handleRequest(request).then( | |
| 54 | (value) => | |
| 55 | options.respond({ | |
| 56 | requestId: request.requestId, | |
| 57 | ok: true, | |
| 58 | ...(value === undefined ? {} : { result: value }), | |
| 59 | }), | |
| 60 | (error) => | |
| 61 | options.respond({ | |
| 62 | requestId: request.requestId, | |
| 63 | ok: false, | |
| 64 | error: serializePreviewAutomationError(error, { | |
| 48 | const event = result.value; | |
| 49 | if (event.type === "connected") { | |
| 50 | activeConnectionId = event.connectionId; | |
| 51 | connectionExplicitlyAnnounced = true; | |
| 52 | } else if (activeConnectionId === null) { | |
| 53 | activeConnectionId = event.connectionId; | |
| 54 | } else if (activeConnectionId !== event.connectionId) { | |
| 55 | if (connectionExplicitlyAnnounced) return; | |
| 56 | activeConnectionId = event.connectionId; | |
| 57 | } | |
| 58 | if (reportedConnectionId !== event.connectionId) { | |
| 59 | reportedConnectionId = event.connectionId; | |
| 60 | get.set(options.connectionAtom, event.connectionId); | |
| 61 | } | |
| 62 | if (event.type === "connected") { | |
| 63 | return; | |
| 64 | } | |
| 65 | const request = event.request; | |
| 66 | void get | |
| 67 | .once(options.requestHandlerAtom) | |
| 68 | .handle(request) | |
| 69 | .then( | |
| 70 | (value) => | |
| 71 | options.respond({ | |
| 72 | clientId: options.clientId, | |
| 73 | connectionId: event.connectionId, | |
| 74 | requestId: request.requestId, | |
| 75 | ok: true, | |
| 76 | ...(value === undefined ? {} : { result: value }), | |
| 77 | }), | |
| 78 | (error) => | |
| 79 | options.respond({ | |
| 80 | clientId: options.clientId, | |
| 81 | connectionId: event.connectionId, | |
| 65 | 82 | requestId: request.requestId, |
| 66 | operation: request.operation, | |
| 67 | environmentId: options.environmentId, | |
| 68 | threadId: request.threadId, | |
| 69 | tabId: request.tabId ?? null, | |
| 83 | ok: false, | |
| 84 | error: serializePreviewAutomationError(error, { | |
| 85 | requestId: request.requestId, | |
| 86 | operation: request.operation, | |
| 87 | environmentId: options.environmentId, | |
| 88 | threadId: request.threadId, | |
| 89 | tabId: request.tabId ?? null, | |
| 90 | }), | |
| 70 | 91 | }), |
| 71 | }), | |
| 72 | ); | |
| 92 | ); | |
| 73 | 93 | }; |
| 74 | 94 | |
| 75 | 95 | get.addFinalizer(() => { |
| 76 | 96 | disposed = true; |
| 77 | 97 | }); |
| 78 | 98 | const initialRequest = get.once(options.requestsAtom); |
| 99 | if (AsyncResult.isSuccess(initialRequest)) { | |
| 100 | activeConnectionId = initialRequest.value.connectionId; | |
| 101 | connectionExplicitlyAnnounced = initialRequest.value.type === "connected"; | |
| 102 | if (initialRequest.value.type === "connected") { | |
| 103 | reportedConnectionId = initialRequest.value.connectionId; | |
| 104 | get.set(options.connectionAtom, initialRequest.value.connectionId); | |
| 105 | } | |
| 106 | } | |
| 79 | 107 | get.subscribe(options.requestsAtom, (result) => { |
| 80 | 108 | requestsVersion += 1; |
| 81 | 109 | consume(result); |
| 82 | 110 | }); |
| 83 | 111 | queueMicrotask(() => { |
| 84 | if (!disposed && requestsVersion === 0) consume(initialRequest); | |
| 112 | const initialConnectionWasSkipped = | |
| 113 | AsyncResult.isSuccess(initialRequest) && | |
| 114 | initialRequest.value.connectionId === activeConnectionId && | |
| 115 | initialRequest.value.connectionId !== reportedConnectionId; | |
| 116 | if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) { | |
| 117 | consume(initialRequest); | |
| 118 | } | |
| 85 | 119 | }); |
| 86 | 120 | }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label)); |
| 87 | 121 | } |
apps/web/src/components/preview/previewAutomationTarget.test.ts created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | needsPreviewAutomationSessionSync, | |
| 6 | resolvePreviewAutomationTarget, | |
| 7 | } from "./previewAutomationTarget"; | |
| 8 | ||
| 9 | const snapshot = (tabId: string): PreviewSessionSnapshot => ({ | |
| 10 | threadId: "thread-1", | |
| 11 | tabId, | |
| 12 | navStatus: { _tag: "Idle" }, | |
| 13 | canGoBack: false, | |
| 14 | canGoForward: false, | |
| 15 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 16 | }); | |
| 17 | ||
| 18 | describe("preview automation target selection", () => { | |
| 19 | it("refreshes authoritative sessions whenever the caller relies on the active tab", () => { | |
| 20 | const active = snapshot("tab-active"); | |
| 21 | expect( | |
| 22 | needsPreviewAutomationSessionSync( | |
| 23 | { snapshot: active, sessions: { [active.tabId]: active } }, | |
| 24 | undefined, | |
| 25 | ), | |
| 26 | ).toBe(true); | |
| 27 | }); | |
| 28 | ||
| 29 | it("refreshes an explicit tab only when it is absent locally", () => { | |
| 30 | const active = snapshot("tab-active"); | |
| 31 | const state = { snapshot: active, sessions: { [active.tabId]: active } }; | |
| 32 | expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false); | |
| 33 | expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true); | |
| 34 | }); | |
| 35 | ||
| 36 | it("does not report the active tab under an unknown requested tab id", () => { | |
| 37 | const active = snapshot("tab-active"); | |
| 38 | expect( | |
| 39 | resolvePreviewAutomationTarget( | |
| 40 | { snapshot: active, sessions: { [active.tabId]: active } }, | |
| 41 | "tab-missing", | |
| 42 | ), | |
| 43 | ).toEqual({ tabId: null, snapshot: null }); | |
| 44 | }); | |
| 45 | }); |
apps/web/src/components/preview/previewAutomationTarget.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface PreviewAutomationSessionIndex { | |
| 4 | readonly snapshot: PreviewSessionSnapshot | null; | |
| 5 | readonly sessions: Readonly<Record<string, PreviewSessionSnapshot>>; | |
| 6 | } | |
| 7 | ||
| 8 | export function needsPreviewAutomationSessionSync( | |
| 9 | state: PreviewAutomationSessionIndex, | |
| 10 | requestedTabId: string | undefined, | |
| 11 | ): boolean { | |
| 12 | return ( | |
| 13 | Object.keys(state.sessions).length === 0 || | |
| 14 | requestedTabId === undefined || | |
| 15 | state.sessions[requestedTabId] === undefined | |
| 16 | ); | |
| 17 | } | |
| 18 | ||
| 19 | export function resolvePreviewAutomationTarget( | |
| 20 | state: PreviewAutomationSessionIndex, | |
| 21 | requestedTabId: string | null, | |
| 22 | ): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } { | |
| 23 | const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot; | |
| 24 | return { tabId: snapshot?.tabId ?? null, snapshot }; | |
| 25 | } |
apps/web/src/components/preview/previewViewportReadiness.test.ts created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; | |
| 4 | ||
| 5 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | |
| 6 | ||
| 7 | describe("isPreviewViewportReady", () => { | |
| 8 | const landscape = { | |
| 9 | _tag: "preset", | |
| 10 | width: 844, | |
| 11 | height: 390, | |
| 12 | presetId: "iphone-12-pro", | |
| 13 | } as const; | |
| 14 | ||
| 15 | it("rejects a stale same-mode preset while React applies the requested orientation", () => { | |
| 16 | expect( | |
| 17 | isPreviewViewportReady({ | |
| 18 | setting: landscape, | |
| 19 | appliedSettingKey: "preset:390:844:iphone-12-pro", | |
| 20 | declaredViewport: { width: 390, height: 844 }, | |
| 21 | renderedViewport: { width: 390, height: 844 }, | |
| 22 | }), | |
| 23 | ).toBe(false); | |
| 24 | }); | |
| 25 | ||
| 26 | it("requires both the declaration and guest viewport to match a fixed request", () => { | |
| 27 | const appliedSettingKey = browserViewportSettingKey(landscape); | |
| 28 | expect( | |
| 29 | isPreviewViewportReady({ | |
| 30 | setting: landscape, | |
| 31 | appliedSettingKey, | |
| 32 | declaredViewport: { width: 390, height: 844 }, | |
| 33 | renderedViewport: { width: 844, height: 390 }, | |
| 34 | }), | |
| 35 | ).toBe(false); | |
| 36 | expect( | |
| 37 | isPreviewViewportReady({ | |
| 38 | setting: landscape, | |
| 39 | appliedSettingKey, | |
| 40 | declaredViewport: { width: 844, height: 390 }, | |
| 41 | renderedViewport: { width: 844, height: 390 }, | |
| 42 | }), | |
| 43 | ).toBe(true); | |
| 44 | }); | |
| 45 | ||
| 46 | it("allows one pixel of Electron rounding tolerance in every mode", () => { | |
| 47 | expect( | |
| 48 | isPreviewViewportReady({ | |
| 49 | setting: { _tag: "fill" }, | |
| 50 | appliedSettingKey: "fill", | |
| 51 | declaredViewport: { width: 500, height: 700 }, | |
| 52 | renderedViewport: { width: 501, height: 699 }, | |
| 53 | }), | |
| 54 | ).toBe(true); | |
| 55 | expect( | |
| 56 | isPreviewViewportReady({ | |
| 57 | setting: landscape, | |
| 58 | appliedSettingKey: browserViewportSettingKey(landscape), | |
| 59 | declaredViewport: { width: 844, height: 390 }, | |
| 60 | renderedViewport: { width: 845, height: 389 }, | |
| 61 | }), | |
| 62 | ).toBe(true); | |
| 63 | expect( | |
| 64 | isPreviewViewportReady({ | |
| 65 | setting: landscape, | |
| 66 | appliedSettingKey: browserViewportSettingKey(landscape), | |
| 67 | declaredViewport: { width: 844, height: 390 }, | |
| 68 | renderedViewport: { width: 846, height: 390 }, | |
| 69 | }), | |
| 70 | ).toBe(false); | |
| 71 | }); | |
| 72 | }); |
apps/web/src/components/preview/previewViewportReadiness.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; | |
| 4 | ||
| 5 | export function isPreviewViewportReady(input: { | |
| 6 | readonly setting: PreviewViewportSetting; | |
| 7 | readonly appliedSettingKey: string | null; | |
| 8 | readonly declaredViewport: PreviewRenderedViewportSize | null; | |
| 9 | readonly renderedViewport: PreviewRenderedViewportSize | null; | |
| 10 | }): boolean { | |
| 11 | const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input; | |
| 12 | if ( | |
| 13 | appliedSettingKey !== browserViewportSettingKey(setting) || | |
| 14 | declaredViewport === null || | |
| 15 | renderedViewport === null | |
| 16 | ) { | |
| 17 | return false; | |
| 18 | } | |
| 19 | ||
| 20 | const expectedViewport = | |
| 21 | setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height }; | |
| 22 | if ( | |
| 23 | setting._tag !== "fill" && | |
| 24 | (declaredViewport.width !== expectedViewport.width || | |
| 25 | declaredViewport.height !== expectedViewport.height) | |
| 26 | ) { | |
| 27 | return false; | |
| 28 | } | |
| 29 | ||
| 30 | // Electron rounds CSS pixels through the guest's fractional zoom/device scale, | |
| 31 | // so a successfully applied fixed viewport can measure one pixel either way. | |
| 32 | const tolerance = 1; | |
| 33 | return ( | |
| 34 | Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance && | |
| 35 | Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance | |
| 36 | ); | |
| 37 | } |
apps/web/src/components/preview/usePreviewSession.ts+2-3| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | applyPreviewServerEvent, |
| 12 | 12 | applyPreviewServerSnapshot, |
| 13 | 13 | readThreadPreviewState, |
| 14 | reconcilePreviewServerSessions, | |
| 14 | 15 | } from "~/previewStateStore"; |
| 15 | 16 | import { previewEnvironment } from "~/state/preview"; |
| 16 | 17 | |
| ... | ... | @@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 50 | 51 | if (result.value.sessions.length > 0) { |
| 51 | 52 | recoveringUrl = null; |
| 52 | 53 | recoveryId += 1; |
| 53 | for (const snapshot of result.value.sessions) { | |
| 54 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 55 | } | |
| 54 | reconcilePreviewServerSessions(threadRef, result.value.sessions); | |
| 56 | 55 | return; |
| 57 | 56 | } |
| 58 | 57 |
apps/web/src/previewStateStore.test.ts+88| ... | ... | @@ -11,10 +11,12 @@ import { |
| 11 | 11 | cancelPreviewSessionClose, |
| 12 | 12 | previewStateAtom, |
| 13 | 13 | readThreadPreviewState, |
| 14 | reconcilePreviewServerSessions, | |
| 14 | 15 | rememberPreviewUrl, |
| 15 | 16 | removePreviewThread, |
| 16 | 17 | resetPreviewStateForTests, |
| 17 | 18 | setActivePreviewTab, |
| 19 | updatePreviewServerSnapshot, | |
| 18 | 20 | } from "./previewStateStore"; |
| 19 | 21 | |
| 20 | 22 | const environmentId = "env-1" as EnvironmentId; |
| ... | ... | @@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => { |
| 108 | 110 | } |
| 109 | 111 | }); |
| 110 | 112 | |
| 113 | it("resized event updates tab viewport without changing the active tab", () => { | |
| 114 | const active = makeSnapshot({ tabId: "tab_a" }); | |
| 115 | const background = makeSnapshot({ tabId: "tab_b" }); | |
| 116 | applyPreviewServerSnapshot(ref, background); | |
| 117 | applyPreviewServerSnapshot(ref, active); | |
| 118 | ||
| 119 | applyPreviewServerEvent(ref, { | |
| 120 | type: "resized", | |
| 121 | threadId: "thread-1", | |
| 122 | tabId: background.tabId, | |
| 123 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 124 | snapshot: { | |
| 125 | ...background, | |
| 126 | viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 }, | |
| 127 | updatedAt: "2026-01-01T00:00:01.000Z", | |
| 128 | }, | |
| 129 | }); | |
| 130 | ||
| 131 | const state = readThreadPreviewState(ref); | |
| 132 | expect(state.activeTabId).toBe(active.tabId); | |
| 133 | expect(state.sessions[background.tabId]?.viewport).toEqual({ | |
| 134 | _tag: "preset", | |
| 135 | presetId: "pixel-8", | |
| 136 | width: 412, | |
| 137 | height: 915, | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 111 | 141 | it("failed event flips the snapshot to LoadFailed when tabId matches", () => { |
| 112 | 142 | const snapshot = makeSnapshot(); |
| 113 | 143 | applyPreviewServerEvent(ref, { |
| ... | ... | @@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => { |
| 292 | 322 | expect(state.desktopOverlay?.canGoBack).toBe(true); |
| 293 | 323 | }); |
| 294 | 324 | |
| 325 | it("updates a background snapshot without changing the active tab", () => { | |
| 326 | const background = makeSnapshot({ tabId: "tab_a" }); | |
| 327 | const active = makeSnapshot({ | |
| 328 | tabId: "tab_b", | |
| 329 | updatedAt: "2026-01-01T00:00:01.000Z", | |
| 330 | }); | |
| 331 | applyPreviewServerSnapshot(ref, background); | |
| 332 | applyPreviewServerSnapshot(ref, active); | |
| 333 | ||
| 334 | const resized = { | |
| 335 | ...background, | |
| 336 | viewport: { _tag: "freeform" as const, width: 900, height: 700 }, | |
| 337 | updatedAt: "2026-01-01T00:00:02.000Z", | |
| 338 | }; | |
| 339 | updatePreviewServerSnapshot(ref, resized); | |
| 340 | ||
| 341 | const state = readThreadPreviewState(ref); | |
| 342 | expect(state.activeTabId).toBe(active.tabId); | |
| 343 | expect(state.snapshot?.tabId).toBe(active.tabId); | |
| 344 | expect(state.sessions[background.tabId]).toEqual(resized); | |
| 345 | }); | |
| 346 | ||
| 347 | it("reconciles an authoritative session list without focusing a background tab", () => { | |
| 348 | const active = makeSnapshot({ tabId: "tab_a" }); | |
| 349 | const stale = makeSnapshot({ | |
| 350 | tabId: "tab_stale", | |
| 351 | updatedAt: "2026-01-01T00:00:01.000Z", | |
| 352 | }); | |
| 353 | applyPreviewServerSnapshot(ref, stale); | |
| 354 | applyPreviewServerSnapshot(ref, active); | |
| 355 | applyPreviewDesktopState(ref, stale.tabId, { | |
| 356 | canGoBack: false, | |
| 357 | canGoForward: false, | |
| 358 | loading: false, | |
| 359 | zoomFactor: 1, | |
| 360 | controller: "none", | |
| 361 | }); | |
| 362 | ||
| 363 | reconcilePreviewServerSessions(ref, [active]); | |
| 364 | ||
| 365 | const state = readThreadPreviewState(ref); | |
| 366 | expect(Object.keys(state.sessions)).toEqual([active.tabId]); | |
| 367 | expect(state.activeTabId).toBe(active.tabId); | |
| 368 | expect(state.snapshot).toEqual(active); | |
| 369 | expect(state.desktopByTabId[stale.tabId]).toBeUndefined(); | |
| 370 | }); | |
| 371 | ||
| 372 | it("clears stale sessions when an authoritative list is empty", () => { | |
| 373 | applyPreviewServerSnapshot(ref, makeSnapshot()); | |
| 374 | ||
| 375 | reconcilePreviewServerSessions(ref, []); | |
| 376 | ||
| 377 | const state = readThreadPreviewState(ref); | |
| 378 | expect(state.sessions).toEqual({}); | |
| 379 | expect(state.activeTabId).toBeNull(); | |
| 380 | expect(state.snapshot).toBeNull(); | |
| 381 | }); | |
| 382 | ||
| 295 | 383 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 296 | 384 | const snapshot = makeSnapshot(); |
| 297 | 385 | applyPreviewServerSnapshot(ref, snapshot); |
apps/web/src/previewStateStore.ts+89-9| ... | ... | @@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { |
| 112 | 112 | return next.slice(0, PREVIEW_RECENT_URL_LIMIT); |
| 113 | 113 | }; |
| 114 | 114 | |
| 115 | const rememberSnapshotUrl = ( | |
| 116 | recentlySeenUrls: string[], | |
| 117 | snapshot: PreviewSessionSnapshot, | |
| 118 | ): string[] => | |
| 119 | snapshot.navStatus._tag === "Idle" | |
| 120 | ? recentlySeenUrls | |
| 121 | : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url); | |
| 122 | ||
| 123 | const latestSnapshot = ( | |
| 124 | sessions: Record<string, PreviewSessionSnapshot>, | |
| 125 | ): PreviewSessionSnapshot | null => | |
| 126 | Object.values(sessions) | |
| 127 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | |
| 128 | .at(-1) ?? null; | |
| 129 | ||
| 115 | 130 | const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { |
| 116 | 131 | if (!current.sessions[tabId]) return current; |
| 117 | 132 | const { [tabId]: _closed, ...sessions } = current.sessions; |
| 118 | 133 | const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; |
| 119 | const nextSnapshot = | |
| 120 | Object.values(sessions) | |
| 121 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | |
| 122 | .at(-1) ?? null; | |
| 134 | const nextSnapshot = latestSnapshot(sessions); | |
| 123 | 135 | const activeTabId = |
| 124 | 136 | current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; |
| 125 | 137 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; |
| ... | ... | @@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven |
| 163 | 175 | updateThreadPreviewState(ref, (current) => { |
| 164 | 176 | switch (event.type) { |
| 165 | 177 | case "opened": |
| 166 | case "navigated": { | |
| 178 | case "navigated": | |
| 179 | case "resized": { | |
| 167 | 180 | const snapshot = event.snapshot; |
| 168 | 181 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; |
| 169 | 182 | const recentlySeenUrls = |
| ... | ... | @@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot( |
| 228 | 241 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; |
| 229 | 242 | const existing = current.sessions[snapshot.tabId]; |
| 230 | 243 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; |
| 231 | const recentlySeenUrls = | |
| 232 | snapshot.navStatus._tag !== "Idle" | |
| 233 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | |
| 234 | : current.recentlySeenUrls; | |
| 244 | const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot); | |
| 235 | 245 | return { |
| 236 | 246 | ...current, |
| 237 | 247 | snapshot, |
| ... | ... | @@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot( |
| 243 | 253 | }); |
| 244 | 254 | } |
| 245 | 255 | |
| 256 | /** | |
| 257 | * Merge a server mutation without changing which tab the user is viewing. | |
| 258 | * | |
| 259 | * Commands such as resize can target background tabs. Their response is | |
| 260 | * authoritative for that tab, but it is not a request to focus the tab. | |
| 261 | */ | |
| 262 | export function updatePreviewServerSnapshot( | |
| 263 | ref: ScopedThreadRef, | |
| 264 | snapshot: PreviewSessionSnapshot, | |
| 265 | ): void { | |
| 266 | updateThreadPreviewState(ref, (current) => { | |
| 267 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | |
| 268 | const existing = current.sessions[snapshot.tabId]; | |
| 269 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; | |
| 270 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | |
| 271 | const activeTabId = | |
| 272 | current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId; | |
| 273 | const activeSnapshot = sessions[activeTabId] ?? snapshot; | |
| 274 | return { | |
| 275 | ...current, | |
| 276 | sessions, | |
| 277 | activeTabId, | |
| 278 | snapshot: activeSnapshot, | |
| 279 | desktopOverlay: current.desktopByTabId[activeTabId] ?? null, | |
| 280 | recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot), | |
| 281 | }; | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Replace the local session index from an authoritative preview.list result. | |
| 287 | * Missing tabs are removed while the current active tab is preserved whenever | |
| 288 | * it still exists in the server result. | |
| 289 | */ | |
| 290 | export function reconcilePreviewServerSessions( | |
| 291 | ref: ScopedThreadRef, | |
| 292 | snapshots: ReadonlyArray<PreviewSessionSnapshot>, | |
| 293 | ): void { | |
| 294 | updateThreadPreviewState(ref, (current) => { | |
| 295 | const sessions: Record<string, PreviewSessionSnapshot> = {}; | |
| 296 | let recentlySeenUrls = current.recentlySeenUrls; | |
| 297 | for (const snapshot of snapshots) { | |
| 298 | if (current.suppressedTabIds.has(snapshot.tabId)) continue; | |
| 299 | const existing = current.sessions[snapshot.tabId]; | |
| 300 | const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot; | |
| 301 | sessions[next.tabId] = next; | |
| 302 | recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next); | |
| 303 | } | |
| 304 | ||
| 305 | const fallback = latestSnapshot(sessions); | |
| 306 | const activeTabId = | |
| 307 | current.activeTabId && sessions[current.activeTabId] | |
| 308 | ? current.activeTabId | |
| 309 | : (fallback?.tabId ?? null); | |
| 310 | const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null; | |
| 311 | const desktopByTabId = Object.fromEntries( | |
| 312 | Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined), | |
| 313 | ); | |
| 314 | return { | |
| 315 | ...current, | |
| 316 | sessions, | |
| 317 | activeTabId, | |
| 318 | snapshot, | |
| 319 | desktopByTabId, | |
| 320 | desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, | |
| 321 | recentlySeenUrls, | |
| 322 | }; | |
| 323 | }); | |
| 324 | } | |
| 325 | ||
| 246 | 326 | export function applyPreviewDesktopState( |
| 247 | 327 | ref: ScopedThreadRef, |
| 248 | 328 | tabId: string, |
packages/client-runtime/src/state/preview.test.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts"; | |
| 4 | ||
| 5 | describe("preview state commands", () => { | |
| 6 | it("keeps focus updates from replacement host connections independent", () => { | |
| 7 | const first = previewAutomationHostFocusConcurrencyKey({ | |
| 8 | environmentId: "environment-1", | |
| 9 | input: { clientId: "client-1", connectionId: "connection-1" }, | |
| 10 | }); | |
| 11 | const replacement = previewAutomationHostFocusConcurrencyKey({ | |
| 12 | environmentId: "environment-1", | |
| 13 | input: { clientId: "client-1", connectionId: "connection-2" }, | |
| 14 | }); | |
| 15 | ||
| 16 | expect(first).not.toBe(replacement); | |
| 17 | }); | |
| 18 | }); |
packages/client-runtime/src/state/preview.ts+21-15| ... | ... | @@ -9,6 +9,14 @@ import { |
| 9 | 9 | createEnvironmentRpcSubscriptionAtomFamily, |
| 10 | 10 | } from "./runtime.ts"; |
| 11 | 11 | |
| 12 | export const previewAutomationHostFocusConcurrencyKey = (value: { | |
| 13 | readonly environmentId: string; | |
| 14 | readonly input: { | |
| 15 | readonly clientId: string; | |
| 16 | readonly connectionId: string; | |
| 17 | }; | |
| 18 | }): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]); | |
| 19 | ||
| 12 | 20 | export function createPreviewEnvironmentAtoms<R, E>( |
| 13 | 21 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 14 | 22 | ) { |
| ... | ... | @@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 54 | 62 | scheduler: lifecycleScheduler, |
| 55 | 63 | concurrency: lifecycleConcurrency, |
| 56 | 64 | }), |
| 65 | resize: createEnvironmentRpcCommand(runtime, { | |
| 66 | label: "environment-data:preview:resize", | |
| 67 | tag: WS_METHODS.previewResize, | |
| 68 | scheduler: lifecycleScheduler, | |
| 69 | concurrency: lifecycleConcurrency, | |
| 70 | }), | |
| 57 | 71 | refresh: createEnvironmentRpcCommand(runtime, { |
| 58 | 72 | label: "environment-data:preview:refresh", |
| 59 | 73 | tag: WS_METHODS.previewRefresh, |
| ... | ... | @@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 82 | 96 | scheduler: automationScheduler, |
| 83 | 97 | concurrency: { |
| 84 | 98 | mode: "singleFlight", |
| 85 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]), | |
| 86 | }, | |
| 87 | }), | |
| 88 | reportAutomationOwner: createEnvironmentRpcCommand(runtime, { | |
| 89 | label: "environment-data:preview:automation-report-owner", | |
| 90 | tag: WS_METHODS.previewAutomationReportOwner, | |
| 91 | scheduler: automationScheduler, | |
| 92 | concurrency: { | |
| 93 | mode: "serial", | |
| 94 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), | |
| 99 | key: ({ environmentId, input }) => | |
| 100 | JSON.stringify([environmentId, input.connectionId, input.requestId]), | |
| 95 | 101 | }, |
| 96 | 102 | }), |
| 97 | clearAutomationOwner: createEnvironmentRpcCommand(runtime, { | |
| 98 | label: "environment-data:preview:automation-clear-owner", | |
| 99 | tag: WS_METHODS.previewAutomationClearOwner, | |
| 103 | focusAutomationHost: createEnvironmentRpcCommand(runtime, { | |
| 104 | label: "environment-data:preview:automation-focus-host", | |
| 105 | tag: WS_METHODS.previewAutomationFocusHost, | |
| 100 | 106 | scheduler: automationScheduler, |
| 101 | 107 | concurrency: { |
| 102 | mode: "serial", | |
| 103 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), | |
| 108 | mode: "latest", | |
| 109 | key: previewAutomationHostFocusConcurrencyKey, | |
| 104 | 110 | }, |
| 105 | 111 | }), |
| 106 | 112 | }; |
packages/contracts/src/ipc.ts+8-7| ... | ... | @@ -69,19 +69,20 @@ import type { |
| 69 | 69 | PreviewOpenInput, |
| 70 | 70 | PreviewRefreshInput, |
| 71 | 71 | PreviewReportStatusInput, |
| 72 | PreviewResizeInput, | |
| 72 | 73 | PreviewSessionSnapshot, |
| 73 | 74 | } from "./preview.ts"; |
| 74 | 75 | import { |
| 75 | 76 | PreviewAutomationClickInput, |
| 76 | 77 | PreviewAutomationEvaluateInput, |
| 77 | PreviewAutomationOwner, | |
| 78 | PreviewAutomationOwnerIdentity, | |
| 78 | PreviewAutomationHost, | |
| 79 | PreviewAutomationHostFocus, | |
| 79 | 80 | PreviewAutomationPressInput, |
| 80 | PreviewAutomationRequest, | |
| 81 | 81 | PreviewAutomationResponse, |
| 82 | 82 | PreviewAutomationScrollInput, |
| 83 | 83 | PreviewAutomationSnapshot, |
| 84 | 84 | PreviewAutomationStatus, |
| 85 | PreviewAutomationStreamEvent, | |
| 85 | 86 | PreviewAutomationTypeInput, |
| 86 | 87 | PreviewAutomationWaitForInput, |
| 87 | 88 | } from "./previewAutomation.ts"; |
| ... | ... | @@ -1154,19 +1155,19 @@ export interface EnvironmentApi { |
| 1154 | 1155 | preview: { |
| 1155 | 1156 | open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>; |
| 1156 | 1157 | navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>; |
| 1158 | resize: (input: typeof PreviewResizeInput.Encoded) => Promise<PreviewSessionSnapshot>; | |
| 1157 | 1159 | refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>; |
| 1158 | 1160 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; |
| 1159 | 1161 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; |
| 1160 | 1162 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; |
| 1161 | 1163 | automation: { |
| 1162 | 1164 | connect: ( |
| 1163 | input: { clientId: string }, | |
| 1164 | callback: (request: PreviewAutomationRequest) => void, | |
| 1165 | input: PreviewAutomationHost, | |
| 1166 | callback: (event: PreviewAutomationStreamEvent) => void, | |
| 1165 | 1167 | options?: { onResubscribe?: () => void }, |
| 1166 | 1168 | ) => () => void; |
| 1167 | 1169 | respond: (response: PreviewAutomationResponse) => Promise<void>; |
| 1168 | reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; | |
| 1169 | clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise<void>; | |
| 1170 | focusHost: (input: PreviewAutomationHostFocus) => Promise<void>; | |
| 1170 | 1171 | }; |
| 1171 | 1172 | onEvent: ( |
| 1172 | 1173 | callback: (event: PreviewEvent) => void, |
packages/contracts/src/preview.test.ts+144| ... | ... | @@ -6,12 +6,26 @@ import { |
| 6 | 6 | PreviewEvent, |
| 7 | 7 | PreviewNavStatus, |
| 8 | 8 | PreviewSessionSnapshot, |
| 9 | PreviewViewportSetting, | |
| 9 | 10 | } from "./preview.ts"; |
| 11 | import { | |
| 12 | PreviewAutomationHost, | |
| 13 | PreviewAutomationError, | |
| 14 | PreviewAutomationResizeInput, | |
| 15 | PreviewAutomationResizeResult, | |
| 16 | PreviewAutomationStatus, | |
| 17 | } from "./previewAutomation.ts"; | |
| 10 | 18 | |
| 11 | 19 | const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); |
| 12 | 20 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); |
| 13 | 21 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); |
| 14 | 22 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); |
| 23 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); | |
| 24 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); | |
| 25 | const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); | |
| 26 | const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); | |
| 27 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); | |
| 28 | const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); | |
| 15 | 29 | |
| 16 | 30 | describe("PreviewNavStatus", () => { |
| 17 | 31 | it("decodes Idle", () => { |
| ... | ... | @@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => { |
| 68 | 82 | }); |
| 69 | 83 | }); |
| 70 | 84 | |
| 85 | describe("PreviewViewportSetting", () => { | |
| 86 | it("decodes fill, freeform, and preset modes", () => { | |
| 87 | expect(decodeViewport({ _tag: "fill" })).toEqual({ _tag: "fill" }); | |
| 88 | expect(decodeViewport({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ | |
| 89 | _tag: "freeform", | |
| 90 | width: 1024, | |
| 91 | height: 768, | |
| 92 | }); | |
| 93 | expect( | |
| 94 | decodeViewport({ | |
| 95 | _tag: "preset", | |
| 96 | presetId: "iphone-15-pro", | |
| 97 | width: 393, | |
| 98 | height: 852, | |
| 99 | }), | |
| 100 | ).toMatchObject({ _tag: "preset", presetId: "iphone-15-pro" }); | |
| 101 | }); | |
| 102 | ||
| 103 | it("rejects unsafe dimensions and oversized render areas", () => { | |
| 104 | expect(() => decodeViewport({ _tag: "freeform", width: 100, height: 800 })).toThrow(); | |
| 105 | expect(() => decodeViewport({ _tag: "freeform", width: 3840, height: 3840 })).toThrow(); | |
| 106 | }); | |
| 107 | }); | |
| 108 | ||
| 109 | describe("PreviewAutomationResizeInput", () => { | |
| 110 | it("requires fields that match the selected mode", () => { | |
| 111 | expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" }); | |
| 112 | expect( | |
| 113 | decodeResizeInput({ mode: "preset", preset: "pixel-7", orientation: "landscape" }), | |
| 114 | ).toMatchObject({ mode: "preset", preset: "pixel-7" }); | |
| 115 | expect(() => decodeResizeInput({ mode: "preset", preset: "pixel-8" })).toThrow(); | |
| 116 | expect(() => decodeResizeInput({ mode: "freeform", width: 1024 })).toThrow(); | |
| 117 | expect(() => decodeResizeInput({ mode: "fill", width: 1024, height: 768 })).toThrow(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("allows fill-mode measurements below the minimum selectable fixed size", () => { | |
| 121 | expect( | |
| 122 | decodeResizeResult({ | |
| 123 | tabId: "preview-t", | |
| 124 | setting: { _tag: "fill" }, | |
| 125 | viewport: { width: 180, height: 120 }, | |
| 126 | }).viewport, | |
| 127 | ).toEqual({ width: 180, height: 120 }); | |
| 128 | }); | |
| 129 | }); | |
| 130 | ||
| 131 | describe("PreviewAutomationHost", () => { | |
| 132 | it("accepts legacy hosts and current operation advertisements", () => { | |
| 133 | expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({ | |
| 134 | clientId: "legacy", | |
| 135 | environmentId: "environment-1", | |
| 136 | }); | |
| 137 | expect( | |
| 138 | decodeAutomationHost({ | |
| 139 | clientId: "current", | |
| 140 | environmentId: "environment-1", | |
| 141 | supportedOperations: ["status", "resize"], | |
| 142 | }).supportedOperations, | |
| 143 | ).toEqual(["status", "resize"]); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 147 | describe("PreviewAutomationError", () => { | |
| 148 | it("preserves a typed non-editable target failure", () => { | |
| 149 | const error = decodeAutomationError({ | |
| 150 | _tag: "PreviewAutomationTargetNotEditableError", | |
| 151 | operation: "type", | |
| 152 | environmentId: "environment-1", | |
| 153 | threadId: "thread-1", | |
| 154 | providerSessionId: "provider-session-1", | |
| 155 | providerInstanceId: "codex", | |
| 156 | clientId: "client-1", | |
| 157 | connectionId: "connection-1", | |
| 158 | requestId: "request-1", | |
| 159 | tabId: "tab-1", | |
| 160 | timeoutMs: 1_000, | |
| 161 | remoteTag: "PreviewAutomationTargetNotEditableError", | |
| 162 | remoteMessageLength: 12, | |
| 163 | cause: {}, | |
| 164 | selectorKind: "focused-element", | |
| 165 | }); | |
| 166 | ||
| 167 | expect(error._tag).toBe("PreviewAutomationTargetNotEditableError"); | |
| 168 | if (error._tag === "PreviewAutomationTargetNotEditableError") { | |
| 169 | expect(error.selectorKind).toBe("focused-element"); | |
| 170 | expect(error.message).toBe("Preview automation type requires an editable focused element."); | |
| 171 | } | |
| 172 | }); | |
| 173 | }); | |
| 174 | ||
| 175 | describe("PreviewAutomationStatus", () => { | |
| 176 | it("accepts old hosts without viewport data and exposes it from current hosts", () => { | |
| 177 | const base = { | |
| 178 | available: true, | |
| 179 | visible: false, | |
| 180 | tabId: "preview-t", | |
| 181 | url: "https://example.com", | |
| 182 | title: "Example", | |
| 183 | loading: false, | |
| 184 | }; | |
| 185 | expect(decodeAutomationStatus(base)).toEqual(base); | |
| 186 | expect( | |
| 187 | decodeAutomationStatus({ | |
| 188 | ...base, | |
| 189 | viewportSetting: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 }, | |
| 190 | viewport: { width: 412, height: 915 }, | |
| 191 | }).viewport, | |
| 192 | ).toEqual({ width: 412, height: 915 }); | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 71 | 196 | describe("PreviewEvent", () => { |
| 72 | 197 | it("decodes opened", () => { |
| 73 | 198 | const event = decodePreviewEvent({ |
| ... | ... | @@ -104,6 +229,25 @@ describe("PreviewEvent", () => { |
| 104 | 229 | } |
| 105 | 230 | }); |
| 106 | 231 | |
| 232 | it("decodes resized with tab viewport state", () => { | |
| 233 | const event = decodePreviewEvent({ | |
| 234 | type: "resized", | |
| 235 | threadId: "t", | |
| 236 | tabId: "preview-t", | |
| 237 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 238 | snapshot: { | |
| 239 | threadId: "t", | |
| 240 | tabId: "preview-t", | |
| 241 | navStatus: { _tag: "Idle" }, | |
| 242 | canGoBack: false, | |
| 243 | canGoForward: false, | |
| 244 | viewport: { _tag: "freeform", width: 1024, height: 768 }, | |
| 245 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 246 | }, | |
| 247 | }); | |
| 248 | expect(event.type).toBe("resized"); | |
| 249 | }); | |
| 250 | ||
| 107 | 251 | it("decodes closed without snapshot", () => { |
| 108 | 252 | const event = decodePreviewEvent({ |
| 109 | 253 | type: "closed", |
packages/contracts/src/preview.ts+110| ... | ... | @@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512)); |
| 17 | 17 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 18 | 18 | export type PreviewTabId = typeof PreviewTabId.Type; |
| 19 | 19 | |
| 20 | export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240; | |
| 21 | export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840; | |
| 22 | export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160; | |
| 23 | ||
| 24 | const PreviewViewportDimension = Schema.Int.check( | |
| 25 | Schema.isBetween({ | |
| 26 | minimum: PREVIEW_VIEWPORT_MIN_DIMENSION, | |
| 27 | maximum: PREVIEW_VIEWPORT_MAX_DIMENSION, | |
| 28 | }), | |
| 29 | ); | |
| 30 | ||
| 31 | const viewportAreaFilter = Schema.makeFilter( | |
| 32 | ({ width, height }: { readonly width: number; readonly height: number }) => | |
| 33 | width * height <= PREVIEW_VIEWPORT_MAX_AREA || | |
| 34 | `Viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`, | |
| 35 | ); | |
| 36 | ||
| 37 | export const PreviewViewportSize = Schema.Struct({ | |
| 38 | width: PreviewViewportDimension, | |
| 39 | height: PreviewViewportDimension, | |
| 40 | }).check(viewportAreaFilter); | |
| 41 | export type PreviewViewportSize = typeof PreviewViewportSize.Type; | |
| 42 | ||
| 43 | /** | |
| 44 | * The page's measured viewport can be smaller than the minimum selectable | |
| 45 | * fixed size while fill mode follows a narrow panel. Keep measurement | |
| 46 | * validation separate from the stricter user-selectable size constraints. | |
| 47 | */ | |
| 48 | export const PreviewRenderedViewportSize = Schema.Struct({ | |
| 49 | width: Schema.Int.check(Schema.isGreaterThan(0)), | |
| 50 | height: Schema.Int.check(Schema.isGreaterThan(0)), | |
| 51 | }); | |
| 52 | export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type; | |
| 53 | ||
| 54 | export const PREVIEW_VIEWPORT_PRESET_IDS = [ | |
| 55 | "iphone-se", | |
| 56 | "iphone-xr", | |
| 57 | "iphone-12-pro", | |
| 58 | "iphone-14-pro-max", | |
| 59 | "pixel-7", | |
| 60 | "samsung-galaxy-s8-plus", | |
| 61 | "samsung-galaxy-s20-ultra", | |
| 62 | "ipad-mini", | |
| 63 | "ipad-air", | |
| 64 | "ipad-pro", | |
| 65 | "surface-pro-7", | |
| 66 | "surface-duo", | |
| 67 | "galaxy-z-fold-5", | |
| 68 | "asus-zenbook-fold", | |
| 69 | "samsung-galaxy-a51-71", | |
| 70 | "nest-hub", | |
| 71 | "nest-hub-max", | |
| 72 | ] as const; | |
| 73 | ||
| 74 | export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS); | |
| 75 | export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type; | |
| 76 | ||
| 77 | /** | |
| 78 | * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions | |
| 79 | * can still reconnect with these values, but new resize requests only expose | |
| 80 | * PREVIEW_VIEWPORT_PRESET_IDS. | |
| 81 | */ | |
| 82 | const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [ | |
| 83 | "desktop-1920x1080", | |
| 84 | "desktop-1440x900", | |
| 85 | "laptop-1366x768", | |
| 86 | "laptop-1280x800", | |
| 87 | "ipad-pro-11", | |
| 88 | "iphone-15-pro", | |
| 89 | "pixel-8", | |
| 90 | "galaxy-s24", | |
| 91 | ] as const; | |
| 92 | ||
| 93 | const StoredPreviewViewportPresetId = Schema.Literals([ | |
| 94 | ...PREVIEW_VIEWPORT_PRESET_IDS, | |
| 95 | ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS, | |
| 96 | ]); | |
| 97 | ||
| 98 | export const PreviewViewportSetting = Schema.Union([ | |
| 99 | Schema.TaggedStruct("fill", {}), | |
| 100 | Schema.TaggedStruct("freeform", { | |
| 101 | ...PreviewViewportSize.fields, | |
| 102 | }).check(viewportAreaFilter), | |
| 103 | Schema.TaggedStruct("preset", { | |
| 104 | ...PreviewViewportSize.fields, | |
| 105 | presetId: StoredPreviewViewportPresetId, | |
| 106 | }).check(viewportAreaFilter), | |
| 107 | ]); | |
| 108 | export type PreviewViewportSetting = typeof PreviewViewportSetting.Type; | |
| 109 | ||
| 110 | export const FILL_PREVIEW_VIEWPORT = { | |
| 111 | _tag: "fill", | |
| 112 | } as const satisfies PreviewViewportSetting; | |
| 113 | ||
| 20 | 114 | export const PreviewNavStatus = Schema.Union([ |
| 21 | 115 | Schema.TaggedStruct("Idle", {}), |
| 22 | 116 | Schema.TaggedStruct("Loading", { |
| ... | ... | @@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ |
| 42 | 136 | navStatus: PreviewNavStatus, |
| 43 | 137 | canGoBack: Schema.Boolean, |
| 44 | 138 | canGoForward: Schema.Boolean, |
| 139 | /** Missing snapshots from older servers are treated as fill-panel mode. */ | |
| 140 | viewport: Schema.optional(PreviewViewportSetting), | |
| 45 | 141 | updatedAt: Schema.String, |
| 46 | 142 | }); |
| 47 | 143 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; |
| ... | ... | @@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({ |
| 76 | 172 | }); |
| 77 | 173 | export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; |
| 78 | 174 | |
| 175 | export const PreviewResizeInput = Schema.Struct({ | |
| 176 | threadId: ThreadId, | |
| 177 | tabId: PreviewTabId, | |
| 178 | viewport: PreviewViewportSetting, | |
| 179 | }); | |
| 180 | export type PreviewResizeInput = typeof PreviewResizeInput.Type; | |
| 181 | ||
| 79 | 182 | export const PreviewCloseInput = Schema.Struct({ |
| 80 | 183 | threadId: ThreadId, |
| 81 | 184 | tabId: Schema.optional(PreviewTabId), |
| ... | ... | @@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({ |
| 110 | 213 | snapshot: PreviewSessionSnapshot, |
| 111 | 214 | }); |
| 112 | 215 | |
| 216 | const PreviewResizedEvent = Schema.Struct({ | |
| 217 | ...PreviewEventBaseSchema.fields, | |
| 218 | type: Schema.Literal("resized"), | |
| 219 | snapshot: PreviewSessionSnapshot, | |
| 220 | }); | |
| 221 | ||
| 113 | 222 | const PreviewFailedEvent = Schema.Struct({ |
| 114 | 223 | ...PreviewEventBaseSchema.fields, |
| 115 | 224 | type: Schema.Literal("failed"), |
| ... | ... | @@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({ |
| 127 | 236 | export const PreviewEvent = Schema.Union([ |
| 128 | 237 | PreviewOpenedEvent, |
| 129 | 238 | PreviewNavigatedEvent, |
| 239 | PreviewResizedEvent, | |
| 130 | 240 | PreviewFailedEvent, |
| 131 | 241 | PreviewClosedEvent, |
| 132 | 242 | ]); |
packages/contracts/src/previewAutomation.ts+162-31| ... | ... | @@ -1,7 +1,14 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { PreviewTabId } from "./preview.ts"; | |
| 4 | import { | |
| 5 | PREVIEW_VIEWPORT_MAX_AREA, | |
| 6 | PreviewRenderedViewportSize, | |
| 7 | PreviewTabId, | |
| 8 | PreviewViewportPresetId, | |
| 9 | PreviewViewportSetting, | |
| 10 | PreviewViewportSize, | |
| 11 | } from "./preview.ts"; | |
| 5 | 12 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 6 | 13 | |
| 7 | 14 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) |
| ... | ... | @@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional( |
| 18 | 25 | .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), |
| 19 | 26 | ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); |
| 20 | 27 | |
| 21 | export const PreviewAutomationOperation = Schema.Literals([ | |
| 28 | /** Operations understood by desktop hosts predating viewport resizing. */ | |
| 29 | export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ | |
| 22 | 30 | "status", |
| 23 | 31 | "open", |
| 24 | 32 | "navigate", |
| ... | ... | @@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([ |
| 31 | 39 | "waitFor", |
| 32 | 40 | "recordingStart", |
| 33 | 41 | "recordingStop", |
| 34 | ]); | |
| 42 | ] as const; | |
| 43 | ||
| 44 | /** Advertised by current desktop hosts for mixed-version routing. */ | |
| 45 | export const PREVIEW_AUTOMATION_OPERATIONS = [ | |
| 46 | ...PREVIEW_AUTOMATION_V1_OPERATIONS, | |
| 47 | "resize", | |
| 48 | ] as const; | |
| 49 | ||
| 50 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); | |
| 35 | 51 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 36 | 52 | |
| 37 | 53 | export const PreviewAutomationStatus = Schema.Struct({ |
| ... | ... | @@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({ |
| 41 | 57 | url: Schema.NullOr(Schema.String), |
| 42 | 58 | title: Schema.NullOr(Schema.String), |
| 43 | 59 | loading: Schema.Boolean, |
| 60 | /** Optional for compatibility with desktop hosts predating viewport sizing. */ | |
| 61 | viewportSetting: Schema.optional(PreviewViewportSetting), | |
| 62 | /** Measured guest-page viewport in CSS pixels when a webview is ready. */ | |
| 63 | viewport: Schema.optional(PreviewRenderedViewportSize), | |
| 44 | 64 | }); |
| 45 | 65 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 46 | 66 | |
| ... | ... | @@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({ |
| 134 | 154 | }); |
| 135 | 155 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; |
| 136 | 156 | |
| 157 | export const PreviewAutomationResizeInput = Schema.Struct({ | |
| 158 | mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({ | |
| 159 | description: | |
| 160 | "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.", | |
| 161 | }), | |
| 162 | preset: Schema.optional( | |
| 163 | PreviewViewportPresetId.annotate({ | |
| 164 | description: "Named viewport from Chrome DevTools' standard device catalog.", | |
| 165 | }), | |
| 166 | ).annotate({ | |
| 167 | description: "Named device size. Required only when mode is preset.", | |
| 168 | }), | |
| 169 | width: Schema.optional( | |
| 170 | PreviewViewportSize.fields.width.annotate({ | |
| 171 | description: "Freeform viewport width in CSS pixels. Required only in freeform mode.", | |
| 172 | }), | |
| 173 | ).annotate({ | |
| 174 | description: "Freeform viewport width in CSS pixels. Required only in freeform mode.", | |
| 175 | }), | |
| 176 | height: Schema.optional( | |
| 177 | PreviewViewportSize.fields.height.annotate({ | |
| 178 | description: "Freeform viewport height in CSS pixels. Required only in freeform mode.", | |
| 179 | }), | |
| 180 | ).annotate({ | |
| 181 | description: "Freeform viewport height in CSS pixels. Required only in freeform mode.", | |
| 182 | }), | |
| 183 | orientation: Schema.optional( | |
| 184 | Schema.Literals(["portrait", "landscape"]).annotate({ | |
| 185 | description: | |
| 186 | "Orientation for a fixed device preset. Defaults to the preset's native orientation.", | |
| 187 | }), | |
| 188 | ).annotate({ | |
| 189 | description: | |
| 190 | "Orientation for a named device preset. It is not accepted in fill or freeform mode.", | |
| 191 | }), | |
| 192 | timeoutMs: OptionalTimeoutMs, | |
| 193 | }) | |
| 194 | .check( | |
| 195 | Schema.makeFilter((input) => { | |
| 196 | const hasPreset = input.preset !== undefined; | |
| 197 | const hasWidth = input.width !== undefined; | |
| 198 | const hasHeight = input.height !== undefined; | |
| 199 | if (hasWidth !== hasHeight) return "Custom dimensions require both width and height."; | |
| 200 | if (input.mode === "fill") { | |
| 201 | return !hasPreset && !hasWidth && input.orientation === undefined | |
| 202 | ? true | |
| 203 | : "Fill mode does not accept a preset, dimensions, or orientation."; | |
| 204 | } | |
| 205 | if (input.mode === "freeform") { | |
| 206 | if (!hasWidth || !hasHeight || hasPreset || input.orientation !== undefined) { | |
| 207 | return "Freeform mode requires width and height and does not accept a preset or orientation."; | |
| 208 | } | |
| 209 | } else if (!hasPreset || hasWidth || hasHeight) { | |
| 210 | return "Preset mode requires a preset and does not accept custom dimensions."; | |
| 211 | } | |
| 212 | if (hasWidth && hasHeight && input.width! * input.height! > PREVIEW_VIEWPORT_MAX_AREA) { | |
| 213 | return `Custom viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`; | |
| 214 | } | |
| 215 | return true; | |
| 216 | }), | |
| 217 | ) | |
| 218 | .annotate({ | |
| 219 | description: | |
| 220 | "Sets the active browser tab to fill-panel, independently resizable freeform, or named device-preset sizing.", | |
| 221 | }); | |
| 222 | export type PreviewAutomationResizeInput = typeof PreviewAutomationResizeInput.Type; | |
| 223 | ||
| 224 | export const PreviewAutomationResizeResult = Schema.Struct({ | |
| 225 | tabId: PreviewTabId, | |
| 226 | setting: PreviewViewportSetting, | |
| 227 | viewport: PreviewRenderedViewportSize, | |
| 228 | }); | |
| 229 | export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type; | |
| 230 | ||
| 137 | 231 | const Locator = TrimmedNonEmptyString.annotate({ |
| 138 | 232 | description: |
| 139 | 233 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", |
| ... | ... | @@ -411,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ |
| 411 | 505 | }); |
| 412 | 506 | export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; |
| 413 | 507 | |
| 414 | export const PreviewAutomationOwnerIdentity = Schema.Struct({ | |
| 415 | clientId: TrimmedNonEmptyString, | |
| 508 | export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | |
| 509 | export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; | |
| 510 | export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); | |
| 511 | export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; | |
| 512 | ||
| 513 | export const PreviewAutomationHostIdentity = Schema.Struct({ | |
| 514 | clientId: PreviewAutomationClientId, | |
| 416 | 515 | environmentId: EnvironmentId, |
| 417 | threadId: ThreadId, | |
| 418 | 516 | }); |
| 419 | export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type; | |
| 517 | export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity.Type; | |
| 518 | ||
| 519 | export const PreviewAutomationHost = Schema.Struct({ | |
| 520 | ...PreviewAutomationHostIdentity.fields, | |
| 521 | /** | |
| 522 | * Missing means the pre-capability-negotiation V1 operation set. This lets | |
| 523 | * a newer server safely coexist with an older desktop during rollout. | |
| 524 | */ | |
| 525 | supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)), | |
| 526 | }); | |
| 527 | export type PreviewAutomationHost = typeof PreviewAutomationHost.Type; | |
| 420 | 528 | |
| 421 | export const PreviewAutomationOwner = Schema.Struct({ | |
| 422 | ...PreviewAutomationOwnerIdentity.fields, | |
| 423 | tabId: Schema.NullOr(PreviewTabId), | |
| 424 | visible: Schema.Boolean, | |
| 425 | supportsAutomation: Schema.Boolean, | |
| 426 | focusedAt: Schema.String, | |
| 529 | export const PreviewAutomationHostFocus = Schema.Struct({ | |
| 530 | ...PreviewAutomationHostIdentity.fields, | |
| 531 | connectionId: PreviewAutomationConnectionId, | |
| 532 | focused: Schema.Boolean, | |
| 427 | 533 | }); |
| 428 | export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; | |
| 534 | export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; | |
| 429 | 535 | |
| 430 | 536 | export const PreviewAutomationRequest = Schema.Struct({ |
| 431 | 537 | requestId: TrimmedNonEmptyString, |
| ... | ... | @@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({ |
| 437 | 543 | }); |
| 438 | 544 | export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; |
| 439 | 545 | |
| 546 | export const PreviewAutomationStreamEvent = Schema.Union([ | |
| 547 | Schema.Struct({ | |
| 548 | type: Schema.Literal("connected"), | |
| 549 | connectionId: PreviewAutomationConnectionId, | |
| 550 | }), | |
| 551 | Schema.Struct({ | |
| 552 | type: Schema.Literal("request"), | |
| 553 | connectionId: PreviewAutomationConnectionId, | |
| 554 | request: PreviewAutomationRequest, | |
| 555 | }), | |
| 556 | ]); | |
| 557 | export type PreviewAutomationStreamEvent = typeof PreviewAutomationStreamEvent.Type; | |
| 558 | ||
| 440 | 559 | export const PreviewAutomationResponse = Schema.Struct({ |
| 560 | clientId: PreviewAutomationClientId, | |
| 561 | connectionId: PreviewAutomationConnectionId, | |
| 441 | 562 | requestId: TrimmedNonEmptyString, |
| 442 | 563 | ok: Schema.Boolean, |
| 443 | 564 | result: Schema.optional(Schema.Unknown), |
| ... | ... | @@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = { |
| 477 | 598 | const PreviewAutomationRequestErrorFields = { |
| 478 | 599 | ...PreviewAutomationScopeErrorFields, |
| 479 | 600 | clientId: TrimmedNonEmptyString, |
| 601 | connectionId: PreviewAutomationConnectionId, | |
| 480 | 602 | requestId: TrimmedNonEmptyString, |
| 481 | 603 | tabId: Schema.optional(PreviewTabId), |
| 482 | 604 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), |
| ... | ... | @@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = { |
| 500 | 622 | cause: Schema.optional(Schema.Defect()), |
| 501 | 623 | }; |
| 502 | 624 | |
| 503 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( | |
| 504 | "PreviewAutomationNoFocusedOwnerError", | |
| 625 | export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass<PreviewAutomationNoAvailableHostError>()( | |
| 626 | "PreviewAutomationNoAvailableHostError", | |
| 505 | 627 | { |
| 506 | 628 | ...PreviewAutomationScopeErrorFields, |
| 507 | 629 | clientId: Schema.optional(TrimmedNonEmptyString), |
| 630 | connectionId: Schema.optional(PreviewAutomationConnectionId), | |
| 508 | 631 | requestId: Schema.optional(TrimmedNonEmptyString), |
| 509 | 632 | tabId: Schema.optional(PreviewTabId), |
| 510 | 633 | timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), |
| ... | ... | @@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas |
| 512 | 635 | }, |
| 513 | 636 | ) { |
| 514 | 637 | override get message(): string { |
| 515 | const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`; | |
| 638 | const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`; | |
| 516 | 639 | return summary; |
| 517 | 640 | } |
| 518 | 641 | } |
| ... | ... | @@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla |
| 598 | 721 | } |
| 599 | 722 | } |
| 600 | 723 | |
| 724 | export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()( | |
| 725 | "PreviewAutomationTargetNotEditableError", | |
| 726 | { | |
| 727 | ...PreviewAutomationRequestErrorFields, | |
| 728 | ...PreviewAutomationRemoteDiagnosticFields, | |
| 729 | selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), | |
| 730 | selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | |
| 731 | }, | |
| 732 | ) { | |
| 733 | override get message(): string { | |
| 734 | if (this.selectorKind === "focused-element") { | |
| 735 | return `Preview automation ${this.operation} requires an editable focused element.`; | |
| 736 | } | |
| 737 | if (this.selectorKind !== undefined && this.selectorLength !== undefined) { | |
| 738 | return `Preview automation ${this.operation} requires an editable ${this.selectorKind} (${this.selectorLength} characters).`; | |
| 739 | } | |
| 740 | return `Preview automation ${this.operation} requires an editable target.`; | |
| 741 | } | |
| 742 | } | |
| 743 | ||
| 601 | 744 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( |
| 602 | 745 | "PreviewAutomationResultTooLargeError", |
| 603 | 746 | { |
| ... | ... | @@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas |
| 615 | 758 | } |
| 616 | 759 | } |
| 617 | 760 | |
| 618 | export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass<PreviewAutomationHostNotConnectedError>()( | |
| 619 | "PreviewAutomationHostNotConnectedError", | |
| 620 | { | |
| 621 | ...PreviewAutomationScopeErrorFields, | |
| 622 | clientId: TrimmedNonEmptyString, | |
| 623 | }, | |
| 624 | ) { | |
| 625 | override get message(): string { | |
| 626 | return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`; | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | 761 | export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()( |
| 631 | 762 | "PreviewAutomationClientDisconnectedError", |
| 632 | 763 | PreviewAutomationRequestErrorFields, |
| ... | ... | @@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC |
| 668 | 799 | |
| 669 | 800 | export const PreviewAutomationError = Schema.Union([ |
| 670 | 801 | PreviewAutomationUnavailableError, |
| 671 | PreviewAutomationNoFocusedOwnerError, | |
| 802 | PreviewAutomationNoAvailableHostError, | |
| 672 | 803 | PreviewAutomationUnsupportedClientError, |
| 673 | 804 | PreviewAutomationTabNotFoundError, |
| 674 | 805 | PreviewAutomationTimeoutError, |
| 675 | 806 | PreviewAutomationControlInterruptedError, |
| 676 | 807 | PreviewAutomationExecutionError, |
| 677 | 808 | PreviewAutomationInvalidSelectorError, |
| 809 | PreviewAutomationTargetNotEditableError, | |
| 678 | 810 | PreviewAutomationResultTooLargeError, |
| 679 | PreviewAutomationHostNotConnectedError, | |
| 680 | 811 | PreviewAutomationClientDisconnectedError, |
| 681 | 812 | PreviewAutomationRequestQueueClosedError, |
| 682 | 813 | PreviewAutomationRemoteUnavailableError, |
packages/contracts/src/rpc.ts+19-17| ... | ... | @@ -103,14 +103,15 @@ import { |
| 103 | 103 | PreviewOpenInput, |
| 104 | 104 | PreviewRefreshInput, |
| 105 | 105 | PreviewReportStatusInput, |
| 106 | PreviewResizeInput, | |
| 106 | 107 | PreviewSessionSnapshot, |
| 107 | 108 | } from "./preview.ts"; |
| 108 | 109 | import { |
| 109 | 110 | PreviewAutomationError, |
| 110 | PreviewAutomationOwner, | |
| 111 | PreviewAutomationOwnerIdentity, | |
| 112 | PreviewAutomationRequest, | |
| 111 | PreviewAutomationHost, | |
| 112 | PreviewAutomationHostFocus, | |
| 113 | 113 | PreviewAutomationResponse, |
| 114 | PreviewAutomationStreamEvent, | |
| 114 | 115 | } from "./previewAutomation.ts"; |
| 115 | 116 | import { |
| 116 | 117 | ServerConfigStreamEvent, |
| ... | ... | @@ -190,14 +191,14 @@ export const WS_METHODS = { |
| 190 | 191 | // Preview methods |
| 191 | 192 | previewOpen: "preview.open", |
| 192 | 193 | previewNavigate: "preview.navigate", |
| 194 | previewResize: "preview.resize", | |
| 193 | 195 | previewRefresh: "preview.refresh", |
| 194 | 196 | previewClose: "preview.close", |
| 195 | 197 | previewList: "preview.list", |
| 196 | 198 | previewReportStatus: "preview.reportStatus", |
| 197 | 199 | previewAutomationConnect: "previewAutomation.connect", |
| 198 | 200 | previewAutomationRespond: "previewAutomation.respond", |
| 199 | previewAutomationReportOwner: "previewAutomation.reportOwner", | |
| 200 | previewAutomationClearOwner: "previewAutomation.clearOwner", | |
| 201 | previewAutomationFocusHost: "previewAutomation.focusHost", | |
| 201 | 202 | |
| 202 | 203 | // Server meta |
| 203 | 204 | serverGetConfig: "server.getConfig", |
| ... | ... | @@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { |
| 528 | 529 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| 529 | 530 | }); |
| 530 | 531 | |
| 532 | export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { | |
| 533 | payload: PreviewResizeInput, | |
| 534 | success: PreviewSessionSnapshot, | |
| 535 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), | |
| 536 | }); | |
| 537 | ||
| 531 | 538 | export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { |
| 532 | 539 | payload: PreviewRefreshInput, |
| 533 | 540 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| ... | ... | @@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, |
| 550 | 557 | }); |
| 551 | 558 | |
| 552 | 559 | export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { |
| 553 | payload: PreviewAutomationOwner, | |
| 554 | success: PreviewAutomationRequest, | |
| 560 | payload: PreviewAutomationHost, | |
| 561 | success: PreviewAutomationStreamEvent, | |
| 555 | 562 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), |
| 556 | 563 | stream: true, |
| 557 | 564 | }); |
| ... | ... | @@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati |
| 561 | 568 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), |
| 562 | 569 | }); |
| 563 | 570 | |
| 564 | export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { | |
| 565 | payload: PreviewAutomationOwner, | |
| 566 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 567 | }); | |
| 568 | ||
| 569 | export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { | |
| 570 | payload: PreviewAutomationOwnerIdentity, | |
| 571 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 571 | export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { | |
| 572 | payload: PreviewAutomationHostFocus, | |
| 573 | error: EnvironmentAuthorizationError, | |
| 572 | 574 | }); |
| 573 | 575 | |
| 574 | 576 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { |
| ... | ... | @@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 728 | 730 | WsSubscribeTerminalMetadataRpc, |
| 729 | 731 | WsPreviewOpenRpc, |
| 730 | 732 | WsPreviewNavigateRpc, |
| 733 | WsPreviewResizeRpc, | |
| 731 | 734 | WsPreviewRefreshRpc, |
| 732 | 735 | WsPreviewCloseRpc, |
| 733 | 736 | WsPreviewListRpc, |
| 734 | 737 | WsPreviewReportStatusRpc, |
| 735 | 738 | WsPreviewAutomationConnectRpc, |
| 736 | 739 | WsPreviewAutomationRespondRpc, |
| 737 | WsPreviewAutomationReportOwnerRpc, | |
| 738 | WsPreviewAutomationClearOwnerRpc, | |
| 740 | WsPreviewAutomationFocusHostRpc, | |
| 739 | 741 | WsSubscribePreviewEventsRpc, |
| 740 | 742 | WsSubscribeDiscoveredLocalServersRpc, |
| 741 | 743 | WsSubscribeServerConfigRpc, |
packages/shared/package.json+4| ... | ... | @@ -163,6 +163,10 @@ |
| 163 | 163 | "types": "./src/preview.ts", |
| 164 | 164 | "import": "./src/preview.ts" |
| 165 | 165 | }, |
| 166 | "./previewViewport": { | |
| 167 | "types": "./src/previewViewport.ts", | |
| 168 | "import": "./src/previewViewport.ts" | |
| 169 | }, | |
| 166 | 170 | "./filePreview": { |
| 167 | 171 | "types": "./src/filePreview.ts", |
| 168 | 172 | "import": "./src/filePreview.ts" |
packages/shared/src/previewViewport.test.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | PREVIEW_VIEWPORT_PRESETS, | |
| 5 | previewViewportLabel, | |
| 6 | previewViewportPresetOrientation, | |
| 7 | resolvePreviewViewport, | |
| 8 | } from "./previewViewport.ts"; | |
| 9 | ||
| 10 | describe("previewViewport", () => { | |
| 11 | it("resolves fill and exact freeform viewports", () => { | |
| 12 | expect(resolvePreviewViewport({ mode: "fill" })).toEqual({ _tag: "fill" }); | |
| 13 | expect(resolvePreviewViewport({ mode: "freeform", width: 1024, height: 768 })).toEqual({ | |
| 14 | _tag: "freeform", | |
| 15 | width: 1024, | |
| 16 | height: 768, | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | it("resolves device presets in either orientation", () => { | |
| 21 | expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-12-pro" })).toEqual({ | |
| 22 | _tag: "preset", | |
| 23 | width: 390, | |
| 24 | height: 844, | |
| 25 | presetId: "iphone-12-pro", | |
| 26 | }); | |
| 27 | expect( | |
| 28 | resolvePreviewViewport({ | |
| 29 | mode: "preset", | |
| 30 | preset: "iphone-12-pro", | |
| 31 | orientation: "landscape", | |
| 32 | }), | |
| 33 | ).toEqual({ | |
| 34 | _tag: "preset", | |
| 35 | width: 844, | |
| 36 | height: 390, | |
| 37 | presetId: "iphone-12-pro", | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("matches Chrome's standard device catalog ordering", () => { | |
| 42 | expect(PREVIEW_VIEWPORT_PRESETS.map((preset) => preset.label)).toEqual([ | |
| 43 | "iPhone SE", | |
| 44 | "iPhone XR", | |
| 45 | "iPhone 12 Pro", | |
| 46 | "iPhone 14 Pro Max", | |
| 47 | "Pixel 7", | |
| 48 | "Samsung Galaxy S8+", | |
| 49 | "Samsung Galaxy S20 Ultra", | |
| 50 | "iPad Mini", | |
| 51 | "iPad Air", | |
| 52 | "iPad Pro", | |
| 53 | "Surface Pro 7", | |
| 54 | "Surface Duo", | |
| 55 | "Galaxy Z Fold 5", | |
| 56 | "Asus Zenbook Fold", | |
| 57 | "Samsung Galaxy A51/71", | |
| 58 | "Nest Hub", | |
| 59 | "Nest Hub Max", | |
| 60 | ]); | |
| 61 | }); | |
| 62 | ||
| 63 | it("formats settings for compact UI", () => { | |
| 64 | expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); | |
| 65 | expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); | |
| 66 | expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( | |
| 67 | "landscape", | |
| 68 | ); | |
| 69 | }); | |
| 70 | }); |
packages/shared/src/previewViewport.ts created+186| ... | ... | @@ -0,0 +1,186 @@ |
| 1 | import type { | |
| 2 | PreviewAutomationResizeInput, | |
| 3 | PreviewViewportPresetId, | |
| 4 | PreviewViewportSetting, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { PREVIEW_VIEWPORT_PRESET_IDS } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | export interface PreviewViewportPreset { | |
| 9 | readonly id: PreviewViewportPresetId; | |
| 10 | readonly label: string; | |
| 11 | readonly category: "Desktop" | "Tablet" | "Phone"; | |
| 12 | readonly detail: string; | |
| 13 | readonly width: number; | |
| 14 | readonly height: number; | |
| 15 | } | |
| 16 | ||
| 17 | type PreviewViewportPresetDefinition = Omit<PreviewViewportPreset, "id">; | |
| 18 | ||
| 19 | // Keep this in Chrome DevTools' default-device order. Dimensions are CSS | |
| 20 | // viewport sizes from Chromium's EmulatedDevices.ts standard catalog. | |
| 21 | const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = { | |
| 22 | "iphone-se": { | |
| 23 | label: "iPhone SE", | |
| 24 | category: "Phone", | |
| 25 | detail: "375 × 667", | |
| 26 | width: 375, | |
| 27 | height: 667, | |
| 28 | }, | |
| 29 | "iphone-xr": { | |
| 30 | label: "iPhone XR", | |
| 31 | category: "Phone", | |
| 32 | detail: "414 × 896", | |
| 33 | width: 414, | |
| 34 | height: 896, | |
| 35 | }, | |
| 36 | "iphone-12-pro": { | |
| 37 | label: "iPhone 12 Pro", | |
| 38 | category: "Phone", | |
| 39 | detail: "390 × 844", | |
| 40 | width: 390, | |
| 41 | height: 844, | |
| 42 | }, | |
| 43 | "iphone-14-pro-max": { | |
| 44 | label: "iPhone 14 Pro Max", | |
| 45 | category: "Phone", | |
| 46 | detail: "430 × 932", | |
| 47 | width: 430, | |
| 48 | height: 932, | |
| 49 | }, | |
| 50 | "pixel-7": { | |
| 51 | label: "Pixel 7", | |
| 52 | category: "Phone", | |
| 53 | detail: "412 × 915", | |
| 54 | width: 412, | |
| 55 | height: 915, | |
| 56 | }, | |
| 57 | "samsung-galaxy-s8-plus": { | |
| 58 | label: "Samsung Galaxy S8+", | |
| 59 | category: "Phone", | |
| 60 | detail: "360 × 740", | |
| 61 | width: 360, | |
| 62 | height: 740, | |
| 63 | }, | |
| 64 | "samsung-galaxy-s20-ultra": { | |
| 65 | label: "Samsung Galaxy S20 Ultra", | |
| 66 | category: "Phone", | |
| 67 | detail: "412 × 915", | |
| 68 | width: 412, | |
| 69 | height: 915, | |
| 70 | }, | |
| 71 | "ipad-mini": { | |
| 72 | label: "iPad Mini", | |
| 73 | category: "Tablet", | |
| 74 | detail: "768 × 1024", | |
| 75 | width: 768, | |
| 76 | height: 1024, | |
| 77 | }, | |
| 78 | "ipad-air": { | |
| 79 | label: "iPad Air", | |
| 80 | category: "Tablet", | |
| 81 | detail: "820 × 1180", | |
| 82 | width: 820, | |
| 83 | height: 1180, | |
| 84 | }, | |
| 85 | "ipad-pro": { | |
| 86 | label: "iPad Pro", | |
| 87 | category: "Tablet", | |
| 88 | detail: "1024 × 1366", | |
| 89 | width: 1024, | |
| 90 | height: 1366, | |
| 91 | }, | |
| 92 | "surface-pro-7": { | |
| 93 | label: "Surface Pro 7", | |
| 94 | category: "Tablet", | |
| 95 | detail: "912 × 1368", | |
| 96 | width: 912, | |
| 97 | height: 1368, | |
| 98 | }, | |
| 99 | "surface-duo": { | |
| 100 | label: "Surface Duo", | |
| 101 | category: "Phone", | |
| 102 | detail: "540 × 720", | |
| 103 | width: 540, | |
| 104 | height: 720, | |
| 105 | }, | |
| 106 | "galaxy-z-fold-5": { | |
| 107 | label: "Galaxy Z Fold 5", | |
| 108 | category: "Phone", | |
| 109 | detail: "344 × 882", | |
| 110 | width: 344, | |
| 111 | height: 882, | |
| 112 | }, | |
| 113 | "asus-zenbook-fold": { | |
| 114 | label: "Asus Zenbook Fold", | |
| 115 | category: "Tablet", | |
| 116 | detail: "853 × 1280", | |
| 117 | width: 853, | |
| 118 | height: 1280, | |
| 119 | }, | |
| 120 | "samsung-galaxy-a51-71": { | |
| 121 | label: "Samsung Galaxy A51/71", | |
| 122 | category: "Phone", | |
| 123 | detail: "412 × 914", | |
| 124 | width: 412, | |
| 125 | height: 914, | |
| 126 | }, | |
| 127 | "nest-hub": { | |
| 128 | label: "Nest Hub", | |
| 129 | category: "Tablet", | |
| 130 | detail: "1024 × 600", | |
| 131 | width: 1024, | |
| 132 | height: 600, | |
| 133 | }, | |
| 134 | "nest-hub-max": { | |
| 135 | label: "Nest Hub Max", | |
| 136 | category: "Tablet", | |
| 137 | detail: "1280 × 800", | |
| 138 | width: 1280, | |
| 139 | height: 800, | |
| 140 | }, | |
| 141 | } as const satisfies Record<PreviewViewportPresetId, PreviewViewportPresetDefinition>; | |
| 142 | ||
| 143 | export const PREVIEW_VIEWPORT_PRESETS: ReadonlyArray<PreviewViewportPreset> = | |
| 144 | PREVIEW_VIEWPORT_PRESET_IDS.map((id) => ({ | |
| 145 | id, | |
| 146 | ...PREVIEW_VIEWPORT_PRESET_DEFINITIONS[id], | |
| 147 | })); | |
| 148 | ||
| 149 | export function resolvePreviewViewport( | |
| 150 | input: PreviewAutomationResizeInput, | |
| 151 | ): PreviewViewportSetting { | |
| 152 | if (input.mode === "fill") return { _tag: "fill" }; | |
| 153 | if (input.mode === "preset" && input.preset !== undefined) { | |
| 154 | const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === input.preset); | |
| 155 | if (!preset) throw new Error(`Unknown preview viewport preset: ${input.preset}`); | |
| 156 | const landscape = input.orientation === "landscape"; | |
| 157 | const portrait = input.orientation === "portrait"; | |
| 158 | const nativePortrait = preset.height >= preset.width; | |
| 159 | const shouldSwap = (landscape && nativePortrait) || (portrait && !nativePortrait); | |
| 160 | return { | |
| 161 | _tag: "preset", | |
| 162 | width: shouldSwap ? preset.height : preset.width, | |
| 163 | height: shouldSwap ? preset.width : preset.height, | |
| 164 | presetId: preset.id, | |
| 165 | }; | |
| 166 | } | |
| 167 | if (input.width === undefined || input.height === undefined) { | |
| 168 | throw new Error("Custom preview viewport requires width and height"); | |
| 169 | } | |
| 170 | return { | |
| 171 | _tag: "freeform", | |
| 172 | width: input.width, | |
| 173 | height: input.height, | |
| 174 | }; | |
| 175 | } | |
| 176 | ||
| 177 | export function previewViewportLabel(viewport: PreviewViewportSetting): string { | |
| 178 | return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; | |
| 179 | } | |
| 180 | ||
| 181 | export function previewViewportPresetOrientation( | |
| 182 | viewport: PreviewViewportSetting, | |
| 183 | ): "portrait" | "landscape" | null { | |
| 184 | if (viewport._tag === "fill" || viewport.width === viewport.height) return null; | |
| 185 | return viewport.width > viewport.height ? "landscape" : "portrait"; | |
| 186 | } |
scripts/dev-runner.test.ts+1| ... | ... | @@ -253,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 253 | 253 | T3CODE_MODE: "web", |
| 254 | 254 | T3CODE_NO_BROWSER: "0", |
| 255 | 255 | T3CODE_HOST: "0.0.0.0", |
| 256 | VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", | |
| 256 | 257 | VITE_WS_URL: "ws://localhost:13773", |
| 257 | 258 | }, |
| 258 | 259 | serverOffset: 0, |
scripts/dev-runner.ts+5-8| ... | ... | @@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined> |
| 158 | 158 | Config.option, |
| 159 | 159 | Config.map((value) => Option.getOrUndefined(value)), |
| 160 | 160 | ); |
| 161 | const optionalUrlConfig = (name: string): Config.Config<URL | undefined> => | |
| 162 | Config.url(name).pipe( | |
| 163 | Config.option, | |
| 164 | Config.map((value) => Option.getOrUndefined(value)), | |
| 165 | ); | |
| 166 | ||
| 167 | 161 | const OffsetConfig = Config.all({ |
| 168 | 162 | portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), |
| 169 | 163 | devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), |
| ... | ... | @@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", { |
| 621 | 615 | ), |
| 622 | 616 | devUrl: Flag.string("dev-url").pipe( |
| 623 | 617 | Flag.withSchema(Schema.URLFromString), |
| 624 | Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), | |
| 625 | Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")), | |
| 618 | Flag.withDescription( | |
| 619 | "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.", | |
| 620 | ), | |
| 621 | Flag.optional, | |
| 622 | Flag.map(Option.getOrUndefined), | |
| 626 | 623 | ), |
| 627 | 624 | dryRun: Flag.boolean("dry-run").pipe( |
| 628 | 625 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), |