| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { |
| 4 | EnvironmentId, |
| 5 | PreviewAutomationClientDisconnectedError, |
| 6 | PreviewAutomationInvalidSelectorError, |
| 7 | PreviewAutomationMalformedResponseError, |
| 8 | PreviewAutomationNoAvailableHostError, |
| 9 | PreviewAutomationTargetNotEditableError, |
| 10 | PreviewTabId, |
| 11 | ProviderInstanceId, |
| 12 | ThreadId, |
| 13 | type PreviewAutomationHost, |
| 14 | type PreviewAutomationRequest, |
| 15 | type PreviewAutomationStreamEvent, |
| 16 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Exit from "effect/Exit"; |
| 20 | import * as Deferred from "effect/Deferred"; |
| 21 | import * as Fiber from "effect/Fiber"; |
| 22 | import * as Result from "effect/Result"; |
| 23 | import * as Scheduler from "effect/Scheduler"; |
| 24 | import * as Stream from "effect/Stream"; |
| 25 | import * as TestClock from "effect/testing/TestClock"; |
| 26 | |
| 27 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 28 | |
| 29 | const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); |
| 30 | |
| 31 | const scope = { |
| 32 | environmentId: EnvironmentId.make("environment-1"), |
| 33 | requestNamespace: "provider-session-1", |
| 34 | thread: { |
| 35 | threadId: ThreadId.make("thread-1"), |
| 36 | providerSessionId: "provider-session-1", |
| 37 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 38 | }, |
| 39 | client: undefined, |
| 40 | capabilities: new Set(["preview"] as const), |
| 41 | issuedAt: 1, |
| 42 | }; |
| 43 | |
| 44 | const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({ |
| 45 | clientId: "client-1", |
| 46 | environmentId: scope.environmentId, |
| 47 | ...overrides, |
| 48 | }); |
| 49 | |
| 50 | type RoutedRequest = PreviewAutomationRequest & { |
| 51 | readonly connectionId: PreviewAutomationStreamEvent["connectionId"]; |
| 52 | }; |
| 53 | |
| 54 | const requestsFrom = ( |
| 55 | events: Stream.Stream<PreviewAutomationStreamEvent>, |
| 56 | onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {}, |
| 57 | ): Stream.Stream<RoutedRequest> => |
| 58 | events.pipe( |
| 59 | Stream.filterMap((event) => { |
| 60 | if (event.type === "connected") { |
| 61 | onConnected(event.connectionId); |
| 62 | return Result.failVoid; |
| 63 | } |
| 64 | return Result.succeed({ ...event.request, connectionId: event.connectionId }); |
| 65 | }), |
| 66 | ); |
| 67 | |
| 68 | it.effect("atomically registers a connected host and correlates its response", () => |
| 69 | Effect.scoped( |
| 70 | Effect.gen(function* () { |
| 71 | const broker = yield* makeBroker; |
| 72 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 73 | yield* Stream.runForEach(requests, (request) => |
| 74 | broker.respond({ |
| 75 | clientId: "client-1", |
| 76 | connectionId: request.connectionId, |
| 77 | requestId: request.requestId, |
| 78 | ok: true, |
| 79 | result: { available: true }, |
| 80 | }), |
| 81 | ).pipe(Effect.forkScoped); |
| 82 | yield* Effect.yieldNow; |
| 83 | |
| 84 | const result = yield* broker.invoke<{ available: boolean }>({ |
| 85 | scope, |
| 86 | operation: "open", |
| 87 | input: {}, |
| 88 | }); |
| 89 | |
| 90 | expect(result).toEqual({ available: true }); |
| 91 | }), |
| 92 | ), |
| 93 | ); |
| 94 | |
| 95 | it.effect("targets multiple tabs explicitly while retaining a default tab", () => |
| 96 | Effect.scoped( |
| 97 | Effect.gen(function* () { |
| 98 | const broker = yield* makeBroker; |
| 99 | const appTabId = PreviewTabId.make("tab-web-app"); |
| 100 | const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); |
| 101 | const openedTabIds = [appTabId, simulatorTabId]; |
| 102 | let openIndex = 0; |
| 103 | const routedRequests: RoutedRequest[] = []; |
| 104 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 105 | yield* Stream.runForEach(requests, (request) => { |
| 106 | routedRequests.push(request); |
| 107 | return broker.respond({ |
| 108 | clientId: "client-1", |
| 109 | connectionId: request.connectionId, |
| 110 | requestId: request.requestId, |
| 111 | ok: true, |
| 112 | result: |
| 113 | request.operation === "open" |
| 114 | ? { available: true, tabId: openedTabIds[openIndex++] } |
| 115 | : { url: "http://localhost:3200" }, |
| 116 | }); |
| 117 | }).pipe(Effect.forkScoped); |
| 118 | yield* Effect.yieldNow; |
| 119 | |
| 120 | yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); |
| 121 | yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); |
| 122 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 123 | yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); |
| 124 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 125 | |
| 126 | expect(routedRequests).toHaveLength(5); |
| 127 | expect(routedRequests[0]?.tabId).toBeUndefined(); |
| 128 | expect(routedRequests[1]?.tabId).toBe(appTabId); |
| 129 | expect(routedRequests[2]?.tabId).toBe(simulatorTabId); |
| 130 | expect(routedRequests[2]?.tabIdExplicit).toBe(false); |
| 131 | expect(routedRequests[3]?.tabId).toBe(appTabId); |
| 132 | expect(routedRequests[3]?.tabIdExplicit).toBe(true); |
| 133 | expect(routedRequests[4]?.tabId).toBe(appTabId); |
| 134 | }), |
| 135 | ), |
| 136 | ); |
| 137 | |
| 138 | it.effect.each([true, false])( |
| 139 | "keeps an older target stable while a newer explicit tab responds (implicit: %s)", |
| 140 | (implicit) => |
| 141 | Effect.scoped( |
| 142 | Effect.gen(function* () { |
| 143 | const broker = yield* makeBroker; |
| 144 | const olderTabId = PreviewTabId.make("tab-older-request"); |
| 145 | const newerTabId = PreviewTabId.make("tab-newer-request"); |
| 146 | const releaseOlderResponse = yield* Deferred.make<void>(); |
| 147 | const routedRequests: RoutedRequest[] = []; |
| 148 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 149 | yield* Stream.runForEach(requests, (request) => { |
| 150 | routedRequests.push(request); |
| 151 | const response = Effect.gen(function* () { |
| 152 | if (request.tabId === olderTabId && request.operation === "snapshot") { |
| 153 | yield* Deferred.await(releaseOlderResponse); |
| 154 | } |
| 155 | yield* broker.respond({ |
| 156 | clientId: "client-1", |
| 157 | connectionId: request.connectionId, |
| 158 | requestId: request.requestId, |
| 159 | ok: true, |
| 160 | result: { url: "http://localhost:3200" }, |
| 161 | }); |
| 162 | if (request.tabId === newerTabId) { |
| 163 | yield* Deferred.succeed(releaseOlderResponse, undefined); |
| 164 | } |
| 165 | }); |
| 166 | return response.pipe(Effect.forkScoped, Effect.asVoid); |
| 167 | }).pipe(Effect.forkScoped); |
| 168 | yield* Effect.yieldNow; |
| 169 | |
| 170 | yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); |
| 171 | let capturedTabId: PreviewTabId | undefined; |
| 172 | const older = yield* broker |
| 173 | .invoke({ |
| 174 | scope, |
| 175 | operation: "snapshot", |
| 176 | input: {}, |
| 177 | ...(implicit ? {} : { tabId: olderTabId }), |
| 178 | onTargetTab: (tabId) => { |
| 179 | capturedTabId = tabId; |
| 180 | }, |
| 181 | }) |
| 182 | .pipe(Effect.forkScoped); |
| 183 | yield* Effect.yieldNow; |
| 184 | const newer = yield* broker |
| 185 | .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) |
| 186 | .pipe(Effect.forkScoped); |
| 187 | yield* Fiber.join(newer); |
| 188 | yield* Fiber.join(older); |
| 189 | yield* broker.invoke({ |
| 190 | scope, |
| 191 | operation: "status", |
| 192 | input: {}, |
| 193 | tabId: olderTabId, |
| 194 | updateCurrentTab: false, |
| 195 | }); |
| 196 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 197 | |
| 198 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); |
| 199 | expect(capturedTabId).toBe(olderTabId); |
| 200 | }), |
| 201 | ), |
| 202 | ); |
| 203 | |
| 204 | it.effect("tracks the tab returned by a targeted recording stop", () => |
| 205 | Effect.scoped( |
| 206 | Effect.gen(function* () { |
| 207 | const broker = yield* makeBroker; |
| 208 | const browsingTabId = PreviewTabId.make("tab-session-b"); |
| 209 | const recordingTabId = PreviewTabId.make("tab-session-a-recording"); |
| 210 | const routedRequests: RoutedRequest[] = []; |
| 211 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 212 | yield* Stream.runForEach(requests, (request) => { |
| 213 | routedRequests.push(request); |
| 214 | return broker.respond({ |
| 215 | clientId: "client-1", |
| 216 | connectionId: request.connectionId, |
| 217 | requestId: request.requestId, |
| 218 | ok: true, |
| 219 | result: |
| 220 | request.operation === "open" |
| 221 | ? { available: true, tabId: browsingTabId } |
| 222 | : request.operation === "recordingStop" |
| 223 | ? { id: "recording-1", tabId: recordingTabId } |
| 224 | : { url: "http://localhost:3200" }, |
| 225 | }); |
| 226 | }).pipe(Effect.forkScoped); |
| 227 | yield* Effect.yieldNow; |
| 228 | |
| 229 | yield* broker.invoke({ scope, operation: "open", input: {} }); |
| 230 | yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); |
| 231 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 232 | |
| 233 | expect(routedRequests.at(-1)?.tabId).toBe(recordingTabId); |
| 234 | }), |
| 235 | ), |
| 236 | ); |
| 237 | |
| 238 | it.effect("does not let a no-tab response suppress an earlier tab decision", () => |
| 239 | Effect.scoped( |
| 240 | Effect.gen(function* () { |
| 241 | const broker = yield* makeBroker; |
| 242 | const initialTabId = PreviewTabId.make("tab-initial"); |
| 243 | const openedTabId = PreviewTabId.make("tab-opened-late"); |
| 244 | const releaseOpenResponse = yield* Deferred.make<void>(); |
| 245 | const routedRequests: RoutedRequest[] = []; |
| 246 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 247 | yield* Stream.runForEach(requests, (request) => { |
| 248 | routedRequests.push(request); |
| 249 | const marker = |
| 250 | typeof request.input === "object" && request.input !== null && "marker" in request.input |
| 251 | ? request.input.marker |
| 252 | : undefined; |
| 253 | const response = Effect.gen(function* () { |
| 254 | if (marker === "older") { |
| 255 | yield* Deferred.await(releaseOpenResponse); |
| 256 | } |
| 257 | yield* broker.respond({ |
| 258 | clientId: "client-1", |
| 259 | connectionId: request.connectionId, |
| 260 | requestId: request.requestId, |
| 261 | ok: true, |
| 262 | result: |
| 263 | request.operation === "open" |
| 264 | ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } |
| 265 | : { url: "http://localhost:3200" }, |
| 266 | }); |
| 267 | if (marker === "newer") { |
| 268 | yield* Deferred.succeed(releaseOpenResponse, undefined); |
| 269 | } |
| 270 | }); |
| 271 | return response.pipe(Effect.forkScoped, Effect.asVoid); |
| 272 | }).pipe(Effect.forkScoped); |
| 273 | yield* Effect.yieldNow; |
| 274 | |
| 275 | yield* broker.invoke({ scope, operation: "open", input: {} }); |
| 276 | const older = yield* broker |
| 277 | .invoke({ |
| 278 | scope, |
| 279 | operation: "open", |
| 280 | input: { marker: "older", reuseExistingTab: false }, |
| 281 | }) |
| 282 | .pipe(Effect.forkScoped); |
| 283 | yield* Effect.yieldNow; |
| 284 | const newer = yield* broker |
| 285 | .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) |
| 286 | .pipe(Effect.forkScoped); |
| 287 | yield* Fiber.join(newer); |
| 288 | yield* Fiber.join(older); |
| 289 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 290 | |
| 291 | expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); |
| 292 | }), |
| 293 | ), |
| 294 | ); |
| 295 | |
| 296 | it.effect("announces a live replacement stream before delivering requests", () => |
| 297 | Effect.scoped( |
| 298 | Effect.gen(function* () { |
| 299 | const broker = yield* makeBroker; |
| 300 | const events = yield* broker.connect(makeHost()); |
| 301 | const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; |
| 302 | const consumer = yield* events.pipe( |
| 303 | Stream.take(2), |
| 304 | Stream.runForEach((event) => { |
| 305 | receivedTypes.push(event.type); |
| 306 | return event.type === "connected" |
| 307 | ? Effect.void |
| 308 | : broker.respond({ |
| 309 | clientId: "client-1", |
| 310 | connectionId: event.connectionId, |
| 311 | requestId: event.request.requestId, |
| 312 | ok: true, |
| 313 | result: "ready", |
| 314 | }); |
| 315 | }), |
| 316 | Effect.forkScoped, |
| 317 | ); |
| 318 | yield* Effect.yieldNow; |
| 319 | |
| 320 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); |
| 321 | yield* Fiber.join(consumer); |
| 322 | |
| 323 | expect(receivedTypes).toEqual(["connected", "request"]); |
| 324 | expect(result).toBe("ready"); |
| 325 | }), |
| 326 | ), |
| 327 | ); |
| 328 | |
| 329 | it.effect( |
| 330 | "keeps a server-host open alive for installation without extending other operations", |
| 331 | () => |
| 332 | Effect.scoped( |
| 333 | Effect.gen(function* () { |
| 334 | const broker = yield* makeBroker; |
| 335 | const received = yield* Deferred.make<RoutedRequest>(); |
| 336 | const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true })); |
| 337 | yield* Stream.runForEach(requests, (request) => |
| 338 | request.operation === "open" |
| 339 | ? Deferred.succeed(received, request) |
| 340 | : broker.respond({ |
| 341 | clientId: "client-1", |
| 342 | connectionId: request.connectionId, |
| 343 | requestId: request.requestId, |
| 344 | ok: true, |
| 345 | result: request.timeoutMs, |
| 346 | }), |
| 347 | ).pipe(Effect.forkScoped); |
| 348 | const opening = yield* broker |
| 349 | .invoke({ scope, operation: "open", input: {} }) |
| 350 | .pipe(Effect.forkScoped); |
| 351 | const request = yield* Deferred.await(received); |
| 352 | yield* TestClock.adjust(16_000); |
| 353 | yield* broker.respond({ |
| 354 | clientId: "client-1", |
| 355 | connectionId: request.connectionId, |
| 356 | requestId: request.requestId, |
| 357 | ok: true, |
| 358 | result: "opened", |
| 359 | }); |
| 360 | expect(yield* Fiber.join(opening)).toBe("opened"); |
| 361 | expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe(15_000); |
| 362 | expect(yield* broker.invoke({ scope, operation: "navigate", input: {} })).toBe(15_000); |
| 363 | }), |
| 364 | ), |
| 365 | ); |
| 366 | |
| 367 | it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 368 | const locator = "role=button[name='request-secret']"; |
| 369 | const remoteMessage = "Unexpected token near remote-secret."; |
| 370 | const remoteError = { |
| 371 | _tag: "PreviewAutomationInvalidSelectorError", |
| 372 | message: remoteMessage, |
| 373 | detail: { selector: "role=button[name='remote-secret']" }, |
| 374 | } as const; |
| 375 | |
| 376 | return Effect.scoped( |
| 377 | Effect.gen(function* () { |
| 378 | const broker = yield* makeBroker; |
| 379 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 380 | yield* Stream.runForEach(requests, (request) => |
| 381 | broker.respond({ |
| 382 | clientId: "client-1", |
| 383 | connectionId: request.connectionId, |
| 384 | requestId: request.requestId, |
| 385 | ok: false, |
| 386 | error: remoteError, |
| 387 | }), |
| 388 | ).pipe(Effect.forkScoped); |
| 389 | yield* Effect.yieldNow; |
| 390 | |
| 391 | const error = yield* broker |
| 392 | .invoke<void>({ |
| 393 | scope, |
| 394 | operation: "click", |
| 395 | input: { locator }, |
| 396 | tabId: PreviewTabId.make("tab-1"), |
| 397 | timeoutMs: 1_234, |
| 398 | }) |
| 399 | .pipe(Effect.flip); |
| 400 | |
| 401 | expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); |
| 402 | expect(error).toMatchObject({ |
| 403 | operation: "click", |
| 404 | environmentId: scope.environmentId, |
| 405 | threadId: scope.thread.threadId, |
| 406 | providerSessionId: scope.thread.providerSessionId, |
| 407 | providerInstanceId: scope.thread.providerInstanceId, |
| 408 | clientId: "client-1", |
| 409 | requestId: "preview-0", |
| 410 | tabId: "tab-1", |
| 411 | timeoutMs: 1_234, |
| 412 | selectorKind: "locator", |
| 413 | selectorLength: locator.length, |
| 414 | remoteTag: "PreviewAutomationInvalidSelectorError", |
| 415 | remoteMessageLength: remoteMessage.length, |
| 416 | remoteDetailKind: "object", |
| 417 | }); |
| 418 | expect(error.message).toBe( |
| 419 | `Preview automation click received an invalid locator (${locator.length} characters).`, |
| 420 | ); |
| 421 | expect(error.message).not.toContain("secret"); |
| 422 | expect(error.cause).toBe(remoteError); |
| 423 | expect("selector" in error).toBe(false); |
| 424 | expect("remoteMessage" in error).toBe(false); |
| 425 | expect("remoteDetail" in error).toBe(false); |
| 426 | }), |
| 427 | ); |
| 428 | }); |
| 429 | |
| 430 | it.effect("classifies a remote non-editable target without collapsing it to execution", () => { |
| 431 | const remoteError = { |
| 432 | _tag: "PreviewAutomationTargetNotEditableError", |
| 433 | message: "remote target details", |
| 434 | detail: { selectorKind: "focused-element" }, |
| 435 | } as const; |
| 436 | |
| 437 | return Effect.scoped( |
| 438 | Effect.gen(function* () { |
| 439 | const broker = yield* makeBroker; |
| 440 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 441 | yield* Stream.runForEach(requests, (request) => |
| 442 | broker.respond({ |
| 443 | clientId: "client-1", |
| 444 | connectionId: request.connectionId, |
| 445 | requestId: request.requestId, |
| 446 | ok: false, |
| 447 | error: remoteError, |
| 448 | }), |
| 449 | ).pipe(Effect.forkScoped); |
| 450 | yield* Effect.yieldNow; |
| 451 | |
| 452 | const error = yield* broker |
| 453 | .invoke<void>({ |
| 454 | scope, |
| 455 | operation: "type", |
| 456 | input: { text: "hello" }, |
| 457 | tabId: PreviewTabId.make("tab-1"), |
| 458 | }) |
| 459 | .pipe(Effect.flip); |
| 460 | |
| 461 | expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError); |
| 462 | expect(error).toMatchObject({ |
| 463 | operation: "type", |
| 464 | tabId: "tab-1", |
| 465 | selectorKind: "focused-element", |
| 466 | remoteTag: "PreviewAutomationTargetNotEditableError", |
| 467 | }); |
| 468 | expect(error.message).toBe("Preview automation type requires an editable focused element."); |
| 469 | }), |
| 470 | ); |
| 471 | }); |
| 472 | |
| 473 | it.effect.each([ |
| 474 | "PreviewAutomationRecordingTransferError", |
| 475 | "PreviewAutomationRecordingDesktopUpdateRequiredError", |
| 476 | "PreviewAutomationRecordingTooLargeError", |
| 477 | "PreviewAutomationRecordingDeadlineExpiredError", |
| 478 | ] as const)("preserves recording failure %s", (tag) => |
| 479 | Effect.scoped( |
| 480 | Effect.gen(function* () { |
| 481 | const broker = yield* makeBroker; |
| 482 | const remoteError = { |
| 483 | _tag: tag, |
| 484 | message: "remote recording details", |
| 485 | detail: { reason: "untrusted-reason", threadId: "untrusted-thread" }, |
| 486 | }; |
| 487 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 488 | yield* Stream.runForEach(requests, (request) => |
| 489 | broker.respond({ |
| 490 | clientId: "client-1", |
| 491 | connectionId: request.connectionId, |
| 492 | requestId: request.requestId, |
| 493 | ok: false, |
| 494 | error: remoteError, |
| 495 | }), |
| 496 | ).pipe(Effect.forkScoped); |
| 497 | yield* Effect.yieldNow; |
| 498 | const error = yield* broker |
| 499 | .invoke<void>({ |
| 500 | scope, |
| 501 | operation: "recordingStop", |
| 502 | input: {}, |
| 503 | }) |
| 504 | .pipe(Effect.flip); |
| 505 | expect(error).toMatchObject({ |
| 506 | _tag: tag, |
| 507 | threadId: scope.thread.threadId, |
| 508 | }); |
| 509 | expect(error.cause).toBe(remoteError); |
| 510 | expect(error.message).not.toContain("remote recording details"); |
| 511 | }), |
| 512 | ), |
| 513 | ); |
| 514 | |
| 515 | it.effect.each([ |
| 516 | { clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true }, |
| 517 | { clientId: "client-1", shown: false }, |
| 518 | ])("tells the agent why its own server browser failed ($clientId)", ({ clientId, shown }) => |
| 519 | Effect.scoped( |
| 520 | Effect.gen(function* () { |
| 521 | const broker = yield* makeBroker; |
| 522 | const requests = requestsFrom(yield* broker.connect(makeHost({ clientId }))); |
| 523 | yield* Stream.runForEach(requests, (request) => |
| 524 | broker.respond({ |
| 525 | clientId, |
| 526 | connectionId: request.connectionId, |
| 527 | requestId: request.requestId, |
| 528 | ok: false, |
| 529 | error: { |
| 530 | _tag: "PreviewAutomationExecutionError", |
| 531 | message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", |
| 532 | }, |
| 533 | }), |
| 534 | ).pipe(Effect.forkScoped); |
| 535 | yield* Effect.yieldNow; |
| 536 | const error = yield* broker |
| 537 | .invoke<void>({ scope, operation: "open", input: {} }) |
| 538 | .pipe(Effect.flip); |
| 539 | expect(error._tag).toBe("PreviewAutomationExecutionError"); |
| 540 | // A desktop or other remote host's text stays out of the agent's context. |
| 541 | expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown); |
| 542 | }), |
| 543 | ), |
| 544 | ); |
| 545 | |
| 546 | it.effect("distinguishes malformed remote failures", () => |
| 547 | Effect.scoped( |
| 548 | Effect.gen(function* () { |
| 549 | const broker = yield* makeBroker; |
| 550 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 551 | yield* Stream.runForEach(requests, (request) => |
| 552 | broker.respond({ |
| 553 | clientId: "client-1", |
| 554 | connectionId: request.connectionId, |
| 555 | requestId: request.requestId, |
| 556 | ok: false, |
| 557 | }), |
| 558 | ).pipe(Effect.forkScoped); |
| 559 | yield* Effect.yieldNow; |
| 560 | |
| 561 | const error = yield* broker |
| 562 | .invoke<void>({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) |
| 563 | .pipe(Effect.flip); |
| 564 | |
| 565 | expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); |
| 566 | expect(error).toMatchObject({ |
| 567 | operation: "status", |
| 568 | environmentId: scope.environmentId, |
| 569 | threadId: scope.thread.threadId, |
| 570 | providerSessionId: scope.thread.providerSessionId, |
| 571 | providerInstanceId: scope.thread.providerInstanceId, |
| 572 | clientId: "client-1", |
| 573 | requestId: "preview-0", |
| 574 | timeoutMs: 2_000, |
| 575 | }); |
| 576 | }), |
| 577 | ), |
| 578 | ); |
| 579 | |
| 580 | it.effect("rejects calls when no connected host exists", () => |
| 581 | Effect.gen(function* () { |
| 582 | const broker = yield* makeBroker; |
| 583 | const error = yield* broker |
| 584 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 585 | .pipe(Effect.flip); |
| 586 | |
| 587 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 588 | expect(error).toMatchObject({ |
| 589 | operation: "status", |
| 590 | environmentId: scope.environmentId, |
| 591 | threadId: scope.thread.threadId, |
| 592 | providerSessionId: scope.thread.providerSessionId, |
| 593 | providerInstanceId: scope.thread.providerInstanceId, |
| 594 | }); |
| 595 | }), |
| 596 | ); |
| 597 | |
| 598 | it.effect("does not create host state from focus updates without a live stream", () => |
| 599 | Effect.gen(function* () { |
| 600 | const broker = yield* makeBroker; |
| 601 | yield* broker.focusHost({ |
| 602 | clientId: "client-1", |
| 603 | environmentId: scope.environmentId, |
| 604 | connectionId: "connection-missing", |
| 605 | focused: true, |
| 606 | }); |
| 607 | |
| 608 | const error = yield* broker |
| 609 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 610 | .pipe(Effect.flip); |
| 611 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 612 | }), |
| 613 | ); |
| 614 | |
| 615 | it.effect("removes host availability when the authoritative request stream disconnects", () => |
| 616 | Effect.scoped( |
| 617 | Effect.gen(function* () { |
| 618 | const broker = yield* makeBroker; |
| 619 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 620 | const beforeAcquisition = yield* broker |
| 621 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 622 | .pipe(Effect.flip); |
| 623 | expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 624 | |
| 625 | const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); |
| 626 | yield* Effect.yieldNow; |
| 627 | yield* Fiber.interrupt(consumer); |
| 628 | |
| 629 | const error = yield* broker |
| 630 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 631 | .pipe(Effect.flip); |
| 632 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 633 | }), |
| 634 | ), |
| 635 | ); |
| 636 | |
| 637 | it.effect("routes requests for background threads through an environment-level host", () => |
| 638 | Effect.scoped( |
| 639 | Effect.gen(function* () { |
| 640 | const broker = yield* makeBroker; |
| 641 | const backgroundThreadId = ThreadId.make("thread-background"); |
| 642 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 643 | let routedThreadId: string | undefined; |
| 644 | yield* Stream.runForEach(requests, (request) => { |
| 645 | routedThreadId = request.threadId; |
| 646 | return broker.respond({ |
| 647 | clientId: "client-1", |
| 648 | connectionId: request.connectionId, |
| 649 | requestId: request.requestId, |
| 650 | ok: true, |
| 651 | result: "background", |
| 652 | }); |
| 653 | }).pipe(Effect.forkScoped); |
| 654 | yield* Effect.yieldNow; |
| 655 | |
| 656 | const result = yield* broker.invoke<string>({ |
| 657 | scope: { |
| 658 | ...scope, |
| 659 | thread: { |
| 660 | ...scope.thread, |
| 661 | threadId: backgroundThreadId, |
| 662 | providerSessionId: "provider-session-background", |
| 663 | }, |
| 664 | }, |
| 665 | operation: "status", |
| 666 | input: {}, |
| 667 | }); |
| 668 | |
| 669 | expect(result).toBe("background"); |
| 670 | expect(routedThreadId).toBe(backgroundThreadId); |
| 671 | }), |
| 672 | ), |
| 673 | ); |
| 674 | |
| 675 | it.effect("never routes a provider session to a host from another environment", () => |
| 676 | Effect.scoped( |
| 677 | Effect.gen(function* () { |
| 678 | const broker = yield* makeBroker; |
| 679 | const matchingRequests = requestsFrom( |
| 680 | yield* broker.connect(makeHost({ clientId: "client-matching" })), |
| 681 | ); |
| 682 | const foreignRequests = requestsFrom( |
| 683 | yield* broker.connect( |
| 684 | makeHost({ |
| 685 | clientId: "client-foreign", |
| 686 | environmentId: EnvironmentId.make("environment-foreign"), |
| 687 | }), |
| 688 | ), |
| 689 | ); |
| 690 | yield* Stream.runForEach(matchingRequests, (request) => |
| 691 | broker.respond({ |
| 692 | clientId: "client-matching", |
| 693 | connectionId: request.connectionId, |
| 694 | requestId: request.requestId, |
| 695 | ok: true, |
| 696 | result: "matching", |
| 697 | }), |
| 698 | ).pipe(Effect.forkScoped); |
| 699 | yield* Stream.runForEach(foreignRequests, (request) => |
| 700 | broker.respond({ |
| 701 | clientId: "client-foreign", |
| 702 | connectionId: request.connectionId, |
| 703 | requestId: request.requestId, |
| 704 | ok: true, |
| 705 | result: "foreign", |
| 706 | }), |
| 707 | ).pipe(Effect.forkScoped); |
| 708 | yield* Effect.yieldNow; |
| 709 | |
| 710 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 711 | "matching", |
| 712 | ); |
| 713 | }), |
| 714 | ), |
| 715 | ); |
| 716 | |
| 717 | it.effect("pins a provider session to its initial host despite later focus changes", () => |
| 718 | Effect.scoped( |
| 719 | Effect.gen(function* () { |
| 720 | const broker = yield* makeBroker; |
| 721 | let firstConnectionId = ""; |
| 722 | let secondConnectionId = ""; |
| 723 | const firstRequests = requestsFrom( |
| 724 | yield* broker.connect(makeHost({ clientId: "client-first" })), |
| 725 | (connectionId) => { |
| 726 | firstConnectionId = connectionId; |
| 727 | }, |
| 728 | ); |
| 729 | const secondRequests = requestsFrom( |
| 730 | yield* broker.connect(makeHost({ clientId: "client-second" })), |
| 731 | (connectionId) => { |
| 732 | secondConnectionId = connectionId; |
| 733 | }, |
| 734 | ); |
| 735 | yield* Stream.runForEach(firstRequests, (request) => |
| 736 | broker.respond({ |
| 737 | clientId: "client-first", |
| 738 | connectionId: request.connectionId, |
| 739 | requestId: request.requestId, |
| 740 | ok: true, |
| 741 | result: "first", |
| 742 | }), |
| 743 | ).pipe(Effect.forkScoped); |
| 744 | yield* Stream.runForEach(secondRequests, (request) => |
| 745 | broker.respond({ |
| 746 | clientId: "client-second", |
| 747 | connectionId: request.connectionId, |
| 748 | requestId: request.requestId, |
| 749 | ok: true, |
| 750 | result: "second", |
| 751 | }), |
| 752 | ).pipe(Effect.forkScoped); |
| 753 | yield* Effect.yieldNow; |
| 754 | |
| 755 | yield* broker.focusHost({ |
| 756 | clientId: "client-first", |
| 757 | environmentId: scope.environmentId, |
| 758 | connectionId: "connection-stale", |
| 759 | focused: true, |
| 760 | liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }], |
| 761 | }); |
| 762 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 763 | "second", |
| 764 | ); |
| 765 | yield* broker.focusHost({ |
| 766 | clientId: "client-first", |
| 767 | environmentId: scope.environmentId, |
| 768 | connectionId: firstConnectionId, |
| 769 | focused: true, |
| 770 | }); |
| 771 | |
| 772 | const firstPinnedScope = { |
| 773 | ...scope, |
| 774 | thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" }, |
| 775 | }; |
| 776 | expect( |
| 777 | yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }), |
| 778 | ).toBe("first"); |
| 779 | |
| 780 | yield* broker.focusHost({ |
| 781 | clientId: "client-second", |
| 782 | environmentId: scope.environmentId, |
| 783 | connectionId: secondConnectionId, |
| 784 | focused: true, |
| 785 | }); |
| 786 | |
| 787 | expect( |
| 788 | yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }), |
| 789 | ).toBe("first"); |
| 790 | expect( |
| 791 | yield* broker.invoke<string>({ |
| 792 | scope: { |
| 793 | ...scope, |
| 794 | thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" }, |
| 795 | }, |
| 796 | operation: "status", |
| 797 | input: {}, |
| 798 | }), |
| 799 | ).toBe("second"); |
| 800 | }), |
| 801 | ), |
| 802 | ); |
| 803 | |
| 804 | it.effect("prefers the live tab owner for new sessions without moving existing leases", () => |
| 805 | Effect.scoped( |
| 806 | Effect.gen(function* () { |
| 807 | const broker = yield* makeBroker; |
| 808 | const connections = new Map<string, string>(); |
| 809 | for (const clientId of ["owner", "other"]) { |
| 810 | const requests = requestsFrom( |
| 811 | yield* broker.connect(makeHost({ clientId })), |
| 812 | (connectionId) => connections.set(clientId, connectionId), |
| 813 | ); |
| 814 | yield* Stream.runForEach(requests, (request) => |
| 815 | broker.respond({ |
| 816 | clientId, |
| 817 | connectionId: request.connectionId, |
| 818 | requestId: request.requestId, |
| 819 | ok: true, |
| 820 | result: clientId, |
| 821 | }), |
| 822 | ).pipe(Effect.forkScoped); |
| 823 | } |
| 824 | yield* Effect.yieldNow; |
| 825 | yield* broker.focusHost({ |
| 826 | clientId: "owner", |
| 827 | environmentId: scope.environmentId, |
| 828 | connectionId: connections.get("owner")!, |
| 829 | focused: false, |
| 830 | liveTabs: [ |
| 831 | { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, |
| 832 | ], |
| 833 | }); |
| 834 | yield* broker.focusHost({ |
| 835 | clientId: "other", |
| 836 | environmentId: scope.environmentId, |
| 837 | connectionId: connections.get("other")!, |
| 838 | focused: true, |
| 839 | liveTabs: [ |
| 840 | { |
| 841 | threadId: scope.thread.threadId, |
| 842 | tabId: PreviewTabId.make("signed-in"), |
| 843 | visible: false, |
| 844 | }, |
| 845 | { |
| 846 | threadId: ThreadId.make("another-thread"), |
| 847 | tabId: PreviewTabId.make("different-tab"), |
| 848 | visible: true, |
| 849 | }, |
| 850 | ], |
| 851 | }); |
| 852 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( |
| 853 | "owner", |
| 854 | ); |
| 855 | expect( |
| 856 | yield* broker.invoke<string>({ |
| 857 | scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } }, |
| 858 | tabId: PreviewTabId.make("signed-in"), |
| 859 | operation: "snapshot", |
| 860 | input: {}, |
| 861 | }), |
| 862 | ).toBe("owner"); |
| 863 | expect( |
| 864 | yield* broker.invoke<string>({ |
| 865 | scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } }, |
| 866 | tabId: PreviewTabId.make("different-tab"), |
| 867 | operation: "evaluate", |
| 868 | input: {}, |
| 869 | }), |
| 870 | ).toBe("other"); |
| 871 | |
| 872 | yield* broker.focusHost({ |
| 873 | clientId: "owner", |
| 874 | environmentId: scope.environmentId, |
| 875 | connectionId: connections.get("owner")!, |
| 876 | focused: false, |
| 877 | liveTabs: [], |
| 878 | }); |
| 879 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( |
| 880 | "owner", |
| 881 | ); |
| 882 | expect( |
| 883 | yield* broker.invoke<string>({ |
| 884 | scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } }, |
| 885 | operation: "evaluate", |
| 886 | input: {}, |
| 887 | }), |
| 888 | ).toBe("other"); |
| 889 | }), |
| 890 | ), |
| 891 | ); |
| 892 | |
| 893 | it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => |
| 894 | Effect.scoped( |
| 895 | Effect.gen(function* () { |
| 896 | const broker = yield* makeBroker; |
| 897 | let focusedConnectionId = ""; |
| 898 | for (const [clientId, supportedOperations] of [ |
| 899 | ["focused", ["status"]], |
| 900 | ["background", ["status", "resize"]], |
| 901 | ] as const) { |
| 902 | const requests = requestsFrom( |
| 903 | yield* broker.connect(makeHost({ clientId, supportedOperations })), |
| 904 | (connectionId) => { |
| 905 | if (clientId === "focused") focusedConnectionId = connectionId; |
| 906 | }, |
| 907 | ); |
| 908 | yield* Stream.runForEach(requests, (request) => |
| 909 | broker.respond({ |
| 910 | clientId, |
| 911 | connectionId: request.connectionId, |
| 912 | requestId: request.requestId, |
| 913 | ok: true, |
| 914 | result: clientId, |
| 915 | }), |
| 916 | ).pipe(Effect.forkScoped); |
| 917 | } |
| 918 | yield* Effect.yieldNow; |
| 919 | yield* broker.focusHost({ |
| 920 | clientId: "focused", |
| 921 | environmentId: scope.environmentId, |
| 922 | connectionId: focusedConnectionId, |
| 923 | focused: true, |
| 924 | }); |
| 925 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 926 | "focused", |
| 927 | ); |
| 928 | }), |
| 929 | ), |
| 930 | ); |
| 931 | |
| 932 | it.effect("does not route new operations to legacy hosts that did not advertise support", () => |
| 933 | Effect.scoped( |
| 934 | Effect.gen(function* () { |
| 935 | const broker = yield* makeBroker; |
| 936 | const legacyEvents = yield* broker.connect(makeHost()); |
| 937 | yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped); |
| 938 | yield* Effect.yieldNow; |
| 939 | |
| 940 | const error = yield* broker |
| 941 | .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } }) |
| 942 | .pipe(Effect.flip); |
| 943 | |
| 944 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 945 | expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId }); |
| 946 | }), |
| 947 | ), |
| 948 | ); |
| 949 | |
| 950 | it.effect("routes resize to a capable host instead of a newer legacy connection", () => |
| 951 | Effect.scoped( |
| 952 | Effect.gen(function* () { |
| 953 | const broker = yield* makeBroker; |
| 954 | const capableRequests = requestsFrom( |
| 955 | yield* broker.connect( |
| 956 | makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), |
| 957 | ), |
| 958 | ); |
| 959 | const legacyRequests = requestsFrom( |
| 960 | yield* broker.connect(makeHost({ clientId: "client-legacy" })), |
| 961 | ); |
| 962 | yield* Stream.runForEach(capableRequests, (request) => |
| 963 | broker.respond({ |
| 964 | clientId: "client-capable", |
| 965 | connectionId: request.connectionId, |
| 966 | requestId: request.requestId, |
| 967 | ok: true, |
| 968 | result: "capable", |
| 969 | }), |
| 970 | ).pipe(Effect.forkScoped); |
| 971 | yield* Stream.runForEach(legacyRequests, (request) => |
| 972 | broker.respond({ |
| 973 | clientId: "client-legacy", |
| 974 | connectionId: request.connectionId, |
| 975 | requestId: request.requestId, |
| 976 | ok: true, |
| 977 | result: "legacy", |
| 978 | }), |
| 979 | ).pipe(Effect.forkScoped); |
| 980 | yield* Effect.yieldNow; |
| 981 | |
| 982 | expect( |
| 983 | yield* broker.invoke<string>({ scope, operation: "resize", input: { mode: "fill" } }), |
| 984 | ).toBe("capable"); |
| 985 | }), |
| 986 | ), |
| 987 | ); |
| 988 | |
| 989 | it.effect("does not move a live legacy assignment to another runtime for resize", () => |
| 990 | Effect.scoped( |
| 991 | Effect.gen(function* () { |
| 992 | const broker = yield* makeBroker; |
| 993 | const legacyRequests = requestsFrom( |
| 994 | yield* broker.connect(makeHost({ clientId: "client-legacy" })), |
| 995 | ); |
| 996 | yield* Stream.runForEach(legacyRequests, (request) => |
| 997 | broker.respond({ |
| 998 | clientId: "client-legacy", |
| 999 | connectionId: request.connectionId, |
| 1000 | requestId: request.requestId, |
| 1001 | ok: true, |
| 1002 | result: "legacy", |
| 1003 | }), |
| 1004 | ).pipe(Effect.forkScoped); |
| 1005 | yield* Effect.yieldNow; |
| 1006 | |
| 1007 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 1008 | "legacy", |
| 1009 | ); |
| 1010 | |
| 1011 | const capableRequests = requestsFrom( |
| 1012 | yield* broker.connect( |
| 1013 | makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), |
| 1014 | ), |
| 1015 | ); |
| 1016 | yield* Stream.runForEach(capableRequests, (request) => |
| 1017 | broker.respond({ |
| 1018 | clientId: "client-capable", |
| 1019 | connectionId: request.connectionId, |
| 1020 | requestId: request.requestId, |
| 1021 | ok: true, |
| 1022 | result: "capable", |
| 1023 | }), |
| 1024 | ).pipe(Effect.forkScoped); |
| 1025 | yield* Effect.yieldNow; |
| 1026 | |
| 1027 | const error = yield* broker |
| 1028 | .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } }) |
| 1029 | .pipe(Effect.flip); |
| 1030 | expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); |
| 1031 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 1032 | "legacy", |
| 1033 | ); |
| 1034 | }), |
| 1035 | ), |
| 1036 | ); |
| 1037 | |
| 1038 | it.effect("ignores stale focus updates for a different environment", () => |
| 1039 | Effect.scoped( |
| 1040 | Effect.gen(function* () { |
| 1041 | const broker = yield* makeBroker; |
| 1042 | let firstConnectionId = ""; |
| 1043 | const firstRequests = requestsFrom( |
| 1044 | yield* broker.connect(makeHost({ clientId: "client-first" })), |
| 1045 | (connectionId) => { |
| 1046 | firstConnectionId = connectionId; |
| 1047 | }, |
| 1048 | ); |
| 1049 | const secondRequests = requestsFrom( |
| 1050 | yield* broker.connect(makeHost({ clientId: "client-second" })), |
| 1051 | ); |
| 1052 | yield* Stream.runForEach(firstRequests, (request) => |
| 1053 | broker.respond({ |
| 1054 | clientId: "client-first", |
| 1055 | connectionId: request.connectionId, |
| 1056 | requestId: request.requestId, |
| 1057 | ok: true, |
| 1058 | result: "first", |
| 1059 | }), |
| 1060 | ).pipe(Effect.forkScoped); |
| 1061 | yield* Stream.runForEach(secondRequests, (request) => |
| 1062 | broker.respond({ |
| 1063 | clientId: "client-second", |
| 1064 | connectionId: request.connectionId, |
| 1065 | requestId: request.requestId, |
| 1066 | ok: true, |
| 1067 | result: "second", |
| 1068 | }), |
| 1069 | ).pipe(Effect.forkScoped); |
| 1070 | yield* Effect.yieldNow; |
| 1071 | |
| 1072 | yield* broker.focusHost({ |
| 1073 | clientId: "client-first", |
| 1074 | environmentId: EnvironmentId.make("environment-stale"), |
| 1075 | connectionId: firstConnectionId, |
| 1076 | focused: true, |
| 1077 | }); |
| 1078 | |
| 1079 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 1080 | "second", |
| 1081 | ); |
| 1082 | }), |
| 1083 | ), |
| 1084 | ); |
| 1085 | |
| 1086 | it.effect("fails over a pinned provider session only after its host disconnects", () => |
| 1087 | Effect.scoped( |
| 1088 | Effect.gen(function* () { |
| 1089 | const broker = yield* makeBroker; |
| 1090 | const firstTabId = PreviewTabId.make("tab-on-first-host"); |
| 1091 | let firstConnectionId = ""; |
| 1092 | let secondRoutedTabId: PreviewTabId | undefined; |
| 1093 | const firstRequests = requestsFrom( |
| 1094 | yield* broker.connect(makeHost({ clientId: "client-first" })), |
| 1095 | (connectionId) => { |
| 1096 | firstConnectionId = connectionId; |
| 1097 | }, |
| 1098 | ); |
| 1099 | const secondRequests = requestsFrom( |
| 1100 | yield* broker.connect(makeHost({ clientId: "client-second" })), |
| 1101 | ); |
| 1102 | const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => |
| 1103 | broker.respond({ |
| 1104 | clientId: "client-first", |
| 1105 | connectionId: request.connectionId, |
| 1106 | requestId: request.requestId, |
| 1107 | ok: true, |
| 1108 | result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", |
| 1109 | }), |
| 1110 | ).pipe(Effect.forkScoped); |
| 1111 | yield* Stream.runForEach(secondRequests, (request) => { |
| 1112 | secondRoutedTabId = request.tabId; |
| 1113 | return broker.respond({ |
| 1114 | clientId: "client-second", |
| 1115 | connectionId: request.connectionId, |
| 1116 | requestId: request.requestId, |
| 1117 | ok: true, |
| 1118 | result: "second", |
| 1119 | }); |
| 1120 | }).pipe(Effect.forkScoped); |
| 1121 | yield* Effect.yieldNow; |
| 1122 | |
| 1123 | yield* broker.focusHost({ |
| 1124 | clientId: "client-first", |
| 1125 | environmentId: scope.environmentId, |
| 1126 | connectionId: firstConnectionId, |
| 1127 | focused: true, |
| 1128 | liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }], |
| 1129 | }); |
| 1130 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ |
| 1131 | host: "first", |
| 1132 | tabId: firstTabId, |
| 1133 | }); |
| 1134 | |
| 1135 | yield* Fiber.interrupt(firstConsumer); |
| 1136 | yield* Effect.yieldNow; |
| 1137 | |
| 1138 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 1139 | "second", |
| 1140 | ); |
| 1141 | expect(secondRoutedTabId).toBeUndefined(); |
| 1142 | }), |
| 1143 | ), |
| 1144 | ); |
| 1145 | |
| 1146 | it.effect("lets the browser host resolve an active tab locally", () => |
| 1147 | Effect.scoped( |
| 1148 | Effect.gen(function* () { |
| 1149 | const broker = yield* makeBroker; |
| 1150 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1151 | let routedTabId: string | undefined; |
| 1152 | yield* Stream.runForEach(requests, (request) => { |
| 1153 | routedTabId = request.tabId; |
| 1154 | return broker.respond({ |
| 1155 | clientId: "client-1", |
| 1156 | connectionId: request.connectionId, |
| 1157 | requestId: request.requestId, |
| 1158 | ok: true, |
| 1159 | }); |
| 1160 | }).pipe(Effect.forkScoped); |
| 1161 | yield* Effect.yieldNow; |
| 1162 | |
| 1163 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); |
| 1164 | |
| 1165 | expect(routedTabId).toBeUndefined(); |
| 1166 | }), |
| 1167 | ), |
| 1168 | ); |
| 1169 | |
| 1170 | it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => |
| 1171 | Effect.scoped( |
| 1172 | Effect.gen(function* () { |
| 1173 | const broker = yield* makeBroker; |
| 1174 | let firstConnectionId = ""; |
| 1175 | let replacementConnectionId = ""; |
| 1176 | const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { |
| 1177 | firstConnectionId = connectionId; |
| 1178 | }); |
| 1179 | yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); |
| 1180 | yield* Effect.yieldNow; |
| 1181 | |
| 1182 | const replacementRequests = requestsFrom( |
| 1183 | yield* broker.connect(makeHost()), |
| 1184 | (connectionId) => { |
| 1185 | replacementConnectionId = connectionId; |
| 1186 | }, |
| 1187 | ); |
| 1188 | yield* Stream.runForEach(replacementRequests, (request) => |
| 1189 | broker.respond({ |
| 1190 | clientId: "client-1", |
| 1191 | connectionId: request.connectionId, |
| 1192 | requestId: request.requestId, |
| 1193 | ok: true, |
| 1194 | result: "replacement", |
| 1195 | }), |
| 1196 | ).pipe(Effect.forkScoped); |
| 1197 | yield* Effect.yieldNow; |
| 1198 | |
| 1199 | expect(replacementConnectionId).not.toBe(firstConnectionId); |
| 1200 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); |
| 1201 | expect(result).toBe("replacement"); |
| 1202 | }), |
| 1203 | ), |
| 1204 | ); |
| 1205 | |
| 1206 | it.effect("does not carry a tab id across a replacement automation stream", () => |
| 1207 | Effect.scoped( |
| 1208 | Effect.gen(function* () { |
| 1209 | const broker = yield* makeBroker; |
| 1210 | const openedTabId = PreviewTabId.make("tab-first-webcontents"); |
| 1211 | const firstRequests = requestsFrom(yield* broker.connect(makeHost())); |
| 1212 | yield* Stream.runForEach(firstRequests, (request) => |
| 1213 | broker.respond({ |
| 1214 | clientId: "client-1", |
| 1215 | connectionId: request.connectionId, |
| 1216 | requestId: request.requestId, |
| 1217 | ok: true, |
| 1218 | result: |
| 1219 | request.operation === "open" |
| 1220 | ? { host: "first", tabId: openedTabId } |
| 1221 | : { host: "first" }, |
| 1222 | }), |
| 1223 | ).pipe(Effect.forkScoped); |
| 1224 | yield* Effect.yieldNow; |
| 1225 | |
| 1226 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ |
| 1227 | host: "first", |
| 1228 | tabId: openedTabId, |
| 1229 | }); |
| 1230 | |
| 1231 | const routedRequests: RoutedRequest[] = []; |
| 1232 | const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); |
| 1233 | yield* Stream.runForEach(replacementRequests, (request) => { |
| 1234 | routedRequests.push(request); |
| 1235 | return broker.respond({ |
| 1236 | clientId: "client-1", |
| 1237 | connectionId: request.connectionId, |
| 1238 | requestId: request.requestId, |
| 1239 | ok: true, |
| 1240 | result: "replacement", |
| 1241 | }); |
| 1242 | }).pipe(Effect.forkScoped); |
| 1243 | yield* Effect.yieldNow; |
| 1244 | |
| 1245 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 1246 | "replacement", |
| 1247 | ); |
| 1248 | expect(routedRequests.at(-1)?.tabId).toBeUndefined(); |
| 1249 | }), |
| 1250 | ), |
| 1251 | ); |
| 1252 | |
| 1253 | it.effect("fails requests assigned to the stream that is replaced", () => |
| 1254 | Effect.scoped( |
| 1255 | Effect.gen(function* () { |
| 1256 | const broker = yield* makeBroker; |
| 1257 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1258 | yield* Stream.runDrain(requests).pipe(Effect.forkScoped); |
| 1259 | const pending = yield* broker |
| 1260 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 1261 | .pipe(Effect.flip, Effect.forkScoped); |
| 1262 | yield* Effect.yieldNow; |
| 1263 | |
| 1264 | const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); |
| 1265 | yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); |
| 1266 | |
| 1267 | const error = yield* Fiber.join(pending); |
| 1268 | expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); |
| 1269 | expect(error).toMatchObject({ |
| 1270 | operation: "status", |
| 1271 | environmentId: scope.environmentId, |
| 1272 | threadId: scope.thread.threadId, |
| 1273 | providerSessionId: scope.thread.providerSessionId, |
| 1274 | providerInstanceId: scope.thread.providerInstanceId, |
| 1275 | clientId: "client-1", |
| 1276 | requestId: "preview-0", |
| 1277 | timeoutMs: 15_000, |
| 1278 | }); |
| 1279 | }), |
| 1280 | ), |
| 1281 | ); |
| 1282 | |
| 1283 | it.effect("accepts responses only from the host that received the request", () => |
| 1284 | Effect.scoped( |
| 1285 | Effect.gen(function* () { |
| 1286 | const broker = yield* makeBroker; |
| 1287 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1288 | yield* Stream.runForEach(requests, (request) => |
| 1289 | Effect.gen(function* () { |
| 1290 | yield* broker.respond({ |
| 1291 | clientId: "client-foreign", |
| 1292 | connectionId: request.connectionId, |
| 1293 | requestId: request.requestId, |
| 1294 | ok: true, |
| 1295 | result: "foreign", |
| 1296 | }); |
| 1297 | yield* broker.respond({ |
| 1298 | clientId: "client-1", |
| 1299 | connectionId: "connection-stale", |
| 1300 | requestId: request.requestId, |
| 1301 | ok: true, |
| 1302 | result: "stale", |
| 1303 | }); |
| 1304 | yield* broker.respond({ |
| 1305 | clientId: "client-1", |
| 1306 | connectionId: request.connectionId, |
| 1307 | requestId: request.requestId, |
| 1308 | ok: true, |
| 1309 | result: "owner", |
| 1310 | }); |
| 1311 | }), |
| 1312 | ).pipe(Effect.forkScoped); |
| 1313 | yield* Effect.yieldNow; |
| 1314 | |
| 1315 | const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} }); |
| 1316 | expect(result).toBe("owner"); |
| 1317 | }), |
| 1318 | ), |
| 1319 | ); |
| 1320 | |
| 1321 | it.effect("discards buffered actions before completing an evicted host stream", () => |
| 1322 | Effect.scoped( |
| 1323 | Effect.gen(function* () { |
| 1324 | const broker = yield* makeBroker; |
| 1325 | const connected = yield* Deferred.make<void>(); |
| 1326 | const received = yield* Deferred.make<void>(); |
| 1327 | const actionRouted = yield* Deferred.make<void>(); |
| 1328 | const releaseConsumer = yield* Deferred.make<void>(); |
| 1329 | const operations: string[] = []; |
| 1330 | const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => { |
| 1331 | if (event.type === "connected") return Deferred.succeed(connected, undefined); |
| 1332 | operations.push(event.request.operation); |
| 1333 | return Deferred.succeed(received, undefined).pipe( |
| 1334 | Effect.andThen(Deferred.await(releaseConsumer)), |
| 1335 | ); |
| 1336 | }).pipe(Effect.forkScoped); |
| 1337 | yield* Deferred.await(connected); |
| 1338 | const timedOut = yield* broker |
| 1339 | .invoke<void>({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 }) |
| 1340 | .pipe(Effect.flip, Effect.forkScoped); |
| 1341 | yield* Deferred.await(received); |
| 1342 | const buffered = yield* broker |
| 1343 | .invoke<void>({ |
| 1344 | scope, |
| 1345 | operation: "click", |
| 1346 | input: {}, |
| 1347 | timeoutMs: 10_000, |
| 1348 | onTargetTab: () => { |
| 1349 | Deferred.doneUnsafe(actionRouted, Effect.void); |
| 1350 | }, |
| 1351 | }) |
| 1352 | .pipe(Effect.flip, Effect.forkScoped); |
| 1353 | yield* Deferred.await(actionRouted); |
| 1354 | yield* TestClock.adjust(2_000); |
| 1355 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1356 | expect(yield* Fiber.join(buffered)).toMatchObject({ |
| 1357 | _tag: "PreviewAutomationClientDisconnectedError", |
| 1358 | }); |
| 1359 | yield* Deferred.succeed(releaseConsumer, undefined); |
| 1360 | expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); |
| 1361 | expect(operations).toEqual(["snapshot"]); |
| 1362 | }), |
| 1363 | ), |
| 1364 | ); |
| 1365 | |
| 1366 | it.effect("rejects a routed action when its generation is evicted before delivery", () => |
| 1367 | Effect.scoped( |
| 1368 | Effect.gen(function* () { |
| 1369 | const broker = yield* makeBroker; |
| 1370 | const connected = yield* Deferred.make<void>(); |
| 1371 | const received = yield* Deferred.make<void>(); |
| 1372 | const actionRouted = yield* Deferred.make<void>(); |
| 1373 | const operations: string[] = []; |
| 1374 | const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => { |
| 1375 | if (event.type === "connected") return Deferred.succeed(connected, undefined); |
| 1376 | operations.push(event.request.operation); |
| 1377 | return Deferred.succeed(received, undefined); |
| 1378 | }).pipe(Effect.forkScoped); |
| 1379 | yield* Deferred.await(connected); |
| 1380 | const timedOut = yield* broker |
| 1381 | .invoke<void>({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 }) |
| 1382 | .pipe(Effect.flip, Effect.forkScoped); |
| 1383 | yield* Deferred.await(received); |
| 1384 | |
| 1385 | // Suspend only this invocation in the gap between route selection and delivery. |
| 1386 | const tasks: Array<() => void> = []; |
| 1387 | let paused = false; |
| 1388 | const dispatcher: Scheduler.SchedulerDispatcher = { |
| 1389 | scheduleTask: (task) => tasks.push(task), |
| 1390 | flush: () => { |
| 1391 | let task: (() => void) | undefined; |
| 1392 | while ((task = tasks.shift()) !== undefined) task(); |
| 1393 | }, |
| 1394 | }; |
| 1395 | const scheduler: Scheduler.Scheduler = { |
| 1396 | executionMode: "async", |
| 1397 | makeDispatcher: () => dispatcher, |
| 1398 | shouldYield: () => paused, |
| 1399 | }; |
| 1400 | const action = yield* broker |
| 1401 | .invoke<void>({ |
| 1402 | scope, |
| 1403 | operation: "click", |
| 1404 | input: {}, |
| 1405 | onTargetTab: () => { |
| 1406 | paused = true; |
| 1407 | Deferred.doneUnsafe(actionRouted, Effect.void); |
| 1408 | }, |
| 1409 | }) |
| 1410 | .pipe( |
| 1411 | Effect.flip, |
| 1412 | Effect.provideService(Scheduler.Scheduler, scheduler), |
| 1413 | Effect.forkScoped, |
| 1414 | ); |
| 1415 | yield* Deferred.await(actionRouted); |
| 1416 | yield* TestClock.adjust(2_000); |
| 1417 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1418 | expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); |
| 1419 | |
| 1420 | paused = false; |
| 1421 | dispatcher.flush(); |
| 1422 | expect(yield* Fiber.join(action)).toMatchObject({ |
| 1423 | _tag: "PreviewAutomationClientDisconnectedError", |
| 1424 | }); |
| 1425 | expect(operations).toEqual(["snapshot"]); |
| 1426 | }), |
| 1427 | ), |
| 1428 | ); |
| 1429 | |
| 1430 | it.effect("keeps a host whose operation timeout arrives just after the budget", () => |
| 1431 | Effect.scoped( |
| 1432 | Effect.gen(function* () { |
| 1433 | const broker = yield* makeBroker; |
| 1434 | const received = yield* Deferred.make<void>(); |
| 1435 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1436 | yield* Stream.runForEach(requests, (request) => |
| 1437 | Effect.gen(function* () { |
| 1438 | if (request.operation === "waitFor") { |
| 1439 | yield* Deferred.succeed(received, undefined); |
| 1440 | // The browser starts its own timer after delivery, so its timeout lands late. |
| 1441 | yield* Effect.sleep(request.timeoutMs + 50); |
| 1442 | } |
| 1443 | yield* broker.respond({ |
| 1444 | clientId: "client-1", |
| 1445 | connectionId: request.connectionId, |
| 1446 | requestId: request.requestId, |
| 1447 | ...(request.operation === "waitFor" |
| 1448 | ? { |
| 1449 | ok: false, |
| 1450 | error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, |
| 1451 | } |
| 1452 | : { ok: true, result: "responsive" }), |
| 1453 | }); |
| 1454 | }), |
| 1455 | ).pipe(Effect.forkScoped); |
| 1456 | const timedOut = yield* broker |
| 1457 | .invoke<void>({ scope, operation: "waitFor", input: {}, timeoutMs: 1_000 }) |
| 1458 | .pipe(Effect.flip, Effect.forkScoped); |
| 1459 | yield* Deferred.await(received); |
| 1460 | yield* TestClock.adjust(1_050); |
| 1461 | expect(yield* Fiber.join(timedOut)).toMatchObject({ |
| 1462 | _tag: "PreviewAutomationTimeoutError", |
| 1463 | remoteTag: "PreviewAutomationTimeoutError", |
| 1464 | }); |
| 1465 | expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive"); |
| 1466 | }), |
| 1467 | ), |
| 1468 | ); |
| 1469 | |
| 1470 | it.effect("keeps the host connected when a background status read times out", () => |
| 1471 | Effect.scoped( |
| 1472 | Effect.gen(function* () { |
| 1473 | const broker = yield* makeBroker; |
| 1474 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1475 | // A busy host answers its actions but not the metadata read behind them. |
| 1476 | yield* Stream.runForEach(requests, (request) => |
| 1477 | request.operation === "status" |
| 1478 | ? Effect.void |
| 1479 | : broker.respond({ |
| 1480 | clientId: "client-1", |
| 1481 | connectionId: request.connectionId, |
| 1482 | requestId: request.requestId, |
| 1483 | ok: true, |
| 1484 | result: { operation: request.operation }, |
| 1485 | }), |
| 1486 | ).pipe(Effect.forkScoped); |
| 1487 | yield* Effect.yieldNow; |
| 1488 | |
| 1489 | const status = yield* broker |
| 1490 | .invoke<void>({ |
| 1491 | scope, |
| 1492 | operation: "status", |
| 1493 | input: {}, |
| 1494 | timeoutMs: 500, |
| 1495 | updateCurrentTab: false, |
| 1496 | }) |
| 1497 | .pipe(Effect.flip, Effect.forkScoped); |
| 1498 | yield* TestClock.adjust(500); |
| 1499 | expect(yield* Fiber.join(status)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1500 | |
| 1501 | expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toEqual({ |
| 1502 | operation: "snapshot", |
| 1503 | }); |
| 1504 | }), |
| 1505 | ), |
| 1506 | ); |