| author | |
| committer | |
| log | fc2cdeceb9ddb28c0de834b9fcb38abf68d99148 |
| tree | 82c06d2faf1c657d442e28e4803a0e56a6a80144 |
| parent | c637dfc454342c156dbe61ee8513f5e0ac0c4691 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 470 insertions(+), 305 deletions(-)
apps/web/src/components/preview/PreviewAutomationOwner.tsx+161-238| ... | ... | @@ -3,19 +3,13 @@ |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | 5 | import { |
| 6 | EnvironmentId, | |
| 7 | 6 | type PreviewAutomationNavigateInput, |
| 8 | 7 | type PreviewAutomationOpenInput, |
| 9 | PreviewAutomationOperation, | |
| 10 | 8 | type PreviewAutomationOwner as PreviewAutomationOwnerState, |
| 11 | 9 | type PreviewAutomationRequest, |
| 12 | 10 | type PreviewAutomationStatus, |
| 13 | PreviewTabId, | |
| 14 | 11 | type ScopedThreadRef, |
| 15 | ThreadId, | |
| 16 | TrimmedNonEmptyString, | |
| 17 | 12 | } from "@t3tools/contracts"; |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | 13 | import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; |
| 20 | 14 | |
| 21 | 15 | import { |
| ... | ... | @@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments"; |
| 31 | 25 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 32 | 26 | |
| 33 | 27 | import { previewBridge } from "./previewBridge"; |
| 28 | import { | |
| 29 | PreviewAutomationNavigationTimeoutError, | |
| 30 | PreviewAutomationOperationError, | |
| 31 | PreviewAutomationOverlayTimeoutError, | |
| 32 | PreviewAutomationRecordingNotActiveError, | |
| 33 | PreviewAutomationStaleOwnerError, | |
| 34 | PreviewAutomationTargetUnavailableError, | |
| 35 | } from "./previewAutomationErrors"; | |
| 34 | 36 | import { |
| 35 | 37 | createLatestPreviewAutomationRequestHandler, |
| 36 | 38 | createPreviewAutomationRequestConsumerAtom, |
| 37 | 39 | } from "./previewAutomationRequestConsumer"; |
| 38 | 40 | |
| 39 | export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationOverlayTimeoutError>()( | |
| 40 | "PreviewAutomationOverlayTimeoutError", | |
| 41 | { | |
| 42 | requestId: TrimmedNonEmptyString, | |
| 43 | environmentId: EnvironmentId, | |
| 44 | threadId: ThreadId, | |
| 45 | timeoutMs: Schema.Int, | |
| 46 | }, | |
| 47 | ) { | |
| 48 | get responseTag() { | |
| 49 | return "PreviewAutomationTimeoutError"; | |
| 50 | } | |
| 51 | ||
| 52 | override get message(): string { | |
| 53 | return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationNavigationTimeoutError>()( | |
| 58 | "PreviewAutomationNavigationTimeoutError", | |
| 59 | { | |
| 60 | requestId: TrimmedNonEmptyString, | |
| 61 | environmentId: EnvironmentId, | |
| 62 | threadId: ThreadId, | |
| 63 | tabId: PreviewTabId, | |
| 64 | readiness: Schema.Literals(["domContentLoaded", "load"]), | |
| 65 | timeoutMs: Schema.Int, | |
| 66 | }, | |
| 67 | ) { | |
| 68 | get responseTag() { | |
| 69 | return "PreviewAutomationTimeoutError"; | |
| 70 | } | |
| 71 | ||
| 72 | override get message(): string { | |
| 73 | return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()( | |
| 78 | "PreviewAutomationStaleOwnerError", | |
| 79 | { | |
| 80 | requestId: TrimmedNonEmptyString, | |
| 81 | environmentId: EnvironmentId, | |
| 82 | expectedThreadId: ThreadId, | |
| 83 | requestedThreadId: ThreadId, | |
| 84 | }, | |
| 85 | ) { | |
| 86 | get responseTag() { | |
| 87 | return "PreviewAutomationUnavailableError"; | |
| 88 | } | |
| 89 | ||
| 90 | override get message(): string { | |
| 91 | return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationTargetUnavailableError>()( | |
| 96 | "PreviewAutomationTargetUnavailableError", | |
| 97 | { | |
| 98 | requestId: TrimmedNonEmptyString, | |
| 99 | operation: PreviewAutomationOperation, | |
| 100 | environmentId: EnvironmentId, | |
| 101 | threadId: ThreadId, | |
| 102 | tabId: Schema.NullOr(PreviewTabId), | |
| 103 | bridgeAvailable: Schema.Boolean, | |
| 104 | }, | |
| 105 | ) { | |
| 106 | get responseTag() { | |
| 107 | return "PreviewAutomationTabNotFoundError"; | |
| 108 | } | |
| 109 | ||
| 110 | override get message(): string { | |
| 111 | return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass<PreviewAutomationRecordingNotActiveError>()( | |
| 116 | "PreviewAutomationRecordingNotActiveError", | |
| 117 | { | |
| 118 | requestId: TrimmedNonEmptyString, | |
| 119 | environmentId: EnvironmentId, | |
| 120 | threadId: ThreadId, | |
| 121 | tabId: PreviewTabId, | |
| 122 | }, | |
| 123 | ) { | |
| 124 | get responseTag() { | |
| 125 | return "PreviewAutomationExecutionError"; | |
| 126 | } | |
| 127 | ||
| 128 | override get message(): string { | |
| 129 | return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | 41 | export function observeAutomationOwnerConnectedGeneration( |
| 134 | 42 | previousGeneration: number | null, |
| 135 | 43 | connectedGeneration: number | null, |
| ... | ... | @@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: { |
| 287 | 195 | |
| 288 | 196 | const handleRequest = useCallback( |
| 289 | 197 | async (request: PreviewAutomationRequest): Promise<unknown> => { |
| 290 | if (request.threadId !== threadRef.threadId) { | |
| 291 | throw new PreviewAutomationStaleOwnerError({ | |
| 198 | let tabId = request.tabId ?? null; | |
| 199 | try { | |
| 200 | if (request.threadId !== threadRef.threadId) { | |
| 201 | throw new PreviewAutomationStaleOwnerError({ | |
| 202 | requestId: request.requestId, | |
| 203 | environmentId: threadRef.environmentId, | |
| 204 | expectedThreadId: threadRef.threadId, | |
| 205 | requestedThreadId: request.threadId, | |
| 206 | }); | |
| 207 | } | |
| 208 | const state = readThreadPreviewState(threadRef); | |
| 209 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 210 | const unavailableTarget = { | |
| 292 | 211 | requestId: request.requestId, |
| 212 | operation: request.operation, | |
| 293 | 213 | environmentId: threadRef.environmentId, |
| 294 | expectedThreadId: threadRef.threadId, | |
| 295 | requestedThreadId: request.threadId, | |
| 296 | }); | |
| 297 | } | |
| 298 | const state = readThreadPreviewState(threadRef); | |
| 299 | const tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 300 | const unavailableTarget = { | |
| 301 | requestId: request.requestId, | |
| 302 | operation: request.operation, | |
| 303 | environmentId: threadRef.environmentId, | |
| 304 | threadId: threadRef.threadId, | |
| 305 | tabId, | |
| 306 | bridgeAvailable: Boolean(previewBridge), | |
| 307 | }; | |
| 308 | switch (request.operation) { | |
| 309 | case "status": | |
| 310 | return currentStatus(threadRef, visible); | |
| 311 | case "open": { | |
| 312 | const input = request.input as PreviewAutomationOpenInput; | |
| 313 | let activeTabId = | |
| 314 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 315 | if (!activeTabId) { | |
| 316 | const result = await open({ | |
| 317 | environmentId: threadRef.environmentId, | |
| 318 | input: { | |
| 319 | threadId: threadRef.threadId, | |
| 320 | ...(input.url ? { url: input.url } : {}), | |
| 321 | }, | |
| 322 | }); | |
| 323 | if (result._tag === "Failure") { | |
| 324 | throw squashAtomCommandFailure(result); | |
| 214 | threadId: threadRef.threadId, | |
| 215 | tabId, | |
| 216 | bridgeAvailable: Boolean(previewBridge), | |
| 217 | }; | |
| 218 | switch (request.operation) { | |
| 219 | case "status": | |
| 220 | return await currentStatus(threadRef, visible); | |
| 221 | case "open": { | |
| 222 | const input = request.input as PreviewAutomationOpenInput; | |
| 223 | let activeTabId = | |
| 224 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 225 | tabId = activeTabId; | |
| 226 | if (!activeTabId) { | |
| 227 | const result = await open({ | |
| 228 | environmentId: threadRef.environmentId, | |
| 229 | input: { | |
| 230 | threadId: threadRef.threadId, | |
| 231 | ...(input.url ? { url: input.url } : {}), | |
| 232 | }, | |
| 233 | }); | |
| 234 | if (result._tag === "Failure") { | |
| 235 | throw squashAtomCommandFailure(result); | |
| 236 | } | |
| 237 | const snapshot = result.value; | |
| 238 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 239 | activeTabId = snapshot.tabId; | |
| 240 | tabId = activeTabId; | |
| 241 | } else if (input.url && previewBridge) { | |
| 242 | await previewBridge.navigate(activeTabId, input.url); | |
| 325 | 243 | } |
| 326 | const snapshot = result.value; | |
| 327 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 328 | activeTabId = snapshot.tabId; | |
| 329 | } else if (input.url && previewBridge) { | |
| 330 | await previewBridge.navigate(activeTabId, input.url); | |
| 331 | } | |
| 332 | if (input.show ?? true) { | |
| 333 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | |
| 334 | } | |
| 335 | await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); | |
| 336 | return currentStatus(threadRef, input.show ?? true); | |
| 337 | } | |
| 338 | case "navigate": { | |
| 339 | if (!previewBridge || !tabId) { | |
| 340 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 341 | } | |
| 342 | const input = request.input as PreviewAutomationNavigateInput; | |
| 343 | const resolution = resolveBrowserNavigationTarget( | |
| 344 | threadRef.environmentId, | |
| 345 | input.target ?? { kind: "url", url: input.url! }, | |
| 346 | ); | |
| 347 | await previewBridge.navigate(tabId, resolution.resolvedUrl); | |
| 348 | await waitForNavigationReadiness( | |
| 349 | threadRef, | |
| 350 | request.requestId, | |
| 351 | tabId, | |
| 352 | input.readiness ?? "load", | |
| 353 | input.timeoutMs ?? request.timeoutMs, | |
| 354 | ); | |
| 355 | return currentStatus(threadRef, visible); | |
| 356 | } | |
| 357 | case "snapshot": | |
| 358 | if (!previewBridge || !tabId) { | |
| 359 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 360 | } | |
| 361 | return previewBridge.automation.snapshot(tabId); | |
| 362 | case "click": | |
| 363 | if (!previewBridge || !tabId) { | |
| 364 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 365 | } | |
| 366 | return previewBridge.automation.click( | |
| 367 | tabId, | |
| 368 | request.input as Parameters<typeof previewBridge.automation.click>[1], | |
| 369 | ); | |
| 370 | case "type": | |
| 371 | if (!previewBridge || !tabId) { | |
| 372 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 373 | } | |
| 374 | return previewBridge.automation.type( | |
| 375 | tabId, | |
| 376 | request.input as Parameters<typeof previewBridge.automation.type>[1], | |
| 377 | ); | |
| 378 | case "press": | |
| 379 | if (!previewBridge || !tabId) { | |
| 380 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 381 | } | |
| 382 | return previewBridge.automation.press( | |
| 383 | tabId, | |
| 384 | request.input as Parameters<typeof previewBridge.automation.press>[1], | |
| 385 | ); | |
| 386 | case "scroll": | |
| 387 | if (!previewBridge || !tabId) { | |
| 388 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 389 | } | |
| 390 | return previewBridge.automation.scroll( | |
| 391 | tabId, | |
| 392 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], | |
| 393 | ); | |
| 394 | case "evaluate": | |
| 395 | if (!previewBridge || !tabId) { | |
| 396 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 397 | } | |
| 398 | return previewBridge.automation.evaluate( | |
| 399 | tabId, | |
| 400 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], | |
| 401 | ); | |
| 402 | case "waitFor": | |
| 403 | if (!previewBridge || !tabId) { | |
| 404 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 405 | } | |
| 406 | return previewBridge.automation.waitFor( | |
| 407 | tabId, | |
| 408 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | |
| 409 | ); | |
| 410 | case "recordingStart": { | |
| 411 | if (!tabId) { | |
| 412 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 244 | if (input.show ?? true) { | |
| 245 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | |
| 246 | } | |
| 247 | await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); | |
| 248 | return await currentStatus(threadRef, input.show ?? true); | |
| 413 | 249 | } |
| 414 | const startedAt = await startBrowserRecording(tabId); | |
| 415 | return { | |
| 416 | tabId, | |
| 417 | recording: true, | |
| 418 | startedAt, | |
| 419 | }; | |
| 420 | } | |
| 421 | case "recordingStop": { | |
| 422 | if (!tabId) { | |
| 423 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 250 | case "navigate": { | |
| 251 | if (!previewBridge || !tabId) { | |
| 252 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 253 | } | |
| 254 | const input = request.input as PreviewAutomationNavigateInput; | |
| 255 | const resolution = resolveBrowserNavigationTarget( | |
| 256 | threadRef.environmentId, | |
| 257 | input.target ?? { kind: "url", url: input.url! }, | |
| 258 | ); | |
| 259 | await previewBridge.navigate(tabId, resolution.resolvedUrl); | |
| 260 | await waitForNavigationReadiness( | |
| 261 | threadRef, | |
| 262 | request.requestId, | |
| 263 | tabId, | |
| 264 | input.readiness ?? "load", | |
| 265 | input.timeoutMs ?? request.timeoutMs, | |
| 266 | ); | |
| 267 | return await currentStatus(threadRef, visible); | |
| 424 | 268 | } |
| 425 | const artifact = await stopBrowserRecording(tabId); | |
| 426 | if (!artifact) { | |
| 427 | throw new PreviewAutomationRecordingNotActiveError({ | |
| 428 | requestId: request.requestId, | |
| 429 | environmentId: threadRef.environmentId, | |
| 430 | threadId: threadRef.threadId, | |
| 269 | case "snapshot": | |
| 270 | if (!previewBridge || !tabId) { | |
| 271 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 272 | } | |
| 273 | return await previewBridge.automation.snapshot(tabId); | |
| 274 | case "click": | |
| 275 | if (!previewBridge || !tabId) { | |
| 276 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 277 | } | |
| 278 | return await previewBridge.automation.click( | |
| 279 | tabId, | |
| 280 | request.input as Parameters<typeof previewBridge.automation.click>[1], | |
| 281 | ); | |
| 282 | case "type": | |
| 283 | if (!previewBridge || !tabId) { | |
| 284 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 285 | } | |
| 286 | return await previewBridge.automation.type( | |
| 287 | tabId, | |
| 288 | request.input as Parameters<typeof previewBridge.automation.type>[1], | |
| 289 | ); | |
| 290 | case "press": | |
| 291 | if (!previewBridge || !tabId) { | |
| 292 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 293 | } | |
| 294 | return await previewBridge.automation.press( | |
| 295 | tabId, | |
| 296 | request.input as Parameters<typeof previewBridge.automation.press>[1], | |
| 297 | ); | |
| 298 | case "scroll": | |
| 299 | if (!previewBridge || !tabId) { | |
| 300 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 301 | } | |
| 302 | return await previewBridge.automation.scroll( | |
| 303 | tabId, | |
| 304 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], | |
| 305 | ); | |
| 306 | case "evaluate": | |
| 307 | if (!previewBridge || !tabId) { | |
| 308 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 309 | } | |
| 310 | return await previewBridge.automation.evaluate( | |
| 311 | tabId, | |
| 312 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], | |
| 313 | ); | |
| 314 | case "waitFor": | |
| 315 | if (!previewBridge || !tabId) { | |
| 316 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 317 | } | |
| 318 | return await previewBridge.automation.waitFor( | |
| 319 | tabId, | |
| 320 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | |
| 321 | ); | |
| 322 | case "recordingStart": { | |
| 323 | if (!tabId) { | |
| 324 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 325 | } | |
| 326 | const startedAt = await startBrowserRecording(tabId); | |
| 327 | return { | |
| 431 | 328 | tabId, |
| 432 | }); | |
| 329 | recording: true, | |
| 330 | startedAt, | |
| 331 | }; | |
| 332 | } | |
| 333 | case "recordingStop": { | |
| 334 | if (!tabId) { | |
| 335 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 336 | } | |
| 337 | const artifact = await stopBrowserRecording(tabId); | |
| 338 | if (!artifact) { | |
| 339 | throw new PreviewAutomationRecordingNotActiveError({ | |
| 340 | requestId: request.requestId, | |
| 341 | environmentId: threadRef.environmentId, | |
| 342 | threadId: threadRef.threadId, | |
| 343 | tabId, | |
| 344 | }); | |
| 345 | } | |
| 346 | return artifact; | |
| 433 | 347 | } |
| 434 | return artifact; | |
| 435 | 348 | } |
| 349 | } catch (cause) { | |
| 350 | throw PreviewAutomationOperationError.fromCause({ | |
| 351 | requestId: request.requestId, | |
| 352 | operation: request.operation, | |
| 353 | environmentId: threadRef.environmentId, | |
| 354 | threadId: threadRef.threadId, | |
| 355 | tabId, | |
| 356 | cause, | |
| 357 | }); | |
| 436 | 358 | } |
| 437 | 359 | }, |
| 438 | 360 | [open, threadRef, visible], |
| ... | ... | @@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: { |
| 448 | 370 | () => |
| 449 | 371 | createPreviewAutomationRequestConsumerAtom({ |
| 450 | 372 | requestsAtom: automationRequestsAtom, |
| 373 | environmentId: threadRef.environmentId, | |
| 451 | 374 | handleRequest: requestHandler.handle, |
| 452 | 375 | respond: (response) => |
| 453 | 376 | respondToAutomation({ |
apps/web/src/components/preview/previewAutomationErrors.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type PreviewAutomationOwner, | |
| 4 | PreviewAutomationOperation, | |
| 5 | type PreviewAutomationRequest, | |
| 6 | type PreviewAutomationResponse, | |
| 7 | PreviewTabId, | |
| 8 | ThreadId, | |
| 9 | TrimmedNonEmptyString, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | export interface PreviewAutomationOperationContext { | |
| 14 | readonly requestId: PreviewAutomationRequest["requestId"]; | |
| 15 | readonly operation: PreviewAutomationRequest["operation"]; | |
| 16 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | |
| 17 | readonly threadId: PreviewAutomationRequest["threadId"]; | |
| 18 | readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null; | |
| 19 | } | |
| 20 | ||
| 21 | export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationOverlayTimeoutError>()( | |
| 22 | "PreviewAutomationOverlayTimeoutError", | |
| 23 | { | |
| 24 | requestId: TrimmedNonEmptyString, | |
| 25 | environmentId: EnvironmentId, | |
| 26 | threadId: ThreadId, | |
| 27 | timeoutMs: Schema.Int, | |
| 28 | }, | |
| 29 | ) { | |
| 30 | get responseTag() { | |
| 31 | return "PreviewAutomationTimeoutError" as const; | |
| 32 | } | |
| 33 | ||
| 34 | override get message(): string { | |
| 35 | return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationNavigationTimeoutError>()( | |
| 40 | "PreviewAutomationNavigationTimeoutError", | |
| 41 | { | |
| 42 | requestId: TrimmedNonEmptyString, | |
| 43 | environmentId: EnvironmentId, | |
| 44 | threadId: ThreadId, | |
| 45 | tabId: PreviewTabId, | |
| 46 | readiness: Schema.Literals(["domContentLoaded", "load"]), | |
| 47 | timeoutMs: Schema.Int, | |
| 48 | }, | |
| 49 | ) { | |
| 50 | get responseTag() { | |
| 51 | return "PreviewAutomationTimeoutError" as const; | |
| 52 | } | |
| 53 | ||
| 54 | override get message(): string { | |
| 55 | return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()( | |
| 60 | "PreviewAutomationStaleOwnerError", | |
| 61 | { | |
| 62 | requestId: TrimmedNonEmptyString, | |
| 63 | environmentId: EnvironmentId, | |
| 64 | expectedThreadId: ThreadId, | |
| 65 | requestedThreadId: ThreadId, | |
| 66 | }, | |
| 67 | ) { | |
| 68 | get responseTag() { | |
| 69 | return "PreviewAutomationUnavailableError" as const; | |
| 70 | } | |
| 71 | ||
| 72 | override get message(): string { | |
| 73 | return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationTargetUnavailableError>()( | |
| 78 | "PreviewAutomationTargetUnavailableError", | |
| 79 | { | |
| 80 | requestId: TrimmedNonEmptyString, | |
| 81 | operation: PreviewAutomationOperation, | |
| 82 | environmentId: EnvironmentId, | |
| 83 | threadId: ThreadId, | |
| 84 | tabId: Schema.NullOr(PreviewTabId), | |
| 85 | bridgeAvailable: Schema.Boolean, | |
| 86 | }, | |
| 87 | ) { | |
| 88 | get responseTag() { | |
| 89 | return "PreviewAutomationTabNotFoundError" as const; | |
| 90 | } | |
| 91 | ||
| 92 | override get message(): string { | |
| 93 | return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass<PreviewAutomationRecordingNotActiveError>()( | |
| 98 | "PreviewAutomationRecordingNotActiveError", | |
| 99 | { | |
| 100 | requestId: TrimmedNonEmptyString, | |
| 101 | environmentId: EnvironmentId, | |
| 102 | threadId: ThreadId, | |
| 103 | tabId: PreviewTabId, | |
| 104 | }, | |
| 105 | ) { | |
| 106 | get responseTag() { | |
| 107 | return "PreviewAutomationExecutionError" as const; | |
| 108 | } | |
| 109 | ||
| 110 | override get message(): string { | |
| 111 | return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()( | |
| 116 | "PreviewAutomationOperationError", | |
| 117 | { | |
| 118 | requestId: TrimmedNonEmptyString, | |
| 119 | operation: PreviewAutomationOperation, | |
| 120 | environmentId: EnvironmentId, | |
| 121 | threadId: ThreadId, | |
| 122 | tabId: Schema.NullOr(PreviewTabId), | |
| 123 | cause: Schema.Defect(), | |
| 124 | }, | |
| 125 | ) { | |
| 126 | static fromCause( | |
| 127 | input: PreviewAutomationOperationContext & { readonly cause: unknown }, | |
| 128 | ): PreviewAutomationOwnerError { | |
| 129 | return isPreviewAutomationOwnerError(input.cause) | |
| 130 | ? input.cause | |
| 131 | : new PreviewAutomationOperationError(input); | |
| 132 | } | |
| 133 | ||
| 134 | get responseTag() { | |
| 135 | return "PreviewAutomationExecutionError" as const; | |
| 136 | } | |
| 137 | ||
| 138 | override get message(): string { | |
| 139 | return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | export const PreviewAutomationOwnerError = Schema.Union([ | |
| 144 | PreviewAutomationOverlayTimeoutError, | |
| 145 | PreviewAutomationNavigationTimeoutError, | |
| 146 | PreviewAutomationStaleOwnerError, | |
| 147 | PreviewAutomationTargetUnavailableError, | |
| 148 | PreviewAutomationRecordingNotActiveError, | |
| 149 | PreviewAutomationOperationError, | |
| 150 | ]); | |
| 151 | export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; | |
| 152 | ||
| 153 | export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); | |
| 154 | ||
| 155 | export function serializePreviewAutomationOwnerError( | |
| 156 | error: PreviewAutomationOwnerError, | |
| 157 | ): NonNullable<PreviewAutomationResponse["error"]> { | |
| 158 | const detail = Object.fromEntries( | |
| 159 | Object.entries(error).filter( | |
| 160 | ([key]) => | |
| 161 | key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", | |
| 162 | ), | |
| 163 | ); | |
| 164 | return { | |
| 165 | _tag: error.responseTag, | |
| 166 | message: error.message, | |
| 167 | ...(Object.keys(detail).length === 0 ? {} : { detail }), | |
| 168 | }; | |
| 169 | } |
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+117-22| ... | ... | @@ -1,19 +1,33 @@ |
| 1 | import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; | |
| 2 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type PreviewAutomationRequest, | |
| 4 | type PreviewAutomationResponse, | |
| 5 | PreviewTabId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 9 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 5 | 10 | |
| 11 | import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; | |
| 6 | 12 | import { |
| 7 | 13 | createPreviewAutomationRequestConsumerAtom, |
| 8 | 14 | serializePreviewAutomationError, |
| 9 | 15 | } from "./previewAutomationRequestConsumer"; |
| 10 | 16 | |
| 11 | const request = (requestId: string): PreviewAutomationRequest => ({ | |
| 17 | const environmentId = EnvironmentId.make("environment-1"); | |
| 18 | const threadId = ThreadId.make("thread-1"); | |
| 19 | const tabId = PreviewTabId.make("tab-1"); | |
| 20 | ||
| 21 | const request = ( | |
| 22 | requestId: string, | |
| 23 | overrides: Partial<PreviewAutomationRequest> = {}, | |
| 24 | ): PreviewAutomationRequest => ({ | |
| 12 | 25 | requestId, |
| 13 | threadId: ThreadId.make("thread-1"), | |
| 26 | threadId, | |
| 14 | 27 | operation: "status", |
| 15 | 28 | input: {}, |
| 16 | 29 | timeoutMs: 15_000, |
| 30 | ...overrides, | |
| 17 | 31 | }); |
| 18 | 32 | |
| 19 | 33 | describe("previewAutomationRequestConsumer", () => { |
| ... | ... | @@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => { |
| 30 | 44 | }); |
| 31 | 45 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 32 | 46 | requestsAtom, |
| 47 | environmentId, | |
| 33 | 48 | handleRequest, |
| 34 | 49 | respond, |
| 35 | 50 | label: "test:preview-automation-consumer", |
| ... | ... | @@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => { |
| 56 | 71 | const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); |
| 57 | 72 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ |
| 58 | 73 | requestsAtom, |
| 74 | environmentId, | |
| 59 | 75 | handleRequest: async () => undefined, |
| 60 | 76 | respond, |
| 61 | 77 | label: "test:preview-automation-initial-request", |
| ... | ... | @@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => { |
| 69 | 85 | registry.dispose(); |
| 70 | 86 | }); |
| 71 | 87 | |
| 72 | it("preserves typed automation errors in responses", () => { | |
| 73 | const error = new Error("No preview tab"); | |
| 74 | error.name = "PreviewAutomationTabNotFoundError"; | |
| 88 | it("preserves tagged automation errors and their structured diagnostics", () => { | |
| 89 | const error = new PreviewAutomationTargetUnavailableError({ | |
| 90 | requestId: "request-1", | |
| 91 | operation: "click", | |
| 92 | environmentId, | |
| 93 | threadId, | |
| 94 | tabId, | |
| 95 | bridgeAvailable: false, | |
| 96 | }); | |
| 75 | 97 | |
| 76 | expect(serializePreviewAutomationError(error)).toEqual({ | |
| 98 | expect( | |
| 99 | serializePreviewAutomationError(error, { | |
| 100 | requestId: "request-1", | |
| 101 | operation: "click", | |
| 102 | environmentId, | |
| 103 | threadId, | |
| 104 | tabId, | |
| 105 | }), | |
| 106 | ).toEqual({ | |
| 77 | 107 | _tag: "PreviewAutomationTabNotFoundError", |
| 78 | message: "No preview tab", | |
| 108 | message: | |
| 109 | "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", | |
| 110 | detail: { | |
| 111 | requestId: "request-1", | |
| 112 | operation: "click", | |
| 113 | environmentId: "environment-1", | |
| 114 | threadId: "thread-1", | |
| 115 | tabId: "tab-1", | |
| 116 | bridgeAvailable: false, | |
| 117 | }, | |
| 79 | 118 | }); |
| 80 | 119 | }); |
| 81 | 120 | |
| 82 | it("serializes structured automation context without leaking causes", () => { | |
| 83 | const error = Object.assign(new Error("Preview target unavailable"), { | |
| 84 | name: "PreviewAutomationTargetUnavailableError", | |
| 85 | _tag: "PreviewAutomationTargetUnavailableError", | |
| 86 | responseTag: "PreviewAutomationTabNotFoundError", | |
| 87 | requestId: "request-1", | |
| 88 | threadId: "thread-1", | |
| 89 | cause: new Error("private bridge failure"), | |
| 90 | }); | |
| 121 | it("correlates unexpected failures without exposing cause details", () => { | |
| 122 | const cause = new Error("private bridge token: preview-secret"); | |
| 123 | const context = { | |
| 124 | requestId: "request-2", | |
| 125 | operation: "snapshot" as const, | |
| 126 | environmentId, | |
| 127 | threadId, | |
| 128 | tabId, | |
| 129 | }; | |
| 130 | const response = serializePreviewAutomationError(cause, context); | |
| 91 | 131 | |
| 92 | expect(serializePreviewAutomationError(error)).toEqual({ | |
| 93 | _tag: "PreviewAutomationTabNotFoundError", | |
| 94 | message: "Preview target unavailable", | |
| 132 | expect(response).toEqual({ | |
| 133 | _tag: "PreviewAutomationExecutionError", | |
| 134 | message: | |
| 135 | "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", | |
| 95 | 136 | detail: { |
| 96 | requestId: "request-1", | |
| 137 | requestId: "request-2", | |
| 138 | operation: "snapshot", | |
| 139 | environmentId: "environment-1", | |
| 97 | 140 | threadId: "thread-1", |
| 141 | tabId: "tab-1", | |
| 98 | 142 | }, |
| 99 | 143 | }); |
| 144 | expect(JSON.stringify(response)).not.toContain("preview-secret"); | |
| 145 | }); | |
| 146 | ||
| 147 | it("sanitizes unexpected handler failures at the response boundary", async () => { | |
| 148 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>( | |
| 149 | AsyncResult.initial<PreviewAutomationRequest, Error>(false), | |
| 150 | ); | |
| 151 | const responses: PreviewAutomationResponse[] = []; | |
| 152 | const consumerAtom = createPreviewAutomationRequestConsumerAtom({ | |
| 153 | requestsAtom, | |
| 154 | environmentId, | |
| 155 | handleRequest: async () => { | |
| 156 | throw new Error("desktop IPC secret: do-not-return"); | |
| 157 | }, | |
| 158 | respond: async (response) => { | |
| 159 | responses.push(response); | |
| 160 | }, | |
| 161 | label: "test:preview-automation-failure-boundary", | |
| 162 | }); | |
| 163 | const registry = AtomRegistry.make(); | |
| 164 | registry.mount(consumerAtom); | |
| 165 | ||
| 166 | registry.set( | |
| 167 | requestsAtom, | |
| 168 | AsyncResult.success( | |
| 169 | request("request-failed", { | |
| 170 | operation: "click", | |
| 171 | tabId, | |
| 172 | }), | |
| 173 | ), | |
| 174 | ); | |
| 175 | ||
| 176 | await vi.waitFor(() => expect(responses).toHaveLength(1)); | |
| 177 | expect(responses[0]).toEqual({ | |
| 178 | requestId: "request-failed", | |
| 179 | ok: false, | |
| 180 | error: { | |
| 181 | _tag: "PreviewAutomationExecutionError", | |
| 182 | message: | |
| 183 | "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", | |
| 184 | detail: { | |
| 185 | requestId: "request-failed", | |
| 186 | operation: "click", | |
| 187 | environmentId: "environment-1", | |
| 188 | threadId: "thread-1", | |
| 189 | tabId: "tab-1", | |
| 190 | }, | |
| 191 | }, | |
| 192 | }); | |
| 193 | expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); | |
| 194 | registry.dispose(); | |
| 100 | 195 | }); |
| 101 | 196 | }); |
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+23-45| ... | ... | @@ -1,6 +1,16 @@ |
| 1 | import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | PreviewAutomationOwner, | |
| 3 | PreviewAutomationRequest, | |
| 4 | PreviewAutomationResponse, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 3 | 7 | |
| 8 | import { | |
| 9 | PreviewAutomationOperationError, | |
| 10 | type PreviewAutomationOperationContext, | |
| 11 | serializePreviewAutomationOwnerError, | |
| 12 | } from "./previewAutomationErrors"; | |
| 13 | ||
| 4 | 14 | type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>; |
| 5 | 15 | type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>; |
| 6 | 16 | |
| ... | ... | @@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR |
| 19 | 29 | |
| 20 | 30 | export function serializePreviewAutomationError( |
| 21 | 31 | error: unknown, |
| 32 | context: PreviewAutomationOperationContext, | |
| 22 | 33 | ): NonNullable<PreviewAutomationResponse["error"]> { |
| 23 | if (error instanceof Error) { | |
| 24 | const explicitDetail = | |
| 25 | "detail" in error && (error as { detail?: unknown }).detail !== undefined | |
| 26 | ? (error as { detail?: unknown }).detail | |
| 27 | : undefined; | |
| 28 | const structuralDetail = | |
| 29 | "_tag" in error && | |
| 30 | typeof (error as { _tag?: unknown })._tag === "string" && | |
| 31 | (error as { _tag: string })._tag.startsWith("PreviewAutomation") | |
| 32 | ? Object.fromEntries( | |
| 33 | Object.entries(error).filter( | |
| 34 | ([key]) => | |
| 35 | key !== "_tag" && | |
| 36 | key !== "cause" && | |
| 37 | key !== "name" && | |
| 38 | key !== "message" && | |
| 39 | key !== "stack" && | |
| 40 | key !== "detail" && | |
| 41 | key !== "responseTag", | |
| 42 | ), | |
| 43 | ) | |
| 44 | : undefined; | |
| 45 | const detail = explicitDetail ?? structuralDetail; | |
| 46 | const responseTag = | |
| 47 | "responseTag" in error && | |
| 48 | typeof (error as { responseTag?: unknown }).responseTag === "string" && | |
| 49 | (error as { responseTag: string }).responseTag.startsWith("PreviewAutomation") | |
| 50 | ? (error as { responseTag: string }).responseTag | |
| 51 | : undefined; | |
| 52 | return { | |
| 53 | _tag: | |
| 54 | responseTag ?? | |
| 55 | (error.name.startsWith("PreviewAutomation") | |
| 56 | ? error.name | |
| 57 | : "PreviewAutomationExecutionError"), | |
| 58 | message: error.message, | |
| 59 | ...(detail === undefined ? {} : { detail }), | |
| 60 | }; | |
| 61 | } | |
| 62 | return { | |
| 63 | _tag: "PreviewAutomationExecutionError", | |
| 64 | message: String(error), | |
| 65 | }; | |
| 34 | return serializePreviewAutomationOwnerError( | |
| 35 | PreviewAutomationOperationError.fromCause({ ...context, cause: error }), | |
| 36 | ); | |
| 66 | 37 | } |
| 67 | 38 | |
| 68 | 39 | export function createPreviewAutomationRequestConsumerAtom<E>(options: { |
| 69 | 40 | readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>; |
| 41 | readonly environmentId: PreviewAutomationOwner["environmentId"]; | |
| 70 | 42 | readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>; |
| 71 | 43 | readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>; |
| 72 | 44 | readonly label: string; |
| ... | ... | @@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom<E>(options: { |
| 89 | 61 | options.respond({ |
| 90 | 62 | requestId: request.requestId, |
| 91 | 63 | ok: false, |
| 92 | error: serializePreviewAutomationError(error), | |
| 64 | error: serializePreviewAutomationError(error, { | |
| 65 | requestId: request.requestId, | |
| 66 | operation: request.operation, | |
| 67 | environmentId: options.environmentId, | |
| 68 | threadId: request.threadId, | |
| 69 | tabId: request.tabId ?? null, | |
| 70 | }), | |
| 93 | 71 | }), |
| 94 | 72 | ); |
| 95 | 73 | }; |