| ... | ... | @@ -2,14 +2,20 @@ |
| 2 | 2 | |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | | import type { |
| 6 | | PreviewAutomationNavigateInput, |
| 7 | | PreviewAutomationOpenInput, |
| 8 | | PreviewAutomationRequest, |
| 9 | | PreviewAutomationOwner as PreviewAutomationOwnerState, |
| 10 | | PreviewAutomationStatus, |
| 11 | | ScopedThreadRef, |
| 5 | import { |
| 6 | EnvironmentId, |
| 7 | type PreviewAutomationNavigateInput, |
| 8 | type PreviewAutomationOpenInput, |
| 9 | PreviewAutomationOperation, |
| 10 | type PreviewAutomationOwner as PreviewAutomationOwnerState, |
| 11 | type PreviewAutomationRequest, |
| 12 | type PreviewAutomationStatus, |
| 13 | PreviewTabId, |
| 14 | type ScopedThreadRef, |
| 15 | ThreadId, |
| 16 | TrimmedNonEmptyString, |
| 12 | 17 | } from "@t3tools/contracts"; |
| 18 | import * as Schema from "effect/Schema"; |
| 13 | 19 | import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; |
| 14 | 20 | |
| 15 | 21 | import { |
| ... | ... | @@ -30,6 +36,100 @@ import { |
| 30 | 36 | createPreviewAutomationRequestConsumerAtom, |
| 31 | 37 | } from "./previewAutomationRequestConsumer"; |
| 32 | 38 | |
| 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 | |
| 33 | 133 | export function observeAutomationOwnerConnectedGeneration( |
| 34 | 134 | previousGeneration: number | null, |
| 35 | 135 | connectedGeneration: number | null, |
| ... | ... | @@ -51,6 +151,7 @@ export function observeAutomationOwnerConnectedGeneration( |
| 51 | 151 | |
| 52 | 152 | const waitForDesktopOverlay = async ( |
| 53 | 153 | threadRef: ScopedThreadRef, |
| 154 | requestId: string, |
| 54 | 155 | timeoutMs: number, |
| 55 | 156 | ): Promise<void> => { |
| 56 | 157 | const deadline = Date.now() + timeoutMs; |
| ... | ... | @@ -63,20 +164,26 @@ const waitForDesktopOverlay = async ( |
| 63 | 164 | } |
| 64 | 165 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); |
| 65 | 166 | } |
| 66 | | const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); |
| 67 | | error.name = "PreviewAutomationTimeoutError"; |
| 68 | | throw error; |
| 167 | throw new PreviewAutomationOverlayTimeoutError({ |
| 168 | requestId, |
| 169 | environmentId: threadRef.environmentId, |
| 170 | threadId: threadRef.threadId, |
| 171 | timeoutMs, |
| 172 | }); |
| 69 | 173 | }; |
| 70 | 174 | |
| 71 | 175 | const waitForNavigationReadiness = async ( |
| 176 | threadRef: ScopedThreadRef, |
| 177 | requestId: string, |
| 72 | 178 | tabId: string, |
| 73 | 179 | readiness: PreviewAutomationNavigateInput["readiness"], |
| 74 | 180 | timeoutMs: number, |
| 75 | 181 | ): Promise<void> => { |
| 76 | | if (!previewBridge || readiness === "none") return; |
| 182 | const targetReadiness = readiness ?? "load"; |
| 183 | if (!previewBridge || targetReadiness === "none") return; |
| 77 | 184 | const deadline = Date.now() + timeoutMs; |
| 78 | 185 | while (Date.now() <= deadline) { |
| 79 | | if (readiness === "domContentLoaded") { |
| 186 | if (targetReadiness === "domContentLoaded") { |
| 80 | 187 | const readyState = await previewBridge.automation.evaluate(tabId, { |
| 81 | 188 | expression: "document.readyState", |
| 82 | 189 | }); |
| ... | ... | @@ -87,9 +194,14 @@ const waitForNavigationReadiness = async ( |
| 87 | 194 | } |
| 88 | 195 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); |
| 89 | 196 | } |
| 90 | | const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); |
| 91 | | error.name = "PreviewAutomationTimeoutError"; |
| 92 | | throw error; |
| 197 | throw new PreviewAutomationNavigationTimeoutError({ |
| 198 | requestId, |
| 199 | environmentId: threadRef.environmentId, |
| 200 | threadId: threadRef.threadId, |
| 201 | tabId, |
| 202 | readiness: targetReadiness, |
| 203 | timeoutMs, |
| 204 | }); |
| 93 | 205 | }; |
| 94 | 206 | |
| 95 | 207 | const currentStatus = async ( |
| ... | ... | @@ -113,12 +225,6 @@ const currentStatus = async ( |
| 113 | 225 | }; |
| 114 | 226 | }; |
| 115 | 227 | |
| 116 | | const previewTabNotFoundError = (): Error => { |
| 117 | | const error = new Error("Preview tab is not initialized."); |
| 118 | | error.name = "PreviewAutomationTabNotFoundError"; |
| 119 | | return error; |
| 120 | | }; |
| 121 | | |
| 122 | 228 | export function PreviewAutomationOwner(props: { |
| 123 | 229 | readonly threadRef: ScopedThreadRef; |
| 124 | 230 | readonly visible: boolean; |
| ... | ... | @@ -182,12 +288,23 @@ export function PreviewAutomationOwner(props: { |
| 182 | 288 | const handleRequest = useCallback( |
| 183 | 289 | async (request: PreviewAutomationRequest): Promise<unknown> => { |
| 184 | 290 | if (request.threadId !== threadRef.threadId) { |
| 185 | | const error = new Error("Preview automation request targeted a stale thread owner."); |
| 186 | | error.name = "PreviewAutomationUnavailableError"; |
| 187 | | throw error; |
| 291 | throw new PreviewAutomationStaleOwnerError({ |
| 292 | requestId: request.requestId, |
| 293 | environmentId: threadRef.environmentId, |
| 294 | expectedThreadId: threadRef.threadId, |
| 295 | requestedThreadId: request.threadId, |
| 296 | }); |
| 188 | 297 | } |
| 189 | 298 | const state = readThreadPreviewState(threadRef); |
| 190 | 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 | }; |
| 191 | 308 | switch (request.operation) { |
| 192 | 309 | case "status": |
| 193 | 310 | return currentStatus(threadRef, visible); |
| ... | ... | @@ -215,11 +332,13 @@ export function PreviewAutomationOwner(props: { |
| 215 | 332 | if (input.show ?? true) { |
| 216 | 333 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); |
| 217 | 334 | } |
| 218 | | await waitForDesktopOverlay(threadRef, request.timeoutMs); |
| 335 | await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); |
| 219 | 336 | return currentStatus(threadRef, input.show ?? true); |
| 220 | 337 | } |
| 221 | 338 | case "navigate": { |
| 222 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 339 | if (!previewBridge || !tabId) { |
| 340 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 341 | } |
| 223 | 342 | const input = request.input as PreviewAutomationNavigateInput; |
| 224 | 343 | const resolution = resolveBrowserNavigationTarget( |
| 225 | 344 | threadRef.environmentId, |
| ... | ... | @@ -227,6 +346,8 @@ export function PreviewAutomationOwner(props: { |
| 227 | 346 | ); |
| 228 | 347 | await previewBridge.navigate(tabId, resolution.resolvedUrl); |
| 229 | 348 | await waitForNavigationReadiness( |
| 349 | threadRef, |
| 350 | request.requestId, |
| 230 | 351 | tabId, |
| 231 | 352 | input.readiness ?? "load", |
| 232 | 353 | input.timeoutMs ?? request.timeoutMs, |
| ... | ... | @@ -234,46 +355,62 @@ export function PreviewAutomationOwner(props: { |
| 234 | 355 | return currentStatus(threadRef, visible); |
| 235 | 356 | } |
| 236 | 357 | case "snapshot": |
| 237 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 358 | if (!previewBridge || !tabId) { |
| 359 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 360 | } |
| 238 | 361 | return previewBridge.automation.snapshot(tabId); |
| 239 | 362 | case "click": |
| 240 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 363 | if (!previewBridge || !tabId) { |
| 364 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 365 | } |
| 241 | 366 | return previewBridge.automation.click( |
| 242 | 367 | tabId, |
| 243 | 368 | request.input as Parameters<typeof previewBridge.automation.click>[1], |
| 244 | 369 | ); |
| 245 | 370 | case "type": |
| 246 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 371 | if (!previewBridge || !tabId) { |
| 372 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 373 | } |
| 247 | 374 | return previewBridge.automation.type( |
| 248 | 375 | tabId, |
| 249 | 376 | request.input as Parameters<typeof previewBridge.automation.type>[1], |
| 250 | 377 | ); |
| 251 | 378 | case "press": |
| 252 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 379 | if (!previewBridge || !tabId) { |
| 380 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 381 | } |
| 253 | 382 | return previewBridge.automation.press( |
| 254 | 383 | tabId, |
| 255 | 384 | request.input as Parameters<typeof previewBridge.automation.press>[1], |
| 256 | 385 | ); |
| 257 | 386 | case "scroll": |
| 258 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 387 | if (!previewBridge || !tabId) { |
| 388 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 389 | } |
| 259 | 390 | return previewBridge.automation.scroll( |
| 260 | 391 | tabId, |
| 261 | 392 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], |
| 262 | 393 | ); |
| 263 | 394 | case "evaluate": |
| 264 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 395 | if (!previewBridge || !tabId) { |
| 396 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 397 | } |
| 265 | 398 | return previewBridge.automation.evaluate( |
| 266 | 399 | tabId, |
| 267 | 400 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], |
| 268 | 401 | ); |
| 269 | 402 | case "waitFor": |
| 270 | | if (!previewBridge || !tabId) throw previewTabNotFoundError(); |
| 403 | if (!previewBridge || !tabId) { |
| 404 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 405 | } |
| 271 | 406 | return previewBridge.automation.waitFor( |
| 272 | 407 | tabId, |
| 273 | 408 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], |
| 274 | 409 | ); |
| 275 | 410 | case "recordingStart": { |
| 276 | | if (!tabId) throw previewTabNotFoundError(); |
| 411 | if (!tabId) { |
| 412 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 413 | } |
| 277 | 414 | const startedAt = await startBrowserRecording(tabId); |
| 278 | 415 | return { |
| 279 | 416 | tabId, |
| ... | ... | @@ -282,9 +419,18 @@ export function PreviewAutomationOwner(props: { |
| 282 | 419 | }; |
| 283 | 420 | } |
| 284 | 421 | case "recordingStop": { |
| 285 | | if (!tabId) throw previewTabNotFoundError(); |
| 422 | if (!tabId) { |
| 423 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); |
| 424 | } |
| 286 | 425 | const artifact = await stopBrowserRecording(tabId); |
| 287 | | if (!artifact) throw new Error("No active recording exists for this preview tab."); |
| 426 | if (!artifact) { |
| 427 | throw new PreviewAutomationRecordingNotActiveError({ |
| 428 | requestId: request.requestId, |
| 429 | environmentId: threadRef.environmentId, |
| 430 | threadId: threadRef.threadId, |
| 431 | tabId, |
| 432 | }); |
| 433 | } |
| 288 | 434 | return artifact; |
| 289 | 435 | } |
| 290 | 436 | } |