| author | |
| committer | |
| log | 44fb34ad5383badfb02c9f7daa1d8486277eeb01 |
| tree | 3ce4b090b77b1eb13c0f1d12b0cdf3f286c75be9 |
| parent | 6245c547c2d88e26434ac7b8e08213f2d9ef8577 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 1248 insertions(+), 207 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts+48-40| ... | ... | @@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 15 | 15 | const environmentId = EnvironmentId.make("environment-mcp-test"); |
| 16 | 16 | const threadId = ThreadId.make("thread-mcp-test"); |
| 17 | 17 | const tabId = PreviewTabId.make("tab-mcp-test"); |
| 18 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); | |
| 18 | 19 | const invocation = { |
| 19 | 20 | environmentId, |
| 20 | 21 | threadId, |
| ... | ... | @@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 154 | 155 | Effect.gen(function* () { |
| 155 | 156 | const server = yield* McpServer.McpServer; |
| 156 | 157 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 158 | const routedRequests: Array<{ | |
| 159 | readonly operation: string; | |
| 160 | readonly tabId?: string | undefined; | |
| 161 | }> = []; | |
| 157 | 162 | const events = yield* broker.connect({ |
| 158 | 163 | clientId: "mcp-test-client", |
| 159 | 164 | environmentId, |
| 160 | 165 | }); |
| 161 | yield* Stream.runForEach(events, (event) => | |
| 162 | event.type === "connected" | |
| 163 | ? Effect.void | |
| 164 | : broker.respond({ | |
| 165 | clientId: "mcp-test-client", | |
| 166 | connectionId: event.connectionId, | |
| 167 | requestId: event.request.requestId, | |
| 168 | ok: true, | |
| 169 | result: | |
| 170 | event.request.operation === "snapshot" | |
| 171 | ? { | |
| 172 | url: "http://example.test/", | |
| 173 | title: "Example", | |
| 174 | loading: false, | |
| 175 | visibleText: "Example", | |
| 176 | interactiveElements: [], | |
| 177 | accessibilityTree: {}, | |
| 178 | consoleEntries: [], | |
| 179 | networkEntries: [], | |
| 180 | actionTimeline: [], | |
| 181 | screenshot: { | |
| 182 | mimeType: "image/png", | |
| 183 | data: Buffer.from("png").toString("base64"), | |
| 184 | width: 10, | |
| 185 | height: 5, | |
| 186 | }, | |
| 187 | } | |
| 188 | : event.request.operation === "press" | |
| 189 | ? undefined | |
| 190 | : { | |
| 191 | available: true, | |
| 192 | visible: true, | |
| 193 | tabId, | |
| 194 | url: "http://example.test/", | |
| 195 | title: "Example", | |
| 196 | loading: false, | |
| 197 | }, | |
| 198 | }), | |
| 199 | ).pipe(Effect.forkScoped); | |
| 166 | yield* Stream.runForEach(events, (event) => { | |
| 167 | if (event.type === "connected") return Effect.void; | |
| 168 | routedRequests.push(event.request); | |
| 169 | return broker.respond({ | |
| 170 | clientId: "mcp-test-client", | |
| 171 | connectionId: event.connectionId, | |
| 172 | requestId: event.request.requestId, | |
| 173 | ok: true, | |
| 174 | result: | |
| 175 | event.request.operation === "snapshot" | |
| 176 | ? { | |
| 177 | url: "http://example.test/", | |
| 178 | title: "Example", | |
| 179 | loading: false, | |
| 180 | visibleText: "Example", | |
| 181 | interactiveElements: [], | |
| 182 | accessibilityTree: {}, | |
| 183 | consoleEntries: [], | |
| 184 | networkEntries: [], | |
| 185 | actionTimeline: [], | |
| 186 | screenshot: { | |
| 187 | mimeType: "image/png", | |
| 188 | data: Buffer.from("png").toString("base64"), | |
| 189 | width: 10, | |
| 190 | height: 5, | |
| 191 | }, | |
| 192 | } | |
| 193 | : event.request.operation === "press" | |
| 194 | ? undefined | |
| 195 | : { | |
| 196 | available: true, | |
| 197 | visible: true, | |
| 198 | tabId, | |
| 199 | url: "http://example.test/", | |
| 200 | title: "Example", | |
| 201 | loading: false, | |
| 202 | }, | |
| 203 | }); | |
| 204 | }).pipe(Effect.forkScoped); | |
| 200 | 205 | yield* Effect.yieldNow; |
| 201 | 206 | |
| 202 | 207 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); |
| ... | ... | @@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 239 | 244 | expect(malformed.isError).toBe(true); |
| 240 | 245 | |
| 241 | 246 | const snapshot = yield* server |
| 242 | .callTool({ name: "preview_snapshot", arguments: {} }) | |
| 247 | .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } }) | |
| 243 | 248 | .pipe( |
| 244 | 249 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 245 | 250 | Effect.provideService(McpSchema.McpServerClient, client), |
| ... | ... | @@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 249 | 254 | expect(snapshot.structuredContent).toMatchObject({ |
| 250 | 255 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, |
| 251 | 256 | }); |
| 257 | expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe( | |
| 258 | alternateTabId, | |
| 259 | ); | |
| 252 | 260 | |
| 253 | 261 | const press = yield* server |
| 254 | 262 | .callTool({ name: "preview_press", arguments: { key: "Enter" } }) |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+242-6| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | type PreviewAutomationStreamEvent, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Deferred from "effect/Deferred"; | |
| 18 | 19 | import * as Fiber from "effect/Fiber"; |
| 19 | 20 | import * as Result from "effect/Result"; |
| 20 | 21 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -84,6 +85,187 @@ it.effect("atomically registers a connected host and correlates its response", ( |
| 84 | 85 | ), |
| 85 | 86 | ); |
| 86 | 87 | |
| 88 | it.effect("targets multiple tabs explicitly while retaining a default tab", () => | |
| 89 | Effect.scoped( | |
| 90 | Effect.gen(function* () { | |
| 91 | const broker = yield* makeBroker; | |
| 92 | const appTabId = PreviewTabId.make("tab-web-app"); | |
| 93 | const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); | |
| 94 | const openedTabIds = [appTabId, simulatorTabId]; | |
| 95 | let openIndex = 0; | |
| 96 | const routedRequests: RoutedRequest[] = []; | |
| 97 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 98 | yield* Stream.runForEach(requests, (request) => { | |
| 99 | routedRequests.push(request); | |
| 100 | return broker.respond({ | |
| 101 | clientId: "client-1", | |
| 102 | connectionId: request.connectionId, | |
| 103 | requestId: request.requestId, | |
| 104 | ok: true, | |
| 105 | result: | |
| 106 | request.operation === "open" | |
| 107 | ? { available: true, tabId: openedTabIds[openIndex++] } | |
| 108 | : { url: "http://localhost:3200" }, | |
| 109 | }); | |
| 110 | }).pipe(Effect.forkScoped); | |
| 111 | yield* Effect.yieldNow; | |
| 112 | ||
| 113 | yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); | |
| 114 | yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); | |
| 115 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 116 | yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); | |
| 117 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 118 | ||
| 119 | expect(routedRequests).toHaveLength(5); | |
| 120 | expect(routedRequests[0]?.tabId).toBeUndefined(); | |
| 121 | expect(routedRequests[1]?.tabId).toBe(appTabId); | |
| 122 | expect(routedRequests[2]?.tabId).toBe(simulatorTabId); | |
| 123 | expect(routedRequests[2]?.tabIdExplicit).toBe(false); | |
| 124 | expect(routedRequests[3]?.tabId).toBe(appTabId); | |
| 125 | expect(routedRequests[3]?.tabIdExplicit).toBe(true); | |
| 126 | expect(routedRequests[4]?.tabId).toBe(appTabId); | |
| 127 | }), | |
| 128 | ), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("does not let an older response replace a newer explicit tab target", () => | |
| 132 | Effect.scoped( | |
| 133 | Effect.gen(function* () { | |
| 134 | const broker = yield* makeBroker; | |
| 135 | const olderTabId = PreviewTabId.make("tab-older-request"); | |
| 136 | const newerTabId = PreviewTabId.make("tab-newer-request"); | |
| 137 | const releaseOlderResponse = yield* Deferred.make<void>(); | |
| 138 | const routedRequests: RoutedRequest[] = []; | |
| 139 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 140 | yield* Stream.runForEach(requests, (request) => { | |
| 141 | routedRequests.push(request); | |
| 142 | const response = Effect.gen(function* () { | |
| 143 | if (request.tabId === olderTabId) { | |
| 144 | yield* Deferred.await(releaseOlderResponse); | |
| 145 | } | |
| 146 | yield* broker.respond({ | |
| 147 | clientId: "client-1", | |
| 148 | connectionId: request.connectionId, | |
| 149 | requestId: request.requestId, | |
| 150 | ok: true, | |
| 151 | result: { url: "http://localhost:3200" }, | |
| 152 | }); | |
| 153 | if (request.tabId === newerTabId) { | |
| 154 | yield* Deferred.succeed(releaseOlderResponse, undefined); | |
| 155 | } | |
| 156 | }); | |
| 157 | return response.pipe(Effect.forkScoped, Effect.asVoid); | |
| 158 | }).pipe(Effect.forkScoped); | |
| 159 | yield* Effect.yieldNow; | |
| 160 | ||
| 161 | const older = yield* broker | |
| 162 | .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) | |
| 163 | .pipe(Effect.forkScoped); | |
| 164 | yield* Effect.yieldNow; | |
| 165 | const newer = yield* broker | |
| 166 | .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) | |
| 167 | .pipe(Effect.forkScoped); | |
| 168 | yield* Fiber.join(newer); | |
| 169 | yield* Fiber.join(older); | |
| 170 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 171 | ||
| 172 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); | |
| 173 | }), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("does not replace the default tab with a globally stopped recording tab", () => | |
| 178 | Effect.scoped( | |
| 179 | Effect.gen(function* () { | |
| 180 | const broker = yield* makeBroker; | |
| 181 | const browsingTabId = PreviewTabId.make("tab-session-b"); | |
| 182 | const recordingTabId = PreviewTabId.make("tab-session-a-recording"); | |
| 183 | const routedRequests: RoutedRequest[] = []; | |
| 184 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 185 | yield* Stream.runForEach(requests, (request) => { | |
| 186 | routedRequests.push(request); | |
| 187 | return broker.respond({ | |
| 188 | clientId: "client-1", | |
| 189 | connectionId: request.connectionId, | |
| 190 | requestId: request.requestId, | |
| 191 | ok: true, | |
| 192 | result: | |
| 193 | request.operation === "open" | |
| 194 | ? { available: true, tabId: browsingTabId } | |
| 195 | : request.operation === "recordingStop" | |
| 196 | ? { id: "recording-1", tabId: recordingTabId } | |
| 197 | : { url: "http://localhost:3200" }, | |
| 198 | }); | |
| 199 | }).pipe(Effect.forkScoped); | |
| 200 | yield* Effect.yieldNow; | |
| 201 | ||
| 202 | yield* broker.invoke({ scope, operation: "open", input: {} }); | |
| 203 | yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); | |
| 204 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 205 | ||
| 206 | expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); | |
| 207 | }), | |
| 208 | ), | |
| 209 | ); | |
| 210 | ||
| 211 | it.effect("does not let a no-tab response suppress an earlier tab decision", () => | |
| 212 | Effect.scoped( | |
| 213 | Effect.gen(function* () { | |
| 214 | const broker = yield* makeBroker; | |
| 215 | const initialTabId = PreviewTabId.make("tab-initial"); | |
| 216 | const openedTabId = PreviewTabId.make("tab-opened-late"); | |
| 217 | const releaseOpenResponse = yield* Deferred.make<void>(); | |
| 218 | const routedRequests: RoutedRequest[] = []; | |
| 219 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 220 | yield* Stream.runForEach(requests, (request) => { | |
| 221 | routedRequests.push(request); | |
| 222 | const marker = | |
| 223 | typeof request.input === "object" && request.input !== null && "marker" in request.input | |
| 224 | ? request.input.marker | |
| 225 | : undefined; | |
| 226 | const response = Effect.gen(function* () { | |
| 227 | if (marker === "older") { | |
| 228 | yield* Deferred.await(releaseOpenResponse); | |
| 229 | } | |
| 230 | yield* broker.respond({ | |
| 231 | clientId: "client-1", | |
| 232 | connectionId: request.connectionId, | |
| 233 | requestId: request.requestId, | |
| 234 | ok: true, | |
| 235 | result: | |
| 236 | request.operation === "open" | |
| 237 | ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } | |
| 238 | : { url: "http://localhost:3200" }, | |
| 239 | }); | |
| 240 | if (marker === "newer") { | |
| 241 | yield* Deferred.succeed(releaseOpenResponse, undefined); | |
| 242 | } | |
| 243 | }); | |
| 244 | return response.pipe(Effect.forkScoped, Effect.asVoid); | |
| 245 | }).pipe(Effect.forkScoped); | |
| 246 | yield* Effect.yieldNow; | |
| 247 | ||
| 248 | yield* broker.invoke({ scope, operation: "open", input: {} }); | |
| 249 | const older = yield* broker | |
| 250 | .invoke({ | |
| 251 | scope, | |
| 252 | operation: "open", | |
| 253 | input: { marker: "older", reuseExistingTab: false }, | |
| 254 | }) | |
| 255 | .pipe(Effect.forkScoped); | |
| 256 | yield* Effect.yieldNow; | |
| 257 | const newer = yield* broker | |
| 258 | .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) | |
| 259 | .pipe(Effect.forkScoped); | |
| 260 | yield* Fiber.join(newer); | |
| 261 | yield* Fiber.join(older); | |
| 262 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 263 | ||
| 264 | expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); | |
| 265 | }), | |
| 266 | ), | |
| 267 | ); | |
| 268 | ||
| 87 | 269 | it.effect("announces a live replacement stream before delivering requests", () => |
| 88 | 270 | Effect.scoped( |
| 89 | 271 | Effect.gen(function* () { |
| ... | ... | @@ -632,7 +814,9 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 632 | 814 | Effect.scoped( |
| 633 | 815 | Effect.gen(function* () { |
| 634 | 816 | const broker = yield* makeBroker; |
| 817 | const firstTabId = PreviewTabId.make("tab-on-first-host"); | |
| 635 | 818 | let firstConnectionId = ""; |
| 819 | let secondRoutedTabId: PreviewTabId | undefined; | |
| 636 | 820 | const firstRequests = requestsFrom( |
| 637 | 821 | yield* broker.connect(makeHost({ clientId: "client-first" })), |
| 638 | 822 | (connectionId) => { |
| ... | ... | @@ -648,18 +832,19 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 648 | 832 | connectionId: request.connectionId, |
| 649 | 833 | requestId: request.requestId, |
| 650 | 834 | ok: true, |
| 651 | result: "first", | |
| 835 | result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", | |
| 652 | 836 | }), |
| 653 | 837 | ).pipe(Effect.forkScoped); |
| 654 | yield* Stream.runForEach(secondRequests, (request) => | |
| 655 | broker.respond({ | |
| 838 | yield* Stream.runForEach(secondRequests, (request) => { | |
| 839 | secondRoutedTabId = request.tabId; | |
| 840 | return broker.respond({ | |
| 656 | 841 | clientId: "client-second", |
| 657 | 842 | connectionId: request.connectionId, |
| 658 | 843 | requestId: request.requestId, |
| 659 | 844 | ok: true, |
| 660 | 845 | result: "second", |
| 661 | }), | |
| 662 | ).pipe(Effect.forkScoped); | |
| 846 | }); | |
| 847 | }).pipe(Effect.forkScoped); | |
| 663 | 848 | yield* Effect.yieldNow; |
| 664 | 849 | |
| 665 | 850 | yield* broker.focusHost({ |
| ... | ... | @@ -668,7 +853,10 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 668 | 853 | connectionId: firstConnectionId, |
| 669 | 854 | focused: true, |
| 670 | 855 | }); |
| 671 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe("first"); | |
| 856 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ | |
| 857 | host: "first", | |
| 858 | tabId: firstTabId, | |
| 859 | }); | |
| 672 | 860 | |
| 673 | 861 | yield* Fiber.interrupt(firstConsumer); |
| 674 | 862 | yield* Effect.yieldNow; |
| ... | ... | @@ -676,6 +864,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 676 | 864 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 677 | 865 | "second", |
| 678 | 866 | ); |
| 867 | expect(secondRoutedTabId).toBeUndefined(); | |
| 679 | 868 | }), |
| 680 | 869 | ), |
| 681 | 870 | ); |
| ... | ... | @@ -740,6 +929,53 @@ it.effect("keeps a replacement stream authoritative when the old stream finalize |
| 740 | 929 | ), |
| 741 | 930 | ); |
| 742 | 931 | |
| 932 | it.effect("does not carry a tab id across a replacement automation stream", () => | |
| 933 | Effect.scoped( | |
| 934 | Effect.gen(function* () { | |
| 935 | const broker = yield* makeBroker; | |
| 936 | const openedTabId = PreviewTabId.make("tab-first-webcontents"); | |
| 937 | const firstRequests = requestsFrom(yield* broker.connect(makeHost())); | |
| 938 | yield* Stream.runForEach(firstRequests, (request) => | |
| 939 | broker.respond({ | |
| 940 | clientId: "client-1", | |
| 941 | connectionId: request.connectionId, | |
| 942 | requestId: request.requestId, | |
| 943 | ok: true, | |
| 944 | result: | |
| 945 | request.operation === "open" | |
| 946 | ? { host: "first", tabId: openedTabId } | |
| 947 | : { host: "first" }, | |
| 948 | }), | |
| 949 | ).pipe(Effect.forkScoped); | |
| 950 | yield* Effect.yieldNow; | |
| 951 | ||
| 952 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ | |
| 953 | host: "first", | |
| 954 | tabId: openedTabId, | |
| 955 | }); | |
| 956 | ||
| 957 | const routedRequests: RoutedRequest[] = []; | |
| 958 | const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); | |
| 959 | yield* Stream.runForEach(replacementRequests, (request) => { | |
| 960 | routedRequests.push(request); | |
| 961 | return broker.respond({ | |
| 962 | clientId: "client-1", | |
| 963 | connectionId: request.connectionId, | |
| 964 | requestId: request.requestId, | |
| 965 | ok: true, | |
| 966 | result: "replacement", | |
| 967 | }); | |
| 968 | }).pipe(Effect.forkScoped); | |
| 969 | yield* Effect.yieldNow; | |
| 970 | ||
| 971 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 972 | "replacement", | |
| 973 | ); | |
| 974 | expect(routedRequests.at(-1)?.tabId).toBeUndefined(); | |
| 975 | }), | |
| 976 | ), | |
| 977 | ); | |
| 978 | ||
| 743 | 979 | it.effect("fails requests assigned to the stream that is replaced", () => |
| 744 | 980 | Effect.scoped( |
| 745 | 981 | Effect.gen(function* () { |
apps/server/src/mcp/PreviewAutomationBroker.ts+56-6| ... | ... | @@ -13,13 +13,13 @@ import { |
| 13 | 13 | PreviewAutomationTargetNotEditableError, |
| 14 | 14 | PreviewAutomationTimeoutError, |
| 15 | 15 | PreviewAutomationUnsupportedClientError, |
| 16 | PreviewTabId, | |
| 16 | 17 | type PreviewAutomationError, |
| 17 | 18 | type PreviewAutomationOperation, |
| 18 | 19 | type PreviewAutomationHost, |
| 19 | 20 | type PreviewAutomationHostFocus, |
| 20 | 21 | type PreviewAutomationResponse, |
| 21 | 22 | type PreviewAutomationStreamEvent, |
| 22 | type PreviewTabId, | |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import * as Context from "effect/Context"; |
| 25 | 25 | import * as Clock from "effect/Clock"; |
| ... | ... | @@ -29,6 +29,7 @@ import * as Effect from "effect/Effect"; |
| 29 | 29 | import * as Layer from "effect/Layer"; |
| 30 | 30 | import * as Option from "effect/Option"; |
| 31 | 31 | import * as Queue from "effect/Queue"; |
| 32 | import * as Schema from "effect/Schema"; | |
| 32 | 33 | import * as Stream from "effect/Stream"; |
| 33 | 34 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 34 | 35 | |
| ... | ... | @@ -79,6 +80,8 @@ interface HostAssignment { |
| 79 | 80 | readonly connectionId: ClientConnection["connectionId"]; |
| 80 | 81 | readonly queue: ClientConnection["queue"]; |
| 81 | 82 | readonly expiresAt: number; |
| 83 | readonly tabId?: PreviewTabId; | |
| 84 | readonly tabSequence?: number; | |
| 82 | 85 | } |
| 83 | 86 | |
| 84 | 87 | interface PreviewAutomationRequestErrorContext { |
| ... | ... | @@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = ( |
| 144 | 147 | const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => |
| 145 | 148 | `${scope.environmentId}\u0000${scope.providerSessionId}`; |
| 146 | 149 | |
| 150 | const isPreviewTabId = Schema.is(PreviewTabId); | |
| 151 | ||
| 152 | const readResultTabId = (result: unknown): PreviewTabId | null | undefined => { | |
| 153 | if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined; | |
| 154 | const tabId = result.tabId; | |
| 155 | return tabId === null || isPreviewTabId(tabId) ? tabId : undefined; | |
| 156 | }; | |
| 157 | ||
| 147 | 158 | const supportsOperation = ( |
| 148 | 159 | connection: ClientConnection, |
| 149 | 160 | operation: PreviewAutomationOperation, |
| ... | ... | @@ -454,15 +465,24 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 454 | 465 | if (!hasLiveAssignment) assignments.delete(assignmentKey); |
| 455 | 466 | return [undefined, { ...current, assignments }] as const; |
| 456 | 467 | } |
| 468 | const canReuseAssignedTab = | |
| 469 | assigned !== undefined && | |
| 470 | assigned.connectionId === connection.connectionId && | |
| 471 | assigned.queue === connection.queue; | |
| 457 | 472 | assignments.set(assignmentKey, { |
| 458 | 473 | clientId: connection.clientId, |
| 459 | 474 | connectionId: connection.connectionId, |
| 460 | 475 | queue: connection.queue, |
| 461 | 476 | expiresAt: input.scope.expiresAt, |
| 477 | ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), | |
| 478 | ...(canReuseAssignedTab && assigned.tabSequence !== undefined | |
| 479 | ? { tabSequence: assigned.tabSequence } | |
| 480 | : {}), | |
| 462 | 481 | }); |
| 463 | 482 | |
| 464 | const requestId = `preview-${current.requestSequence}`; | |
| 465 | const tabId = input.tabId; | |
| 483 | const requestSequence = current.requestSequence; | |
| 484 | const requestId = `preview-${requestSequence}`; | |
| 485 | const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined); | |
| 466 | 486 | const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); |
| 467 | 487 | const context: PreviewAutomationRequestErrorContext = { |
| 468 | 488 | operation: input.operation, |
| ... | ... | @@ -480,7 +500,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 480 | 500 | const pending = new Map(current.pending); |
| 481 | 501 | pending.set(requestId, { queue: connection.queue, deferred, context }); |
| 482 | 502 | return [ |
| 483 | { connection, requestId, requestContext: context }, | |
| 503 | { connection, requestId, requestContext: context, requestSequence }, | |
| 484 | 504 | { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, |
| 485 | 505 | ] as const; |
| 486 | 506 | }); |
| ... | ... | @@ -493,7 +513,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 493 | 513 | providerInstanceId: input.scope.providerInstanceId, |
| 494 | 514 | }); |
| 495 | 515 | } |
| 496 | const { connection, requestId, requestContext } = route; | |
| 516 | const { connection, requestId, requestContext, requestSequence } = route; | |
| 497 | 517 | const removePending = SynchronizedRef.update(state, (next) => { |
| 498 | 518 | if (!next.pending.has(requestId)) return next; |
| 499 | 519 | const pending = new Map(next.pending); |
| ... | ... | @@ -508,6 +528,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 508 | 528 | requestId, |
| 509 | 529 | threadId: input.scope.threadId, |
| 510 | 530 | tabId: requestContext.tabId, |
| 531 | tabIdExplicit: input.tabId !== undefined, | |
| 511 | 532 | operation: input.operation, |
| 512 | 533 | input: input.input, |
| 513 | 534 | timeoutMs, |
| ... | ... | @@ -526,7 +547,36 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 526 | 547 | onSome: (value) => Effect.succeed(value as A), |
| 527 | 548 | }); |
| 528 | 549 | }); |
| 529 | return yield* awaitResponse().pipe(Effect.ensuring(removePending)); | |
| 550 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); | |
| 551 | // A stop artifact identifies the globally recorded tab, not the caller's browsing target. | |
| 552 | const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); | |
| 553 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; | |
| 554 | if (resultTabId === undefined) return result; | |
| 555 | const assignmentKey = hostAssignmentKey(input.scope); | |
| 556 | yield* SynchronizedRef.update(state, (current) => { | |
| 557 | const assignment = current.assignments.get(assignmentKey); | |
| 558 | if ( | |
| 559 | !assignment || | |
| 560 | assignment.connectionId !== connection.connectionId || | |
| 561 | assignment.queue !== connection.queue || | |
| 562 | (assignment.tabSequence ?? -1) > requestSequence | |
| 563 | ) { | |
| 564 | return current; | |
| 565 | } | |
| 566 | const assignments = new Map(current.assignments); | |
| 567 | if (resultTabId === null) { | |
| 568 | const { tabId: _tabId, ...withoutTabId } = assignment; | |
| 569 | assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence }); | |
| 570 | } else { | |
| 571 | assignments.set(assignmentKey, { | |
| 572 | ...assignment, | |
| 573 | ...(resultTabId === undefined ? {} : { tabId: resultTabId }), | |
| 574 | tabSequence: requestSequence, | |
| 575 | }); | |
| 576 | } | |
| 577 | return { ...current, assignments }; | |
| 578 | }); | |
| 579 | return result; | |
| 530 | 580 | }); |
| 531 | 581 | |
| 532 | 582 | return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke }); |
apps/server/src/mcp/toolkits/preview/handlers.ts+33-13| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | 6 | PreviewAutomationResizeResult, |
| 7 | 7 | PreviewAutomationSnapshot, |
| 8 | 8 | PreviewAutomationStatus, |
| 9 | PreviewTabId, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | |
| 11 | 12 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| ... | ... | @@ -16,6 +17,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 16 | 17 | operation: PreviewAutomationOperation, |
| 17 | 18 | input: unknown, |
| 18 | 19 | timeoutMs?: number, |
| 20 | tabId?: PreviewTabId, | |
| 19 | 21 | ): Effect.fn.Return< |
| 20 | 22 | A, |
| 21 | 23 | import("@t3tools/contracts").PreviewAutomationError, |
| ... | ... | @@ -28,31 +30,49 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 28 | 30 | operation, |
| 29 | 31 | input, |
| 30 | 32 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 33 | ...(tabId === undefined ? {} : { tabId }), | |
| 31 | 34 | }); |
| 32 | 35 | }); |
| 33 | 36 | |
| 37 | const invokeTargeted = <A>( | |
| 38 | operation: PreviewAutomationOperation, | |
| 39 | input: { | |
| 40 | readonly tabId?: PreviewTabId | undefined; | |
| 41 | readonly [key: string]: unknown; | |
| 42 | }, | |
| 43 | timeoutMs?: number, | |
| 44 | ) => { | |
| 45 | const { tabId, ...operationInput } = input; | |
| 46 | return invoke<A>(operation, operationInput, timeoutMs, tabId); | |
| 47 | }; | |
| 48 | ||
| 34 | 49 | const handlers = { |
| 35 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), | |
| 50 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), | |
| 36 | 51 | preview_open: (input) => |
| 37 | invoke<PreviewAutomationStatus>("open", { | |
| 52 | invokeTargeted<PreviewAutomationStatus>("open", { | |
| 38 | 53 | ...input, |
| 39 | 54 | show: input.show ?? true, |
| 40 | 55 | reuseExistingTab: input.reuseExistingTab ?? true, |
| 41 | 56 | }), |
| 42 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | |
| 57 | preview_navigate: (input) => | |
| 58 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | |
| 43 | 59 | preview_resize: (input) => |
| 44 | invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), | |
| 45 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), | |
| 46 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 47 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 48 | preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)), | |
| 49 | preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)), | |
| 60 | invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), | |
| 61 | preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}), | |
| 62 | preview_click: (input) => | |
| 63 | invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 64 | preview_type: (input) => | |
| 65 | invokeTargeted<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 66 | preview_press: (input) => invokeTargeted<void>("press", input).pipe(Effect.as(null)), | |
| 67 | preview_scroll: (input) => invokeTargeted<void>("scroll", input).pipe(Effect.as(null)), | |
| 50 | 68 | preview_evaluate: (input) => |
| 51 | invoke<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)), | |
| 69 | invokeTargeted<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)), | |
| 52 | 70 | preview_wait_for: (input) => |
| 53 | invoke<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 54 | preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), | |
| 55 | preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), | |
| 71 | invokeTargeted<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), | |
| 72 | preview_recording_start: (input) => | |
| 73 | invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}), | |
| 74 | preview_recording_stop: (input) => | |
| 75 | invokeTargeted<PreviewAutomationRecordingArtifact>("recordingStop", input ?? {}), | |
| 56 | 76 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; |
| 57 | 77 | |
| 58 | 78 | const { preview_snapshot, ...standardHandlers } = handlers; |
apps/server/src/mcp/toolkits/preview/tools.test.ts+4| ... | ... | @@ -27,6 +27,10 @@ it("exports provider-compatible object schemas with described parameters", () => |
| 27 | 27 | expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); |
| 28 | 28 | expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); |
| 29 | 29 | expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); |
| 30 | expect( | |
| 31 | schema.properties?.tabId, | |
| 32 | `${tool.name} must allow an explicit collaborative browser tab target`, | |
| 33 | ).toBeDefined(); | |
| 30 | 34 | for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { |
| 31 | 35 | expect( |
| 32 | 36 | schemaHasDescription(fieldSchema), |
apps/server/src/mcp/toolkits/preview/tools.ts+17-12| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | PreviewAutomationScrollInput, |
| 13 | 13 | PreviewAutomationSnapshot, |
| 14 | 14 | PreviewAutomationStatus, |
| 15 | PreviewAutomationTabTargetInput, | |
| 15 | 16 | PreviewAutomationTypeInput, |
| 16 | 17 | PreviewAutomationWaitForInput, |
| 17 | 18 | } from "@t3tools/contracts"; |
| ... | ... | @@ -37,7 +38,8 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => |
| 37 | 38 | |
| 38 | 39 | export const PreviewStatusTool = Tool.make("preview_status", { |
| 39 | 40 | description: |
| 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.", | |
| 41 | "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", | |
| 42 | parameters: PreviewAutomationTabTargetInput, | |
| 41 | 43 | success: PreviewAutomationStatus, |
| 42 | 44 | failure: PreviewAutomationError, |
| 43 | 45 | dependencies, |
| ... | ... | @@ -50,7 +52,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { |
| 50 | 52 | export const PreviewOpenTool = browserTool( |
| 51 | 53 | Tool.make("preview_open", { |
| 52 | 54 | description: |
| 53 | "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", | |
| 55 | "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", | |
| 54 | 56 | parameters: PreviewAutomationOpenInput, |
| 55 | 57 | success: PreviewAutomationStatus, |
| 56 | 58 | failure: PreviewAutomationError, |
| ... | ... | @@ -63,7 +65,7 @@ export const PreviewOpenTool = browserTool( |
| 63 | 65 | export const PreviewNavigateTool = safeBrowserTool( |
| 64 | 66 | Tool.make("preview_navigate", { |
| 65 | 67 | description: |
| 66 | "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", | |
| 68 | "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", | |
| 67 | 69 | parameters: PreviewAutomationNavigateInput, |
| 68 | 70 | success: PreviewAutomationStatus, |
| 69 | 71 | failure: PreviewAutomationError, |
| ... | ... | @@ -74,7 +76,7 @@ export const PreviewNavigateTool = safeBrowserTool( |
| 74 | 76 | export const PreviewResizeTool = safeBrowserTool( |
| 75 | 77 | Tool.make("preview_resize", { |
| 76 | 78 | 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.", | |
| 79 | "Resize a collaborative browser tab, optionally selected by tabId. 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 | 80 | parameters: PreviewAutomationResizeInput, |
| 79 | 81 | success: PreviewAutomationResizeResult, |
| 80 | 82 | failure: PreviewAutomationError, |
| ... | ... | @@ -87,7 +89,8 @@ export const PreviewResizeTool = safeBrowserTool( |
| 87 | 89 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 88 | 90 | Tool.make("preview_snapshot", { |
| 89 | 91 | description: |
| 90 | "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", | |
| 92 | "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", | |
| 93 | parameters: PreviewAutomationTabTargetInput, | |
| 91 | 94 | success: PreviewAutomationSnapshot, |
| 92 | 95 | failure: PreviewAutomationError, |
| 93 | 96 | dependencies, |
| ... | ... | @@ -97,7 +100,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( |
| 97 | 100 | export const PreviewClickTool = browserTool( |
| 98 | 101 | Tool.make("preview_click", { |
| 99 | 102 | description: |
| 100 | "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", | |
| 103 | "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", | |
| 101 | 104 | parameters: PreviewAutomationClickInput, |
| 102 | 105 | success: Schema.Null, |
| 103 | 106 | failure: PreviewAutomationError, |
| ... | ... | @@ -108,7 +111,7 @@ export const PreviewClickTool = browserTool( |
| 108 | 111 | export const PreviewTypeTool = browserTool( |
| 109 | 112 | Tool.make("preview_type", { |
| 110 | 113 | description: |
| 111 | "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", | |
| 114 | "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", | |
| 112 | 115 | parameters: PreviewAutomationTypeInput, |
| 113 | 116 | success: Schema.Null, |
| 114 | 117 | failure: PreviewAutomationError, |
| ... | ... | @@ -119,7 +122,7 @@ export const PreviewTypeTool = browserTool( |
| 119 | 122 | export const PreviewPressTool = browserTool( |
| 120 | 123 | Tool.make("preview_press", { |
| 121 | 124 | description: |
| 122 | "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", | |
| 125 | "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", | |
| 123 | 126 | parameters: PreviewAutomationPressInput, |
| 124 | 127 | success: Schema.Null, |
| 125 | 128 | failure: PreviewAutomationError, |
| ... | ... | @@ -130,7 +133,7 @@ export const PreviewPressTool = browserTool( |
| 130 | 133 | export const PreviewScrollTool = safeBrowserTool( |
| 131 | 134 | Tool.make("preview_scroll", { |
| 132 | 135 | description: |
| 133 | "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", | |
| 136 | "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", | |
| 134 | 137 | parameters: PreviewAutomationScrollInput, |
| 135 | 138 | success: Schema.Null, |
| 136 | 139 | failure: PreviewAutomationError, |
| ... | ... | @@ -141,7 +144,7 @@ export const PreviewScrollTool = safeBrowserTool( |
| 141 | 144 | export const PreviewEvaluateTool = browserTool( |
| 142 | 145 | Tool.make("preview_evaluate", { |
| 143 | 146 | description: |
| 144 | "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", | |
| 147 | "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", | |
| 145 | 148 | parameters: PreviewAutomationEvaluateInput, |
| 146 | 149 | success: Schema.Unknown, |
| 147 | 150 | failure: PreviewAutomationError, |
| ... | ... | @@ -152,7 +155,7 @@ export const PreviewEvaluateTool = browserTool( |
| 152 | 155 | export const PreviewWaitForTool = readonlyBrowserTool( |
| 153 | 156 | Tool.make("preview_wait_for", { |
| 154 | 157 | description: |
| 155 | "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", | |
| 158 | "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", | |
| 156 | 159 | parameters: PreviewAutomationWaitForInput, |
| 157 | 160 | success: Schema.Null, |
| 158 | 161 | failure: PreviewAutomationError, |
| ... | ... | @@ -163,7 +166,8 @@ export const PreviewWaitForTool = readonlyBrowserTool( |
| 163 | 166 | export const PreviewRecordingStartTool = safeBrowserTool( |
| 164 | 167 | Tool.make("preview_recording_start", { |
| 165 | 168 | description: |
| 166 | "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", | |
| 169 | "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", | |
| 170 | parameters: PreviewAutomationTabTargetInput, | |
| 167 | 171 | success: PreviewAutomationRecordingStatus, |
| 168 | 172 | failure: PreviewAutomationError, |
| 169 | 173 | dependencies, |
| ... | ... | @@ -173,6 +177,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( |
| 173 | 177 | export const PreviewRecordingStopTool = safeBrowserTool( |
| 174 | 178 | Tool.make("preview_recording_stop", { |
| 175 | 179 | description: "Stop the active browser recording and save it as a local evidence artifact.", |
| 180 | parameters: PreviewAutomationTabTargetInput, | |
| 176 | 181 | success: PreviewAutomationRecordingArtifact, |
| 177 | 182 | failure: PreviewAutomationError, |
| 178 | 183 | dependencies, |
apps/server/src/provider/Layers/CursorAdapter.test.ts+12-17| ... | ... | @@ -1021,24 +1021,19 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1021 | 1021 | assert.equal(turnCompleted.payload.stopReason, "cancelled"); |
| 1022 | 1022 | } |
| 1023 | 1023 | |
| 1024 | const requests = yield* waitForJsonLogMatch( | |
| 1025 | requestLogPath, | |
| 1026 | (entry) => entry.method === "session/cancel", | |
| 1027 | ); | |
| 1024 | const isCancelledApprovalResponse = (entry: Record<string, unknown>) => | |
| 1025 | !("method" in entry) && | |
| 1026 | typeof entry.result === "object" && | |
| 1027 | entry.result !== null && | |
| 1028 | "outcome" in entry.result && | |
| 1029 | typeof entry.result.outcome === "object" && | |
| 1030 | entry.result.outcome !== null && | |
| 1031 | "outcome" in entry.result.outcome && | |
| 1032 | entry.result.outcome.outcome === "cancelled"; | |
| 1033 | yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); | |
| 1034 | const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); | |
| 1028 | 1035 | assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); |
| 1029 | assert.isTrue( | |
| 1030 | requests.some( | |
| 1031 | (entry) => | |
| 1032 | !("method" in entry) && | |
| 1033 | typeof entry.result === "object" && | |
| 1034 | entry.result !== null && | |
| 1035 | "outcome" in entry.result && | |
| 1036 | typeof entry.result.outcome === "object" && | |
| 1037 | entry.result.outcome !== null && | |
| 1038 | "outcome" in entry.result.outcome && | |
| 1039 | entry.result.outcome.outcome === "cancelled", | |
| 1040 | ), | |
| 1041 | ); | |
| 1036 | assert.isTrue(requests.some(isCancelledApprovalResponse)); | |
| 1042 | 1037 | |
| 1043 | 1038 | yield* adapter.stopSession(threadId); |
| 1044 | 1039 | }), |
apps/web/src/browser/BrowserSurfaceSlot.tsx+6-6| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { useEffect, useRef } from "react"; | |
| 3 | import { useLayoutEffect, useRef } from "react"; | |
| 4 | 4 | |
| 5 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | |
| 5 | import { acquireBrowserSurface } from "./browserSurfaceStore"; | |
| 6 | 6 | |
| 7 | 7 | export function BrowserSurfaceSlot(props: { |
| 8 | 8 | readonly tabId: string; |
| ... | ... | @@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: { |
| 12 | 12 | const { tabId, visible, className } = props; |
| 13 | 13 | const elementRef = useRef<HTMLDivElement | null>(null); |
| 14 | 14 | |
| 15 | useEffect(() => { | |
| 15 | useLayoutEffect(() => { | |
| 16 | 16 | const element = elementRef.current; |
| 17 | 17 | if (!element) return; |
| 18 | const lease = acquireBrowserSurface(tabId); | |
| 18 | 19 | const update = () => { |
| 19 | 20 | const rect = element.getBoundingClientRect(); |
| 20 | useBrowserSurfaceStore.getState().present( | |
| 21 | tabId, | |
| 21 | lease.present( | |
| 22 | 22 | { |
| 23 | 23 | x: Math.round(rect.x), |
| 24 | 24 | y: Math.round(rect.y), |
| ... | ... | @@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: { |
| 37 | 37 | observer.disconnect(); |
| 38 | 38 | window.removeEventListener("resize", update); |
| 39 | 39 | window.removeEventListener("scroll", update, true); |
| 40 | useBrowserSurfaceStore.getState().hide(tabId); | |
| 40 | lease.release(); | |
| 41 | 41 | }; |
| 42 | 42 | }, [tabId, visible]); |
| 43 | 43 |
apps/web/src/browser/HostedBrowserWebview.tsx+20-27| ... | ... | @@ -8,13 +8,13 @@ import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | |
| 11 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; | |
| 11 | import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; | |
| 12 | 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 13 | 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; |
| 14 | import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; | |
| 15 | 14 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 16 | 15 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 17 | 16 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 17 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; | |
| 18 | 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| 20 | 20 | |
| ... | ... | @@ -46,7 +46,8 @@ export function HostedBrowserWebview(props: { |
| 46 | 46 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 47 | 47 | const wrapperRef = useRef<HTMLDivElement | null>(null); |
| 48 | 48 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 49 | const [lockedAspectRatio, setLockedAspectRatio] = useState<number | null>(null); | |
| 49 | const [aspectRatioLocked, setAspectRatioLocked] = useState(false); | |
| 50 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); | |
| 50 | 51 | const presentation = useBrowserSurfaceStore( |
| 51 | 52 | useShallow((state) => { |
| 52 | 53 | const current = state.byTabId[tabId]; |
| ... | ... | @@ -56,10 +57,13 @@ export function HostedBrowserWebview(props: { |
| 56 | 57 | }; |
| 57 | 58 | }), |
| 58 | 59 | ); |
| 59 | const recording = useActiveBrowserRecordingTabId() === tabId; | |
| 60 | ||
| 61 | 60 | usePreviewBridge({ threadRef, tabId }); |
| 62 | 61 | |
| 62 | useEffect(() => { | |
| 63 | if (presentation.visible || activeRecordingTabId !== tabId) return; | |
| 64 | void stopBrowserRecording(tabId).catch(() => undefined); | |
| 65 | }, [activeRecordingTabId, presentation.visible, tabId]); | |
| 66 | ||
| 63 | 67 | useEffect(() => { |
| 64 | 68 | const lease = acquireDesktopTab(tabId); |
| 65 | 69 | tabLeaseRef.current = lease; |
| ... | ... | @@ -115,9 +119,11 @@ export function HostedBrowserWebview(props: { |
| 115 | 119 | const viewportHeight = viewport._tag === "fill" ? null : viewport.height; |
| 116 | 120 | const viewportAspectRatio = |
| 117 | 121 | viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; |
| 118 | useEffect(() => { | |
| 119 | setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); | |
| 120 | }, [viewportAspectRatio]); | |
| 122 | const lockedAspectRatio = | |
| 123 | aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null; | |
| 124 | const handleAspectRatioChange = useCallback((aspectRatio: number | null) => { | |
| 125 | setAspectRatioLocked(aspectRatio !== null); | |
| 126 | }, []); | |
| 121 | 127 | const hiddenSize = |
| 122 | 128 | viewport._tag !== "fill" |
| 123 | 129 | ? { |
| ... | ... | @@ -170,24 +176,11 @@ export function HostedBrowserWebview(props: { |
| 170 | 176 | |
| 171 | 177 | if (!config) return null; |
| 172 | 178 | |
| 173 | const wrapperStyle = | |
| 174 | active && lastRect | |
| 175 | ? { | |
| 176 | left: lastRect.x, | |
| 177 | top: lastRect.y, | |
| 178 | width: lastRect.width, | |
| 179 | height: lastRect.height, | |
| 180 | zIndex: 30, | |
| 181 | pointerEvents: "auto" as const, | |
| 182 | } | |
| 183 | : { | |
| 184 | left: 0, | |
| 185 | top: 0, | |
| 186 | width: hiddenSize.width, | |
| 187 | height: hiddenSize.height, | |
| 188 | zIndex: recording ? 0 : -1, | |
| 189 | pointerEvents: "none" as const, | |
| 190 | }; | |
| 179 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ | |
| 180 | active, | |
| 181 | rect: lastRect, | |
| 182 | hiddenSize, | |
| 183 | }); | |
| 191 | 184 | |
| 192 | 185 | return ( |
| 193 | 186 | <div |
| ... | ... | @@ -203,7 +196,7 @@ export function HostedBrowserWebview(props: { |
| 203 | 196 | setting={effectiveViewport} |
| 204 | 197 | width={Math.max(1, Math.round(containerSize.width))} |
| 205 | 198 | aspectRatio={lockedAspectRatio} |
| 206 | onAspectRatioChange={setLockedAspectRatio} | |
| 199 | onAspectRatioChange={handleAspectRatioChange} | |
| 207 | 200 | onChange={commitViewportChange} |
| 208 | 201 | /> |
| 209 | 202 | ) : null} |
apps/web/src/browser/browserRecording.test.ts created+294| ... | ... | @@ -0,0 +1,294 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } = | |
| 4 | vi.hoisted(() => { | |
| 5 | const events: string[] = []; | |
| 6 | const surfaceState = { | |
| 7 | byTabId: {} as Record<string, unknown>, | |
| 8 | }; | |
| 9 | return { | |
| 10 | events, | |
| 11 | onFrame: vi.fn(() => vi.fn()), | |
| 12 | registrySet: vi.fn((_atom: unknown, value: string | null) => { | |
| 13 | events.push(value === null ? "clear" : `publish:${value}`); | |
| 14 | }), | |
| 15 | save: vi.fn(async () => ({ | |
| 16 | id: "recording-test", | |
| 17 | tabId: "recording-tab", | |
| 18 | path: "/tmp/recording-test.webm", | |
| 19 | mimeType: "video/webm" as const, | |
| 20 | sizeBytes: 0, | |
| 21 | createdAt: "2026-06-26T00:00:00.000Z", | |
| 22 | })), | |
| 23 | startScreencast: vi.fn(async () => { | |
| 24 | events.push("start-screencast"); | |
| 25 | }), | |
| 26 | stopScreencast: vi.fn(async () => undefined), | |
| 27 | surfaceState, | |
| 28 | }; | |
| 29 | }); | |
| 30 | ||
| 31 | vi.mock("~/components/preview/previewBridge", () => ({ | |
| 32 | previewBridge: { | |
| 33 | recording: { onFrame, save, startScreencast, stopScreencast }, | |
| 34 | }, | |
| 35 | })); | |
| 36 | ||
| 37 | vi.mock("~/rpc/atomRegistry", () => ({ | |
| 38 | appAtomRegistry: { set: registrySet }, | |
| 39 | })); | |
| 40 | ||
| 41 | vi.mock("./browserSurfaceStore", () => ({ | |
| 42 | useBrowserSurfaceStore: { | |
| 43 | getState: () => surfaceState, | |
| 44 | }, | |
| 45 | })); | |
| 46 | ||
| 47 | import { | |
| 48 | BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, | |
| 49 | BrowserRecordingConflictError, | |
| 50 | BrowserRecordingOperationError, | |
| 51 | BrowserRecordingRequiresVisibleTabError, | |
| 52 | startBrowserRecording, | |
| 53 | stopBrowserRecording, | |
| 54 | } from "./browserRecording"; | |
| 55 | ||
| 56 | class FakeMediaRecorder { | |
| 57 | static isTypeSupported(): boolean { | |
| 58 | return true; | |
| 59 | } | |
| 60 | ||
| 61 | state: RecordingState = "inactive"; | |
| 62 | private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>(); | |
| 63 | ||
| 64 | addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { | |
| 65 | const listeners = this.listeners.get(type) ?? new Set(); | |
| 66 | listeners.add(listener); | |
| 67 | this.listeners.set(type, listeners); | |
| 68 | } | |
| 69 | ||
| 70 | start(): void { | |
| 71 | this.state = "recording"; | |
| 72 | } | |
| 73 | ||
| 74 | stop(): void { | |
| 75 | this.state = "inactive"; | |
| 76 | for (const listener of this.listeners.get("stop") ?? []) { | |
| 77 | if (typeof listener === "function") listener(new Event("stop")); | |
| 78 | else listener.handleEvent(new Event("stop")); | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | describe("browser recording", () => { | |
| 84 | beforeEach(() => { | |
| 85 | events.length = 0; | |
| 86 | surfaceState.byTabId = { | |
| 87 | "recording-tab": { | |
| 88 | visible: true, | |
| 89 | rect: { x: 0, y: 0, width: 800, height: 600 }, | |
| 90 | content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, | |
| 91 | }, | |
| 92 | }; | |
| 93 | vi.clearAllMocks(); | |
| 94 | vi.stubGlobal("window", globalThis); | |
| 95 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); | |
| 96 | vi.stubGlobal("document", { | |
| 97 | createElement: () => ({ | |
| 98 | width: 0, | |
| 99 | height: 0, | |
| 100 | captureStream: () => ({}), | |
| 101 | getContext: () => ({ drawImage: vi.fn() }), | |
| 102 | }), | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | afterEach(() => { | |
| 107 | vi.useRealTimers(); | |
| 108 | vi.unstubAllGlobals(); | |
| 109 | }); | |
| 110 | ||
| 111 | it("starts recording for a visible tab", async () => { | |
| 112 | await startBrowserRecording("recording-tab"); | |
| 113 | ||
| 114 | expect(events).toEqual(["start-screencast", "publish:recording-tab"]); | |
| 115 | ||
| 116 | await stopBrowserRecording("recording-tab"); | |
| 117 | }); | |
| 118 | ||
| 119 | it("rejects recording for a hidden tab before starting screencast", async () => { | |
| 120 | surfaceState.byTabId = { | |
| 121 | "recording-tab": { | |
| 122 | visible: false, | |
| 123 | rect: { x: 0, y: 0, width: 800, height: 600 }, | |
| 124 | content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, | |
| 125 | }, | |
| 126 | }; | |
| 127 | ||
| 128 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 129 | BrowserRecordingRequiresVisibleTabError, | |
| 130 | ); | |
| 131 | ||
| 132 | expect(startScreencast).not.toHaveBeenCalled(); | |
| 133 | expect(registrySet).not.toHaveBeenCalled(); | |
| 134 | }); | |
| 135 | ||
| 136 | it("does not report success for a second start while the first is still starting", async () => { | |
| 137 | let finishStartingScreencast: (() => void) | undefined; | |
| 138 | startScreencast.mockImplementationOnce(async () => { | |
| 139 | events.push("start-screencast"); | |
| 140 | await new Promise<void>((resolve) => { | |
| 141 | finishStartingScreencast = resolve; | |
| 142 | }); | |
| 143 | }); | |
| 144 | ||
| 145 | const firstStart = startBrowserRecording("recording-tab"); | |
| 146 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | |
| 147 | ||
| 148 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 149 | BrowserRecordingConflictError, | |
| 150 | ); | |
| 151 | ||
| 152 | finishStartingScreencast?.(); | |
| 153 | await firstStart; | |
| 154 | await stopBrowserRecording("recording-tab"); | |
| 155 | }); | |
| 156 | ||
| 157 | it("does not report success for a start while the recording is stopping", async () => { | |
| 158 | let finishStoppingScreencast: (() => void) | undefined; | |
| 159 | stopScreencast.mockImplementationOnce(async () => { | |
| 160 | await new Promise<void>((resolve) => { | |
| 161 | finishStoppingScreencast = resolve; | |
| 162 | }); | |
| 163 | return undefined; | |
| 164 | }); | |
| 165 | ||
| 166 | await startBrowserRecording("recording-tab"); | |
| 167 | const stopPromise = stopBrowserRecording("recording-tab"); | |
| 168 | await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); | |
| 169 | ||
| 170 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 171 | BrowserRecordingConflictError, | |
| 172 | ); | |
| 173 | ||
| 174 | finishStoppingScreencast?.(); | |
| 175 | await stopPromise; | |
| 176 | }); | |
| 177 | ||
| 178 | it("shares an in-progress stop with duplicate callers", async () => { | |
| 179 | let finishStoppingScreencast: (() => void) | undefined; | |
| 180 | stopScreencast.mockImplementationOnce(async () => { | |
| 181 | await new Promise<void>((resolve) => { | |
| 182 | finishStoppingScreencast = resolve; | |
| 183 | }); | |
| 184 | return undefined; | |
| 185 | }); | |
| 186 | ||
| 187 | await startBrowserRecording("recording-tab"); | |
| 188 | const firstStop = stopBrowserRecording("recording-tab"); | |
| 189 | await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); | |
| 190 | const duplicateStop = stopBrowserRecording("recording-tab"); | |
| 191 | ||
| 192 | finishStoppingScreencast?.(); | |
| 193 | const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]); | |
| 194 | ||
| 195 | expect(duplicateArtifact).toEqual(firstArtifact); | |
| 196 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 197 | expect(save).toHaveBeenCalledOnce(); | |
| 198 | }); | |
| 199 | ||
| 200 | it("stops a screencast that finishes starting after cancellation", async () => { | |
| 201 | let finishStartingScreencast: (() => void) | undefined; | |
| 202 | startScreencast.mockImplementationOnce(async () => { | |
| 203 | events.push("start-screencast"); | |
| 204 | await new Promise<void>((resolve) => { | |
| 205 | finishStartingScreencast = resolve; | |
| 206 | }); | |
| 207 | }); | |
| 208 | ||
| 209 | const startPromise = startBrowserRecording("recording-tab"); | |
| 210 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | |
| 211 | BrowserRecordingOperationError, | |
| 212 | ); | |
| 213 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | |
| 214 | ||
| 215 | const stopPromise = stopBrowserRecording("recording-tab"); | |
| 216 | await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); | |
| 217 | finishStartingScreencast?.(); | |
| 218 | ||
| 219 | await rejectedStart; | |
| 220 | await stopPromise; | |
| 221 | expect(stopScreencast).toHaveBeenCalledTimes(2); | |
| 222 | expect(events.at(-1)).toBe("clear"); | |
| 223 | }); | |
| 224 | ||
| 225 | it("does not release the recording slot until a cancelled start settles", async () => { | |
| 226 | let finishStartingScreencast: (() => void) | undefined; | |
| 227 | startScreencast.mockImplementationOnce(async () => { | |
| 228 | events.push("start-screencast"); | |
| 229 | await new Promise<void>((resolve) => { | |
| 230 | finishStartingScreencast = resolve; | |
| 231 | }); | |
| 232 | }); | |
| 233 | ||
| 234 | const firstStart = startBrowserRecording("recording-tab"); | |
| 235 | const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( | |
| 236 | BrowserRecordingOperationError, | |
| 237 | ); | |
| 238 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | |
| 239 | ||
| 240 | const stopPromise = stopBrowserRecording("recording-tab"); | |
| 241 | const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); | |
| 242 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 243 | const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; | |
| 244 | ||
| 245 | finishStartingScreencast?.(); | |
| 246 | await rejectedFirstStart; | |
| 247 | await stopPromise; | |
| 248 | await restartAfterStop; | |
| 249 | await stopBrowserRecording("recording-tab"); | |
| 250 | ||
| 251 | expect(startCallsBeforeFirstSettled).toBe(1); | |
| 252 | }); | |
| 253 | ||
| 254 | it("fails a stop that waits too long for startup without freeing the recording slot", async () => { | |
| 255 | vi.useFakeTimers(); | |
| 256 | let finishStartingScreencast: (() => void) | undefined; | |
| 257 | startScreencast.mockImplementationOnce(async () => { | |
| 258 | events.push("start-screencast"); | |
| 259 | await new Promise<void>((resolve) => { | |
| 260 | finishStartingScreencast = resolve; | |
| 261 | }); | |
| 262 | }); | |
| 263 | ||
| 264 | const startPromise = startBrowserRecording("recording-tab"); | |
| 265 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | |
| 266 | BrowserRecordingOperationError, | |
| 267 | ); | |
| 268 | expect(startScreencast).toHaveBeenCalledOnce(); | |
| 269 | ||
| 270 | const stopPromise = stopBrowserRecording("recording-tab"); | |
| 271 | await Promise.resolve(); | |
| 272 | await Promise.resolve(); | |
| 273 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 274 | ||
| 275 | const rejection = expect(stopPromise).rejects.toMatchObject({ | |
| 276 | operation: "wait-startup", | |
| 277 | tabId: "recording-tab", | |
| 278 | }); | |
| 279 | await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); | |
| 280 | ||
| 281 | await rejection; | |
| 282 | expect(save).not.toHaveBeenCalled(); | |
| 283 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 284 | BrowserRecordingConflictError, | |
| 285 | ); | |
| 286 | ||
| 287 | finishStartingScreencast?.(); | |
| 288 | await rejectedStart; | |
| 289 | const cleanupResult = await stopBrowserRecording("recording-tab"); | |
| 290 | expect(cleanupResult).toBeNull(); | |
| 291 | expect(save).not.toHaveBeenCalled(); | |
| 292 | expect(events.at(-1)).toBe("clear"); | |
| 293 | }); | |
| 294 | }); |
apps/web/src/browser/browserRecording.ts+201-49| ... | ... | @@ -46,6 +46,17 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl |
| 46 | 46 | } |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass<BrowserRecordingRequiresVisibleTabError>()( | |
| 50 | "BrowserRecordingRequiresVisibleTabError", | |
| 51 | { | |
| 52 | tabId: Schema.String, | |
| 53 | }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return `Browser recording requires tab ${this.tabId} to be visible.`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 49 | 60 | export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( |
| 50 | 61 | "BrowserRecordingOperationError", |
| 51 | 62 | { |
| ... | ... | @@ -55,6 +66,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 55 | 66 | "start-media-recorder", |
| 56 | 67 | "start-screencast", |
| 57 | 68 | "stop-screencast", |
| 69 | "wait-startup", | |
| 58 | 70 | "stop-media-recorder", |
| 59 | 71 | "save-artifact", |
| 60 | 72 | "cleanup", |
| ... | ... | @@ -68,6 +80,16 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 68 | 80 | } |
| 69 | 81 | } |
| 70 | 82 | |
| 83 | const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); | |
| 84 | ||
| 85 | type BrowserRecordingLifecycle = | |
| 86 | | { readonly phase: "starting" } | |
| 87 | | { readonly phase: "recording" } | |
| 88 | | { | |
| 89 | readonly phase: "stopping"; | |
| 90 | readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>; | |
| 91 | }; | |
| 92 | ||
| 71 | 93 | interface ActiveRecording { |
| 72 | 94 | readonly tabId: string; |
| 73 | 95 | readonly canvas: HTMLCanvasElement; |
| ... | ... | @@ -76,6 +98,8 @@ interface ActiveRecording { |
| 76 | 98 | readonly chunks: Blob[]; |
| 77 | 99 | readonly mimeType: string; |
| 78 | 100 | readonly startedAt: string; |
| 101 | readonly startupSettled: Promise<void>; | |
| 102 | lifecycle: BrowserRecordingLifecycle; | |
| 79 | 103 | } |
| 80 | 104 | |
| 81 | 105 | const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe( |
| ... | ... | @@ -90,6 +114,8 @@ export function useActiveBrowserRecordingTabId(): string | null { |
| 90 | 114 | let active: ActiveRecording | null = null; |
| 91 | 115 | let unsubscribeFrames: (() => void) | null = null; |
| 92 | 116 | |
| 117 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; | |
| 118 | ||
| 93 | 119 | export function readActiveBrowserRecordingTabId(): string | null { |
| 94 | 120 | return active?.tabId ?? null; |
| 95 | 121 | } |
| ... | ... | @@ -131,17 +157,58 @@ const clearActiveRecording = (recording: ActiveRecording): void => { |
| 131 | 157 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); |
| 132 | 158 | }; |
| 133 | 159 | |
| 160 | const recordingStartupCancelledError = ( | |
| 161 | recording: ActiveRecording, | |
| 162 | cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`), | |
| 163 | ): BrowserRecordingOperationError => | |
| 164 | new BrowserRecordingOperationError({ | |
| 165 | operation: "start-screencast", | |
| 166 | tabId: recording.tabId, | |
| 167 | cause, | |
| 168 | }); | |
| 169 | ||
| 170 | const isRecordingStarting = (recording: ActiveRecording): boolean => | |
| 171 | active === recording && recording.lifecycle.phase === "starting"; | |
| 172 | ||
| 173 | const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { | |
| 174 | let timeout: ReturnType<typeof setTimeout> | null = null; | |
| 175 | try { | |
| 176 | await Promise.race([ | |
| 177 | recording.startupSettled, | |
| 178 | new Promise<void>((_, reject) => { | |
| 179 | timeout = setTimeout(() => { | |
| 180 | reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`)); | |
| 181 | }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); | |
| 182 | }), | |
| 183 | ]); | |
| 184 | } catch (cause) { | |
| 185 | throw new BrowserRecordingOperationError({ | |
| 186 | operation: "wait-startup", | |
| 187 | tabId: recording.tabId, | |
| 188 | cause, | |
| 189 | }); | |
| 190 | } finally { | |
| 191 | if (timeout !== null) clearTimeout(timeout); | |
| 192 | } | |
| 193 | }; | |
| 194 | ||
| 195 | const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => | |
| 196 | isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; | |
| 197 | ||
| 134 | 198 | export async function startBrowserRecording(tabId: string): Promise<string> { |
| 135 | 199 | const bridge = previewBridge; |
| 136 | 200 | if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); |
| 137 | 201 | if (active) { |
| 138 | if (active.tabId === tabId) return active.startedAt; | |
| 202 | if (active.tabId === tabId && active.lifecycle.phase === "recording") { | |
| 203 | return active.startedAt; | |
| 204 | } | |
| 139 | 205 | throw new BrowserRecordingConflictError({ |
| 140 | 206 | requestedTabId: tabId, |
| 141 | 207 | activeTabId: active.tabId, |
| 142 | 208 | }); |
| 143 | 209 | } |
| 144 | 210 | const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; |
| 211 | if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId }); | |
| 145 | 212 | const recordingSize = surface?.content ?? surface?.rect; |
| 146 | 213 | const canvas = document.createElement("canvas"); |
| 147 | 214 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); |
| ... | ... | @@ -171,65 +238,104 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 171 | 238 | } |
| 172 | 239 | const startedAt = new Date().toISOString(); |
| 173 | 240 | const chunks: Blob[] = []; |
| 241 | let settleStartup: (() => void) | undefined; | |
| 242 | const startupSettled = new Promise<void>((resolve) => { | |
| 243 | settleStartup = resolve; | |
| 244 | }); | |
| 174 | 245 | recorder.addEventListener("dataavailable", (event) => { |
| 175 | 246 | if (event.data.size > 0) chunks.push(event.data); |
| 176 | 247 | }); |
| 177 | const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; | |
| 248 | const recording: ActiveRecording = { | |
| 249 | tabId, | |
| 250 | canvas, | |
| 251 | context, | |
| 252 | recorder, | |
| 253 | chunks, | |
| 254 | mimeType, | |
| 255 | startedAt, | |
| 256 | startupSettled, | |
| 257 | lifecycle: { phase: "starting" }, | |
| 258 | }; | |
| 178 | 259 | active = recording; |
| 179 | 260 | try { |
| 180 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | |
| 181 | } catch (cause) { | |
| 182 | clearActiveRecording(recording); | |
| 183 | throw new BrowserRecordingOperationError({ | |
| 184 | operation: "subscribe-frames", | |
| 185 | tabId, | |
| 186 | cause, | |
| 187 | }); | |
| 188 | } | |
| 189 | try { | |
| 190 | recorder.start(1_000); | |
| 191 | } catch (cause) { | |
| 192 | clearActiveRecording(recording); | |
| 193 | throw new BrowserRecordingOperationError({ | |
| 194 | operation: "start-media-recorder", | |
| 195 | tabId, | |
| 196 | cause, | |
| 197 | }); | |
| 198 | } | |
| 199 | try { | |
| 200 | await bridge.recording.startScreencast(tabId); | |
| 201 | } catch (cause) { | |
| 202 | let cleanupCause: unknown; | |
| 203 | 261 | try { |
| 204 | await stopMediaRecorder(recorder); | |
| 205 | } catch (error) { | |
| 206 | cleanupCause = error; | |
| 207 | } finally { | |
| 262 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | |
| 263 | } catch (cause) { | |
| 208 | 264 | clearActiveRecording(recording); |
| 265 | throw new BrowserRecordingOperationError({ | |
| 266 | operation: "subscribe-frames", | |
| 267 | tabId, | |
| 268 | cause, | |
| 269 | }); | |
| 209 | 270 | } |
| 210 | throw new BrowserRecordingOperationError({ | |
| 211 | operation: "start-screencast", | |
| 212 | tabId, | |
| 213 | cause: | |
| 214 | cleanupCause === undefined | |
| 215 | ? cause | |
| 216 | : new AggregateError( | |
| 217 | [cause, cleanupCause], | |
| 218 | `Browser recording start and cleanup failed for tab ${tabId}.`, | |
| 219 | { cause }, | |
| 220 | ), | |
| 221 | }); | |
| 271 | try { | |
| 272 | recorder.start(1_000); | |
| 273 | } catch (cause) { | |
| 274 | clearActiveRecording(recording); | |
| 275 | throw new BrowserRecordingOperationError({ | |
| 276 | operation: "start-media-recorder", | |
| 277 | tabId, | |
| 278 | cause, | |
| 279 | }); | |
| 280 | } | |
| 281 | if (!isRecordingStarting(recording)) { | |
| 282 | throw recordingStartupCancelledError(recording); | |
| 283 | } | |
| 284 | try { | |
| 285 | await bridge.recording.startScreencast(tabId); | |
| 286 | } catch (cause) { | |
| 287 | if (!isRecordingStarting(recording)) { | |
| 288 | throw recordingStartupCancelledError(recording, cause); | |
| 289 | } | |
| 290 | let cleanupCause: unknown; | |
| 291 | try { | |
| 292 | await stopMediaRecorder(recorder); | |
| 293 | } catch (error) { | |
| 294 | cleanupCause = error; | |
| 295 | } finally { | |
| 296 | clearActiveRecording(recording); | |
| 297 | } | |
| 298 | throw new BrowserRecordingOperationError({ | |
| 299 | operation: "start-screencast", | |
| 300 | tabId, | |
| 301 | cause: | |
| 302 | cleanupCause === undefined | |
| 303 | ? cause | |
| 304 | : new AggregateError( | |
| 305 | [cause, cleanupCause], | |
| 306 | `Browser recording start and cleanup failed for tab ${tabId}.`, | |
| 307 | { cause }, | |
| 308 | ), | |
| 309 | }); | |
| 310 | } | |
| 311 | if (!isRecordingStarting(recording)) { | |
| 312 | try { | |
| 313 | await bridge.recording.stopScreencast(tabId); | |
| 314 | } catch (cause) { | |
| 315 | throw recordingStartupCancelledError( | |
| 316 | recording, | |
| 317 | new AggregateError( | |
| 318 | [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause], | |
| 319 | `Browser recording startup cancellation failed for tab ${tabId}.`, | |
| 320 | { cause }, | |
| 321 | ), | |
| 322 | ); | |
| 323 | } | |
| 324 | throw recordingStartupCancelledError(recording); | |
| 325 | } | |
| 326 | recording.lifecycle = { phase: "recording" }; | |
| 327 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); | |
| 328 | return startedAt; | |
| 329 | } finally { | |
| 330 | settleStartup?.(); | |
| 222 | 331 | } |
| 223 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); | |
| 224 | return startedAt; | |
| 225 | 332 | } |
| 226 | 333 | |
| 227 | export async function stopBrowserRecording( | |
| 228 | tabId: string, | |
| 229 | ): Promise<DesktopPreviewRecordingArtifact | null> { | |
| 230 | const bridge = previewBridge; | |
| 231 | const recording = active; | |
| 232 | if (!bridge || !recording || recording.tabId !== tabId) return null; | |
| 334 | const finalizeBrowserRecording = async ( | |
| 335 | bridge: NonNullable<typeof previewBridge>, | |
| 336 | recording: ActiveRecording, | |
| 337 | ): Promise<DesktopPreviewRecordingArtifact> => { | |
| 338 | const { tabId } = recording; | |
| 233 | 339 | let result: |
| 234 | 340 | | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } |
| 235 | 341 | | { readonly _tag: "Failure"; readonly error: unknown }; |
| ... | ... | @@ -243,6 +349,7 @@ export async function stopBrowserRecording( |
| 243 | 349 | cause, |
| 244 | 350 | }); |
| 245 | 351 | } |
| 352 | await waitForRecordingStartupToSettle(recording); | |
| 246 | 353 | try { |
| 247 | 354 | await stopMediaRecorder(recording.recorder); |
| 248 | 355 | } catch (cause) { |
| ... | ... | @@ -271,6 +378,14 @@ export async function stopBrowserRecording( |
| 271 | 378 | result = { _tag: "Failure", error }; |
| 272 | 379 | } |
| 273 | 380 | |
| 381 | if (result._tag === "Failure" && isStartupWaitTimeout(result.error)) { | |
| 382 | // Do not clear `active` yet. The renderer-side start promise can still | |
| 383 | // resolve later, and its cancellation path will call `stopScreencast`. | |
| 384 | // Keeping the slot reserved prevents a newer recording for this tab from | |
| 385 | // being started and then accidentally stopped by the older late cleanup. | |
| 386 | throw result.error; | |
| 387 | } | |
| 388 | ||
| 274 | 389 | let cleanupError: BrowserRecordingOperationError | undefined; |
| 275 | 390 | try { |
| 276 | 391 | await stopMediaRecorder(recording.recorder); |
| ... | ... | @@ -300,4 +415,41 @@ export async function stopBrowserRecording( |
| 300 | 415 | } |
| 301 | 416 | if (cleanupError) throw cleanupError; |
| 302 | 417 | return result.artifact; |
| 418 | }; | |
| 419 | ||
| 420 | const discardBrowserRecording = async ( | |
| 421 | bridge: NonNullable<typeof previewBridge>, | |
| 422 | recording: ActiveRecording, | |
| 423 | ): Promise<null> => { | |
| 424 | try { | |
| 425 | await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined); | |
| 426 | await stopMediaRecorder(recording.recorder).catch(() => undefined); | |
| 427 | return null; | |
| 428 | } finally { | |
| 429 | clearActiveRecording(recording); | |
| 430 | } | |
| 431 | }; | |
| 432 | ||
| 433 | export function stopBrowserRecording( | |
| 434 | tabId: string, | |
| 435 | ): Promise<DesktopPreviewRecordingArtifact | null> { | |
| 436 | const bridge = previewBridge; | |
| 437 | const recording = active; | |
| 438 | if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); | |
| 439 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; | |
| 440 | ||
| 441 | const stopPromise = Promise.resolve() | |
| 442 | .then(() => finalizeBrowserRecording(bridge, recording)) | |
| 443 | .catch((error) => { | |
| 444 | if (isStartupWaitTimeout(error) && active === recording) { | |
| 445 | const cleanupAfterStartup = recording.startupSettled.then(() => | |
| 446 | discardBrowserRecording(bridge, recording), | |
| 447 | ); | |
| 448 | recording.lifecycle = { phase: "stopping", stopPromise: cleanupAfterStartup }; | |
| 449 | void cleanupAfterStartup.catch(() => undefined); | |
| 450 | } | |
| 451 | throw error; | |
| 452 | }); | |
| 453 | recording.lifecycle = { phase: "stopping", stopPromise }; | |
| 454 | return stopPromise; | |
| 303 | 455 | } |
apps/web/src/browser/browserRecordingScope.test.ts+6-1| ... | ... | @@ -3,9 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; |
| 4 | 4 | |
| 5 | 5 | describe("resolveBrowserRecordingStopTarget", () => { |
| 6 | it("stops the active recording even after the requested tab changes", () => { | |
| 6 | it("stops the active recording when no explicit tab was requested", () => { | |
| 7 | 7 | expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); |
| 8 | 8 | expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); |
| 9 | 9 | expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); |
| 10 | 10 | }); |
| 11 | ||
| 12 | it("only stops an explicitly requested tab when it owns the recording", () => { | |
| 13 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); | |
| 14 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); | |
| 15 | }); | |
| 11 | 16 | }); |
apps/web/src/browser/browserRecordingScope.ts+6-2| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { | |
| 2 | return activeTabId; | |
| 1 | export function resolveBrowserRecordingStopTarget( | |
| 2 | activeTabId: string | null, | |
| 3 | requestedTabId?: string, | |
| 4 | ): string | null { | |
| 5 | if (activeTabId === null) return null; | |
| 6 | return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; | |
| 3 | 7 | } |
apps/web/src/browser/browserSurfaceStore.test.ts+44-4| ... | ... | @@ -1,8 +1,16 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 2 | 2 | |
| 3 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | |
| 3 | import { | |
| 4 | acquireBrowserSurface, | |
| 5 | resolveBrowserSurfacePanelRect, | |
| 6 | useBrowserSurfaceStore, | |
| 7 | } from "./browserSurfaceStore"; | |
| 4 | 8 | |
| 5 | 9 | describe("browserSurfaceStore", () => { |
| 10 | beforeEach(() => { | |
| 11 | useBrowserSurfaceStore.setState({ byTabId: {} }); | |
| 12 | }); | |
| 13 | ||
| 6 | 14 | it("tracks content dimensions for a browser that has never been visible", () => { |
| 7 | 15 | const tabId = "hidden-browser-surface-content-test"; |
| 8 | 16 | useBrowserSurfaceStore.getState().presentContent(tabId, { |
| ... | ... | @@ -28,11 +36,43 @@ describe("browserSurfaceStore", () => { |
| 28 | 36 | expect( |
| 29 | 37 | resolveBrowserSurfacePanelRect( |
| 30 | 38 | { |
| 31 | hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 }, | |
| 32 | active: { rect: liveRect, visible: true, content: null, updatedAt: 2 }, | |
| 39 | hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, | |
| 40 | active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, | |
| 33 | 41 | }, |
| 34 | 42 | "hidden", |
| 35 | 43 | ), |
| 36 | 44 | ).toEqual(liveRect); |
| 37 | 45 | }); |
| 46 | ||
| 47 | it("ignores updates and releases from a stale surface lease", () => { | |
| 48 | const tabId = "leased-browser-surface"; | |
| 49 | const staleRect = { x: 0, y: 0, width: 500, height: 700 }; | |
| 50 | const liveRect = { x: 10, y: 20, width: 900, height: 640 }; | |
| 51 | const staleLease = acquireBrowserSurface(tabId); | |
| 52 | staleLease.present(staleRect, true); | |
| 53 | ||
| 54 | const liveLease = acquireBrowserSurface(tabId); | |
| 55 | liveLease.present(liveRect, true); | |
| 56 | staleLease.present(staleRect, true); | |
| 57 | staleLease.release(); | |
| 58 | ||
| 59 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | |
| 60 | rect: liveRect, | |
| 61 | visible: true, | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | it("hides a surface when its current lease is released", () => { | |
| 66 | const tabId = "released-browser-surface"; | |
| 67 | const lease = acquireBrowserSurface(tabId); | |
| 68 | lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); | |
| 69 | ||
| 70 | lease.release(); | |
| 71 | lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); | |
| 72 | ||
| 73 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | |
| 74 | visible: false, | |
| 75 | owner: null, | |
| 76 | }); | |
| 77 | }); | |
| 38 | 78 | }); |
apps/web/src/browser/browserSurfaceStore.ts+56-7| ... | ... | @@ -12,6 +12,7 @@ export interface BrowserSurfacePresentation { |
| 12 | 12 | readonly visible: boolean; |
| 13 | 13 | readonly content: BrowserSurfaceContentPresentation | null; |
| 14 | 14 | readonly updatedAt: number; |
| 15 | readonly owner: symbol | null; | |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | 18 | export interface BrowserSurfaceContentPresentation { |
| ... | ... | @@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation { |
| 26 | 27 | |
| 27 | 28 | interface BrowserSurfaceStoreState { |
| 28 | 29 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 29 | readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; | |
| 30 | readonly claim: (tabId: string, owner: symbol) => void; | |
| 31 | readonly present: ( | |
| 32 | tabId: string, | |
| 33 | owner: symbol, | |
| 34 | rect: BrowserSurfaceRect, | |
| 35 | visible: boolean, | |
| 36 | ) => void; | |
| 30 | 37 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; |
| 31 | readonly hide: (tabId: string) => void; | |
| 38 | readonly release: (tabId: string, owner: symbol) => void; | |
| 39 | } | |
| 40 | ||
| 41 | export interface BrowserSurfaceLease { | |
| 42 | readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; | |
| 43 | readonly release: () => void; | |
| 32 | 44 | } |
| 33 | 45 | |
| 34 | 46 | export function resolveBrowserSurfacePanelRect( |
| ... | ... | @@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): |
| 60 | 72 | |
| 61 | 73 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ |
| 62 | 74 | byTabId: {}, |
| 63 | present: (tabId, rect, visible) => | |
| 75 | claim: (tabId, owner) => | |
| 76 | set((state) => { | |
| 77 | const current = state.byTabId[tabId]; | |
| 78 | if (current?.owner === owner) return state; | |
| 79 | return { | |
| 80 | byTabId: { | |
| 81 | ...state.byTabId, | |
| 82 | [tabId]: { | |
| 83 | rect: current?.rect ?? null, | |
| 84 | visible: false, | |
| 85 | content: current?.content ?? null, | |
| 86 | updatedAt: Date.now(), | |
| 87 | owner, | |
| 88 | }, | |
| 89 | }, | |
| 90 | }; | |
| 91 | }), | |
| 92 | present: (tabId, owner, rect, visible) => | |
| 64 | 93 | set((state) => { |
| 65 | 94 | const current = state.byTabId[tabId]; |
| 95 | if (current?.owner !== owner) return state; | |
| 66 | 96 | if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; |
| 67 | 97 | return { |
| 68 | 98 | byTabId: { |
| 69 | 99 | ...state.byTabId, |
| 70 | [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() }, | |
| 100 | [tabId]: { ...current, rect, visible, updatedAt: Date.now() }, | |
| 71 | 101 | }, |
| 72 | 102 | }; |
| 73 | 103 | }), |
| ... | ... | @@ -83,6 +113,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 83 | 113 | visible: false, |
| 84 | 114 | content, |
| 85 | 115 | updatedAt: Date.now(), |
| 116 | owner: null, | |
| 86 | 117 | }, |
| 87 | 118 | }, |
| 88 | 119 | }; |
| ... | ... | @@ -107,15 +138,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 107 | 138 | }, |
| 108 | 139 | }; |
| 109 | 140 | }), |
| 110 | hide: (tabId) => | |
| 141 | release: (tabId, owner) => | |
| 111 | 142 | set((state) => { |
| 112 | 143 | const current = state.byTabId[tabId]; |
| 113 | if (!current || !current.visible) return state; | |
| 144 | if (current?.owner !== owner) return state; | |
| 114 | 145 | return { |
| 115 | 146 | byTabId: { |
| 116 | 147 | ...state.byTabId, |
| 117 | [tabId]: { ...current, visible: false, updatedAt: Date.now() }, | |
| 148 | [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, | |
| 118 | 149 | }, |
| 119 | 150 | }; |
| 120 | 151 | }), |
| 121 | 152 | })); |
| 153 | ||
| 154 | export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease { | |
| 155 | const owner = Symbol(`browser-surface:${tabId}`); | |
| 156 | let released = false; | |
| 157 | useBrowserSurfaceStore.getState().claim(tabId, owner); | |
| 158 | ||
| 159 | return { | |
| 160 | present: (rect, visible) => { | |
| 161 | if (released) return; | |
| 162 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); | |
| 163 | }, | |
| 164 | release: () => { | |
| 165 | if (released) return; | |
| 166 | released = true; | |
| 167 | useBrowserSurfaceStore.getState().release(tabId, owner); | |
| 168 | }, | |
| 169 | }; | |
| 170 | } |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | HIDDEN_BROWSER_WEBVIEW_OFFSET, | |
| 5 | resolveHostedBrowserWebviewWrapperStyle, | |
| 6 | } from "./hostedBrowserWebviewStyle"; | |
| 7 | ||
| 8 | describe("resolveHostedBrowserWebviewWrapperStyle", () => { | |
| 9 | it("places an active webview on its presented surface", () => { | |
| 10 | expect( | |
| 11 | resolveHostedBrowserWebviewWrapperStyle({ | |
| 12 | active: true, | |
| 13 | rect: { x: 12, y: 34, width: 800, height: 600 }, | |
| 14 | hiddenSize: { width: 1280, height: 800 }, | |
| 15 | }), | |
| 16 | ).toEqual({ | |
| 17 | left: 12, | |
| 18 | top: 34, | |
| 19 | width: 800, | |
| 20 | height: 600, | |
| 21 | zIndex: 30, | |
| 22 | pointerEvents: "auto", | |
| 23 | }); | |
| 24 | }); | |
| 25 | ||
| 26 | it("keeps an inactive webview paintable while moving it offscreen", () => { | |
| 27 | const style = resolveHostedBrowserWebviewWrapperStyle({ | |
| 28 | active: false, | |
| 29 | rect: { x: 12, y: 34, width: 800, height: 600 }, | |
| 30 | hiddenSize: { width: 393, height: 852 }, | |
| 31 | }); | |
| 32 | ||
| 33 | expect(style).toEqual({ | |
| 34 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, | |
| 35 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, | |
| 36 | width: 393, | |
| 37 | height: 852, | |
| 38 | zIndex: -1, | |
| 39 | pointerEvents: "none", | |
| 40 | visibility: "visible", | |
| 41 | }); | |
| 42 | }); | |
| 43 | }); |
apps/web/src/browser/hostedBrowserWebviewStyle.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import type { BrowserSurfaceRect } from "./browserSurfaceStore"; | |
| 2 | ||
| 3 | export interface HostedBrowserWebviewSize { | |
| 4 | readonly width: number; | |
| 5 | readonly height: number; | |
| 6 | } | |
| 7 | ||
| 8 | export interface HostedBrowserWebviewWrapperStyle { | |
| 9 | readonly left: number; | |
| 10 | readonly top: number; | |
| 11 | readonly width: number; | |
| 12 | readonly height: number; | |
| 13 | readonly zIndex: number; | |
| 14 | readonly pointerEvents: "auto" | "none"; | |
| 15 | readonly visibility?: "visible"; | |
| 16 | } | |
| 17 | ||
| 18 | export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; | |
| 19 | ||
| 20 | export function resolveHostedBrowserWebviewWrapperStyle(input: { | |
| 21 | readonly active: boolean; | |
| 22 | readonly rect: BrowserSurfaceRect | null; | |
| 23 | readonly hiddenSize: HostedBrowserWebviewSize; | |
| 24 | }): HostedBrowserWebviewWrapperStyle { | |
| 25 | const { active, hiddenSize, rect } = input; | |
| 26 | if (active && rect) { | |
| 27 | return { | |
| 28 | left: rect.x, | |
| 29 | top: rect.y, | |
| 30 | width: rect.width, | |
| 31 | height: rect.height, | |
| 32 | zIndex: 30, | |
| 33 | pointerEvents: "auto", | |
| 34 | }; | |
| 35 | } | |
| 36 | ||
| 37 | return { | |
| 38 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, | |
| 39 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, | |
| 40 | width: hiddenSize.width, | |
| 41 | height: hiddenSize.height, | |
| 42 | zIndex: -1, | |
| 43 | pointerEvents: "none", | |
| 44 | // Keep the guest CSS-visible even while physically offscreen. Electron | |
| 45 | // webviews can keep metadata/status alive under `visibility:hidden` while | |
| 46 | // CDP Runtime/Input commands stall, which breaks offscreen automation. | |
| 47 | visibility: "visible", | |
| 48 | }; | |
| 49 | } |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+31-12| ... | ... | @@ -56,6 +56,7 @@ import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationR |
| 56 | 56 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; |
| 57 | 57 | import { |
| 58 | 58 | needsPreviewAutomationSessionSync, |
| 59 | resolvePreviewAutomationOpenTab, | |
| 59 | 60 | resolvePreviewAutomationTarget, |
| 60 | 61 | } from "./previewAutomationTarget"; |
| 61 | 62 | import { isPreviewViewportReady } from "./previewViewportReadiness"; |
| ... | ... | @@ -230,6 +231,16 @@ const currentStatus = async ( |
| 230 | 231 | }; |
| 231 | 232 | }; |
| 232 | 233 | |
| 234 | const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => { | |
| 235 | throw squashAtomCommandFailure(result); | |
| 236 | }; | |
| 237 | ||
| 238 | const raisePreviewAutomationHostError = ( | |
| 239 | error: PreviewAutomationRecordingNotActiveError, | |
| 240 | ): never => { | |
| 241 | throw error; | |
| 242 | }; | |
| 243 | ||
| 233 | 244 | export function PreviewAutomationHosts() { |
| 234 | 245 | const { environments } = useEnvironments(); |
| 235 | 246 | if (!isElectron || !previewBridge?.automation) return null; |
| ... | ... | @@ -304,7 +315,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 304 | 315 | registry.refresh(previewEnvironment.list(listTarget)); |
| 305 | 316 | const result = await listPreviews(listTarget); |
| 306 | 317 | if (result._tag === "Failure") { |
| 307 | throw squashAtomCommandFailure(result); | |
| 318 | return raiseAtomCommandFailure(result); | |
| 308 | 319 | } |
| 309 | 320 | reconcilePreviewServerSessions(threadRef, result.value.sessions); |
| 310 | 321 | state = readThreadPreviewState(threadRef); |
| ... | ... | @@ -332,8 +343,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 332 | 343 | return await currentStatus(threadRef, tabId); |
| 333 | 344 | case "open": { |
| 334 | 345 | const input = request.input as PreviewAutomationOpenInput; |
| 335 | let activeTabId = | |
| 336 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 346 | let activeTabId = resolvePreviewAutomationOpenTab( | |
| 347 | state, | |
| 348 | request.tabId, | |
| 349 | input.reuseExistingTab ?? true, | |
| 350 | ); | |
| 337 | 351 | let activeSnapshot = activeTabId |
| 338 | 352 | ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) |
| 339 | 353 | : undefined; |
| ... | ... | @@ -348,7 +362,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 348 | 362 | }, |
| 349 | 363 | }); |
| 350 | 364 | if (result._tag === "Failure") { |
| 351 | throw squashAtomCommandFailure(result); | |
| 365 | return raiseAtomCommandFailure(result); | |
| 352 | 366 | } |
| 353 | 367 | const snapshot = result.value; |
| 354 | 368 | applyPreviewServerSnapshot(threadRef, snapshot); |
| ... | ... | @@ -416,7 +430,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 416 | 430 | }, |
| 417 | 431 | }); |
| 418 | 432 | if (result._tag === "Failure") { |
| 419 | throw squashAtomCommandFailure(result); | |
| 433 | return raiseAtomCommandFailure(result); | |
| 420 | 434 | } |
| 421 | 435 | updatePreviewServerSnapshot(threadRef, result.value); |
| 422 | 436 | const viewport = await waitForRenderedViewport( |
| ... | ... | @@ -492,15 +506,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 492 | 506 | } |
| 493 | 507 | case "recordingStop": { |
| 494 | 508 | const recordingTabId = readActiveBrowserRecordingTabId(); |
| 495 | const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId); | |
| 509 | const stopTabId = resolveBrowserRecordingStopTarget( | |
| 510 | recordingTabId, | |
| 511 | request.tabIdExplicit ? request.tabId : undefined, | |
| 512 | ); | |
| 496 | 513 | const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; |
| 497 | 514 | if (!artifact) { |
| 498 | throw new PreviewAutomationRecordingNotActiveError({ | |
| 499 | requestId: request.requestId, | |
| 500 | environmentId, | |
| 501 | threadId: request.threadId, | |
| 502 | tabId, | |
| 503 | }); | |
| 515 | return raisePreviewAutomationHostError( | |
| 516 | new PreviewAutomationRecordingNotActiveError({ | |
| 517 | requestId: request.requestId, | |
| 518 | environmentId, | |
| 519 | threadId: request.threadId, | |
| 520 | tabId, | |
| 521 | }), | |
| 522 | ); | |
| 504 | 523 | } |
| 505 | 524 | return artifact; |
| 506 | 525 | } |
apps/web/src/components/preview/previewAutomationTarget.test.ts+14| ... | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | needsPreviewAutomationSessionSync, |
| 6 | resolvePreviewAutomationOpenTab, | |
| 6 | 7 | resolvePreviewAutomationTarget, |
| 7 | 8 | } from "./previewAutomationTarget"; |
| 8 | 9 | |
| ... | ... | @@ -42,4 +43,17 @@ describe("preview automation target selection", () => { |
| 42 | 43 | ), |
| 43 | 44 | ).toEqual({ tabId: null, snapshot: null }); |
| 44 | 45 | }); |
| 46 | ||
| 47 | it("reuses the provider session's pinned tab instead of the mutable UI tab", () => { | |
| 48 | const uiActive = snapshot("tab-ui-active"); | |
| 49 | const agentTab = snapshot("tab-opened-by-agent"); | |
| 50 | const state = { | |
| 51 | snapshot: uiActive, | |
| 52 | sessions: { [uiActive.tabId]: uiActive, [agentTab.tabId]: agentTab }, | |
| 53 | }; | |
| 54 | ||
| 55 | expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, true)).toBe(agentTab.tabId); | |
| 56 | expect(resolvePreviewAutomationOpenTab(state, undefined, true)).toBe(uiActive.tabId); | |
| 57 | expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, false)).toBeNull(); | |
| 58 | }); | |
| 45 | 59 | }); |
apps/web/src/components/preview/previewAutomationTarget.ts+12| ... | ... | @@ -23,3 +23,15 @@ export function resolvePreviewAutomationTarget( |
| 23 | 23 | const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot; |
| 24 | 24 | return { tabId: snapshot?.tabId ?? null, snapshot }; |
| 25 | 25 | } |
| 26 | ||
| 27 | export function resolvePreviewAutomationOpenTab( | |
| 28 | state: PreviewAutomationSessionIndex, | |
| 29 | requestedTabId: string | undefined, | |
| 30 | reuseExistingTab: boolean, | |
| 31 | ): string | null { | |
| 32 | if (!reuseExistingTab) return null; | |
| 33 | if (requestedTabId !== undefined) { | |
| 34 | return state.sessions[requestedTabId]?.tabId ?? null; | |
| 35 | } | |
| 36 | return state.snapshot?.tabId ?? null; | |
| 37 | } |
packages/contracts/src/preview.test.ts+16| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | import { |
| 12 | 12 | PreviewAutomationHost, |
| 13 | 13 | PreviewAutomationError, |
| 14 | PreviewAutomationOpenInput, | |
| 14 | 15 | PreviewAutomationResizeInput, |
| 15 | 16 | PreviewAutomationResizeResult, |
| 16 | 17 | PreviewAutomationStatus, |
| ... | ... | @@ -22,6 +23,7 @@ const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); |
| 22 | 23 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); |
| 23 | 24 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); |
| 24 | 25 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); |
| 26 | const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); | |
| 25 | 27 | const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); |
| 26 | 28 | const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); |
| 27 | 29 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); |
| ... | ... | @@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => { |
| 128 | 130 | }); |
| 129 | 131 | }); |
| 130 | 132 | |
| 133 | describe("preview automation tab targeting", () => { | |
| 134 | it("accepts an explicit tab and rejects contradictory open behavior", () => { | |
| 135 | expect(decodeResizeInput({ tabId: "tab-app", mode: "fill" })).toMatchObject({ | |
| 136 | tabId: "tab-app", | |
| 137 | mode: "fill", | |
| 138 | }); | |
| 139 | expect(decodeOpenInput({ tabId: "tab-app", reuseExistingTab: true })).toMatchObject({ | |
| 140 | tabId: "tab-app", | |
| 141 | reuseExistingTab: true, | |
| 142 | }); | |
| 143 | expect(() => decodeOpenInput({ tabId: "tab-app", reuseExistingTab: false })).toThrow(); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 131 | 147 | describe("PreviewAutomationHost", () => { |
| 132 | 148 | it("accepts legacy hosts and current operation advertisements", () => { |
| 133 | 149 | expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({ |
packages/contracts/src/previewAutomation.ts+38-5| ... | ... | @@ -50,6 +50,21 @@ export const PREVIEW_AUTOMATION_OPERATIONS = [ |
| 50 | 50 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); |
| 51 | 51 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 52 | 52 | |
| 53 | const PreviewAutomationTabTargetFields = { | |
| 54 | tabId: Schema.optional( | |
| 55 | PreviewTabId.annotate({ | |
| 56 | description: | |
| 57 | "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", | |
| 58 | }), | |
| 59 | ).annotate({ | |
| 60 | description: | |
| 61 | "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", | |
| 62 | }), | |
| 63 | }; | |
| 64 | ||
| 65 | export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); | |
| 66 | export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; | |
| 67 | ||
| 53 | 68 | export const PreviewAutomationStatus = Schema.Struct({ |
| 54 | 69 | available: Schema.Boolean, |
| 55 | 70 | visible: Schema.Boolean, |
| ... | ... | @@ -65,6 +80,7 @@ export const PreviewAutomationStatus = Schema.Struct({ |
| 65 | 80 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 66 | 81 | |
| 67 | 82 | export const PreviewAutomationOpenInput = Schema.Struct({ |
| 83 | ...PreviewAutomationTabTargetFields, | |
| 68 | 84 | url: Schema.optional(BoundedUrl).annotate({ |
| 69 | 85 | description: |
| 70 | 86 | "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", |
| ... | ... | @@ -77,13 +93,21 @@ export const PreviewAutomationOpenInput = Schema.Struct({ |
| 77 | 93 | reuseExistingTab: Schema.optional( |
| 78 | 94 | Schema.Boolean.annotate({ |
| 79 | 95 | description: |
| 80 | "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.", | |
| 96 | "Reuse tabId when supplied, otherwise this agent session's current tab. Defaults to true; set false to create a new tab.", | |
| 81 | 97 | }), |
| 82 | 98 | ), |
| 83 | }).annotate({ | |
| 84 | description: | |
| 85 | "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", | |
| 86 | }); | |
| 99 | }) | |
| 100 | .check( | |
| 101 | Schema.makeFilter( | |
| 102 | (input) => | |
| 103 | !(input.tabId !== undefined && input.reuseExistingTab === false) || | |
| 104 | "tabId cannot be combined with reuseExistingTab=false.", | |
| 105 | ), | |
| 106 | ) | |
| 107 | .annotate({ | |
| 108 | description: | |
| 109 | "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", | |
| 110 | }); | |
| 87 | 111 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; |
| 88 | 112 | |
| 89 | 113 | export const BrowserNavigationTarget = Schema.Union([ |
| ... | ... | @@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([ |
| 117 | 141 | export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; |
| 118 | 142 | |
| 119 | 143 | export const PreviewAutomationNavigateInput = Schema.Struct({ |
| 144 | ...PreviewAutomationTabTargetFields, | |
| 120 | 145 | url: Schema.optional(BoundedUrl).annotate({ |
| 121 | 146 | description: |
| 122 | 147 | "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.", |
| ... | ... | @@ -155,6 +180,7 @@ export const PreviewAutomationNavigateInput = Schema.Struct({ |
| 155 | 180 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; |
| 156 | 181 | |
| 157 | 182 | export const PreviewAutomationResizeInput = Schema.Struct({ |
| 183 | ...PreviewAutomationTabTargetFields, | |
| 158 | 184 | mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({ |
| 159 | 185 | description: |
| 160 | 186 | "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.", |
| ... | ... | @@ -239,6 +265,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({ |
| 239 | 265 | }); |
| 240 | 266 | |
| 241 | 267 | export const PreviewAutomationClickInput = Schema.Struct({ |
| 268 | ...PreviewAutomationTabTargetFields, | |
| 242 | 269 | selector: Schema.optional(LegacySelector).annotate({ |
| 243 | 270 | description: |
| 244 | 271 | "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", |
| ... | ... | @@ -277,6 +304,7 @@ export const PreviewAutomationClickInput = Schema.Struct({ |
| 277 | 304 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; |
| 278 | 305 | |
| 279 | 306 | export const PreviewAutomationTypeInput = Schema.Struct({ |
| 307 | ...PreviewAutomationTabTargetFields, | |
| 280 | 308 | text: Schema.String.annotate({ description: "Literal text to insert." }), |
| 281 | 309 | selector: Schema.optional(LegacySelector).annotate({ |
| 282 | 310 | description: "Legacy CSS selector for the input. Prefer locator.", |
| ... | ... | @@ -306,6 +334,7 @@ export const PreviewAutomationTypeInput = Schema.Struct({ |
| 306 | 334 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; |
| 307 | 335 | |
| 308 | 336 | export const PreviewAutomationPressInput = Schema.Struct({ |
| 337 | ...PreviewAutomationTabTargetFields, | |
| 309 | 338 | key: Schema.String.check(Schema.isTrimmed()) |
| 310 | 339 | .check( |
| 311 | 340 | Schema.isNonEmpty({ |
| ... | ... | @@ -326,6 +355,7 @@ export const PreviewAutomationPressInput = Schema.Struct({ |
| 326 | 355 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; |
| 327 | 356 | |
| 328 | 357 | export const PreviewAutomationScrollInput = Schema.Struct({ |
| 358 | ...PreviewAutomationTabTargetFields, | |
| 329 | 359 | deltaX: Schema.optional( |
| 330 | 360 | Schema.Finite.annotate({ |
| 331 | 361 | description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", |
| ... | ... | @@ -360,6 +390,7 @@ export const PreviewAutomationScrollInput = Schema.Struct({ |
| 360 | 390 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; |
| 361 | 391 | |
| 362 | 392 | export const PreviewAutomationEvaluateInput = Schema.Struct({ |
| 393 | ...PreviewAutomationTabTargetFields, | |
| 363 | 394 | expression: Schema.String.check(Schema.isTrimmed()) |
| 364 | 395 | .check( |
| 365 | 396 | Schema.isNonEmpty({ |
| ... | ... | @@ -388,6 +419,7 @@ export const PreviewAutomationEvaluateInput = Schema.Struct({ |
| 388 | 419 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; |
| 389 | 420 | |
| 390 | 421 | export const PreviewAutomationWaitForInput = Schema.Struct({ |
| 422 | ...PreviewAutomationTabTargetFields, | |
| 391 | 423 | selector: Schema.optional(LegacySelector).annotate({ |
| 392 | 424 | description: "Legacy CSS selector that must match an element. Prefer locator.", |
| 393 | 425 | }), |
| ... | ... | @@ -537,6 +569,7 @@ export const PreviewAutomationRequest = Schema.Struct({ |
| 537 | 569 | requestId: TrimmedNonEmptyString, |
| 538 | 570 | threadId: ThreadId, |
| 539 | 571 | tabId: Schema.optional(PreviewTabId), |
| 572 | tabIdExplicit: Schema.optional(Schema.Boolean), | |
| 540 | 573 | operation: PreviewAutomationOperation, |
| 541 | 574 | input: Schema.Unknown, |
| 542 | 575 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), |