| 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,25 +100,41 @@ function shellSingleQuote(value) { |
| 100 | return `'${value.replaceAll("'", "'\\''")}'`; | 100 | return `'${value.replaceAll("'", "'\\''")}'`; |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { | 103 | export function makeDevelopmentLauncherScript({ |
| 104 | const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); | 104 | electronBinaryPath, |
| 105 | mainEntryPath, | ||
| 106 | desktopRoot, | ||
| 107 | environment, | ||
| 108 | }) { | ||
| 105 | const envEntries = [ | 109 | const envEntries = [ |
| 106 | ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], | 110 | ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL], |
| 107 | ["T3CODE_PORT", process.env.T3CODE_PORT], | 111 | ["T3CODE_PORT", environment.T3CODE_PORT], |
| 108 | ["T3CODE_HOME", process.env.T3CODE_HOME], | 112 | ["T3CODE_HOME", environment.T3CODE_HOME], |
| 109 | ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH], | 113 | ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], |
| 110 | ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], | 114 | ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], |
| 111 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], | 115 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], |
| 112 | ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], | 116 | ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], |
| 113 | ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); | 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 | NodeFS.writeFileSync( | 130 | NodeFS.writeFileSync( |
| 115 | targetBinaryPath, | 131 | targetBinaryPath, |
| 116 | [ | 132 | makeDevelopmentLauncherScript({ |
| 117 | "#!/bin/sh", | 133 | electronBinaryPath, |
| 118 | ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), | 134 | mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"), |
| 119 | `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, | 135 | desktopRoot: desktopDir, |
| 120 | "", | 136 | environment: process.env, |
| 121 | ].join("\n"), | 137 | }), |
| 122 | ); | 138 | ); |
| 123 | NodeFS.chmodSync(targetBinaryPath, 0o755); | 139 | NodeFS.chmodSync(targetBinaryPath, 0o755); |
| 124 | } | 140 | } |
| ... | @@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) { | ... | @@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) { |
| 286 | currentMetadata && | 302 | currentMetadata && |
| 287 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) | 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 | registerMacLauncherBundle(targetAppBundlePath); | 311 | registerMacLauncherBundle(targetAppBundlePath); |
| 290 | return targetBinaryPath; | 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,16 +18,25 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 18 | import * as BrowserSession from "./BrowserSession.ts"; | 18 | import * as BrowserSession from "./BrowserSession.ts"; |
| 19 | import * as PreviewManager from "./Manager.ts"; | 19 | import * as PreviewManager from "./Manager.ts"; |
| 20 | 20 | ||
| 21 | const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = | 21 | const { |
| 22 | vi.hoisted(() => ({ | 22 | createFromPath, |
| 23 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), | 23 | fromId, |
| 24 | fromId: vi.fn(() => null), | 24 | getFocusedWebContents, |
| 25 | mkdir: vi.fn((_path: string) => undefined), | 25 | mkdir, |
| 26 | showItemInFolder: vi.fn(), | 26 | showItemInFolder, |
| 27 | webviewSend: vi.fn(), | 27 | webviewSend, |
| 28 | writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), | 28 | writeFile, |
| 29 | writeImage: vi.fn(), | 29 | writeImage, |
| 30 | })); | 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 | vi.mock("electron", () => ({ | 41 | vi.mock("electron", () => ({ |
| 33 | clipboard: { | 42 | clipboard: { |
| ... | @@ -44,6 +53,7 @@ vi.mock("electron", () => ({ | ... | @@ -44,6 +53,7 @@ vi.mock("electron", () => ({ |
| 44 | }, | 53 | }, |
| 45 | webContents: { | 54 | webContents: { |
| 46 | fromId, | 55 | fromId, |
| 56 | getFocusedWebContents, | ||
| 47 | }, | 57 | }, |
| 48 | })); | 58 | })); |
| 49 | 59 | ||
| ... | @@ -97,6 +107,8 @@ const withManager = <A>( | ... | @@ -97,6 +107,8 @@ const withManager = <A>( |
| 97 | describe("PreviewManager", () => { | 107 | describe("PreviewManager", () => { |
| 98 | beforeEach(() => { | 108 | beforeEach(() => { |
| 99 | fromId.mockClear(); | 109 | fromId.mockClear(); |
| 110 | getFocusedWebContents.mockReset(); | ||
| 111 | getFocusedWebContents.mockReturnValue(null); | ||
| 100 | mkdir.mockClear(); | 112 | mkdir.mockClear(); |
| 101 | writeFile.mockClear(); | 113 | writeFile.mockClear(); |
| 102 | showItemInFolder.mockClear(); | 114 | showItemInFolder.mockClear(); |
| ... | @@ -250,6 +262,197 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("captures a PNG screenshot into browser artifacts", () => | 456 | effectIt.effect("captures a PNG screenshot into browser artifacts", () => |
| 254 | withManager((manager) => | 457 | withManager((manager) => |
| 255 | Effect.gen(function* () { | 458 | Effect.gen(function* () { |
| ... | @@ -511,6 +714,178 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("still interrupts agent control for a different human pointer event", () => | 889 | effectIt.effect("still interrupts agent control for a different human pointer event", () => |
| 515 | withManager((manager) => | 890 | withManager((manager) => |
| 516 | Effect.gen(function* () { | 891 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+232-74| ... | @@ -62,6 +62,7 @@ import { | ... | @@ -62,6 +62,7 @@ import { |
| 62 | } from "./GuestProtocol.ts"; | 62 | } from "./GuestProtocol.ts"; |
| 63 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; | 63 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; |
| 64 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; | 64 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; |
| 65 | import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; | ||
| 65 | 66 | ||
| 66 | export type PreviewNavStatus = | 67 | export type PreviewNavStatus = |
| 67 | | { kind: "Idle" } | 68 | | { kind: "Idle" } |
| ... | @@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 854 | commandParams?: Record<string, unknown>, | 855 | commandParams?: Record<string, unknown>, |
| 855 | ) => Effect.Effect<unknown, PreviewManagerError>; | 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 | const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>( | 871 | const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>( |
| 858 | tabId: string, | 872 | tabId: string, |
| 859 | wc: Electron.WebContents, | 873 | wc: Electron.WebContents, |
| 860 | action: string, | 874 | action: string, |
| 861 | use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>, | 875 | use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect<A, PreviewManagerError>, |
| 862 | ) { | 876 | ) { |
| 863 | const sequence = yield* nextCounter(actionSequenceRef); | 877 | const sequence = yield* nextCounter(actionSequenceRef); |
| 864 | const startedAt = yield* currentIso; | 878 | const startedAt = yield* currentIso; |
| ... | @@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 899 | return result; | 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 | const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( | 933 | const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( |
| 905 | exit: Exit.Exit<A, PreviewManagerError>, | 934 | exit: Exit.Exit<A, PreviewManagerError>, |
| ... | @@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1091 | wc: Electron.WebContents, | 1120 | wc: Electron.WebContents, |
| 1092 | ) { | 1121 | ) { |
| 1093 | const scope = yield* Scope.fork(parentScope, "sequential"); | 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 | if (wc.isDestroyed()) return; | 1126 | if (wc.isDestroyed()) return; |
| 1096 | yield* update(tabId, { | 1127 | const zoomFactor = yield* attempt( |
| 1097 | navStatus: computeNavStatus(wc), | 1128 | { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, |
| 1098 | canGoBack: wc.navigationHistory.canGoBack(), | 1129 | () => wc.getZoomFactor(), |
| 1099 | canGoForward: wc.navigationHistory.canGoForward(), | 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()); | 1164 | const sync = () => runFork(syncState(true)); |
| 1103 | const failed = (_event: Event, code: number, description: string): void => { | 1165 | const syncNavigation = () => runFork(syncState(false)); |
| 1104 | if (code === -3) return; | 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 | runFork( | 1174 | runFork( |
| 1106 | update(tabId, { | 1175 | update(tabId, { |
| 1107 | navStatus: { | 1176 | navStatus: { |
| 1108 | kind: "LoadFailed", | 1177 | kind: "LoadFailed", |
| 1109 | url: wc.getURL(), | 1178 | url: validatedUrl || wc.getURL(), |
| 1110 | title: wc.getTitle(), | 1179 | title: wc.getTitle(), |
| 1111 | code, | 1180 | code, |
| 1112 | description, | 1181 | description, |
| ... | @@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1161 | yield* Scope.addFinalizer( | 1230 | yield* Scope.addFinalizer( |
| 1162 | scope, | 1231 | scope, |
| 1163 | attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { | 1232 | attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { |
| 1164 | wc.off("did-navigate", sync); | 1233 | wc.off("did-navigate", syncNavigation); |
| 1165 | wc.off("did-navigate-in-page", sync); | 1234 | wc.off("did-navigate-in-page", syncNavigation); |
| 1166 | wc.off("page-title-updated", sync); | 1235 | wc.off("page-title-updated", sync); |
| 1167 | wc.off("did-start-loading", sync); | 1236 | wc.off("did-start-loading", sync); |
| 1168 | wc.off("did-stop-loading", sync); | 1237 | wc.off("did-stop-loading", sync); |
| ... | @@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1173 | ); | 1242 | ); |
| 1174 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { | 1243 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| 1175 | yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { | 1244 | yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { |
| 1176 | wc.on("did-navigate", sync); | 1245 | wc.on("did-navigate", syncNavigation); |
| 1177 | wc.on("did-navigate-in-page", sync); | 1246 | wc.on("did-navigate-in-page", syncNavigation); |
| 1178 | wc.on("page-title-updated", sync); | 1247 | wc.on("page-title-updated", sync); |
| 1179 | wc.on("did-start-loading", sync); | 1248 | wc.on("did-start-loading", sync); |
| 1180 | wc.on("did-stop-loading", sync); | 1249 | wc.on("did-stop-loading", sync); |
| ... | @@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1280 | const attached = yield* Ref.get(attachedRef); | 1349 | const attached = yield* Ref.get(attachedRef); |
| 1281 | const annotationTheme = yield* Ref.get(annotationThemeRef); | 1350 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1282 | if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { | 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 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1357 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1284 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), | 1358 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1285 | ); | 1359 | ); |
| 1286 | return; | 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 | yield* Effect.all( | 1365 | yield* Effect.all( |
| 1290 | [ | 1366 | [ |
| 1291 | detachControlSession(tab.webContentsId), | 1367 | detachControlSession(replacedWebContentsId), |
| 1292 | detachListeners(tab.webContentsId), | 1368 | detachListeners(replacedWebContentsId), |
| 1293 | cancelPickElement(tabId), | 1369 | cancelPickElement(tabId), |
| 1294 | ], | 1370 | ], |
| 1295 | { concurrency: 3, discard: true }, | 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 | yield* attachListeners(tabId, wc); | 1386 | yield* attachListeners(tabId, wc); |
| 1299 | runFork(ensureControlSession(wc).pipe(Effect.ignore)); | 1387 | runFork(ensureControlSession(wc).pipe(Effect.ignore)); |
| 1300 | const registeredAt = yield* currentIso; | 1388 | const registeredAt = yield* currentIso; |
| ... | @@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1313 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | 1401 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1314 | canGoBack: wc.navigationHistory.canGoBack(), | 1402 | canGoBack: wc.navigationHistory.canGoBack(), |
| 1315 | canGoForward: wc.navigationHistory.canGoForward(), | 1403 | canGoForward: wc.navigationHistory.canGoForward(), |
| 1404 | zoomFactor, | ||
| 1316 | updatedAt: registeredAt, | 1405 | updatedAt: registeredAt, |
| 1317 | }; | 1406 | }; |
| 1318 | return [ | 1407 | return [ |
| ... | @@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1330 | } | 1419 | } |
| 1331 | const { state: registered, pendingUrl } = registration.value; | 1420 | const { state: registered, pendingUrl } = registration.value; |
| 1332 | yield* emit(tabId, registered); | 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 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1422 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1339 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), | 1423 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1340 | ); | 1424 | ); |
| ... | @@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1946 | input: PreviewAutomationClickInput, | 2030 | input: PreviewAutomationClickInput, |
| 1947 | send: SendCommand, | 2031 | send: SendCommand, |
| 1948 | ) { | 2032 | ) { |
| 1949 | yield* Effect.all( | 2033 | yield* prepareAutomationInput(send, true); |
| 1950 | [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], | ||
| 1951 | { concurrency: 2, discard: true }, | ||
| 1952 | ); | ||
| 1953 | const point = yield* resolveClickPoint(tabId, send, input); | 2034 | const point = yield* resolveClickPoint(tabId, send, input); |
| 1954 | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( | 2035 | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( |
| 1955 | tabId, | 2036 | tabId, |
| ... | @@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | tabId: string, | 2096 | tabId: string, |
| 2016 | send: SendCommand, | 2097 | send: SendCommand, |
| 2017 | input: PreviewAutomationTypeInput, | 2098 | input: PreviewAutomationTypeInput, |
| ... | @@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2021 | const locatorJson = locator | 2102 | const locatorJson = locator |
| 2022 | ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) | 2103 | ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) |
| 2023 | : null; | 2104 | : null; |
| 2105 | const textJson = yield* encodeJson( | ||
| 2106 | { operation: "automationType.encodeText", tabId }, | ||
| 2107 | input.text, | ||
| 2108 | ); | ||
| 2024 | const result = yield* evaluateWithDebugger< | 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 | tabId, | 2115 | tabId, |
| 2028 | send, | 2116 | send, |
| ... | @@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2030 | try { | 2118 | try { |
| 2031 | const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; | 2119 | const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; |
| 2032 | if (!element) return { notFound: true }; | 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 | element.focus(); | 2127 | element.focus(); |
| 2034 | if (${input.clear ?? false}) { | 2128 | if (document.activeElement !== element) return { notEditable: true }; |
| 2035 | if ("value" in element) element.value = ""; | 2129 | const clear = ${input.clear ?? false}; |
| 2036 | else if (element.isContentEditable) element.textContent = ""; | 2130 | if (clear) { |
| 2037 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | 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 | return { ok: true }; | 2169 | return { ok: true }; |
| 2040 | } catch (error) { | 2170 | } catch (error) { |
| 2041 | return { invalidSelector: true, message: String(error) }; | 2171 | return { invalidSelector: true, message: String(error) }; |
| ... | @@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2059 | ...automationSelectorDiagnostics(input), | 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 | const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( | 2200 | const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( |
| ... | @@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2066 | input: PreviewAutomationTypeInput, | 2202 | input: PreviewAutomationTypeInput, |
| 2067 | send: SendCommand, | 2203 | send: SendCommand, |
| 2068 | ) { | 2204 | ) { |
| 2069 | yield* send("Runtime.enable"); | 2205 | // CDP Input.insertText silently drops text until Electron has activated a hidden |
| 2070 | yield* focusAutomationTarget(tabId, send, input); | 2206 | // guest WebContents with a pointer event. Editing in the page runtime keeps |
| 2071 | yield* send("Input.insertText", { text: input.text }); | 2207 | // background automation deterministic without stealing foreground app focus. |
| 2072 | const textJson = yield* encodeJson( | 2208 | yield* typeIntoAutomationTarget(tabId, send, input); |
| 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 | ); | ||
| 2086 | }); | 2209 | }); |
| 2087 | 2210 | ||
| 2088 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( | 2211 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( |
| ... | @@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2097 | 2220 | ||
| 2098 | const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( | 2221 | const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( |
| 2099 | tabId: string, | 2222 | tabId: string, |
| 2223 | wc: Electron.WebContents, | ||
| 2100 | input: PreviewAutomationPressInput, | 2224 | input: PreviewAutomationPressInput, |
| 2101 | send: SendCommand, | 2225 | send: SendCommand, |
| 2226 | sendCleanup: SendCommand, | ||
| 2102 | ) { | 2227 | ) { |
| 2103 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | 2228 | yield* prepareAutomationInput(send, false); |
| 2104 | switch (modifier) { | 2229 | const keySequence = makePreviewAutomationKeySequence(input); |
| 2105 | case "Alt": | 2230 | const previouslyFocused = yield* attempt( |
| 2106 | return value | 1; | 2231 | { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, |
| 2107 | case "Control": | 2232 | () => webContents.getFocusedWebContents(), |
| 2108 | return value | 2; | 2233 | ); |
| 2109 | case "Meta": | 2234 | let keyDownAttempted = false; |
| 2110 | return value | 4; | 2235 | const releaseInput = Effect.gen(function* () { |
| 2111 | case "Shift": | 2236 | if (keyDownAttempted) { |
| 2112 | return value | 8; | 2237 | yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore); |
| 2113 | } | 2238 | } |
| 2114 | }, 0); | 2239 | yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( |
| 2115 | const key = input.key; | 2240 | Effect.ignore, |
| 2116 | const text = key.length === 1 ? key : undefined; | 2241 | ); |
| 2117 | const params = { | 2242 | if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) { |
| 2118 | key, | 2243 | yield* attempt( |
| 2119 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | 2244 | { |
| 2120 | modifiers, | 2245 | operation: "automationPress.restoreFocusedWebContents", |
| 2121 | ...(text ? { text, unmodifiedText: text } : {}), | 2246 | tabId, |
| 2122 | }; | 2247 | webContentsId: previouslyFocused.id, |
| 2123 | yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); | 2248 | }, |
| 2124 | yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | 2249 | () => previouslyFocused.focus(), |
| 2125 | yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | 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 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( | 2271 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( |
| ... | @@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2130 | input: PreviewAutomationPressInput, | 2273 | input: PreviewAutomationPressInput, |
| 2131 | ) { | 2274 | ) { |
| 2132 | const wc = yield* requireWebContents(tabId); | 2275 | const wc = yield* requireWebContents(tabId); |
| 2133 | yield* withControlSession(tabId, wc, "press", (send) => | 2276 | yield* withControlSession(tabId, wc, "press", (send, sendCleanup) => |
| 2134 | performAutomationPress(tabId, input, send), | 2277 | performAutomationPress(tabId, wc, input, send, sendCleanup), |
| 2135 | ); | 2278 | ); |
| 2136 | }); | 2279 | }); |
| 2137 | 2280 | ||
| ... | @@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas | ... | @@ -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 | export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()( | 2689 | export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()( |
| 2533 | "PreviewAutomationCoordinatesOutsideViewportError", | 2690 | "PreviewAutomationCoordinatesOutsideViewportError", |
| 2534 | { | 2691 | { |
| ... | @@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([ | ... | @@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([ |
| 2631 | PreviewAutomationDebuggerAttachedError, | 2788 | PreviewAutomationDebuggerAttachedError, |
| 2632 | PreviewAutomationEvaluationError, | 2789 | PreviewAutomationEvaluationError, |
| 2633 | PreviewAutomationTargetNotFoundError, | 2790 | PreviewAutomationTargetNotFoundError, |
| 2791 | PreviewAutomationTargetNotEditableError, | ||
| 2634 | PreviewAutomationCoordinatesOutsideViewportError, | 2792 | PreviewAutomationCoordinatesOutsideViewportError, |
| 2635 | PreviewAutomationInvalidSelectorError, | 2793 | PreviewAutomationInvalidSelectorError, |
| 2636 | PreviewAutomationResultTooLargeError, | 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,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; | ||
| 7 | 8 | ||
| 8 | import type * as Electron from "electron"; | 9 | import type * as Electron from "electron"; |
| 9 | import { vi } from "vite-plus/test"; | 10 | import { vi } from "vite-plus/test"; |
| ... | @@ -47,12 +48,14 @@ function makeFakeBrowserWindow() { | ... | @@ -47,12 +48,14 @@ function makeFakeBrowserWindow() { |
| 47 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); | 48 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 48 | const webContents = { | 49 | const webContents = { |
| 49 | copyImageAt: vi.fn(), | 50 | copyImageAt: vi.fn(), |
| 51 | getURL: vi.fn(() => "t3code-dev://app/"), | ||
| 50 | isLoadingMainFrame: vi.fn(() => false), | 52 | isLoadingMainFrame: vi.fn(() => false), |
| 51 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { | 53 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 52 | webContentsListeners.set(eventName, listener); | 54 | webContentsListeners.set(eventName, listener); |
| 53 | }), | 55 | }), |
| 54 | once: vi.fn(), | 56 | once: vi.fn(), |
| 55 | openDevTools: vi.fn(), | 57 | openDevTools: vi.fn(), |
| 58 | reload: vi.fn(), | ||
| 56 | replaceMisspelling: vi.fn(), | 59 | replaceMisspelling: vi.fn(), |
| 57 | send: vi.fn(), | 60 | send: vi.fn(), |
| 58 | setWindowOpenHandler: vi.fn(), | 61 | setWindowOpenHandler: vi.fn(), |
| ... | @@ -79,6 +82,7 @@ function makeFakeBrowserWindow() { | ... | @@ -79,6 +82,7 @@ function makeFakeBrowserWindow() { |
| 79 | window: window as unknown as Electron.BrowserWindow, | 82 | window: window as unknown as Electron.BrowserWindow, |
| 80 | loadURL: window.loadURL, | 83 | loadURL: window.loadURL, |
| 81 | openDevTools: webContents.openDevTools, | 84 | openDevTools: webContents.openDevTools, |
| 85 | reload: webContents.reload, | ||
| 82 | setAutoHideCursor: window.setAutoHideCursor, | 86 | setAutoHideCursor: window.setAutoHideCursor, |
| 83 | webContentsListeners, | 87 | webContentsListeners, |
| 84 | }; | 88 | }; |
| ... | @@ -237,6 +241,73 @@ describe("DesktopWindow", () => { | ... | @@ -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 | it.effect("opens safe off-origin renderer navigations in the system browser", () => | 311 | it.effect("opens safe off-origin renderer navigations in the system browser", () => |
| 241 | Effect.gen(function* () { | 312 | Effect.gen(function* () { |
| 242 | const fakeWindow = makeFakeBrowserWindow(); | 313 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+95-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; | ||
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 5 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| ... | @@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40; | ... | @@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40; |
| 22 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | 23 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| 23 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | 24 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; |
| 24 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | 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 | type WindowTitleBarOptions = Pick< | 37 | type WindowTitleBarOptions = Pick< |
| 27 | Electron.BrowserWindowConstructorOptions, | 38 | Electron.BrowserWindowConstructorOptions, |
| ... | @@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: { | ... | @@ -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 | function getWindowTitleBarOptions( | 116 | function getWindowTitleBarOptions( |
| 90 | shouldUseDarkColors: boolean, | 117 | shouldUseDarkColors: boolean, |
| 91 | platform: NodeJS.Platform, | 118 | platform: NodeJS.Platform, |
| ... | @@ -152,6 +179,7 @@ export const make = Effect.gen(function* () { | ... | @@ -152,6 +179,7 @@ export const make = Effect.gen(function* () { |
| 152 | const previewManager = yield* PreviewManager.PreviewManager; | 179 | const previewManager = yield* PreviewManager.PreviewManager; |
| 153 | const state = yield* DesktopState.DesktopState; | 180 | const state = yield* DesktopState.DesktopState; |
| 154 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); | 181 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 182 | const runFork = Effect.runForkWith(context); | ||
| 155 | const runPromise = Effect.runPromiseWith(context); | 183 | const runPromise = Effect.runPromiseWith(context); |
| 156 | 184 | ||
| 157 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< | 185 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< |
| ... | @@ -277,7 +305,61 @@ export const make = Effect.gen(function* () { | ... | @@ -277,7 +305,61 @@ export const make = Effect.gen(function* () { |
| 277 | event.preventDefault(); | 305 | event.preventDefault(); |
| 278 | window.setTitle(environment.displayName); | 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 | window.webContents.on("did-finish-load", () => { | 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 | window.setTitle(environment.displayName); | 363 | window.setTitle(environment.displayName); |
| 282 | }); | 364 | }); |
| 283 | window.webContents.on( | 365 | window.webContents.on( |
| ... | @@ -286,11 +368,22 @@ export const make = Effect.gen(function* () { | ... | @@ -286,11 +368,22 @@ export const make = Effect.gen(function* () { |
| 286 | if (!isMainFrame) { | 368 | if (!isMainFrame) { |
| 287 | return; | 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 | void runPromise( | 381 | void runPromise( |
| 290 | logWindowWarning("main window failed to load", { | 382 | logWindowWarning("main window failed to load", { |
| 291 | errorCode, | 383 | errorCode, |
| 292 | errorDescription, | 384 | errorDescription, |
| 293 | url: validatedURL, | 385 | url: validatedURL, |
| 386 | ...(retryInMs === undefined ? {} : { retryInMs }), | ||
| 294 | }), | 387 | }), |
| 295 | ); | 388 | ); |
| 296 | }, | 389 | }, |
| ... | @@ -312,14 +405,13 @@ export const make = Effect.gen(function* () { | ... | @@ -312,14 +405,13 @@ export const make = Effect.gen(function* () { |
| 312 | void runPromise(electronWindow.reveal(window)); | 405 | void runPromise(electronWindow.reveal(window)); |
| 313 | }); | 406 | }); |
| 314 | 407 | ||
| 408 | loadApplication(); | ||
| 315 | if (environment.isDevelopment) { | 409 | if (environment.isDevelopment) { |
| 316 | void window.loadURL(applicationUrl); | ||
| 317 | window.webContents.openDevTools({ mode: "detach" }); | 410 | window.webContents.openDevTools({ mode: "detach" }); |
| 318 | } else { | ||
| 319 | void window.loadURL(applicationUrl); | ||
| 320 | } | 411 | } |
| 321 | 412 | ||
| 322 | window.on("closed", () => { | 413 | window.on("closed", () => { |
| 414 | clearDevelopmentLoadRetry(); | ||
| 323 | void runPromise(electronWindow.clearMain(Option.some(window))); | 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,5 +1,6 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { NodeHttpServer } from "@effect/platform-node"; | 2 | import { NodeHttpServer } from "@effect/platform-node"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 4 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| ... | @@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({ | ... | @@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({ |
| 34 | }); | 35 | }); |
| 35 | const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( | 36 | const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( |
| 36 | Layer.provideMerge(McpServer.McpServer.layer), | 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 | it("normalizes empty successful notification responses to accepted", () => { | 41 | it("normalizes empty successful notification responses to accepted", () => { |
| ... | @@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () => | ... | @@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () => |
| 54 | Effect.gen(function* () { | 55 | Effect.gen(function* () { |
| 55 | const server = yield* McpServer.McpServer; | 56 | const server = yield* McpServer.McpServer; |
| 56 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 57 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 57 | const requests = yield* broker.connect({ | 58 | const events = yield* broker.connect({ |
| 58 | clientId: "mcp-failure-client", | 59 | clientId: "mcp-failure-client", |
| 59 | environmentId, | 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) => | 62 | yield* Stream.runForEach(events, (event) => |
| 67 | broker.respond({ | 63 | event.type === "connected" |
| 68 | requestId: request.requestId, | 64 | ? Effect.void |
| 69 | ok: false, | 65 | : broker.respond({ |
| 70 | error: { | 66 | clientId: "mcp-failure-client", |
| 71 | _tag: "PreviewAutomationExecutionError", | 67 | connectionId: event.connectionId, |
| 72 | message: "sensitive renderer failure", | 68 | requestId: event.request.requestId, |
| 73 | detail: { consoleOutput: "sensitive browser output" }, | 69 | ok: false, |
| 74 | }, | 70 | error: { |
| 75 | }), | 71 | _tag: "PreviewAutomationExecutionError", |
| 72 | message: "sensitive renderer failure", | ||
| 73 | detail: { consoleOutput: "sensitive browser output" }, | ||
| 74 | }, | ||
| 75 | }), | ||
| 76 | ).pipe(Effect.forkScoped); | 76 | ).pipe(Effect.forkScoped); |
| 77 | yield* Effect.yieldNow; | 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 | const snapshot = yield* server | 79 | const snapshot = yield* server |
| 89 | .callTool({ name: "preview_snapshot", arguments: {} }) | 80 | .callTool({ name: "preview_snapshot", arguments: {} }) |
| ... | @@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 163 | Effect.gen(function* () { | 154 | Effect.gen(function* () { |
| 164 | const server = yield* McpServer.McpServer; | 155 | const server = yield* McpServer.McpServer; |
| 165 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 156 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 166 | const requests = yield* broker.connect({ | 157 | const events = yield* broker.connect({ |
| 167 | clientId: "mcp-test-client", | 158 | clientId: "mcp-test-client", |
| 168 | environmentId, | 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) => | 161 | yield* Stream.runForEach(events, (event) => |
| 176 | broker.respond({ | 162 | event.type === "connected" |
| 177 | requestId: request.requestId, | 163 | ? Effect.void |
| 178 | ok: true, | 164 | : broker.respond({ |
| 179 | result: | 165 | clientId: "mcp-test-client", |
| 180 | request.operation === "snapshot" | 166 | connectionId: event.connectionId, |
| 181 | ? { | 167 | requestId: event.request.requestId, |
| 182 | url: "http://example.test/", | 168 | ok: true, |
| 183 | title: "Example", | 169 | result: |
| 184 | loading: false, | 170 | event.request.operation === "snapshot" |
| 185 | visibleText: "Example", | 171 | ? { |
| 186 | interactiveElements: [], | 172 | url: "http://example.test/", |
| 187 | accessibilityTree: {}, | 173 | title: "Example", |
| 188 | consoleEntries: [], | 174 | loading: false, |
| 189 | networkEntries: [], | 175 | visibleText: "Example", |
| 190 | actionTimeline: [], | 176 | interactiveElements: [], |
| 191 | screenshot: { | 177 | accessibilityTree: {}, |
| 192 | mimeType: "image/png", | 178 | consoleEntries: [], |
| 193 | data: Buffer.from("png").toString("base64"), | 179 | networkEntries: [], |
| 194 | width: 10, | 180 | actionTimeline: [], |
| 195 | height: 5, | 181 | screenshot: { |
| 196 | }, | 182 | mimeType: "image/png", |
| 197 | } | 183 | data: Buffer.from("png").toString("base64"), |
| 198 | : request.operation === "press" | 184 | width: 10, |
| 199 | ? undefined | 185 | height: 5, |
| 200 | : { | 186 | }, |
| 201 | available: true, | 187 | } |
| 202 | visible: true, | 188 | : event.request.operation === "press" |
| 203 | tabId, | 189 | ? undefined |
| 204 | url: "http://example.test/", | 190 | : { |
| 205 | title: "Example", | 191 | available: true, |
| 206 | loading: false, | 192 | visible: true, |
| 207 | }, | 193 | tabId, |
| 208 | }), | 194 | url: "http://example.test/", |
| 195 | title: "Example", | ||
| 196 | loading: false, | ||
| 197 | }, | ||
| 198 | }), | ||
| 209 | ).pipe(Effect.forkScoped); | 199 | ).pipe(Effect.forkScoped); |
| 210 | yield* Effect.yieldNow; | 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 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | 202 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); |
| 222 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | 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,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({ |
| 214 | path: "/mcp", | 214 | path: "/mcp", |
| 215 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | 215 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); |
| 216 | 216 | ||
| 217 | export const layer = PreviewToolkitRegistrationLive.pipe( | 217 | export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); |
| 218 | Layer.provideMerge(McpTransportLive), | ||
| 219 | Layer.provide(PreviewAutomationBroker.layer), | ||
| 220 | ); |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+584-74| ... | @@ -1,20 +1,28 @@ | ... | @@ -1,20 +1,28 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 1 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 2 | import { | 3 | import { |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | PreviewAutomationClientDisconnectedError, | 5 | PreviewAutomationClientDisconnectedError, |
| 5 | PreviewAutomationInvalidSelectorError, | 6 | PreviewAutomationInvalidSelectorError, |
| 6 | PreviewAutomationMalformedResponseError, | 7 | PreviewAutomationMalformedResponseError, |
| 7 | PreviewAutomationNoFocusedOwnerError, | 8 | PreviewAutomationNoAvailableHostError, |
| 9 | PreviewAutomationTargetNotEditableError, | ||
| 10 | PreviewTabId, | ||
| 8 | ProviderInstanceId, | 11 | ProviderInstanceId, |
| 9 | ThreadId, | 12 | ThreadId, |
| 10 | type PreviewAutomationOwner, | 13 | type PreviewAutomationHost, |
| 14 | type PreviewAutomationRequest, | ||
| 15 | type PreviewAutomationStreamEvent, | ||
| 11 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 12 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; | 18 | import * as Fiber from "effect/Fiber"; |
| 19 | import * as Result from "effect/Result"; | ||
| 14 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| 15 | 21 | ||
| 16 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | 22 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 17 | 23 | ||
| 24 | const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); | ||
| 25 | |||
| 18 | const scope = { | 26 | const scope = { |
| 19 | environmentId: EnvironmentId.make("environment-1"), | 27 | environmentId: EnvironmentId.make("environment-1"), |
| 20 | threadId: ThreadId.make("thread-1"), | 28 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -22,27 +30,42 @@ const scope = { | ... | @@ -22,27 +30,42 @@ const scope = { |
| 22 | providerInstanceId: ProviderInstanceId.make("codex"), | 30 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 23 | capabilities: new Set(["preview"] as const), | 31 | capabilities: new Set(["preview"] as const), |
| 24 | issuedAt: 1, | 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 | clientId: "client-1", | 37 | clientId: "client-1", |
| 30 | environmentId: scope.environmentId, | 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 | ...overrides, | 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 | Effect.scoped( | 61 | Effect.scoped( |
| 41 | Effect.gen(function* () { | 62 | Effect.gen(function* () { |
| 42 | const broker = yield* PreviewAutomationBroker.make; | 63 | const broker = yield* makeBroker; |
| 43 | const requests = yield* broker.connect(makeOwner()); | 64 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 44 | yield* Stream.runForEach(requests, (request) => | 65 | yield* Stream.runForEach(requests, (request) => |
| 45 | broker.respond({ | 66 | broker.respond({ |
| 67 | clientId: "client-1", | ||
| 68 | connectionId: request.connectionId, | ||
| 46 | requestId: request.requestId, | 69 | requestId: request.requestId, |
| 47 | ok: true, | 70 | ok: true, |
| 48 | result: { available: true }, | 71 | result: { available: true }, |
| ... | @@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response", | ... | @@ -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 | it.effect("preserves bounded request and remote selector diagnostics", () => { | 120 | it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 65 | const locator = "role=button[name='request-secret']"; | 121 | const locator = "role=button[name='request-secret']"; |
| 66 | const remoteMessage = "Unexpected token near remote-secret."; | 122 | const remoteMessage = "Unexpected token near remote-secret."; |
| ... | @@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { | ... | @@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 72 | 128 | ||
| 73 | return Effect.scoped( | 129 | return Effect.scoped( |
| 74 | Effect.gen(function* () { | 130 | Effect.gen(function* () { |
| 75 | const broker = yield* PreviewAutomationBroker.make; | 131 | const broker = yield* makeBroker; |
| 76 | const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); | 132 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 77 | yield* Stream.runForEach(requests, (request) => | 133 | yield* Stream.runForEach(requests, (request) => |
| 78 | broker.respond({ | 134 | broker.respond({ |
| 135 | clientId: "client-1", | ||
| 136 | connectionId: request.connectionId, | ||
| 79 | requestId: request.requestId, | 137 | requestId: request.requestId, |
| 80 | ok: false, | 138 | ok: false, |
| 81 | error: remoteError, | 139 | error: remoteError, |
| ... | @@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { | ... | @@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 88 | scope, | 146 | scope, |
| 89 | operation: "click", | 147 | operation: "click", |
| 90 | input: { locator }, | 148 | input: { locator }, |
| 149 | tabId: PreviewTabId.make("tab-1"), | ||
| 91 | timeoutMs: 1_234, | 150 | timeoutMs: 1_234, |
| 92 | }) | 151 | }) |
| 93 | .pipe(Effect.flip); | 152 | .pipe(Effect.flip); |
| ... | @@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { | ... | @@ -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 | it.effect("distinguishes malformed remote failures", () => | 226 | it.effect("distinguishes malformed remote failures", () => |
| 125 | Effect.scoped( | 227 | Effect.scoped( |
| 126 | Effect.gen(function* () { | 228 | Effect.gen(function* () { |
| 127 | const broker = yield* PreviewAutomationBroker.make; | 229 | const broker = yield* makeBroker; |
| 128 | const requests = yield* broker.connect(makeOwner()); | 230 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 129 | yield* Stream.runForEach(requests, (request) => | 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 | ).pipe(Effect.forkScoped); | 238 | ).pipe(Effect.forkScoped); |
| 132 | yield* Effect.yieldNow; | 239 | yield* Effect.yieldNow; |
| 133 | 240 | ||
| ... | @@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () => | ... | @@ -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 | Effect.gen(function* () { | 261 | Effect.gen(function* () { |
| 155 | const broker = yield* PreviewAutomationBroker.make; | 262 | const broker = yield* makeBroker; |
| 156 | const error = yield* broker | 263 | const error = yield* broker |
| 157 | .invoke<void>({ scope, operation: "status", input: {} }) | 264 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 158 | .pipe(Effect.flip); | 265 | .pipe(Effect.flip); |
| 159 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | 266 | |
| 267 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); | ||
| 160 | expect(error).toMatchObject({ | 268 | expect(error).toMatchObject({ |
| 161 | operation: "status", | 269 | operation: "status", |
| 162 | environmentId: scope.environmentId, | 270 | environmentId: scope.environmentId, |
| ... | @@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () => | ... | @@ -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 | Effect.scoped( | 296 | Effect.scoped( |
| 172 | Effect.gen(function* () { | 297 | Effect.gen(function* () { |
| 173 | const broker = yield* PreviewAutomationBroker.make; | 298 | const broker = yield* makeBroker; |
| 174 | const requests = yield* broker.connect( | 299 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 175 | makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), | 300 | const beforeAcquisition = yield* broker |
| 176 | ); | 301 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 177 | yield* Stream.runForEach(requests, (request) => | 302 | .pipe(Effect.flip); |
| 178 | broker.respond({ requestId: request.requestId, ok: true }), | 303 | expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 179 | ).pipe(Effect.forkScoped); | 304 | |
| 305 | const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); | ||
| 180 | yield* Effect.yieldNow; | 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 | Effect.scoped( | 318 | Effect.scoped( |
| 189 | Effect.gen(function* () { | 319 | Effect.gen(function* () { |
| 190 | const broker = yield* PreviewAutomationBroker.make; | 320 | const broker = yield* makeBroker; |
| 191 | const requests = yield* broker.connect(makeOwner({ tabId: null })); | 321 | const backgroundThreadId = ThreadId.make("thread-background"); |
| 192 | let routedTabId: string | undefined; | 322 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 323 | let routedThreadId: string | undefined; | ||
| 193 | yield* Stream.runForEach(requests, (request) => { | 324 | yield* Stream.runForEach(requests, (request) => { |
| 194 | routedTabId = request.tabId; | 325 | routedThreadId = request.threadId; |
| 195 | return broker.respond({ requestId: request.requestId, ok: true }); | 326 | return broker.respond({ |
| 327 | clientId: "client-1", | ||
| 328 | connectionId: request.connectionId, | ||
| 329 | requestId: request.requestId, | ||
| 330 | ok: true, | ||
| 331 | result: "background", | ||
| 332 | }); | ||
| 196 | }).pipe(Effect.forkScoped); | 333 | }).pipe(Effect.forkScoped); |
| 197 | yield* Effect.yieldNow; | 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 | Effect.scoped( | 353 | Effect.scoped( |
| 208 | Effect.gen(function* () { | 354 | Effect.gen(function* () { |
| 209 | const broker = yield* PreviewAutomationBroker.make; | 355 | const broker = yield* makeBroker; |
| 210 | const firstRequests = yield* broker.connect(makeOwner()); | 356 | const matchingRequests = requestsFrom( |
| 211 | yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); | 357 | yield* broker.connect(makeHost({ clientId: "client-matching" })), |
| 212 | yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true })); | 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 | let routedTabId: string | undefined; | 688 | let routedTabId: string | undefined; |
| 216 | yield* Stream.runForEach(reconnectedRequests, (request) => { | 689 | yield* Stream.runForEach(requests, (request) => { |
| 217 | routedTabId = request.tabId; | 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 | }).pipe(Effect.forkScoped); | 697 | }).pipe(Effect.forkScoped); |
| 220 | yield* Effect.yieldNow; | 698 | yield* Effect.yieldNow; |
| 221 | 699 | ||
| 222 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | 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 | Effect.scoped( | 708 | Effect.scoped( |
| 231 | Effect.gen(function* () { | 709 | Effect.gen(function* () { |
| 232 | const broker = yield* PreviewAutomationBroker.make; | 710 | const broker = yield* makeBroker; |
| 233 | const requests = yield* broker.connect(makeOwner()); | 711 | let firstConnectionId = ""; |
| 234 | yield* Stream.runForEach(requests, (request) => | 712 | let replacementConnectionId = ""; |
| 235 | broker.respond({ requestId: request.requestId, ok: true }), | 713 | const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { |
| 236 | ).pipe(Effect.forkScoped); | 714 | firstConnectionId = connectionId; |
| 715 | }); | ||
| 716 | yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); | ||
| 237 | yield* Effect.yieldNow; | 717 | yield* Effect.yieldNow; |
| 238 | 718 | ||
| 239 | yield* broker.clearOwner({ | 719 | const replacementRequests = requestsFrom( |
| 240 | clientId: "client-1", | 720 | yield* broker.connect(makeHost()), |
| 241 | environmentId: scope.environmentId, | 721 | (connectionId) => { |
| 242 | threadId: ThreadId.make("thread-stale"), | 722 | replacementConnectionId = connectionId; |
| 243 | }); | 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 | Effect.scoped( | 744 | Effect.scoped( |
| 252 | Effect.gen(function* () { | 745 | Effect.gen(function* () { |
| 253 | const broker = yield* PreviewAutomationBroker.make; | 746 | const broker = yield* makeBroker; |
| 254 | const _requests = yield* broker.connect(makeOwner()); | 747 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 748 | yield* Stream.runDrain(requests).pipe(Effect.forkScoped); | ||
| 255 | const pending = yield* broker | 749 | const pending = yield* broker |
| 256 | .invoke<void>({ scope, operation: "status", input: {} }) | 750 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 257 | .pipe(Effect.flip, Effect.forkScoped); | 751 | .pipe(Effect.flip, Effect.forkScoped); |
| 258 | yield* Effect.yieldNow; | 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 | const error = yield* Fiber.join(pending); | 757 | const error = yield* Fiber.join(pending); |
| 263 | expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); | 758 | expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); |
| ... | @@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec | ... | @@ -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 | Effect.scoped( | 774 | Effect.scoped( |
| 280 | Effect.gen(function* () { | 775 | Effect.gen(function* () { |
| 281 | const broker = yield* PreviewAutomationBroker.make; | 776 | const broker = yield* makeBroker; |
| 282 | const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" })); | 777 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 283 | yield* Stream.runForEach(requests, (request) => | 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 | ).pipe(Effect.forkScoped); | 802 | ).pipe(Effect.forkScoped); |
| 286 | yield* Effect.yieldNow; | 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 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); | 805 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); |
| 295 | 806 | expect(result).toBe("owner"); | |
| 296 | expect(result).toBe("connected"); | ||
| 297 | }), | 807 | }), |
| 298 | ), | 808 | ), |
| 299 | ); | 809 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts+254-129| ... | @@ -1,26 +1,29 @@ | ... | @@ -1,26 +1,29 @@ |
| 1 | import { | 1 | import { |
| 2 | PREVIEW_AUTOMATION_V1_OPERATIONS, | ||
| 2 | PreviewAutomationClientDisconnectedError, | 3 | PreviewAutomationClientDisconnectedError, |
| 3 | PreviewAutomationControlInterruptedError, | 4 | PreviewAutomationControlInterruptedError, |
| 4 | PreviewAutomationExecutionError, | 5 | PreviewAutomationExecutionError, |
| 5 | PreviewAutomationHostNotConnectedError, | ||
| 6 | PreviewAutomationInvalidSelectorError, | 6 | PreviewAutomationInvalidSelectorError, |
| 7 | PreviewAutomationMalformedResponseError, | 7 | PreviewAutomationMalformedResponseError, |
| 8 | PreviewAutomationNoFocusedOwnerError, | 8 | PreviewAutomationNoAvailableHostError, |
| 9 | PreviewAutomationRemoteUnavailableError, | 9 | PreviewAutomationRemoteUnavailableError, |
| 10 | PreviewAutomationRequestQueueClosedError, | 10 | PreviewAutomationRequestQueueClosedError, |
| 11 | PreviewAutomationResultTooLargeError, | 11 | PreviewAutomationResultTooLargeError, |
| 12 | PreviewAutomationTabNotFoundError, | 12 | PreviewAutomationTabNotFoundError, |
| 13 | PreviewAutomationTargetNotEditableError, | ||
| 13 | PreviewAutomationTimeoutError, | 14 | PreviewAutomationTimeoutError, |
| 14 | PreviewAutomationUnsupportedClientError, | 15 | PreviewAutomationUnsupportedClientError, |
| 15 | type PreviewAutomationError, | 16 | type PreviewAutomationError, |
| 16 | type PreviewAutomationOperation, | 17 | type PreviewAutomationOperation, |
| 17 | type PreviewAutomationOwner, | 18 | type PreviewAutomationHost, |
| 18 | type PreviewAutomationOwnerIdentity, | 19 | type PreviewAutomationHostFocus, |
| 19 | type PreviewAutomationRequest, | ||
| 20 | type PreviewAutomationResponse, | 20 | type PreviewAutomationResponse, |
| 21 | type PreviewAutomationStreamEvent, | ||
| 21 | type PreviewTabId, | 22 | type PreviewTabId, |
| 22 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 23 | import * as Context from "effect/Context"; | 24 | import * as Context from "effect/Context"; |
| 25 | import * as Clock from "effect/Clock"; | ||
| 26 | import * as Crypto from "effect/Crypto"; | ||
| 24 | import * as Deferred from "effect/Deferred"; | 27 | import * as Deferred from "effect/Deferred"; |
| 25 | import * as Effect from "effect/Effect"; | 28 | import * as Effect from "effect/Effect"; |
| 26 | import * as Layer from "effect/Layer"; | 29 | import * as Layer from "effect/Layer"; |
| ... | @@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service< | ... | @@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service< |
| 43 | PreviewAutomationBroker, | 46 | PreviewAutomationBroker, |
| 44 | { | 47 | { |
| 45 | readonly connect: ( | 48 | readonly connect: ( |
| 46 | owner: PreviewAutomationOwner, | 49 | host: PreviewAutomationHost, |
| 47 | ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | 50 | ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>; |
| 48 | readonly reportOwner: ( | 51 | readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>; |
| 49 | owner: PreviewAutomationOwner, | ||
| 50 | ) => Effect.Effect<void, PreviewAutomationError>; | ||
| 51 | readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>; | ||
| 52 | readonly respond: ( | 52 | readonly respond: ( |
| 53 | response: PreviewAutomationResponse, | 53 | response: PreviewAutomationResponse, |
| 54 | ) => Effect.Effect<void, PreviewAutomationError>; | 54 | ) => Effect.Effect<void, PreviewAutomationError>; |
| ... | @@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service< | ... | @@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service< |
| 60 | 60 | ||
| 61 | interface ClientConnection { | 61 | interface ClientConnection { |
| 62 | readonly clientId: string; | 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 | interface PendingRequest { | 71 | interface PendingRequest { |
| ... | @@ -69,6 +74,13 @@ interface PendingRequest { | ... | @@ -69,6 +74,13 @@ interface PendingRequest { |
| 69 | readonly context: PreviewAutomationRequestErrorContext; | 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 | interface PreviewAutomationRequestErrorContext { | 84 | interface PreviewAutomationRequestErrorContext { |
| 73 | readonly operation: PreviewAutomationOperation; | 85 | readonly operation: PreviewAutomationOperation; |
| 74 | readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; | 86 | readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; |
| ... | @@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext { | ... | @@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext { |
| 76 | readonly providerSessionId: string; | 88 | readonly providerSessionId: string; |
| 77 | readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; | 89 | readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; |
| 78 | readonly clientId: string; | 90 | readonly clientId: string; |
| 91 | readonly connectionId: ClientConnection["connectionId"]; | ||
| 79 | readonly requestId: string; | 92 | readonly requestId: string; |
| 80 | readonly tabId?: PreviewTabId; | 93 | readonly tabId?: PreviewTabId; |
| 81 | readonly timeoutMs: number; | 94 | readonly timeoutMs: number; |
| ... | @@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext { | ... | @@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext { |
| 85 | 98 | ||
| 86 | interface BrokerState { | 99 | interface BrokerState { |
| 87 | readonly clients: ReadonlyMap<string, ClientConnection>; | 100 | readonly clients: ReadonlyMap<string, ClientConnection>; |
| 88 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | 101 | readonly assignments: ReadonlyMap<string, HostAssignment>; |
| 89 | readonly pending: ReadonlyMap<string, PendingRequest>; | 102 | readonly pending: ReadonlyMap<string, PendingRequest>; |
| 90 | readonly requestSequence: number; | 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 | const selectorDiagnosticsFromInput = ( | 131 | const selectorDiagnosticsFromInput = ( |
| 94 | input: unknown, | 132 | input: unknown, |
| 95 | ): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => { | 133 | ): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => { |
| ... | @@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = ( | ... | @@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = ( |
| 103 | return {}; | 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 | type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; | 152 | type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; |
| 107 | 153 | ||
| 108 | function remoteDetailKind(detail: unknown): RemoteDetailKind { | 154 | function remoteDetailKind(detail: unknown): RemoteDetailKind { |
| ... | @@ -131,8 +177,8 @@ const classifyResponseError = ( | ... | @@ -131,8 +177,8 @@ const classifyResponseError = ( |
| 131 | cause: error, | 177 | cause: error, |
| 132 | }; | 178 | }; |
| 133 | switch (error._tag) { | 179 | switch (error._tag) { |
| 134 | case "PreviewAutomationNoFocusedOwnerError": | 180 | case "PreviewAutomationNoAvailableHostError": |
| 135 | return new PreviewAutomationNoFocusedOwnerError({ | 181 | return new PreviewAutomationNoAvailableHostError({ |
| 136 | ...context, | 182 | ...context, |
| 137 | ...remoteDiagnostics, | 183 | ...remoteDiagnostics, |
| 138 | }); | 184 | }); |
| ... | @@ -162,6 +208,36 @@ const classifyResponseError = ( | ... | @@ -162,6 +208,36 @@ const classifyResponseError = ( |
| 162 | ...remoteDiagnostics, | 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 | case "PreviewAutomationResultTooLargeError": { | 241 | case "PreviewAutomationResultTooLargeError": { |
| 166 | const detail = | 242 | const detail = |
| 167 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | 243 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; |
| ... | @@ -193,36 +269,21 @@ const classifyResponseError = ( | ... | @@ -193,36 +269,21 @@ const classifyResponseError = ( |
| 193 | }; | 269 | }; |
| 194 | 270 | ||
| 195 | export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | 271 | export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 272 | const crypto = yield* Crypto.Crypto; | ||
| 196 | const state = yield* SynchronizedRef.make<BrokerState>({ | 273 | const state = yield* SynchronizedRef.make<BrokerState>({ |
| 197 | clients: new Map(), | 274 | clients: new Map(), |
| 198 | owners: new Map(), | 275 | assignments: new Map(), |
| 199 | pending: new Map(), | 276 | pending: new Map(), |
| 200 | requestSequence: 0, | 277 | requestSequence: 0, |
| 278 | focusSequence: 0, | ||
| 201 | }); | 279 | }); |
| 202 | 280 | ||
| 203 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | 281 | const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* ( |
| 204 | clientId: string, | ||
| 205 | queue: ClientConnection["queue"], | 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 | yield* Effect.forEach( | 285 | yield* Effect.forEach( |
| 225 | toFail, | 286 | disconnected, |
| 226 | ({ deferred, context }) => | 287 | ({ deferred, context }) => |
| 227 | Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), | 288 | Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), |
| 228 | { discard: true }, | 289 | { discard: true }, |
| ... | @@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 230 | yield* Queue.shutdown(queue); | 291 | yield* Queue.shutdown(queue); |
| 231 | }); | 292 | }); |
| 232 | 293 | ||
| 233 | const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( | 294 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( |
| 234 | "PreviewAutomationBroker.connect", | 295 | clientId: string, |
| 235 | )(function* (owner) { | 296 | queue: ClientConnection["queue"], |
| 236 | const clientId = owner.clientId; | 297 | ) { |
| 237 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | 298 | const disconnected = yield* SynchronizedRef.modify(state, (current) => { |
| 238 | const previous = yield* SynchronizedRef.modify(state, (current) => { | 299 | const removed = removeConnectionFromState(current, clientId, queue); |
| 239 | const clients = new Map(current.clients); | 300 | return [removed.disconnected, removed.state] as const; |
| 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; | ||
| 251 | }); | 301 | }); |
| 252 | if (previous) yield* disconnect(clientId, previous.queue); | 302 | yield* closeConnection(queue, disconnected); |
| 253 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | ||
| 254 | }); | 303 | }); |
| 255 | 304 | ||
| 256 | const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( | 305 | const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( |
| 257 | "PreviewAutomationBroker.reportOwner", | 306 | host: PreviewAutomationHost, |
| 258 | )(function* (owner) { | 307 | ) { |
| 259 | yield* SynchronizedRef.update(state, (current) => { | 308 | const clientId = host.clientId; |
| 260 | const owners = new Map(current.owners); | 309 | const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent>(); |
| 261 | owners.set(owner.clientId, owner); | 310 | const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); |
| 262 | return { ...current, owners }; | 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( | 345 | const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( |
| 267 | "PreviewAutomationBroker.clearOwner", | 346 | "PreviewAutomationBroker.connect", |
| 268 | )(function* (owner) { | 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 | yield* SynchronizedRef.update(state, (current) => { | 360 | yield* SynchronizedRef.update(state, (current) => { |
| 270 | const currentOwner = current.owners.get(owner.clientId); | 361 | const currentHost = current.clients.get(host.clientId); |
| 271 | if ( | 362 | if ( |
| 272 | !currentOwner || | 363 | !currentHost || |
| 273 | currentOwner.environmentId !== owner.environmentId || | 364 | currentHost.environmentId !== host.environmentId || |
| 274 | currentOwner.threadId !== owner.threadId | 365 | currentHost.connectionId !== host.connectionId |
| 275 | ) { | 366 | ) { |
| 276 | return current; | 367 | return current; |
| 277 | } | 368 | } |
| 278 | const owners = new Map(current.owners); | 369 | const clients = new Map(current.clients); |
| 279 | owners.delete(owner.clientId); | 370 | const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence; |
| 280 | return { ...current, owners }; | 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,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 286 | )(function* (response) { | 382 | )(function* (response) { |
| 287 | const pending = yield* SynchronizedRef.modify(state, (current) => { | 383 | const pending = yield* SynchronizedRef.modify(state, (current) => { |
| 288 | const entry = current.pending.get(response.requestId); | 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 | const next = new Map(current.pending); | 392 | const next = new Map(current.pending); |
| 291 | next.delete(response.requestId); | 393 | next.delete(response.requestId); |
| 292 | return [entry, { ...current, pending: next }] as const; | 394 | return [entry, { ...current, pending: next }] as const; |
| ... | @@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 307 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( | 409 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( |
| 308 | input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0], | 410 | input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0], |
| 309 | ): Effect.fn.Return<A, PreviewAutomationError> { | 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 | const timeoutMs = input.timeoutMs ?? 15_000; | 412 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 352 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | 413 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 353 | const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { | 414 | const now = yield* Clock.currentTimeMillis; |
| 354 | const requestId = `preview-${next.requestSequence}`; | 415 | const route = yield* SynchronizedRef.modify(state, (current) => { |
| 355 | const tabId = input.tabId ?? owner.tabId ?? undefined; | 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 | const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); | 466 | const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); |
| 357 | const context: PreviewAutomationRequestErrorContext = { | 467 | const context: PreviewAutomationRequestErrorContext = { |
| 358 | operation: input.operation, | 468 | operation: input.operation, |
| ... | @@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 360 | threadId: input.scope.threadId, | 470 | threadId: input.scope.threadId, |
| 361 | providerSessionId: input.scope.providerSessionId, | 471 | providerSessionId: input.scope.providerSessionId, |
| 362 | providerInstanceId: input.scope.providerInstanceId, | 472 | providerInstanceId: input.scope.providerInstanceId, |
| 363 | clientId: owner.clientId, | 473 | clientId: connection.clientId, |
| 474 | connectionId: connection.connectionId, | ||
| 364 | requestId, | 475 | requestId, |
| 365 | ...(tabId === undefined ? {} : { tabId }), | 476 | ...(tabId === undefined ? {} : { tabId }), |
| 366 | timeoutMs, | 477 | timeoutMs, |
| 367 | ...selectorDiagnostics, | 478 | ...selectorDiagnostics, |
| 368 | }; | 479 | }; |
| 369 | const pending = new Map(next.pending); | 480 | const pending = new Map(current.pending); |
| 370 | pending.set(requestId, { queue: connection.queue, deferred, context }); | 481 | pending.set(requestId, { queue: connection.queue, deferred, context }); |
| 371 | return [ | 482 | return [ |
| 372 | [requestId, context] as const, | 483 | { connection, requestId, requestContext: context }, |
| 373 | { ...next, pending, requestSequence: next.requestSequence + 1 }, | 484 | { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, |
| 374 | ] as const; | 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 | const removePending = SynchronizedRef.update(state, (next) => { | 497 | const removePending = SynchronizedRef.update(state, (next) => { |
| 377 | if (!next.pending.has(requestId)) return next; | 498 | if (!next.pending.has(requestId)) return next; |
| 378 | const pending = new Map(next.pending); | 499 | const pending = new Map(next.pending); |
| ... | @@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 381 | }); | 502 | }); |
| 382 | const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { | 503 | const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { |
| 383 | const offered = yield* Queue.offer(connection.queue, { | 504 | const offered = yield* Queue.offer(connection.queue, { |
| 384 | requestId, | 505 | type: "request", |
| 385 | threadId: input.scope.threadId, | 506 | connectionId: connection.connectionId, |
| 386 | tabId: requestContext.tabId, | 507 | request: { |
| 387 | operation: input.operation, | 508 | requestId, |
| 388 | input: input.input, | 509 | threadId: input.scope.threadId, |
| 389 | timeoutMs, | 510 | tabId: requestContext.tabId, |
| 511 | operation: input.operation, | ||
| 512 | input: input.input, | ||
| 513 | timeoutMs, | ||
| 514 | }, | ||
| 390 | }); | 515 | }); |
| 391 | if (!offered) { | 516 | if (!offered) { |
| 392 | const completion = yield* Deferred.poll(deferred); | 517 | const completion = yield* Deferred.poll(deferred); |
| ... | @@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 404 | return yield* awaitResponse().pipe(Effect.ensuring(removePending)); | 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 | }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); | 533 | }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); |
| 409 | 534 | ||
| 410 | export const layer = Layer.effect(PreviewAutomationBroker, make); | 535 | export const layer = Layer.effect(PreviewAutomationBroker, make); |
apps/server/src/mcp/toolkits/preview/handlers.ts+3| ... | @@ -3,6 +3,7 @@ import type { | ... | @@ -3,6 +3,7 @@ import type { |
| 3 | PreviewAutomationOperation, | 3 | PreviewAutomationOperation, |
| 4 | PreviewAutomationRecordingArtifact, | 4 | PreviewAutomationRecordingArtifact, |
| 5 | PreviewAutomationRecordingStatus, | 5 | PreviewAutomationRecordingStatus, |
| 6 | PreviewAutomationResizeResult, | ||
| 6 | PreviewAutomationSnapshot, | 7 | PreviewAutomationSnapshot, |
| 7 | PreviewAutomationStatus, | 8 | PreviewAutomationStatus, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| ... | @@ -39,6 +40,8 @@ const handlers = { | ... | @@ -39,6 +40,8 @@ const handlers = { |
| 39 | reuseExistingTab: input.reuseExistingTab ?? true, | 40 | reuseExistingTab: input.reuseExistingTab ?? true, |
| 40 | }), | 41 | }), |
| 41 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | 42 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 43 | preview_resize: (input) => | ||
| 44 | invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), | ||
| 42 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), | 45 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), |
| 43 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), | 46 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), |
| 44 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), | 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,6 +7,8 @@ import { |
| 7 | PreviewAutomationPressInput, | 7 | PreviewAutomationPressInput, |
| 8 | PreviewAutomationRecordingArtifact, | 8 | PreviewAutomationRecordingArtifact, |
| 9 | PreviewAutomationRecordingStatus, | 9 | PreviewAutomationRecordingStatus, |
| 10 | PreviewAutomationResizeInput, | ||
| 11 | PreviewAutomationResizeResult, | ||
| 10 | PreviewAutomationScrollInput, | 12 | PreviewAutomationScrollInput, |
| 11 | PreviewAutomationSnapshot, | 13 | PreviewAutomationSnapshot, |
| 12 | PreviewAutomationStatus, | 14 | PreviewAutomationStatus, |
| ... | @@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => | ... | @@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => |
| 35 | 37 | ||
| 36 | export const PreviewStatusTool = Tool.make("preview_status", { | 38 | export const PreviewStatusTool = Tool.make("preview_status", { |
| 37 | description: | 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 | success: PreviewAutomationStatus, | 41 | success: PreviewAutomationStatus, |
| 40 | failure: PreviewAutomationError, | 42 | failure: PreviewAutomationError, |
| 41 | dependencies, | 43 | dependencies, |
| ... | @@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool( | ... | @@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool( |
| 69 | }).annotate(Tool.Title, "Navigate browser preview"), | 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 | export const PreviewSnapshotTool = readonlyBrowserTool( | 87 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 73 | Tool.make("preview_snapshot", { | 88 | Tool.make("preview_snapshot", { |
| 74 | description: | 89 | description: |
| ... | @@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make( | ... | @@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make( |
| 168 | PreviewStatusTool, | 183 | PreviewStatusTool, |
| 169 | PreviewOpenTool, | 184 | PreviewOpenTool, |
| 170 | PreviewNavigateTool, | 185 | PreviewNavigateTool, |
| 186 | PreviewResizeTool, | ||
| 171 | PreviewSnapshotTool, | 187 | PreviewSnapshotTool, |
| 172 | PreviewClickTool, | 188 | PreviewClickTool, |
| 173 | PreviewTypeTool, | 189 | PreviewTypeTool, |
| ... | @@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make( | ... | @@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make( |
| 183 | PreviewStatusTool, | 199 | PreviewStatusTool, |
| 184 | PreviewOpenTool, | 200 | PreviewOpenTool, |
| 185 | PreviewNavigateTool, | 201 | PreviewNavigateTool, |
| 202 | PreviewResizeTool, | ||
| 186 | PreviewClickTool, | 203 | PreviewClickTool, |
| 187 | PreviewTypeTool, | 204 | PreviewTypeTool, |
| 188 | PreviewPressTool, | 205 | PreviewPressTool, |
apps/server/src/preview/Manager.test.ts+55| ... | @@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { | ... | @@ -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 | it.effect("reportStatus emits failed for LoadFailed nav", () => | 209 | it.effect("reportStatus emits failed for LoadFailed nav", () => |
| 155 | Effect.gen(function* () { | 210 | Effect.gen(function* () { |
| 156 | const threadId = freshThreadId(); | 211 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+38| ... | @@ -20,6 +20,8 @@ import { | ... | @@ -20,6 +20,8 @@ import { |
| 20 | type PreviewOpenInput, | 20 | type PreviewOpenInput, |
| 21 | type PreviewRefreshInput, | 21 | type PreviewRefreshInput, |
| 22 | type PreviewReportStatusInput, | 22 | type PreviewReportStatusInput, |
| 23 | type PreviewResizeInput, | ||
| 24 | FILL_PREVIEW_VIEWPORT, | ||
| 23 | PreviewSessionLookupError, | 25 | PreviewSessionLookupError, |
| 24 | type PreviewSessionSnapshot, | 26 | type PreviewSessionSnapshot, |
| 25 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| ... | @@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service< | ... | @@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service< |
| 45 | input: PreviewNavigateInput, | 47 | input: PreviewNavigateInput, |
| 46 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | 48 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 47 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; | 49 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; |
| 50 | readonly resize: ( | ||
| 51 | input: PreviewResizeInput, | ||
| 52 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | ||
| 48 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; | 53 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; |
| 49 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; | 54 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; |
| 50 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; | 55 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; |
| ... | @@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: { | ... | @@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: { |
| 114 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, | 119 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, |
| 115 | canGoBack: false, | 120 | canGoBack: false, |
| 116 | canGoForward: false, | 121 | canGoForward: false, |
| 122 | viewport: FILL_PREVIEW_VIEWPORT, | ||
| 117 | updatedAt: input.updatedAt, | 123 | updatedAt: input.updatedAt, |
| 118 | }); | 124 | }); |
| 119 | 125 | ||
| ... | @@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: { |
| 127 | navStatus: { _tag: "Idle" }, | 133 | navStatus: { _tag: "Idle" }, |
| 128 | canGoBack: false, | 134 | canGoBack: false, |
| 129 | canGoForward: false, | 135 | canGoForward: false, |
| 136 | viewport: FILL_PREVIEW_VIEWPORT, | ||
| 130 | updatedAt: input.updatedAt, | 137 | updatedAt: input.updatedAt, |
| 131 | }); | 138 | }); |
| 132 | 139 | ||
| ... | @@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 237 | navStatus: { _tag: "Success", url, title: resolvedTitle }, | 244 | navStatus: { _tag: "Success", url, title: resolvedTitle }, |
| 238 | canGoBack: session.snapshot.canGoBack, | 245 | canGoBack: session.snapshot.canGoBack, |
| 239 | canGoForward: session.snapshot.canGoForward, | 246 | canGoForward: session.snapshot.canGoForward, |
| 247 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | ||
| 240 | updatedAt, | 248 | updatedAt, |
| 241 | }; | 249 | }; |
| 242 | return { | 250 | return { |
| ... | @@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 269 | navStatus: input.navStatus, | 277 | navStatus: input.navStatus, |
| 270 | canGoBack: input.canGoBack, | 278 | canGoBack: input.canGoBack, |
| 271 | canGoForward: input.canGoForward, | 279 | canGoForward: input.canGoForward, |
| 280 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | ||
| 272 | updatedAt, | 281 | updatedAt, |
| 273 | }; | 282 | }; |
| 274 | const emit: PreviewEvent = | 283 | const emit: PreviewEvent = |
| ... | @@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -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 | const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( | 339 | const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( |
| 303 | function* (input) { | 340 | function* (input) { |
| 304 | // Verify the session exists; the desktop bridge handles the actual reload | 341 | // Verify the session exists; the desktop bridge handles the actual reload |
| ... | @@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 360 | open, | 397 | open, |
| 361 | navigate, | 398 | navigate, |
| 362 | reportStatus, | 399 | reportStatus, |
| 400 | resize, | ||
| 363 | refresh, | 401 | refresh, |
| 364 | close, | 402 | close, |
| 365 | list, | 403 | list, |
apps/server/src/server.test.ts+41| ... | @@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: { |
| 653 | Layer.mock(PreviewManager.PreviewManager)({ | 653 | Layer.mock(PreviewManager.PreviewManager)({ |
| 654 | open: () => Effect.die("PreviewManager not stubbed in this test"), | 654 | open: () => Effect.die("PreviewManager not stubbed in this test"), |
| 655 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), | 655 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), |
| 656 | resize: () => Effect.die("PreviewManager not stubbed in this test"), | ||
| 656 | reportStatus: () => Effect.void, | 657 | reportStatus: () => Effect.void, |
| 657 | refresh: () => Effect.void, | 658 | refresh: () => Effect.void, |
| 658 | close: () => Effect.void, | 659 | close: () => Effect.void, |
| ... | @@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4125 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => | 4169 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => |
| 4129 | Effect.gen(function* () { | 4170 | Effect.gen(function* () { |
| 4130 | const fs = yield* FileSystem.FileSystem; | 4171 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/server.ts+2-1| ... | @@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide | ... | @@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide |
| 36 | import * as TerminalManager from "./terminal/Manager.ts"; | 36 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 37 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; | 37 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 38 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; | 38 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| 39 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | ||
| 39 | import * as PreviewManager from "./preview/Manager.ts"; | 40 | import * as PreviewManager from "./preview/Manager.ts"; |
| 40 | import * as PortScanner from "./preview/PortScanner.ts"; | 41 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 41 | import * as ProcessRunner from "./processRunner.ts"; | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| ... | @@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll( | ... | @@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 357 | websocketRpcRouteLayer, | 358 | websocketRpcRouteLayer, |
| 358 | ), | 359 | ), |
| 359 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), | 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 | export const makeServerLayer = Layer.unwrap( | 363 | export const makeServerLayer = Layer.unwrap( |
| 363 | Effect.gen(function* () { | 364 | Effect.gen(function* () { |
apps/server/src/ws.ts+19-19| ... | @@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | ... | @@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 330 | [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], | 330 | [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], |
| 331 | [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], | 331 | [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], |
| 332 | [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], | 332 | [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], |
| 333 | [WS_METHODS.previewResize, AuthOrchestrationOperateScope], | ||
| 333 | [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], | 334 | [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], |
| 334 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], | 335 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], |
| 335 | [WS_METHODS.previewList, AuthOrchestrationReadScope], | 336 | [WS_METHODS.previewList, AuthOrchestrationReadScope], |
| 336 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], | 337 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], |
| 337 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], | 338 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], |
| 338 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], | 339 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], |
| 339 | [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], | 340 | [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], |
| 340 | [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], | ||
| 341 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], | 341 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], |
| 342 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], | 342 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], |
| 343 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], | 343 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], |
| ... | @@ -385,7 +385,10 @@ function toAuthAccessStreamEvent( | ... | @@ -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 | WsRpcGroup.toLayer( | 392 | WsRpcGroup.toLayer( |
| 390 | Effect.gen(function* () { | 393 | Effect.gen(function* () { |
| 391 | const currentSessionId = currentSession.sessionId; | 394 | const currentSessionId = currentSession.sessionId; |
| ... | @@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 400 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; | 403 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| 401 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | 404 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 402 | const terminalManager = yield* TerminalManager.TerminalManager; | 405 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 403 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | ||
| 404 | const previewManager = yield* PreviewManager.PreviewManager; | 406 | const previewManager = yield* PreviewManager.PreviewManager; |
| 405 | const portDiscovery = yield* PortScanner.PortDiscovery; | 407 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 406 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; | 408 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| ... | @@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1622 | observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { | 1624 | observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { |
| 1623 | "rpc.aggregate": "preview", | 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 | [WS_METHODS.previewRefresh]: (input) => | 1631 | [WS_METHODS.previewRefresh]: (input) => |
| 1626 | observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { | 1632 | observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { |
| 1627 | "rpc.aggregate": "preview", | 1633 | "rpc.aggregate": "preview", |
| ... | @@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1650 | previewAutomationBroker.respond(input), | 1656 | previewAutomationBroker.respond(input), |
| 1651 | { "rpc.aggregate": "preview-automation" }, | 1657 | { "rpc.aggregate": "preview-automation" }, |
| 1652 | ), | 1658 | ), |
| 1653 | [WS_METHODS.previewAutomationReportOwner]: (input) => | 1659 | [WS_METHODS.previewAutomationFocusHost]: (input) => |
| 1654 | observeRpcEffect( | ||
| 1655 | WS_METHODS.previewAutomationReportOwner, | ||
| 1656 | previewAutomationBroker.reportOwner(input), | ||
| 1657 | { "rpc.aggregate": "preview-automation" }, | ||
| 1658 | ), | ||
| 1659 | [WS_METHODS.previewAutomationClearOwner]: (input) => | ||
| 1660 | observeRpcEffect( | 1660 | observeRpcEffect( |
| 1661 | WS_METHODS.previewAutomationClearOwner, | 1661 | WS_METHODS.previewAutomationFocusHost, |
| 1662 | previewAutomationBroker.clearOwner(input), | 1662 | previewAutomationBroker.focusHost(input), |
| 1663 | { "rpc.aggregate": "preview-automation" }, | 1663 | { "rpc.aggregate": "preview-automation" }, |
| 1664 | ), | 1664 | ), |
| 1665 | [WS_METHODS.subscribePreviewEvents]: (_input) => | 1665 | [WS_METHODS.subscribePreviewEvents]: (_input) => |
| ... | @@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1791 | ); | 1791 | ); |
| 1792 | 1792 | ||
| 1793 | export const websocketRpcRouteLayer = Layer.unwrap( | 1793 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 1794 | Effect.succeed( | 1794 | Effect.gen(function* () { |
| 1795 | HttpRouter.add( | 1795 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 1796 | return HttpRouter.add( | ||
| 1796 | "GET", | 1797 | "GET", |
| 1797 | "/ws", | 1798 | "/ws", |
| 1798 | Effect.gen(function* () { | 1799 | Effect.gen(function* () { |
| ... | @@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1811 | disableTracing: true, | 1812 | disableTracing: true, |
| 1812 | }).pipe( | 1813 | }).pipe( |
| 1813 | Effect.provide( | 1814 | Effect.provide( |
| 1814 | makeWsRpcLayer(session).pipe( | 1815 | makeWsRpcLayer(session, previewAutomationBroker).pipe( |
| 1815 | Layer.provideMerge(RpcSerialization.layerJson), | 1816 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1816 | Layer.provide(PreviewAutomationBroker.layer), | ||
| 1817 | Layer.provide(ProviderMaintenanceRunner.layer), | 1817 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1818 | Layer.provide( | 1818 | Layer.provide( |
| 1819 | SourceControlDiscovery.layer.pipe( | 1819 | SourceControlDiscovery.layer.pipe( |
| ... | @@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1850 | EnvironmentInternalError: HttpServerRespondable.toResponse, | 1850 | EnvironmentInternalError: HttpServerRespondable.toResponse, |
| 1851 | }), | 1851 | }), |
| 1852 | ), | 1852 | ), |
| 1853 | ), | 1853 | ); |
| 1854 | ), | 1854 | }), |
| 1855 | ); | 1855 | ); |
apps/web/src/AppRoot.test.tsx+5-3| ... | @@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router"; | ... | @@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 6 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; | ||
| 6 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | 7 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 7 | import type { AppRouter } from "./router"; | 8 | import type { AppRouter } from "./router"; |
| 8 | import { AppRoot } from "./AppRoot"; | 9 | import { AppRoot } from "./AppRoot"; |
| 9 | 10 | ||
| 10 | describe("AppRoot", () => { | 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 | const root = AppRoot({ router: {} as AppRouter }); | 13 | const root = AppRoot({ router: {} as AppRouter }); |
| 13 | 14 | ||
| 14 | expect(root.type).toBe(AppAtomRegistryProvider); | 15 | expect(root.type).toBe(AppAtomRegistryProvider); |
| 15 | const children = Children.toArray( | 16 | const children = Children.toArray( |
| 16 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, | 17 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, |
| 17 | ); | 18 | ); |
| 18 | expect(children).toHaveLength(2); | 19 | expect(children).toHaveLength(3); |
| 19 | expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); | 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,6 +1,7 @@ |
| 1 | import { RouterProvider } from "@tanstack/react-router"; | 1 | import { RouterProvider } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 4 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; | ||
| 4 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | 5 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 5 | import type { AppRouter } from "./router"; | 6 | import type { AppRouter } from "./router"; |
| 6 | 7 | ||
| ... | @@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { | ... | @@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { |
| 13 | return ( | 14 | return ( |
| 14 | <AppAtomRegistryProvider> | 15 | <AppAtomRegistryProvider> |
| 15 | <RouterProvider router={router} /> | 16 | <RouterProvider router={router} /> |
| 17 | <PreviewAutomationHosts /> | ||
| 16 | <ElectronBrowserHost /> | 18 | <ElectronBrowserHost /> |
| 17 | </AppAtomRegistryProvider> | 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,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; | ||
| 4 | import { useEffect, useMemo } from "react"; | 5 | import { useEffect, useMemo } from "react"; |
| 5 | 6 | ||
| 6 | import { isElectron } from "~/env"; | 7 | import { isElectron } from "~/env"; |
| ... | @@ -22,7 +23,7 @@ export function ElectronBrowserHost() { | ... | @@ -22,7 +23,7 @@ export function ElectronBrowserHost() { |
| 22 | ? Object.values(previewState.sessions).map((snapshot) => ({ | 23 | ? Object.values(previewState.sessions).map((snapshot) => ({ |
| 23 | threadRef, | 24 | threadRef, |
| 24 | snapshot, | 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,7 +74,7 @@ export function ElectronBrowserHost() { |
| 73 | if (!isElectron) return null; | 74 | if (!isElectron) return null; |
| 74 | return ( | 75 | return ( |
| 75 | <div className="contents" data-electron-browser-host> | 76 | <div className="contents" data-electron-browser-host> |
| 76 | {sessions.map(({ threadRef, snapshot }) => { | 77 | {sessions.map(({ threadRef, snapshot, zoomFactor }) => { |
| 77 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; | 78 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; |
| 78 | return ( | 79 | return ( |
| 79 | <HostedBrowserWebview | 80 | <HostedBrowserWebview |
| ... | @@ -81,6 +82,8 @@ export function ElectronBrowserHost() { | ... | @@ -81,6 +82,8 @@ export function ElectronBrowserHost() { |
| 81 | threadRef={threadRef} | 82 | threadRef={threadRef} |
| 82 | tabId={snapshot.tabId} | 83 | tabId={snapshot.tabId} |
| 83 | initialUrl={url} | 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,16 +1,22 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 3 | import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; |
| 4 | import { useShallow } from "zustand/react/shallow"; | 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 | import { previewBridge } from "~/components/preview/previewBridge"; | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | ||
| 9 | 10 | ||
| 10 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; | 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 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; | 17 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; | ||
| 14 | 20 | ||
| 15 | interface ElectronWebview extends HTMLElement { | 21 | interface ElectronWebview extends HTMLElement { |
| 16 | src: string; | 22 | src: string; |
| ... | @@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement { | ... | @@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement { |
| 18 | preload?: string; | 24 | preload?: string; |
| 19 | webpreferences?: string; | 25 | webpreferences?: string; |
| 20 | getWebContentsId: () => number; | 26 | getWebContentsId: () => number; |
| 27 | executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>; | ||
| 21 | } | 28 | } |
| 22 | 29 | ||
| 23 | declare global { | 30 | declare global { |
| ... | @@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: { | ... | @@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: { |
| 30 | readonly threadRef: ScopedThreadRef; | 37 | readonly threadRef: ScopedThreadRef; |
| 31 | readonly tabId: string; | 38 | readonly tabId: string; |
| 32 | readonly initialUrl: string | null; | 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 | const config = usePreviewWebviewConfig(threadRef.environmentId); | 44 | const config = usePreviewWebviewConfig(threadRef.environmentId); |
| 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); | 45 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); |
| 37 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | 46 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 47 | const wrapperRef = useRef<HTMLDivElement | null>(null); | ||
| 38 | const webviewRef = useRef<ElectronWebview | null>(null); | 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 | const recording = useActiveBrowserRecordingTabId() === tabId; | 59 | const recording = useActiveBrowserRecordingTabId() === tabId; |
| 41 | 60 | ||
| 42 | usePreviewBridge({ threadRef, tabId }); | 61 | usePreviewBridge({ threadRef, tabId }); |
| ... | @@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: { | ... | @@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: { |
| 89 | }; | 108 | }; |
| 90 | }, [config, tabId]); | 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 | if (!config) return null; | 171 | if (!config) return null; |
| 93 | const active = presentation?.visible === true && presentation.rect !== null; | 172 | |
| 94 | const lastRect = presentation?.rect; | 173 | const wrapperStyle = |
| 95 | const style = | ||
| 96 | active && lastRect | 174 | active && lastRect |
| 97 | ? { | 175 | ? { |
| 98 | left: lastRect.x, | 176 | left: lastRect.x, |
| ... | @@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: { | ... | @@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: { |
| 105 | : { | 183 | : { |
| 106 | left: 0, | 184 | left: 0, |
| 107 | top: 0, | 185 | top: 0, |
| 108 | width: lastRect?.width ?? 1280, | 186 | width: hiddenSize.width, |
| 109 | height: lastRect?.height ?? 800, | 187 | height: hiddenSize.height, |
| 110 | zIndex: recording ? 0 : -1, | 188 | zIndex: recording ? 0 : -1, |
| 111 | pointerEvents: "none" as const, | 189 | pointerEvents: "none" as const, |
| 112 | }; | 190 | }; |
| 113 | 191 | ||
| 114 | return ( | 192 | return ( |
| 115 | <webview | 193 | <div |
| 116 | ref={setWebviewRef} | 194 | ref={wrapperRef} |
| 117 | src={initialSrcRef.current} | 195 | className="fixed overflow-hidden bg-muted/35" |
| 118 | partition={config.partition} | 196 | style={{ ...wrapperStyle, overscrollBehavior: "contain" }} |
| 119 | webpreferences={config.webPreferences} | 197 | onScroll={syncContentPresentation} |
| 120 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | 198 | data-preview-viewport={tabId} |
| 121 | data-preview-tab={tabId} | 199 | > |
| 122 | aria-hidden={active ? undefined : true} | 200 | <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}> |
| 123 | className="fixed flex overflow-hidden bg-background" | 201 | {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( |
| 124 | style={style} | 202 | <BrowserDeviceToolbar |
| 125 | /> | 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,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null { |
| 90 | let active: ActiveRecording | null = null; | 90 | let active: ActiveRecording | null = null; |
| 91 | let unsubscribeFrames: (() => void) | null = null; | 91 | let unsubscribeFrames: (() => void) | null = null; |
| 92 | 92 | ||
| 93 | export function readActiveBrowserRecordingTabId(): string | null { | ||
| 94 | return active?.tabId ?? null; | ||
| 95 | } | ||
| 96 | |||
| 93 | const preferredMimeType = (): string => { | 97 | const preferredMimeType = (): string => { |
| 94 | const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; | 98 | const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; |
| 95 | return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; | 99 | return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; |
| ... | @@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> { | ... | @@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 137 | activeTabId: active.tabId, | 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 | const canvas = document.createElement("canvas"); | 146 | const canvas = document.createElement("canvas"); |
| 142 | canvas.width = Math.max(1, rect?.width ?? 1280); | 147 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); |
| 143 | canvas.height = Math.max(1, rect?.height ?? 800); | 148 | canvas.height = Math.max(1, recordingSize?.height ?? 800); |
| 144 | const context = canvas.getContext("2d", { alpha: false }); | 149 | const context = canvas.getContext("2d", { alpha: false }); |
| 145 | if (!context) { | 150 | if (!context) { |
| 146 | throw new BrowserRecordingCanvasUnavailableError({ | 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,15 +10,47 @@ export interface BrowserSurfaceRect { |
| 10 | export interface BrowserSurfacePresentation { | 10 | export interface BrowserSurfacePresentation { |
| 11 | readonly rect: BrowserSurfaceRect | null; | 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | readonly visible: boolean; | 12 | readonly visible: boolean; |
| 13 | readonly content: BrowserSurfaceContentPresentation | null; | ||
| 13 | readonly updatedAt: number; | 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 | interface BrowserSurfaceStoreState { | 27 | interface BrowserSurfaceStoreState { |
| 17 | readonly byTabId: Record<string, BrowserSurfacePresentation>; | 28 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 18 | readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; | 29 | readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; |
| 30 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; | ||
| 19 | readonly hide: (tabId: string) => void; | 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 | const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => | 54 | const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => |
| 23 | left !== null && | 55 | left !== null && |
| 24 | left.x === right.x && | 56 | left.x === right.x && |
| ... | @@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = | ... | @@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 35 | return { | 67 | return { |
| 36 | byTabId: { | 68 | byTabId: { |
| 37 | ...state.byTabId, | 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,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 129 | import { closePreviewSession } from "./preview/closePreviewSession"; | 129 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 130 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 130 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 131 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 131 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 132 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | ||
| 133 | import { RightPanelTabs } from "./RightPanelTabs"; | 132 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 134 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | 133 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 135 | import { BranchToolbar } from "./BranchToolbar"; | 134 | import { BranchToolbar } from "./BranchToolbar"; |
| ... | @@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 4704 | 4703 | ||
| 4705 | return ( | 4704 | return ( |
| 4706 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> | 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 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} | 4706 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} |
| 4711 | <div | 4707 | <div |
| 4712 | className={cn( | 4708 | className={cn( |
apps/web/src/components/preview/AgentBrowserCursor.tsx+12-2| ... | @@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react"; | ... | @@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react"; |
| 5 | import { useEffect, useState } from "react"; | 5 | import { useEffect, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; | 7 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; |
| 8 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | ||
| 8 | 9 | ||
| 9 | import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; | 10 | import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; |
| 10 | 11 | ||
| ... | @@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: { | ... | @@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: { |
| 17 | }) { | 18 | }) { |
| 18 | const { tabId, zoomFactor, controller } = props; | 19 | const { tabId, zoomFactor, controller } = props; |
| 19 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); | 20 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); |
| 21 | const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null); | ||
| 20 | 22 | ||
| 21 | if (!event) return null; | 23 | if (!event) return null; |
| 22 | 24 | ||
| ... | @@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: { | ... | @@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: { |
| 24 | <AgentBrowserCursorEvent | 26 | <AgentBrowserCursorEvent |
| 25 | key={event.sequence} | 27 | key={event.sequence} |
| 26 | event={event} | 28 | event={event} |
| 29 | content={content} | ||
| 27 | zoomFactor={zoomFactor} | 30 | zoomFactor={zoomFactor} |
| 28 | controller={controller} | 31 | controller={controller} |
| 29 | /> | 32 | /> |
| ... | @@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: { | ... | @@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: { |
| 32 | 35 | ||
| 33 | function AgentBrowserCursorEvent(props: { | 36 | function AgentBrowserCursorEvent(props: { |
| 34 | readonly event: DesktopPreviewPointerEvent; | 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 | readonly zoomFactor: number; | 45 | readonly zoomFactor: number; |
| 36 | readonly controller: BrowserController; | 46 | readonly controller: BrowserController; |
| 37 | }) { | 47 | }) { |
| 38 | const { event, zoomFactor, controller } = props; | 48 | const { event, content, zoomFactor, controller } = props; |
| 39 | const [active, setActive] = useState(true); | 49 | const [active, setActive] = useState(true); |
| 40 | 50 | ||
| 41 | useEffect(() => { | 51 | useEffect(() => { |
| ... | @@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: { | ... | @@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: { |
| 48 | className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" | 58 | className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" |
| 49 | style={{ | 59 | style={{ |
| 50 | opacity: agentBrowserCursorOpacity(active, controller), | 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 | aria-hidden="true" | 63 | aria-hidden="true" |
| 54 | data-agent-browser-cursor | 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,6 +19,10 @@ interface Props { |
| 19 | hasWebContents: boolean; | 19 | hasWebContents: boolean; |
| 20 | /** Current zoom factor as a number (1.0 = 100%). */ | 20 | /** Current zoom factor as a number (1.0 = 100%). */ |
| 21 | zoomFactor: number; | 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,7 +30,13 @@ interface Props { |
| 26 | * controls, and storage-clearing actions. Only mounted by `PreviewView` | 30 | * controls, and storage-clearing actions. Only mounted by `PreviewView` |
| 27 | * when the desktop bridge is present, so we can call it unconditionally. | 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 | if (!previewBridge) return null; | 40 | if (!previewBridge) return null; |
| 31 | const bridge = previewBridge; | 41 | const bridge = previewBridge; |
| 32 | const tabDisabled = !tabId || !hasWebContents; | 42 | const tabDisabled = !tabId || !hasWebContents; |
| ... | @@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { | ... | @@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { |
| 59 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> | 69 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> |
| 60 | Open DevTools | 70 | Open DevTools |
| 61 | </MenuItem> | 71 | </MenuItem> |
| 72 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> | ||
| 73 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} | ||
| 74 | </MenuItem> | ||
| 75 | <MenuSeparator /> | ||
| 62 | {/* | 76 | {/* |
| 63 | Zoom row: label + inline control cluster. `closeOnClick=false` | 77 | Zoom row: label + inline control cluster. `closeOnClick=false` |
| 64 | keeps the menu open while the user clicks the +/− buttons. | 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,13 +1,22 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 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 | import { useCallback, useEffect, useRef, useState } from "react"; | 10 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 11 | ||
| 7 | import { useComposerDraftStore } from "~/composerDraftStore"; | 12 | import { useComposerDraftStore } from "~/composerDraftStore"; |
| 8 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; | 13 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 9 | import { ensureLocalApi } from "~/localApi"; | 14 | import { ensureLocalApi } from "~/localApi"; |
| 10 | import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; | 15 | import { |
| 16 | rememberPreviewUrl, | ||
| 17 | updatePreviewServerSnapshot, | ||
| 18 | useThreadPreviewState, | ||
| 19 | } from "~/previewStateStore"; | ||
| 11 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 20 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 12 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; | 21 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 13 | import { previewEnvironment } from "~/state/preview"; | 22 | import { previewEnvironment } from "~/state/preview"; |
| ... | @@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; | ... | @@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 20 | import { formatPreviewUrl } from "./previewUrlPresentation"; | 29 | import { formatPreviewUrl } from "./previewUrlPresentation"; |
| 21 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 30 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 22 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | 31 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 32 | import { | ||
| 33 | commitBrowserViewportChange, | ||
| 34 | subscribeBrowserViewportChange, | ||
| 35 | } from "~/browser/browserViewportActions"; | ||
| 36 | import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; | ||
| 23 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 37 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 24 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | 38 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 25 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | 39 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 26 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; | 40 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 41 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | ||
| 27 | import { useLoadingProgress } from "./useLoadingProgress"; | 42 | import { useLoadingProgress } from "./useLoadingProgress"; |
| 28 | import { usePreviewSession } from "./usePreviewSession"; | 43 | import { usePreviewSession } from "./usePreviewSession"; |
| 29 | import { ZoomIndicator } from "./ZoomIndicator"; | 44 | import { ZoomIndicator } from "./ZoomIndicator"; |
| ... | @@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 60 | const environment = useEnvironment(threadRef.environmentId); | 75 | const environment = useEnvironment(threadRef.environmentId); |
| 61 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); | 76 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); |
| 62 | const open = useAtomCommand(previewEnvironment.open); | 77 | const open = useAtomCommand(previewEnvironment.open); |
| 78 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); | ||
| 63 | 79 | ||
| 64 | usePreviewSession(threadRef); | 80 | usePreviewSession(threadRef); |
| 65 | 81 | ||
| ... | @@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 91 | environmentHttpBaseUrl, | 107 | environmentHttpBaseUrl, |
| 92 | }) ?? undefined) | 108 | }) ?? undefined) |
| 93 | : undefined; | 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 | const handleSubmitUrl = useCallback( | 115 | const handleSubmitUrl = useCallback( |
| 96 | async (next: string) => { | 116 | async (next: string) => { |
| ... | @@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 131 | if (previewBridge && tabId) void previewBridge.resetZoom(tabId); | 151 | if (previewBridge && tabId) void previewBridge.resetZoom(tabId); |
| 132 | }, [tabId]); | 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 | const handleBack = useCallback(() => { | 199 | const handleBack = useCallback(() => { |
| 135 | if (previewBridge && tabId) void previewBridge.goBack(tabId); | 200 | if (previewBridge && tabId) void previewBridge.goBack(tabId); |
| 136 | }, [tabId]); | 201 | }, [tabId]); |
| ... | @@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 527 | tabId={tabId} | 592 | tabId={tabId} |
| 528 | hasWebContents={desktopOverlay !== null} | 593 | hasWebContents={desktopOverlay !== null} |
| 529 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} | 594 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
| 595 | deviceToolbarVisible={viewport._tag !== "fill"} | ||
| 596 | onToggleDeviceToolbar={handleToggleDeviceToolbar} | ||
| 530 | /> | 597 | /> |
| 531 | ) : null | 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,6 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | type PreviewAutomationOwner, | 3 | type PreviewAutomationHost, |
| 4 | PreviewAutomationOperation, | 4 | PreviewAutomationOperation, |
| 5 | type PreviewAutomationRequest, | 5 | type PreviewAutomationRequest, |
| 6 | type PreviewAutomationResponse, | 6 | type PreviewAutomationResponse, |
| ... | @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; |
| 13 | export interface PreviewAutomationOperationContext { | 13 | export interface PreviewAutomationOperationContext { |
| 14 | readonly requestId: PreviewAutomationRequest["requestId"]; | 14 | readonly requestId: PreviewAutomationRequest["requestId"]; |
| 15 | readonly operation: PreviewAutomationRequest["operation"]; | 15 | readonly operation: PreviewAutomationRequest["operation"]; |
| 16 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | 16 | readonly environmentId: PreviewAutomationHost["environmentId"]; |
| 17 | readonly threadId: PreviewAutomationRequest["threadId"]; | 17 | readonly threadId: PreviewAutomationRequest["threadId"]; |
| 18 | readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null; | 18 | readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null; |
| 19 | } | 19 | } |
| ... | @@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC | ... | @@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC |
| 56 | } | 56 | } |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()( | 59 | export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationViewportTimeoutError>()( |
| 60 | "PreviewAutomationStaleOwnerError", | 60 | "PreviewAutomationViewportTimeoutError", |
| 61 | { | 61 | { |
| 62 | requestId: TrimmedNonEmptyString, | 62 | requestId: TrimmedNonEmptyString, |
| 63 | environmentId: EnvironmentId, | 63 | environmentId: EnvironmentId, |
| 64 | expectedThreadId: ThreadId, | 64 | threadId: ThreadId, |
| 65 | requestedThreadId: ThreadId, | 65 | tabId: PreviewTabId, |
| 66 | timeoutMs: Schema.Int, | ||
| 66 | }, | 67 | }, |
| 67 | ) { | 68 | ) { |
| 68 | get responseTag() { | 69 | get responseTag() { |
| 69 | return "PreviewAutomationUnavailableError" as const; | 70 | return "PreviewAutomationTimeoutError" as const; |
| 70 | } | 71 | } |
| 71 | 72 | ||
| 72 | override get message(): string { | 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,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError |
| 100 | requestId: TrimmedNonEmptyString, | 101 | requestId: TrimmedNonEmptyString, |
| 101 | environmentId: EnvironmentId, | 102 | environmentId: EnvironmentId, |
| 102 | threadId: ThreadId, | 103 | threadId: ThreadId, |
| 103 | tabId: PreviewTabId, | 104 | tabId: Schema.NullOr(PreviewTabId), |
| 104 | }, | 105 | }, |
| 105 | ) { | 106 | ) { |
| 106 | get responseTag() { | 107 | get responseTag() { |
| ... | @@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError | ... | @@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError |
| 108 | } | 109 | } |
| 109 | 110 | ||
| 110 | override get message(): string { | 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 | export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()( | 171 | export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()( |
| 116 | "PreviewAutomationOperationError", | 172 | "PreviewAutomationOperationError", |
| 117 | { | 173 | { |
| ... | @@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre | ... | @@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre |
| 125 | ) { | 181 | ) { |
| 126 | static fromCause( | 182 | static fromCause( |
| 127 | input: PreviewAutomationOperationContext & { readonly cause: unknown }, | 183 | input: PreviewAutomationOperationContext & { readonly cause: unknown }, |
| 128 | ): PreviewAutomationOwnerError { | 184 | ): PreviewAutomationHostError { |
| 129 | return isPreviewAutomationOwnerError(input.cause) | 185 | if (isPreviewAutomationHostError(input.cause)) return input.cause; |
| 130 | ? 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 | : new PreviewAutomationOperationError(input); | 196 | : new PreviewAutomationOperationError(input); |
| 132 | } | 197 | } |
| 133 | 198 | ||
| ... | @@ -140,20 +205,21 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre | ... | @@ -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 | PreviewAutomationOverlayTimeoutError, | 209 | PreviewAutomationOverlayTimeoutError, |
| 145 | PreviewAutomationNavigationTimeoutError, | 210 | PreviewAutomationNavigationTimeoutError, |
| 146 | PreviewAutomationStaleOwnerError, | 211 | PreviewAutomationViewportTimeoutError, |
| 147 | PreviewAutomationTargetUnavailableError, | 212 | PreviewAutomationTargetUnavailableError, |
| 148 | PreviewAutomationRecordingNotActiveError, | 213 | PreviewAutomationRecordingNotActiveError, |
| 214 | PreviewAutomationTargetNotEditableHostError, | ||
| 149 | PreviewAutomationOperationError, | 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( | 221 | export function serializePreviewAutomationHostError( |
| 156 | error: PreviewAutomationOwnerError, | 222 | error: PreviewAutomationHostError, |
| 157 | ): NonNullable<PreviewAutomationResponse["error"]> { | 223 | ): NonNullable<PreviewAutomationResponse["error"]> { |
| 158 | const detail = Object.fromEntries( | 224 | const detail = Object.fromEntries( |
| 159 | Object.entries(error).filter( | 225 | Object.entries(error).filter( |
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+226-15| ... | @@ -2,13 +2,18 @@ import { | ... | @@ -2,13 +2,18 @@ import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | type PreviewAutomationRequest, | 3 | type PreviewAutomationRequest, |
| 4 | type PreviewAutomationResponse, | 4 | type PreviewAutomationResponse, |
| 5 | type PreviewAutomationStreamEvent, | ||
| 5 | PreviewTabId, | 6 | PreviewTabId, |
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 9 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 9 | import { describe, expect, it, vi } from "vite-plus/test"; | 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 | import { | 17 | import { |
| 13 | createPreviewAutomationRequestConsumerAtom, | 18 | createPreviewAutomationRequestConsumerAtom, |
| 14 | serializePreviewAutomationError, | 19 | serializePreviewAutomationError, |
| ... | @@ -17,6 +22,8 @@ import { | ... | @@ -17,6 +22,8 @@ import { |
| 17 | const environmentId = EnvironmentId.make("environment-1"); | 22 | const environmentId = EnvironmentId.make("environment-1"); |
| 18 | const threadId = ThreadId.make("thread-1"); | 23 | const threadId = ThreadId.make("thread-1"); |
| 19 | const tabId = PreviewTabId.make("tab-1"); | 24 | const tabId = PreviewTabId.make("tab-1"); |
| 25 | const clientId = "client-1"; | ||
| 26 | const connectionId = "connection-1"; | ||
| 20 | 27 | ||
| 21 | const request = ( | 28 | const request = ( |
| 22 | requestId: string, | 29 | requestId: string, |
| ... | @@ -30,10 +37,88 @@ const request = ( | ... | @@ -30,10 +37,88 @@ const request = ( |
| 30 | ...overrides, | 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 | describe("previewAutomationRequestConsumer", () => { | 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 | it("consumes every request emitted before React can render", async () => { | 119 | it("consumes every request emitted before React can render", async () => { |
| 35 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>( | 120 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( |
| 36 | AsyncResult.initial<PreviewAutomationRequest, Error>(false), | 121 | AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false), |
| 37 | ); | 122 | ); |
| 38 | const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({ | 123 | const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({ |
| 39 | requestId: value.requestId, | 124 | requestId: value.requestId, |
| ... | @@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => { |
| 42 | const respond = vi.fn(async (response: PreviewAutomationResponse) => { | 127 | const respond = vi.fn(async (response: PreviewAutomationResponse) => { |
| 43 | responses.push(response); | 128 | responses.push(response); |
| 44 | }); | 129 | }); |
| 130 | const state = consumerState(handleRequest); | ||
| 45 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | 131 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 46 | requestsAtom, | 132 | requestsAtom, |
| 133 | clientId, | ||
| 134 | connectionAtom: state.connectionAtom, | ||
| 47 | environmentId, | 135 | environmentId, |
| 48 | handleRequest, | 136 | requestHandlerAtom: state.requestHandlerAtom, |
| 49 | respond, | 137 | respond, |
| 50 | label: "test:preview-automation-consumer", | 138 | label: "test:preview-automation-consumer", |
| 51 | }); | 139 | }); |
| 52 | const registry = AtomRegistry.make(); | 140 | const registry = AtomRegistry.make(); |
| 53 | registry.mount(consumerAtom); | 141 | registry.mount(consumerAtom); |
| 54 | 142 | ||
| 55 | registry.set(requestsAtom, AsyncResult.success(request("request-1"))); | 143 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1"))); |
| 56 | registry.set(requestsAtom, AsyncResult.success(request("request-2"))); | 144 | registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2"))); |
| 57 | 145 | ||
| 58 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); | 146 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); |
| 59 | expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([ | 147 | expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([ |
| ... | @@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => { |
| 64 | registry.dispose(); | 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 | it("consumes a request that arrived immediately before the consumer mounted", async () => { | 187 | it("consumes a request that arrived immediately before the consumer mounted", async () => { |
| 68 | const requestsAtom = Atom.make( | 188 | const requestsAtom = Atom.make( |
| 69 | AsyncResult.success<PreviewAutomationRequest, Error>(request("request-ready")), | 189 | AsyncResult.success<PreviewAutomationStreamEvent, Error>(requestEvent("request-ready")), |
| 70 | ); | 190 | ); |
| 71 | const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); | 191 | const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); |
| 192 | const state = consumerState(async () => undefined); | ||
| 72 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | 193 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 73 | requestsAtom, | 194 | requestsAtom, |
| 195 | clientId, | ||
| 196 | connectionAtom: state.connectionAtom, | ||
| 74 | environmentId, | 197 | environmentId, |
| 75 | handleRequest: async () => undefined, | 198 | requestHandlerAtom: state.requestHandlerAtom, |
| 76 | respond, | 199 | respond, |
| 77 | label: "test:preview-automation-initial-request", | 200 | label: "test:preview-automation-initial-request", |
| 78 | }); | 201 | }); |
| ... | @@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => { |
| 81 | registry.mount(consumerAtom); | 204 | registry.mount(consumerAtom); |
| 82 | 205 | ||
| 83 | await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); | 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 | registry.dispose(); | 213 | registry.dispose(); |
| 86 | }); | 214 | }); |
| 87 | 215 | ||
| ... | @@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -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 | it("correlates unexpected failures without exposing cause details", () => { | 327 | it("correlates unexpected failures without exposing cause details", () => { |
| 122 | const cause = new Error("private bridge token: preview-secret"); | 328 | const cause = new Error("private bridge token: preview-secret"); |
| 123 | const context = { | 329 | const context = { |
| ... | @@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => { |
| 145 | }); | 351 | }); |
| 146 | 352 | ||
| 147 | it("sanitizes unexpected handler failures at the response boundary", async () => { | 353 | it("sanitizes unexpected handler failures at the response boundary", async () => { |
| 148 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>( | 354 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( |
| 149 | AsyncResult.initial<PreviewAutomationRequest, Error>(false), | 355 | AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false), |
| 150 | ); | 356 | ); |
| 151 | const responses: PreviewAutomationResponse[] = []; | 357 | const responses: PreviewAutomationResponse[] = []; |
| 358 | const state = consumerState(async () => { | ||
| 359 | throw new Error("desktop IPC secret: do-not-return"); | ||
| 360 | }); | ||
| 152 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | 361 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 153 | requestsAtom, | 362 | requestsAtom, |
| 363 | clientId, | ||
| 364 | connectionAtom: state.connectionAtom, | ||
| 154 | environmentId, | 365 | environmentId, |
| 155 | handleRequest: async () => { | 366 | requestHandlerAtom: state.requestHandlerAtom, |
| 156 | throw new Error("desktop IPC secret: do-not-return"); | ||
| 157 | }, | ||
| 158 | respond: async (response) => { | 367 | respond: async (response) => { |
| 159 | responses.push(response); | 368 | responses.push(response); |
| 160 | }, | 369 | }, |
| ... | @@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => { |
| 166 | registry.set( | 375 | registry.set( |
| 167 | requestsAtom, | 376 | requestsAtom, |
| 168 | AsyncResult.success( | 377 | AsyncResult.success( |
| 169 | request("request-failed", { | 378 | requestEvent("request-failed", { |
| 170 | operation: "click", | 379 | operation: "click", |
| 171 | tabId, | 380 | tabId, |
| 172 | }), | 381 | }), |
| ... | @@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => { | ... | @@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => { |
| 175 | 384 | ||
| 176 | await vi.waitFor(() => expect(responses).toHaveLength(1)); | 385 | await vi.waitFor(() => expect(responses).toHaveLength(1)); |
| 177 | expect(responses[0]).toEqual({ | 386 | expect(responses[0]).toEqual({ |
| 387 | clientId, | ||
| 388 | connectionId, | ||
| 178 | requestId: "request-failed", | 389 | requestId: "request-failed", |
| 179 | ok: false, | 390 | ok: false, |
| 180 | error: { | 391 | error: { |
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+76-42| ... | @@ -1,87 +1,121 @@ | ... | @@ -1,87 +1,121 @@ |
| 1 | import type { | 1 | import type { |
| 2 | PreviewAutomationOwner, | 2 | PreviewAutomationHost, |
| 3 | PreviewAutomationRequest, | 3 | PreviewAutomationRequest, |
| 4 | PreviewAutomationResponse, | 4 | PreviewAutomationResponse, |
| 5 | PreviewAutomationStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | 8 | ||
| 8 | import { | 9 | import { |
| 9 | PreviewAutomationOperationError, | 10 | PreviewAutomationOperationError, |
| 10 | type PreviewAutomationOperationContext, | 11 | type PreviewAutomationOperationContext, |
| 11 | serializePreviewAutomationOwnerError, | 12 | serializePreviewAutomationHostError, |
| 12 | } from "./previewAutomationErrors"; | 13 | } from "./previewAutomationErrors"; |
| 13 | 14 | ||
| 14 | type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>; | 15 | type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, 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 | } | ||
| 29 | 16 | ||
| 30 | export function serializePreviewAutomationError( | 17 | export function serializePreviewAutomationError( |
| 31 | error: unknown, | 18 | error: unknown, |
| 32 | context: PreviewAutomationOperationContext, | 19 | context: PreviewAutomationOperationContext, |
| 33 | ): NonNullable<PreviewAutomationResponse["error"]> { | 20 | ): NonNullable<PreviewAutomationResponse["error"]> { |
| 34 | return serializePreviewAutomationOwnerError( | 21 | return serializePreviewAutomationHostError( |
| 35 | PreviewAutomationOperationError.fromCause({ ...context, cause: error }), | 22 | PreviewAutomationOperationError.fromCause({ ...context, cause: error }), |
| 36 | ); | 23 | ); |
| 37 | } | 24 | } |
| 38 | 25 | ||
| 39 | export function createPreviewAutomationRequestConsumerAtom<E>(options: { | 26 | export function createPreviewAutomationRequestConsumerAtom<E>(options: { |
| 40 | readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>; | 27 | readonly requestsAtom: Atom.Atom<AutomationStreamResult<E>>; |
| 41 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | 28 | readonly clientId: PreviewAutomationHost["clientId"]; |
| 42 | readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>; | 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 | readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>; | 34 | readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>; |
| 44 | readonly label: string; | 35 | readonly label: string; |
| 45 | }): Atom.Atom<void> { | 36 | }): Atom.Atom<void> { |
| 46 | return Atom.make((get) => { | 37 | return Atom.make((get) => { |
| 38 | get.mount(options.connectionAtom); | ||
| 39 | get.mount(options.requestHandlerAtom); | ||
| 47 | let disposed = false; | 40 | let disposed = false; |
| 41 | let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; | ||
| 42 | let connectionExplicitlyAnnounced = false; | ||
| 43 | let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; | ||
| 48 | let requestsVersion = 0; | 44 | let requestsVersion = 0; |
| 49 | 45 | ||
| 50 | const consume = (result: AutomationRequestResult<E>) => { | 46 | const consume = (result: AutomationStreamResult<E>) => { |
| 51 | if (!AsyncResult.isSuccess(result)) return; | 47 | if (!AsyncResult.isSuccess(result)) return; |
| 52 | const request = result.value; | 48 | const event = result.value; |
| 53 | void options.handleRequest(request).then( | 49 | if (event.type === "connected") { |
| 54 | (value) => | 50 | activeConnectionId = event.connectionId; |
| 55 | options.respond({ | 51 | connectionExplicitlyAnnounced = true; |
| 56 | requestId: request.requestId, | 52 | } else if (activeConnectionId === null) { |
| 57 | ok: true, | 53 | activeConnectionId = event.connectionId; |
| 58 | ...(value === undefined ? {} : { result: value }), | 54 | } else if (activeConnectionId !== event.connectionId) { |
| 59 | }), | 55 | if (connectionExplicitlyAnnounced) return; |
| 60 | (error) => | 56 | activeConnectionId = event.connectionId; |
| 61 | options.respond({ | 57 | } |
| 62 | requestId: request.requestId, | 58 | if (reportedConnectionId !== event.connectionId) { |
| 63 | ok: false, | 59 | reportedConnectionId = event.connectionId; |
| 64 | error: serializePreviewAutomationError(error, { | 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 | requestId: request.requestId, | 82 | requestId: request.requestId, |
| 66 | operation: request.operation, | 83 | ok: false, |
| 67 | environmentId: options.environmentId, | 84 | error: serializePreviewAutomationError(error, { |
| 68 | threadId: request.threadId, | 85 | requestId: request.requestId, |
| 69 | tabId: request.tabId ?? null, | 86 | operation: request.operation, |
| 87 | environmentId: options.environmentId, | ||
| 88 | threadId: request.threadId, | ||
| 89 | tabId: request.tabId ?? null, | ||
| 90 | }), | ||
| 70 | }), | 91 | }), |
| 71 | }), | 92 | ); |
| 72 | ); | ||
| 73 | }; | 93 | }; |
| 74 | 94 | ||
| 75 | get.addFinalizer(() => { | 95 | get.addFinalizer(() => { |
| 76 | disposed = true; | 96 | disposed = true; |
| 77 | }); | 97 | }); |
| 78 | const initialRequest = get.once(options.requestsAtom); | 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 | get.subscribe(options.requestsAtom, (result) => { | 107 | get.subscribe(options.requestsAtom, (result) => { |
| 80 | requestsVersion += 1; | 108 | requestsVersion += 1; |
| 81 | consume(result); | 109 | consume(result); |
| 82 | }); | 110 | }); |
| 83 | queueMicrotask(() => { | 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 | }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label)); | 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,6 +11,7 @@ import { |
| 11 | applyPreviewServerEvent, | 11 | applyPreviewServerEvent, |
| 12 | applyPreviewServerSnapshot, | 12 | applyPreviewServerSnapshot, |
| 13 | readThreadPreviewState, | 13 | readThreadPreviewState, |
| 14 | reconcilePreviewServerSessions, | ||
| 14 | } from "~/previewStateStore"; | 15 | } from "~/previewStateStore"; |
| 15 | import { previewEnvironment } from "~/state/preview"; | 16 | import { previewEnvironment } from "~/state/preview"; |
| 16 | 17 | ||
| ... | @@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { | ... | @@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 50 | if (result.value.sessions.length > 0) { | 51 | if (result.value.sessions.length > 0) { |
| 51 | recoveringUrl = null; | 52 | recoveringUrl = null; |
| 52 | recoveryId += 1; | 53 | recoveryId += 1; |
| 53 | for (const snapshot of result.value.sessions) { | 54 | reconcilePreviewServerSessions(threadRef, result.value.sessions); |
| 54 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 55 | } | ||
| 56 | return; | 55 | return; |
| 57 | } | 56 | } |
| 58 | 57 |
apps/web/src/previewStateStore.test.ts+88| ... | @@ -11,10 +11,12 @@ import { | ... | @@ -11,10 +11,12 @@ import { |
| 11 | cancelPreviewSessionClose, | 11 | cancelPreviewSessionClose, |
| 12 | previewStateAtom, | 12 | previewStateAtom, |
| 13 | readThreadPreviewState, | 13 | readThreadPreviewState, |
| 14 | reconcilePreviewServerSessions, | ||
| 14 | rememberPreviewUrl, | 15 | rememberPreviewUrl, |
| 15 | removePreviewThread, | 16 | removePreviewThread, |
| 16 | resetPreviewStateForTests, | 17 | resetPreviewStateForTests, |
| 17 | setActivePreviewTab, | 18 | setActivePreviewTab, |
| 19 | updatePreviewServerSnapshot, | ||
| 18 | } from "./previewStateStore"; | 20 | } from "./previewStateStore"; |
| 19 | 21 | ||
| 20 | const environmentId = "env-1" as EnvironmentId; | 22 | const environmentId = "env-1" as EnvironmentId; |
| ... | @@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -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 | it("failed event flips the snapshot to LoadFailed when tabId matches", () => { | 141 | it("failed event flips the snapshot to LoadFailed when tabId matches", () => { |
| 112 | const snapshot = makeSnapshot(); | 142 | const snapshot = makeSnapshot(); |
| 113 | applyPreviewServerEvent(ref, { | 143 | applyPreviewServerEvent(ref, { |
| ... | @@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => { |
| 292 | expect(state.desktopOverlay?.canGoBack).toBe(true); | 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 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { | 383 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 296 | const snapshot = makeSnapshot(); | 384 | const snapshot = makeSnapshot(); |
| 297 | applyPreviewServerSnapshot(ref, snapshot); | 385 | applyPreviewServerSnapshot(ref, snapshot); |
apps/web/src/previewStateStore.ts+89-9| ... | @@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { | ... | @@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { |
| 112 | return next.slice(0, PREVIEW_RECENT_URL_LIMIT); | 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 | const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { | 130 | const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { |
| 116 | if (!current.sessions[tabId]) return current; | 131 | if (!current.sessions[tabId]) return current; |
| 117 | const { [tabId]: _closed, ...sessions } = current.sessions; | 132 | const { [tabId]: _closed, ...sessions } = current.sessions; |
| 118 | const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; | 133 | const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; |
| 119 | const nextSnapshot = | 134 | const nextSnapshot = latestSnapshot(sessions); |
| 120 | Object.values(sessions) | ||
| 121 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | ||
| 122 | .at(-1) ?? null; | ||
| 123 | const activeTabId = | 135 | const activeTabId = |
| 124 | current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; | 136 | current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; |
| 125 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; | 137 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; |
| ... | @@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven | ... | @@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven |
| 163 | updateThreadPreviewState(ref, (current) => { | 175 | updateThreadPreviewState(ref, (current) => { |
| 164 | switch (event.type) { | 176 | switch (event.type) { |
| 165 | case "opened": | 177 | case "opened": |
| 166 | case "navigated": { | 178 | case "navigated": |
| 179 | case "resized": { | ||
| 167 | const snapshot = event.snapshot; | 180 | const snapshot = event.snapshot; |
| 168 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | 181 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; |
| 169 | const recentlySeenUrls = | 182 | const recentlySeenUrls = |
| ... | @@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot( | ... | @@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot( |
| 228 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | 241 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; |
| 229 | const existing = current.sessions[snapshot.tabId]; | 242 | const existing = current.sessions[snapshot.tabId]; |
| 230 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; | 243 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; |
| 231 | const recentlySeenUrls = | 244 | const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot); |
| 232 | snapshot.navStatus._tag !== "Idle" | ||
| 233 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | ||
| 234 | : current.recentlySeenUrls; | ||
| 235 | return { | 245 | return { |
| 236 | ...current, | 246 | ...current, |
| 237 | snapshot, | 247 | snapshot, |
| ... | @@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot( | ... | @@ -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 | export function applyPreviewDesktopState( | 326 | export function applyPreviewDesktopState( |
| 247 | ref: ScopedThreadRef, | 327 | ref: ScopedThreadRef, |
| 248 | tabId: string, | 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,6 +9,14 @@ import { |
| 9 | createEnvironmentRpcSubscriptionAtomFamily, | 9 | createEnvironmentRpcSubscriptionAtomFamily, |
| 10 | } from "./runtime.ts"; | 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 | export function createPreviewEnvironmentAtoms<R, E>( | 20 | export function createPreviewEnvironmentAtoms<R, E>( |
| 13 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | 21 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 14 | ) { | 22 | ) { |
| ... | @@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>( | ... | @@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 54 | scheduler: lifecycleScheduler, | 62 | scheduler: lifecycleScheduler, |
| 55 | concurrency: lifecycleConcurrency, | 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 | refresh: createEnvironmentRpcCommand(runtime, { | 71 | refresh: createEnvironmentRpcCommand(runtime, { |
| 58 | label: "environment-data:preview:refresh", | 72 | label: "environment-data:preview:refresh", |
| 59 | tag: WS_METHODS.previewRefresh, | 73 | tag: WS_METHODS.previewRefresh, |
| ... | @@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>( | ... | @@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 82 | scheduler: automationScheduler, | 96 | scheduler: automationScheduler, |
| 83 | concurrency: { | 97 | concurrency: { |
| 84 | mode: "singleFlight", | 98 | mode: "singleFlight", |
| 85 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]), | 99 | key: ({ environmentId, input }) => |
| 86 | }, | 100 | JSON.stringify([environmentId, input.connectionId, input.requestId]), |
| 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]), | ||
| 95 | }, | 101 | }, |
| 96 | }), | 102 | }), |
| 97 | clearAutomationOwner: createEnvironmentRpcCommand(runtime, { | 103 | focusAutomationHost: createEnvironmentRpcCommand(runtime, { |
| 98 | label: "environment-data:preview:automation-clear-owner", | 104 | label: "environment-data:preview:automation-focus-host", |
| 99 | tag: WS_METHODS.previewAutomationClearOwner, | 105 | tag: WS_METHODS.previewAutomationFocusHost, |
| 100 | scheduler: automationScheduler, | 106 | scheduler: automationScheduler, |
| 101 | concurrency: { | 107 | concurrency: { |
| 102 | mode: "serial", | 108 | mode: "latest", |
| 103 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), | 109 | key: previewAutomationHostFocusConcurrencyKey, |
| 104 | }, | 110 | }, |
| 105 | }), | 111 | }), |
| 106 | }; | 112 | }; |
packages/contracts/src/ipc.ts+8-7| ... | @@ -69,19 +69,20 @@ import type { | ... | @@ -69,19 +69,20 @@ import type { |
| 69 | PreviewOpenInput, | 69 | PreviewOpenInput, |
| 70 | PreviewRefreshInput, | 70 | PreviewRefreshInput, |
| 71 | PreviewReportStatusInput, | 71 | PreviewReportStatusInput, |
| 72 | PreviewResizeInput, | ||
| 72 | PreviewSessionSnapshot, | 73 | PreviewSessionSnapshot, |
| 73 | } from "./preview.ts"; | 74 | } from "./preview.ts"; |
| 74 | import { | 75 | import { |
| 75 | PreviewAutomationClickInput, | 76 | PreviewAutomationClickInput, |
| 76 | PreviewAutomationEvaluateInput, | 77 | PreviewAutomationEvaluateInput, |
| 77 | PreviewAutomationOwner, | 78 | PreviewAutomationHost, |
| 78 | PreviewAutomationOwnerIdentity, | 79 | PreviewAutomationHostFocus, |
| 79 | PreviewAutomationPressInput, | 80 | PreviewAutomationPressInput, |
| 80 | PreviewAutomationRequest, | ||
| 81 | PreviewAutomationResponse, | 81 | PreviewAutomationResponse, |
| 82 | PreviewAutomationScrollInput, | 82 | PreviewAutomationScrollInput, |
| 83 | PreviewAutomationSnapshot, | 83 | PreviewAutomationSnapshot, |
| 84 | PreviewAutomationStatus, | 84 | PreviewAutomationStatus, |
| 85 | PreviewAutomationStreamEvent, | ||
| 85 | PreviewAutomationTypeInput, | 86 | PreviewAutomationTypeInput, |
| 86 | PreviewAutomationWaitForInput, | 87 | PreviewAutomationWaitForInput, |
| 87 | } from "./previewAutomation.ts"; | 88 | } from "./previewAutomation.ts"; |
| ... | @@ -1154,19 +1155,19 @@ export interface EnvironmentApi { | ... | @@ -1154,19 +1155,19 @@ export interface EnvironmentApi { |
| 1154 | preview: { | 1155 | preview: { |
| 1155 | open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>; | 1156 | open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>; |
| 1156 | navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>; | 1157 | navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>; |
| 1158 | resize: (input: typeof PreviewResizeInput.Encoded) => Promise<PreviewSessionSnapshot>; | ||
| 1157 | refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>; | 1159 | refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>; |
| 1158 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; | 1160 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; |
| 1159 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; | 1161 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; |
| 1160 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; | 1162 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; |
| 1161 | automation: { | 1163 | automation: { |
| 1162 | connect: ( | 1164 | connect: ( |
| 1163 | input: { clientId: string }, | 1165 | input: PreviewAutomationHost, |
| 1164 | callback: (request: PreviewAutomationRequest) => void, | 1166 | callback: (event: PreviewAutomationStreamEvent) => void, |
| 1165 | options?: { onResubscribe?: () => void }, | 1167 | options?: { onResubscribe?: () => void }, |
| 1166 | ) => () => void; | 1168 | ) => () => void; |
| 1167 | respond: (response: PreviewAutomationResponse) => Promise<void>; | 1169 | respond: (response: PreviewAutomationResponse) => Promise<void>; |
| 1168 | reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; | 1170 | focusHost: (input: PreviewAutomationHostFocus) => Promise<void>; |
| 1169 | clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise<void>; | ||
| 1170 | }; | 1171 | }; |
| 1171 | onEvent: ( | 1172 | onEvent: ( |
| 1172 | callback: (event: PreviewEvent) => void, | 1173 | callback: (event: PreviewEvent) => void, |
packages/contracts/src/preview.test.ts+144| ... | @@ -6,12 +6,26 @@ import { | ... | @@ -6,12 +6,26 @@ import { |
| 6 | PreviewEvent, | 6 | PreviewEvent, |
| 7 | PreviewNavStatus, | 7 | PreviewNavStatus, |
| 8 | PreviewSessionSnapshot, | 8 | PreviewSessionSnapshot, |
| 9 | PreviewViewportSetting, | ||
| 9 | } from "./preview.ts"; | 10 | } from "./preview.ts"; |
| 11 | import { | ||
| 12 | PreviewAutomationHost, | ||
| 13 | PreviewAutomationError, | ||
| 14 | PreviewAutomationResizeInput, | ||
| 15 | PreviewAutomationResizeResult, | ||
| 16 | PreviewAutomationStatus, | ||
| 17 | } from "./previewAutomation.ts"; | ||
| 10 | 18 | ||
| 11 | const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); | 19 | const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); |
| 12 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); | 20 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); |
| 13 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); | 21 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); |
| 14 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); | 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 | describe("PreviewNavStatus", () => { | 30 | describe("PreviewNavStatus", () => { |
| 17 | it("decodes Idle", () => { | 31 | it("decodes Idle", () => { |
| ... | @@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => { | ... | @@ -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 | describe("PreviewEvent", () => { | 196 | describe("PreviewEvent", () => { |
| 72 | it("decodes opened", () => { | 197 | it("decodes opened", () => { |
| 73 | const event = decodePreviewEvent({ | 198 | const event = decodePreviewEvent({ |
| ... | @@ -104,6 +229,25 @@ describe("PreviewEvent", () => { | ... | @@ -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 | it("decodes closed without snapshot", () => { | 251 | it("decodes closed without snapshot", () => { |
| 108 | const event = decodePreviewEvent({ | 252 | const event = decodePreviewEvent({ |
| 109 | type: "closed", | 253 | type: "closed", |
packages/contracts/src/preview.ts+110| ... | @@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512)); | ... | @@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512)); |
| 17 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | 17 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 18 | export type PreviewTabId = typeof PreviewTabId.Type; | 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 | export const PreviewNavStatus = Schema.Union([ | 114 | export const PreviewNavStatus = Schema.Union([ |
| 21 | Schema.TaggedStruct("Idle", {}), | 115 | Schema.TaggedStruct("Idle", {}), |
| 22 | Schema.TaggedStruct("Loading", { | 116 | Schema.TaggedStruct("Loading", { |
| ... | @@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ | ... | @@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ |
| 42 | navStatus: PreviewNavStatus, | 136 | navStatus: PreviewNavStatus, |
| 43 | canGoBack: Schema.Boolean, | 137 | canGoBack: Schema.Boolean, |
| 44 | canGoForward: Schema.Boolean, | 138 | canGoForward: Schema.Boolean, |
| 139 | /** Missing snapshots from older servers are treated as fill-panel mode. */ | ||
| 140 | viewport: Schema.optional(PreviewViewportSetting), | ||
| 45 | updatedAt: Schema.String, | 141 | updatedAt: Schema.String, |
| 46 | }); | 142 | }); |
| 47 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; | 143 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; |
| ... | @@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({ | ... | @@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({ |
| 76 | }); | 172 | }); |
| 77 | export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; | 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 | export const PreviewCloseInput = Schema.Struct({ | 182 | export const PreviewCloseInput = Schema.Struct({ |
| 80 | threadId: ThreadId, | 183 | threadId: ThreadId, |
| 81 | tabId: Schema.optional(PreviewTabId), | 184 | tabId: Schema.optional(PreviewTabId), |
| ... | @@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({ | ... | @@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({ |
| 110 | snapshot: PreviewSessionSnapshot, | 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 | const PreviewFailedEvent = Schema.Struct({ | 222 | const PreviewFailedEvent = Schema.Struct({ |
| 114 | ...PreviewEventBaseSchema.fields, | 223 | ...PreviewEventBaseSchema.fields, |
| 115 | type: Schema.Literal("failed"), | 224 | type: Schema.Literal("failed"), |
| ... | @@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({ | ... | @@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({ |
| 127 | export const PreviewEvent = Schema.Union([ | 236 | export const PreviewEvent = Schema.Union([ |
| 128 | PreviewOpenedEvent, | 237 | PreviewOpenedEvent, |
| 129 | PreviewNavigatedEvent, | 238 | PreviewNavigatedEvent, |
| 239 | PreviewResizedEvent, | ||
| 130 | PreviewFailedEvent, | 240 | PreviewFailedEvent, |
| 131 | PreviewClosedEvent, | 241 | PreviewClosedEvent, |
| 132 | ]); | 242 | ]); |
packages/contracts/src/previewAutomation.ts+162-31| ... | @@ -1,7 +1,14 @@ | ... | @@ -1,7 +1,14 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 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 | import { ProviderInstanceId } from "./providerInstance.ts"; | 12 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 6 | 13 | ||
| 7 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) | 14 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) |
| ... | @@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional( | ... | @@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional( |
| 18 | .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), | 25 | .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), |
| 19 | ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); | 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 | "status", | 30 | "status", |
| 23 | "open", | 31 | "open", |
| 24 | "navigate", | 32 | "navigate", |
| ... | @@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([ | ... | @@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([ |
| 31 | "waitFor", | 39 | "waitFor", |
| 32 | "recordingStart", | 40 | "recordingStart", |
| 33 | "recordingStop", | 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 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; | 51 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 36 | 52 | ||
| 37 | export const PreviewAutomationStatus = Schema.Struct({ | 53 | export const PreviewAutomationStatus = Schema.Struct({ |
| ... | @@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({ | ... | @@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({ |
| 41 | url: Schema.NullOr(Schema.String), | 57 | url: Schema.NullOr(Schema.String), |
| 42 | title: Schema.NullOr(Schema.String), | 58 | title: Schema.NullOr(Schema.String), |
| 43 | loading: Schema.Boolean, | 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 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; | 65 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 46 | 66 | ||
| ... | @@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({ | ... | @@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({ |
| 134 | }); | 154 | }); |
| 135 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; | 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 | const Locator = TrimmedNonEmptyString.annotate({ | 231 | const Locator = TrimmedNonEmptyString.annotate({ |
| 138 | description: | 232 | description: |
| 139 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", | 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,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ |
| 411 | }); | 505 | }); |
| 412 | export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; | 506 | export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; |
| 413 | 507 | ||
| 414 | export const PreviewAutomationOwnerIdentity = Schema.Struct({ | 508 | export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 415 | clientId: TrimmedNonEmptyString, | 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 | environmentId: EnvironmentId, | 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({ | 529 | export const PreviewAutomationHostFocus = Schema.Struct({ |
| 422 | ...PreviewAutomationOwnerIdentity.fields, | 530 | ...PreviewAutomationHostIdentity.fields, |
| 423 | tabId: Schema.NullOr(PreviewTabId), | 531 | connectionId: PreviewAutomationConnectionId, |
| 424 | visible: Schema.Boolean, | 532 | focused: Schema.Boolean, |
| 425 | supportsAutomation: Schema.Boolean, | ||
| 426 | focusedAt: Schema.String, | ||
| 427 | }); | 533 | }); |
| 428 | export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; | 534 | export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; |
| 429 | 535 | ||
| 430 | export const PreviewAutomationRequest = Schema.Struct({ | 536 | export const PreviewAutomationRequest = Schema.Struct({ |
| 431 | requestId: TrimmedNonEmptyString, | 537 | requestId: TrimmedNonEmptyString, |
| ... | @@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({ | ... | @@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({ |
| 437 | }); | 543 | }); |
| 438 | export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; | 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 | export const PreviewAutomationResponse = Schema.Struct({ | 559 | export const PreviewAutomationResponse = Schema.Struct({ |
| 560 | clientId: PreviewAutomationClientId, | ||
| 561 | connectionId: PreviewAutomationConnectionId, | ||
| 441 | requestId: TrimmedNonEmptyString, | 562 | requestId: TrimmedNonEmptyString, |
| 442 | ok: Schema.Boolean, | 563 | ok: Schema.Boolean, |
| 443 | result: Schema.optional(Schema.Unknown), | 564 | result: Schema.optional(Schema.Unknown), |
| ... | @@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = { | ... | @@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = { |
| 477 | const PreviewAutomationRequestErrorFields = { | 598 | const PreviewAutomationRequestErrorFields = { |
| 478 | ...PreviewAutomationScopeErrorFields, | 599 | ...PreviewAutomationScopeErrorFields, |
| 479 | clientId: TrimmedNonEmptyString, | 600 | clientId: TrimmedNonEmptyString, |
| 601 | connectionId: PreviewAutomationConnectionId, | ||
| 480 | requestId: TrimmedNonEmptyString, | 602 | requestId: TrimmedNonEmptyString, |
| 481 | tabId: Schema.optional(PreviewTabId), | 603 | tabId: Schema.optional(PreviewTabId), |
| 482 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), | 604 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), |
| ... | @@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = { | ... | @@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = { |
| 500 | cause: Schema.optional(Schema.Defect()), | 622 | cause: Schema.optional(Schema.Defect()), |
| 501 | }; | 623 | }; |
| 502 | 624 | ||
| 503 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( | 625 | export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass<PreviewAutomationNoAvailableHostError>()( |
| 504 | "PreviewAutomationNoFocusedOwnerError", | 626 | "PreviewAutomationNoAvailableHostError", |
| 505 | { | 627 | { |
| 506 | ...PreviewAutomationScopeErrorFields, | 628 | ...PreviewAutomationScopeErrorFields, |
| 507 | clientId: Schema.optional(TrimmedNonEmptyString), | 629 | clientId: Schema.optional(TrimmedNonEmptyString), |
| 630 | connectionId: Schema.optional(PreviewAutomationConnectionId), | ||
| 508 | requestId: Schema.optional(TrimmedNonEmptyString), | 631 | requestId: Schema.optional(TrimmedNonEmptyString), |
| 509 | tabId: Schema.optional(PreviewTabId), | 632 | tabId: Schema.optional(PreviewTabId), |
| 510 | timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), | 633 | timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), |
| ... | @@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas | ... | @@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas |
| 512 | }, | 635 | }, |
| 513 | ) { | 636 | ) { |
| 514 | override get message(): string { | 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 | return summary; | 639 | return summary; |
| 517 | } | 640 | } |
| 518 | } | 641 | } |
| ... | @@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla | ... | @@ -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 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( | 744 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( |
| 602 | "PreviewAutomationResultTooLargeError", | 745 | "PreviewAutomationResultTooLargeError", |
| 603 | { | 746 | { |
| ... | @@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas | ... | @@ -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 | export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()( | 761 | export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()( |
| 631 | "PreviewAutomationClientDisconnectedError", | 762 | "PreviewAutomationClientDisconnectedError", |
| 632 | PreviewAutomationRequestErrorFields, | 763 | PreviewAutomationRequestErrorFields, |
| ... | @@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC | ... | @@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC |
| 668 | 799 | ||
| 669 | export const PreviewAutomationError = Schema.Union([ | 800 | export const PreviewAutomationError = Schema.Union([ |
| 670 | PreviewAutomationUnavailableError, | 801 | PreviewAutomationUnavailableError, |
| 671 | PreviewAutomationNoFocusedOwnerError, | 802 | PreviewAutomationNoAvailableHostError, |
| 672 | PreviewAutomationUnsupportedClientError, | 803 | PreviewAutomationUnsupportedClientError, |
| 673 | PreviewAutomationTabNotFoundError, | 804 | PreviewAutomationTabNotFoundError, |
| 674 | PreviewAutomationTimeoutError, | 805 | PreviewAutomationTimeoutError, |
| 675 | PreviewAutomationControlInterruptedError, | 806 | PreviewAutomationControlInterruptedError, |
| 676 | PreviewAutomationExecutionError, | 807 | PreviewAutomationExecutionError, |
| 677 | PreviewAutomationInvalidSelectorError, | 808 | PreviewAutomationInvalidSelectorError, |
| 809 | PreviewAutomationTargetNotEditableError, | ||
| 678 | PreviewAutomationResultTooLargeError, | 810 | PreviewAutomationResultTooLargeError, |
| 679 | PreviewAutomationHostNotConnectedError, | ||
| 680 | PreviewAutomationClientDisconnectedError, | 811 | PreviewAutomationClientDisconnectedError, |
| 681 | PreviewAutomationRequestQueueClosedError, | 812 | PreviewAutomationRequestQueueClosedError, |
| 682 | PreviewAutomationRemoteUnavailableError, | 813 | PreviewAutomationRemoteUnavailableError, |
packages/contracts/src/rpc.ts+19-17| ... | @@ -103,14 +103,15 @@ import { | ... | @@ -103,14 +103,15 @@ import { |
| 103 | PreviewOpenInput, | 103 | PreviewOpenInput, |
| 104 | PreviewRefreshInput, | 104 | PreviewRefreshInput, |
| 105 | PreviewReportStatusInput, | 105 | PreviewReportStatusInput, |
| 106 | PreviewResizeInput, | ||
| 106 | PreviewSessionSnapshot, | 107 | PreviewSessionSnapshot, |
| 107 | } from "./preview.ts"; | 108 | } from "./preview.ts"; |
| 108 | import { | 109 | import { |
| 109 | PreviewAutomationError, | 110 | PreviewAutomationError, |
| 110 | PreviewAutomationOwner, | 111 | PreviewAutomationHost, |
| 111 | PreviewAutomationOwnerIdentity, | 112 | PreviewAutomationHostFocus, |
| 112 | PreviewAutomationRequest, | ||
| 113 | PreviewAutomationResponse, | 113 | PreviewAutomationResponse, |
| 114 | PreviewAutomationStreamEvent, | ||
| 114 | } from "./previewAutomation.ts"; | 115 | } from "./previewAutomation.ts"; |
| 115 | import { | 116 | import { |
| 116 | ServerConfigStreamEvent, | 117 | ServerConfigStreamEvent, |
| ... | @@ -190,14 +191,14 @@ export const WS_METHODS = { | ... | @@ -190,14 +191,14 @@ export const WS_METHODS = { |
| 190 | // Preview methods | 191 | // Preview methods |
| 191 | previewOpen: "preview.open", | 192 | previewOpen: "preview.open", |
| 192 | previewNavigate: "preview.navigate", | 193 | previewNavigate: "preview.navigate", |
| 194 | previewResize: "preview.resize", | ||
| 193 | previewRefresh: "preview.refresh", | 195 | previewRefresh: "preview.refresh", |
| 194 | previewClose: "preview.close", | 196 | previewClose: "preview.close", |
| 195 | previewList: "preview.list", | 197 | previewList: "preview.list", |
| 196 | previewReportStatus: "preview.reportStatus", | 198 | previewReportStatus: "preview.reportStatus", |
| 197 | previewAutomationConnect: "previewAutomation.connect", | 199 | previewAutomationConnect: "previewAutomation.connect", |
| 198 | previewAutomationRespond: "previewAutomation.respond", | 200 | previewAutomationRespond: "previewAutomation.respond", |
| 199 | previewAutomationReportOwner: "previewAutomation.reportOwner", | 201 | previewAutomationFocusHost: "previewAutomation.focusHost", |
| 200 | previewAutomationClearOwner: "previewAutomation.clearOwner", | ||
| 201 | 202 | ||
| 202 | // Server meta | 203 | // Server meta |
| 203 | serverGetConfig: "server.getConfig", | 204 | serverGetConfig: "server.getConfig", |
| ... | @@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { | ... | @@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { |
| 528 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), | 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 | export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { | 538 | export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { |
| 532 | payload: PreviewRefreshInput, | 539 | payload: PreviewRefreshInput, |
| 533 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), | 540 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| ... | @@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, | ... | @@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, |
| 550 | }); | 557 | }); |
| 551 | 558 | ||
| 552 | export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { | 559 | export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { |
| 553 | payload: PreviewAutomationOwner, | 560 | payload: PreviewAutomationHost, |
| 554 | success: PreviewAutomationRequest, | 561 | success: PreviewAutomationStreamEvent, |
| 555 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | 562 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), |
| 556 | stream: true, | 563 | stream: true, |
| 557 | }); | 564 | }); |
| ... | @@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati | ... | @@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati |
| 561 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | 568 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), |
| 562 | }); | 569 | }); |
| 563 | 570 | ||
| 564 | export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { | 571 | export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { |
| 565 | payload: PreviewAutomationOwner, | 572 | payload: PreviewAutomationHostFocus, |
| 566 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | 573 | error: EnvironmentAuthorizationError, |
| 567 | }); | ||
| 568 | |||
| 569 | export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { | ||
| 570 | payload: PreviewAutomationOwnerIdentity, | ||
| 571 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | ||
| 572 | }); | 574 | }); |
| 573 | 575 | ||
| 574 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { | 576 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { |
| ... | @@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 728 | WsSubscribeTerminalMetadataRpc, | 730 | WsSubscribeTerminalMetadataRpc, |
| 729 | WsPreviewOpenRpc, | 731 | WsPreviewOpenRpc, |
| 730 | WsPreviewNavigateRpc, | 732 | WsPreviewNavigateRpc, |
| 733 | WsPreviewResizeRpc, | ||
| 731 | WsPreviewRefreshRpc, | 734 | WsPreviewRefreshRpc, |
| 732 | WsPreviewCloseRpc, | 735 | WsPreviewCloseRpc, |
| 733 | WsPreviewListRpc, | 736 | WsPreviewListRpc, |
| 734 | WsPreviewReportStatusRpc, | 737 | WsPreviewReportStatusRpc, |
| 735 | WsPreviewAutomationConnectRpc, | 738 | WsPreviewAutomationConnectRpc, |
| 736 | WsPreviewAutomationRespondRpc, | 739 | WsPreviewAutomationRespondRpc, |
| 737 | WsPreviewAutomationReportOwnerRpc, | 740 | WsPreviewAutomationFocusHostRpc, |
| 738 | WsPreviewAutomationClearOwnerRpc, | ||
| 739 | WsSubscribePreviewEventsRpc, | 741 | WsSubscribePreviewEventsRpc, |
| 740 | WsSubscribeDiscoveredLocalServersRpc, | 742 | WsSubscribeDiscoveredLocalServersRpc, |
| 741 | WsSubscribeServerConfigRpc, | 743 | WsSubscribeServerConfigRpc, |
packages/shared/package.json+4| ... | @@ -163,6 +163,10 @@ | ... | @@ -163,6 +163,10 @@ |
| 163 | "types": "./src/preview.ts", | 163 | "types": "./src/preview.ts", |
| 164 | "import": "./src/preview.ts" | 164 | "import": "./src/preview.ts" |
| 165 | }, | 165 | }, |
| 166 | "./previewViewport": { | ||
| 167 | "types": "./src/previewViewport.ts", | ||
| 168 | "import": "./src/previewViewport.ts" | ||
| 169 | }, | ||
| 166 | "./filePreview": { | 170 | "./filePreview": { |
| 167 | "types": "./src/filePreview.ts", | 171 | "types": "./src/filePreview.ts", |
| 168 | "import": "./src/filePreview.ts" | 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,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 253 | T3CODE_MODE: "web", | 253 | T3CODE_MODE: "web", |
| 254 | T3CODE_NO_BROWSER: "0", | 254 | T3CODE_NO_BROWSER: "0", |
| 255 | T3CODE_HOST: "0.0.0.0", | 255 | T3CODE_HOST: "0.0.0.0", |
| 256 | VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", | ||
| 256 | VITE_WS_URL: "ws://localhost:13773", | 257 | VITE_WS_URL: "ws://localhost:13773", |
| 257 | }, | 258 | }, |
| 258 | serverOffset: 0, | 259 | serverOffset: 0, |
scripts/dev-runner.ts+5-8| ... | @@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined> | ... | @@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined> |
| 158 | Config.option, | 158 | Config.option, |
| 159 | Config.map((value) => Option.getOrUndefined(value)), | 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 | const OffsetConfig = Config.all({ | 161 | const OffsetConfig = Config.all({ |
| 168 | portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), | 162 | portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), |
| 169 | devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), | 163 | devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), |
| ... | @@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", { | ... | @@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", { |
| 621 | ), | 615 | ), |
| 622 | devUrl: Flag.string("dev-url").pipe( | 616 | devUrl: Flag.string("dev-url").pipe( |
| 623 | Flag.withSchema(Schema.URLFromString), | 617 | Flag.withSchema(Schema.URLFromString), |
| 624 | Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), | 618 | Flag.withDescription( |
| 625 | Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")), | 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 | dryRun: Flag.boolean("dry-run").pipe( | 624 | dryRun: Flag.boolean("dry-run").pipe( |
| 628 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), | 625 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), |