| author | |
| committer | |
| log | 894d33419dceb366eecda8e5e27c6fc043c3959e |
| tree | c73aa93cc0033ab05ea152d1b74a2e93af4f3eb8 |
| parent | d4cd7d5c33122473da22ae118c93477ef7e38310 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 371 insertions(+), 14 deletions(-)
apps/server/src/mcp/PreviewAutomationBroker.test.ts+126| ... | @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang | ... | @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang |
| 685 | environmentId: scope.environmentId, | 685 | environmentId: scope.environmentId, |
| 686 | connectionId: "connection-stale", | 686 | connectionId: "connection-stale", |
| 687 | focused: true, | 687 | focused: true, |
| 688 | liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], | ||
| 688 | }); | 689 | }); |
| 689 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | 690 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 690 | "second", | 691 | "second", |
| ... | @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang | ... | @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang |
| 725 | ), | 726 | ), |
| 726 | ); | 727 | ); |
| 727 | 728 | ||
| 729 | it.effect("prefers the live tab owner for new sessions without moving existing leases", () => | ||
| 730 | Effect.scoped( | ||
| 731 | Effect.gen(function* () { | ||
| 732 | const broker = yield* makeBroker; | ||
| 733 | const connections = new Map<string, string>(); | ||
| 734 | for (const clientId of ["owner", "other"]) { | ||
| 735 | const requests = requestsFrom( | ||
| 736 | yield* broker.connect(makeHost({ clientId })), | ||
| 737 | (connectionId) => connections.set(clientId, connectionId), | ||
| 738 | ); | ||
| 739 | yield* Stream.runForEach(requests, (request) => | ||
| 740 | broker.respond({ | ||
| 741 | clientId, | ||
| 742 | connectionId: request.connectionId, | ||
| 743 | requestId: request.requestId, | ||
| 744 | ok: true, | ||
| 745 | result: clientId, | ||
| 746 | }), | ||
| 747 | ).pipe(Effect.forkScoped); | ||
| 748 | } | ||
| 749 | yield* Effect.yieldNow; | ||
| 750 | yield* broker.focusHost({ | ||
| 751 | clientId: "owner", | ||
| 752 | environmentId: scope.environmentId, | ||
| 753 | connectionId: connections.get("owner")!, | ||
| 754 | focused: false, | ||
| 755 | liveTabs: [ | ||
| 756 | { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, | ||
| 757 | ], | ||
| 758 | }); | ||
| 759 | yield* broker.focusHost({ | ||
| 760 | clientId: "other", | ||
| 761 | environmentId: scope.environmentId, | ||
| 762 | connectionId: connections.get("other")!, | ||
| 763 | focused: true, | ||
| 764 | liveTabs: [ | ||
| 765 | { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, | ||
| 766 | { | ||
| 767 | threadId: ThreadId.make("another-thread"), | ||
| 768 | tabId: PreviewTabId.make("different-tab"), | ||
| 769 | visible: true, | ||
| 770 | }, | ||
| 771 | ], | ||
| 772 | }); | ||
| 773 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( | ||
| 774 | "owner", | ||
| 775 | ); | ||
| 776 | expect( | ||
| 777 | yield* broker.invoke<string>({ | ||
| 778 | scope: { ...scope, providerSessionId: "explicit-owner" }, | ||
| 779 | tabId: PreviewTabId.make("signed-in"), | ||
| 780 | operation: "snapshot", | ||
| 781 | input: {}, | ||
| 782 | }), | ||
| 783 | ).toBe("owner"); | ||
| 784 | expect( | ||
| 785 | yield* broker.invoke<string>({ | ||
| 786 | scope: { ...scope, providerSessionId: "other-tab" }, | ||
| 787 | tabId: PreviewTabId.make("different-tab"), | ||
| 788 | operation: "evaluate", | ||
| 789 | input: {}, | ||
| 790 | }), | ||
| 791 | ).toBe("other"); | ||
| 792 | |||
| 793 | yield* broker.focusHost({ | ||
| 794 | clientId: "owner", | ||
| 795 | environmentId: scope.environmentId, | ||
| 796 | connectionId: connections.get("owner")!, | ||
| 797 | focused: false, | ||
| 798 | liveTabs: [], | ||
| 799 | }); | ||
| 800 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( | ||
| 801 | "owner", | ||
| 802 | ); | ||
| 803 | expect( | ||
| 804 | yield* broker.invoke<string>({ | ||
| 805 | scope: { ...scope, providerSessionId: "after-tab-closed" }, | ||
| 806 | operation: "evaluate", | ||
| 807 | input: {}, | ||
| 808 | }), | ||
| 809 | ).toBe("other"); | ||
| 810 | }), | ||
| 811 | ), | ||
| 812 | ); | ||
| 813 | |||
| 814 | it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => | ||
| 815 | Effect.scoped( | ||
| 816 | Effect.gen(function* () { | ||
| 817 | const broker = yield* makeBroker; | ||
| 818 | let focusedConnectionId = ""; | ||
| 819 | for (const [clientId, supportedOperations] of [ | ||
| 820 | ["focused", ["status"]], | ||
| 821 | ["background", ["status", "resize"]], | ||
| 822 | ] as const) { | ||
| 823 | const requests = requestsFrom( | ||
| 824 | yield* broker.connect(makeHost({ clientId, supportedOperations })), | ||
| 825 | (connectionId) => { | ||
| 826 | if (clientId === "focused") focusedConnectionId = connectionId; | ||
| 827 | }, | ||
| 828 | ); | ||
| 829 | yield* Stream.runForEach(requests, (request) => | ||
| 830 | broker.respond({ | ||
| 831 | clientId, | ||
| 832 | connectionId: request.connectionId, | ||
| 833 | requestId: request.requestId, | ||
| 834 | ok: true, | ||
| 835 | result: clientId, | ||
| 836 | }), | ||
| 837 | ).pipe(Effect.forkScoped); | ||
| 838 | } | ||
| 839 | yield* Effect.yieldNow; | ||
| 840 | yield* broker.focusHost({ | ||
| 841 | clientId: "focused", | ||
| 842 | environmentId: scope.environmentId, | ||
| 843 | connectionId: focusedConnectionId, | ||
| 844 | focused: true, | ||
| 845 | }); | ||
| 846 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | ||
| 847 | "focused", | ||
| 848 | ); | ||
| 849 | }), | ||
| 850 | ), | ||
| 851 | ); | ||
| 852 | |||
| 728 | it.effect("does not route new operations to legacy hosts that did not advertise support", () => | 853 | it.effect("does not route new operations to legacy hosts that did not advertise support", () => |
| 729 | Effect.scoped( | 854 | Effect.scoped( |
| 730 | Effect.gen(function* () { | 855 | Effect.gen(function* () { |
| ... | @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" | ... | @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 921 | environmentId: scope.environmentId, | 1046 | environmentId: scope.environmentId, |
| 922 | connectionId: firstConnectionId, | 1047 | connectionId: firstConnectionId, |
| 923 | focused: true, | 1048 | focused: true, |
| 1049 | liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], | ||
| 924 | }); | 1050 | }); |
| 925 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ | 1051 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ |
| 926 | host: "first", | 1052 | host: "first", |
apps/server/src/mcp/PreviewAutomationBroker.ts+12-1| ... | @@ -73,6 +73,7 @@ interface ClientConnection { | ... | @@ -73,6 +73,7 @@ interface ClientConnection { |
| 73 | readonly environmentId: PreviewAutomationHost["environmentId"]; | 73 | readonly environmentId: PreviewAutomationHost["environmentId"]; |
| 74 | readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>; | 74 | readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>; |
| 75 | readonly focused: boolean; | 75 | readonly focused: boolean; |
| 76 | readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>; | ||
| 76 | readonly focusOrder: number; | 77 | readonly focusOrder: number; |
| 77 | readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>; | 78 | readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>; |
| 78 | } | 79 | } |
| ... | @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 376 | environmentId: host.environmentId, | 377 | environmentId: host.environmentId, |
| 377 | supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), | 378 | supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), |
| 378 | focused: false, | 379 | focused: false, |
| 380 | liveTabs: [], | ||
| 379 | focusOrder: 0, | 381 | focusOrder: 0, |
| 380 | queue, | 382 | queue, |
| 381 | }; | 383 | }; |
| ... | @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 432 | clients.set(host.clientId, { | 434 | clients.set(host.clientId, { |
| 433 | ...currentHost, | 435 | ...currentHost, |
| 434 | focused: host.focused, | 436 | focused: host.focused, |
| 437 | liveTabs: host.liveTabs ?? currentHost.liveTabs, | ||
| 435 | focusOrder: host.focused ? focusSequence : currentHost.focusOrder, | 438 | focusOrder: host.focused ? focusSequence : currentHost.focusOrder, |
| 436 | }); | 439 | }); |
| 437 | return { ...current, clients, focusSequence }; | 440 | return { ...current, clients, focusSequence }; |
| ... | @@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 492 | // operation is not silently moved to a newer client: the caller gets a | 495 | // operation is not silently moved to a newer client: the caller gets a |
| 493 | // capability failure and can deliberately start a fresh provider | 496 | // capability failure and can deliberately start a fresh provider |
| 494 | // session. A dead lease is pruned above and may fail over. | 497 | // session. A dead lease is pruned above and may fail over. |
| 498 | const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => | ||
| 499 | host.liveTabs.some( | ||
| 500 | (tab) => | ||
| 501 | tab.threadId === input.scope.threadId && | ||
| 502 | (!visibleOnly || tab.visible === true) && | ||
| 503 | (input.tabId === undefined || tab.tabId === input.tabId), | ||
| 504 | ); | ||
| 495 | const connection = | 505 | const connection = |
| 496 | hasLiveAssignment && supportsOperation(assignedConnection, input.operation) | 506 | hasLiveAssignment && supportsOperation(assignedConnection, input.operation) |
| 497 | ? assignedConnection | 507 | ? assignedConnection |
| ... | @@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 505 | ) | 515 | ) |
| 506 | .sort( | 516 | .sort( |
| 507 | (left, right) => | 517 | (left, right) => |
| 508 | right.supportedOperations.size - left.supportedOperations.size || | 518 | Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || |
| 519 | Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || | ||
| 509 | Number(right.focused) - Number(left.focused) || | 520 | Number(right.focused) - Number(left.focused) || |
| 510 | right.focusOrder - left.focusOrder, | 521 | right.focusOrder - left.focusOrder, |
| 511 | )[0]; | 522 | )[0]; |
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx+177-4| ... | @@ -8,13 +8,22 @@ import { | ... | @@ -8,13 +8,22 @@ import { |
| 8 | type PreviewOpenInput, | 8 | type PreviewOpenInput, |
| 9 | type PreviewSessionSnapshot, | 9 | type PreviewSessionSnapshot, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 12 | import * as Cause from "effect/Cause"; | ||
| 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 12 | import { act } from "react"; | 14 | import { act } from "react"; |
| 13 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 15 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 15 | 17 | ||
| 18 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | ||
| 19 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | ||
| 16 | import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; | 20 | import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; |
| 17 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; | 21 | import { |
| 22 | applyPreviewServerSnapshot, | ||
| 23 | readThreadPreviewState, | ||
| 24 | resetPreviewStateForTests, | ||
| 25 | applyPreviewDesktopState, | ||
| 26 | } from "~/previewStateStore"; | ||
| 18 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; | 27 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; |
| 19 | 28 | ||
| 20 | import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; | 29 | import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; |
| ... | @@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({ |
| 31 | vi.fn< | 40 | vi.fn< |
| 32 | (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void> | 41 | (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void> |
| 33 | >(), | 42 | >(), |
| 34 | focus: vi.fn(async () => undefined), | 43 | focus: vi.fn<() => Promise<AtomCommandResult<void, Error>>>(), |
| 35 | })); | 44 | })); |
| 36 | 45 | ||
| 37 | vi.mock("~/localApi", () => ({ | 46 | vi.mock("~/localApi", () => ({ |
| ... | @@ -110,12 +119,21 @@ beforeEach(async () => { | ... | @@ -110,12 +119,21 @@ beforeEach(async () => { |
| 110 | vi.clearAllMocks(); | 119 | vi.clearAllMocks(); |
| 111 | mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); | 120 | mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); |
| 112 | mocks.respond.mockReset(); | 121 | mocks.respond.mockReset(); |
| 122 | mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | ||
| 113 | __resetClientSettingsPersistenceForTests(); | 123 | __resetClientSettingsPersistenceForTests(); |
| 114 | resetPreviewStateForTests(); | 124 | resetPreviewStateForTests(); |
| 125 | useBrowserSurfaceStore.setState({ byTabId: {} }); | ||
| 115 | appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); | 126 | appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); |
| 116 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 127 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 117 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); | 128 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); |
| 118 | vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); | 129 | vi.stubGlobal( |
| 130 | "document", | ||
| 131 | Object.assign(new EventTarget(), { | ||
| 132 | hasFocus: () => false, | ||
| 133 | visibilityState: "visible", | ||
| 134 | querySelectorAll: () => [], | ||
| 135 | }), | ||
| 136 | ); | ||
| 119 | await act(() => { | 137 | await act(() => { |
| 120 | renderer = create( | 138 | renderer = create( |
| 121 | <AppAtomRegistryProvider> | 139 | <AppAtomRegistryProvider> |
| ... | @@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => { | ... | @@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => { |
| 161 | input: { threadId, viewport, profileId: "work" }, | 179 | input: { threadId, viewport, profileId: "work" }, |
| 162 | }); | 180 | }); |
| 163 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); | 181 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); |
| 164 | await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); | 182 | await expect(response.promise).resolves.toMatchObject({ |
| 183 | requestId: "open-request", | ||
| 184 | ok: true, | ||
| 185 | result: { available: false, tabId: snapshot.tabId }, | ||
| 186 | }); | ||
| 165 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); | 187 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); |
| 166 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); | 188 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); |
| 167 | }); | 189 | }); |
| ... | @@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => { | ... | @@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => { |
| 188 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); | 210 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); |
| 189 | }); | 211 | }); |
| 190 | }); | 212 | }); |
| 213 | |||
| 214 | describe("PreviewAutomationHosts ownership", () => { | ||
| 215 | it("reports only local live tabs and removes ownership when their web contents close", async () => { | ||
| 216 | await act(() => { | ||
| 217 | appAtomRegistry.set( | ||
| 218 | requestsAtom, | ||
| 219 | AsyncResult.success({ type: "connected", connectionId: "automation-connection" }), | ||
| 220 | ); | ||
| 221 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 222 | }); | ||
| 223 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 224 | expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), | ||
| 225 | ); | ||
| 226 | const overlay = { | ||
| 227 | hasWebContents: true, | ||
| 228 | canGoBack: false, | ||
| 229 | canGoForward: false, | ||
| 230 | loading: false, | ||
| 231 | zoomFactor: 1, | ||
| 232 | pictureInPicture: false, | ||
| 233 | colorScheme: "system" as const, | ||
| 234 | audioMuted: false, | ||
| 235 | audible: false, | ||
| 236 | controller: "none" as const, | ||
| 237 | favicon: null, | ||
| 238 | }; | ||
| 239 | await act(() => applyPreviewDesktopState(threadRef, snapshot.tabId, overlay)); | ||
| 240 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 241 | expect.objectContaining({ | ||
| 242 | input: expect.objectContaining({ | ||
| 243 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: false }], | ||
| 244 | }), | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | const reportCount = mocks.focus.mock.calls.length; | ||
| 248 | await act(() => | ||
| 249 | applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, loading: true }), | ||
| 250 | ); | ||
| 251 | expect(mocks.focus).toHaveBeenCalledTimes(reportCount); | ||
| 252 | const runtimeTabId = previewRuntimeTabId(threadRef, null, snapshot.tabId); | ||
| 253 | const owner = Symbol(); | ||
| 254 | await act(() => { | ||
| 255 | useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); | ||
| 256 | useBrowserSurfaceStore | ||
| 257 | .getState() | ||
| 258 | .present(runtimeTabId, owner, { x: 0, y: 0, width: 800, height: 600 }, true, 0, 1); | ||
| 259 | }); | ||
| 260 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 261 | expect.objectContaining({ | ||
| 262 | input: expect.objectContaining({ | ||
| 263 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], | ||
| 264 | }), | ||
| 265 | }), | ||
| 266 | ); | ||
| 267 | for (const visibilityState of ["hidden", "visible"]) { | ||
| 268 | await act(() => { | ||
| 269 | Object.assign(document, { visibilityState }); | ||
| 270 | document.dispatchEvent(new Event("visibilitychange")); | ||
| 271 | }); | ||
| 272 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 273 | expect.objectContaining({ | ||
| 274 | input: expect.objectContaining({ | ||
| 275 | focused: false, | ||
| 276 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: visibilityState === "visible" }], | ||
| 277 | }), | ||
| 278 | }), | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | await act(() => { | ||
| 282 | appAtomRegistry.set( | ||
| 283 | requestsAtom, | ||
| 284 | AsyncResult.success({ type: "connected", connectionId: "reconnected" }), | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 288 | expect.objectContaining({ | ||
| 289 | input: expect.objectContaining({ | ||
| 290 | connectionId: "reconnected", | ||
| 291 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], | ||
| 292 | }), | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | await act(() => | ||
| 296 | applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, hasWebContents: false }), | ||
| 297 | ); | ||
| 298 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 299 | expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), | ||
| 300 | ); | ||
| 301 | }); | ||
| 302 | |||
| 303 | it.each([false, true])( | ||
| 304 | "retries failed reports without clearing newer connection reports (reconnect: %s)", | ||
| 305 | async (reconnect) => { | ||
| 306 | const report = deferred<Awaited<ReturnType<typeof mocks.focus>>>(); | ||
| 307 | mocks.focus.mockReturnValueOnce(report.promise); | ||
| 308 | await act(() => { | ||
| 309 | appAtomRegistry.set( | ||
| 310 | requestsAtom, | ||
| 311 | AsyncResult.success({ type: "connected", connectionId: "first" }), | ||
| 312 | ); | ||
| 313 | }); | ||
| 314 | if (reconnect) { | ||
| 315 | await act(() => { | ||
| 316 | appAtomRegistry.set( | ||
| 317 | requestsAtom, | ||
| 318 | AsyncResult.success({ type: "connected", connectionId: "second" }), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | } | ||
| 322 | await act(async () => { | ||
| 323 | report.resolve(AsyncResult.failure(Cause.fail(new Error("Focus report failed")))); | ||
| 324 | await report.promise; | ||
| 325 | }); | ||
| 326 | expect(mocks.focus).toHaveBeenCalledTimes(reconnect ? 2 : 1); | ||
| 327 | await act(() => { | ||
| 328 | document.dispatchEvent(new Event("visibilitychange")); | ||
| 329 | }); | ||
| 330 | expect(mocks.focus).toHaveBeenCalledTimes(2); | ||
| 331 | expect(mocks.focus).toHaveBeenLastCalledWith( | ||
| 332 | expect.objectContaining({ | ||
| 333 | input: expect.objectContaining({ connectionId: reconnect ? "second" : "first" }), | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | }, | ||
| 337 | ); | ||
| 338 | |||
| 339 | it("does not claim an available runtime from a server snapshot alone", async () => { | ||
| 340 | const response = deferred<PreviewAutomationResponse>(); | ||
| 341 | mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); | ||
| 342 | await act(async () => { | ||
| 343 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 344 | appAtomRegistry.set( | ||
| 345 | requestsAtom, | ||
| 346 | AsyncResult.success({ | ||
| 347 | ...requestEvent, | ||
| 348 | request: { | ||
| 349 | ...requestEvent.request, | ||
| 350 | operation: "status", | ||
| 351 | tabId: snapshot.tabId, | ||
| 352 | input: {}, | ||
| 353 | }, | ||
| 354 | }), | ||
| 355 | ); | ||
| 356 | await response.promise; | ||
| 357 | }); | ||
| 358 | await expect(response.promise).resolves.toMatchObject({ | ||
| 359 | ok: true, | ||
| 360 | result: { available: false, tabId: snapshot.tabId }, | ||
| 361 | }); | ||
| 362 | }); | ||
| 363 | }); |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+47-9| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; | 3 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | ||
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import { | 6 | import { |
| 6 | FILL_PREVIEW_VIEWPORT, | 7 | FILL_PREVIEW_VIEWPORT, |
| ... | @@ -22,12 +23,14 @@ import { | ... | @@ -22,12 +23,14 @@ import { |
| 22 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | 23 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 23 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | 24 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; |
| 24 | import { Atom } from "effect/unstable/reactivity"; | 25 | import { Atom } from "effect/unstable/reactivity"; |
| 26 | import { useShallow } from "zustand/react/shallow"; | ||
| 25 | 27 | ||
| 26 | import { | 28 | import { |
| 27 | applyPreviewServerSnapshot, | 29 | applyPreviewServerSnapshot, |
| 28 | readThreadPreviewState, | 30 | readThreadPreviewState, |
| 29 | reconcilePreviewServerSessions, | 31 | reconcilePreviewServerSessions, |
| 30 | updatePreviewServerSnapshot, | 32 | updatePreviewServerSnapshot, |
| 33 | useActivePreviewSessions, | ||
| 31 | } from "~/previewStateStore"; | 34 | } from "~/previewStateStore"; |
| 32 | import { | 35 | import { |
| 33 | browserMiniPlayerSource, | 36 | browserMiniPlayerSource, |
| ... | @@ -250,7 +253,7 @@ const currentStatus = async ( | ... | @@ -250,7 +253,7 @@ const currentStatus = async ( |
| 250 | } | 253 | } |
| 251 | const navStatus = snapshot?.navStatus; | 254 | const navStatus = snapshot?.navStatus; |
| 252 | return { | 255 | return { |
| 253 | available: Boolean(previewBridge?.automation), | 256 | available: false, |
| 254 | visible, | 257 | visible, |
| 255 | tabId, | 258 | tabId, |
| 256 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, | 259 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, |
| ... | @@ -292,6 +295,30 @@ export function PreviewAutomationHosts() { | ... | @@ -292,6 +295,30 @@ export function PreviewAutomationHosts() { |
| 292 | 295 | ||
| 293 | function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { | 296 | function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { |
| 294 | const { environmentId } = props; | 297 | const { environmentId } = props; |
| 298 | const previewSessions = useActivePreviewSessions(); | ||
| 299 | const visibleRuntimeTabIds = useBrowserSurfaceStore( | ||
| 300 | useShallow((state) => | ||
| 301 | Object.keys(state.byTabId).filter((tabId) => state.byTabId[tabId]?.visible), | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | const liveTabs = useMemo( | ||
| 305 | () => | ||
| 306 | Object.entries(previewSessions).flatMap(([key, state]) => { | ||
| 307 | const ref = parseScopedThreadKey(key); | ||
| 308 | if (ref?.environmentId !== environmentId) return []; | ||
| 309 | return Object.values(state.sessions) | ||
| 310 | .filter((tab) => state.desktopByTabId[tab.tabId]?.hasWebContents) | ||
| 311 | .map((tab) => ({ | ||
| 312 | threadId: ref.threadId, | ||
| 313 | tabId: tab.tabId, | ||
| 314 | visible: visibleRuntimeTabIds.includes( | ||
| 315 | previewRuntimeTabId(ref, state.serverEpoch, tab.tabId), | ||
| 316 | ), | ||
| 317 | })); | ||
| 318 | }), | ||
| 319 | [environmentId, previewSessions, visibleRuntimeTabIds], | ||
| 320 | ); | ||
| 321 | const lastFocusReportRef = useRef<string | null>(null); | ||
| 295 | const registry = useContext(RegistryContext); | 322 | const registry = useContext(RegistryContext); |
| 296 | const [automationClientId] = useState(createPreviewAutomationClientId); | 323 | const [automationClientId] = useState(createPreviewAutomationClientId); |
| 297 | const initialAutomationHost = useMemo<PreviewAutomationHostState>( | 324 | const initialAutomationHost = useMemo<PreviewAutomationHostState>( |
| ... | @@ -804,24 +831,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -804,24 +831,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 804 | useEffect(() => { | 831 | useEffect(() => { |
| 805 | const report = () => { | 832 | const report = () => { |
| 806 | if (!automationConnectionId) return; | 833 | if (!automationConnectionId) return; |
| 807 | void focusAutomationHost({ | 834 | const input = { |
| 835 | clientId: automationClientId, | ||
| 808 | environmentId, | 836 | environmentId, |
| 809 | input: { | 837 | connectionId: automationConnectionId, |
| 810 | clientId: automationClientId, | 838 | focused: document.hasFocus() && document.visibilityState === "visible", |
| 811 | environmentId, | 839 | liveTabs: liveTabs.map((tab) => ({ |
| 812 | connectionId: automationConnectionId, | 840 | ...tab, |
| 813 | focused: document.hasFocus(), | 841 | visible: tab.visible && document.visibilityState === "visible", |
| 814 | }, | 842 | })), |
| 843 | }; | ||
| 844 | const reportKey = JSON.stringify(input); | ||
| 845 | if (lastFocusReportRef.current === reportKey) return; | ||
| 846 | lastFocusReportRef.current = reportKey; | ||
| 847 | void focusAutomationHost({ environmentId, input }).then((result) => { | ||
| 848 | if (result._tag === "Failure" && lastFocusReportRef.current === reportKey) { | ||
| 849 | lastFocusReportRef.current = null; | ||
| 850 | } | ||
| 815 | }); | 851 | }); |
| 816 | }; | 852 | }; |
| 817 | report(); | 853 | report(); |
| 818 | window.addEventListener("focus", report); | 854 | window.addEventListener("focus", report); |
| 819 | window.addEventListener("blur", report); | 855 | window.addEventListener("blur", report); |
| 856 | document.addEventListener("visibilitychange", report); | ||
| 820 | return () => { | 857 | return () => { |
| 821 | window.removeEventListener("focus", report); | 858 | window.removeEventListener("focus", report); |
| 822 | window.removeEventListener("blur", report); | 859 | window.removeEventListener("blur", report); |
| 860 | document.removeEventListener("visibilitychange", report); | ||
| 823 | }; | 861 | }; |
| 824 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); | 862 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]); |
| 825 | 863 | ||
| 826 | return null; | 864 | return null; |
| 827 | } | 865 | } |
packages/contracts/src/previewAutomation.ts+9| ... | @@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({ | ... | @@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({ |
| 590 | ...PreviewAutomationHostIdentity.fields, | 590 | ...PreviewAutomationHostIdentity.fields, |
| 591 | connectionId: PreviewAutomationConnectionId, | 591 | connectionId: PreviewAutomationConnectionId, |
| 592 | focused: Schema.Boolean, | 592 | focused: Schema.Boolean, |
| 593 | liveTabs: Schema.optional( | ||
| 594 | Schema.Array( | ||
| 595 | Schema.Struct({ | ||
| 596 | threadId: ThreadId, | ||
| 597 | tabId: PreviewTabId, | ||
| 598 | visible: Schema.optional(Schema.Boolean), | ||
| 599 | }), | ||
| 600 | ), | ||
| 601 | ), | ||
| 593 | }); | 602 | }); |
| 594 | export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; | 603 | export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; |
| 595 | 604 |