authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 13:50:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 13:50:19-07:00
logfc2cdeceb9ddb28c0de834b9fcb38abf68d99148
tree82c06d2faf1c657d442e28e4803a0e56a6a80144
parentc637dfc454342c156dbe61ee8513f5e0ac0c4691
signature Signed by PGP key B5690EEEBB952194

[codex] Structure preview automation boundary failures (#3436)

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,19 +3,13 @@
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {5import {
6 EnvironmentId,
7 type PreviewAutomationNavigateInput,6 type PreviewAutomationNavigateInput,
8 type PreviewAutomationOpenInput,7 type PreviewAutomationOpenInput,
9 PreviewAutomationOperation,
10 type PreviewAutomationOwner as PreviewAutomationOwnerState,8 type PreviewAutomationOwner as PreviewAutomationOwnerState,
11 type PreviewAutomationRequest,9 type PreviewAutomationRequest,
12 type PreviewAutomationStatus,10 type PreviewAutomationStatus,
13 PreviewTabId,
14 type ScopedThreadRef,11 type ScopedThreadRef,
15 ThreadId,
16 TrimmedNonEmptyString,
17} from "@t3tools/contracts";12} from "@t3tools/contracts";
18import * as Schema from "effect/Schema";
19import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";13import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
2014
21import {15import {
...@@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments";...@@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments";
31import { useAtomCommand } from "~/state/use-atom-command";25import { useAtomCommand } from "~/state/use-atom-command";
3226
33import { previewBridge } from "./previewBridge";27import { previewBridge } from "./previewBridge";
28import {
29 PreviewAutomationNavigationTimeoutError,
30 PreviewAutomationOperationError,
31 PreviewAutomationOverlayTimeoutError,
32 PreviewAutomationRecordingNotActiveError,
33 PreviewAutomationStaleOwnerError,
34 PreviewAutomationTargetUnavailableError,
35} from "./previewAutomationErrors";
34import {36import {
35 createLatestPreviewAutomationRequestHandler,37 createLatestPreviewAutomationRequestHandler,
36 createPreviewAutomationRequestConsumerAtom,38 createPreviewAutomationRequestConsumerAtom,
37} from "./previewAutomationRequestConsumer";39} from "./previewAutomationRequestConsumer";
3840
39export 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
57export 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
77export 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
95export 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
115export 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
133export function observeAutomationOwnerConnectedGeneration(41export function observeAutomationOwnerConnectedGeneration(
134 previousGeneration: number | null,42 previousGeneration: number | null,
135 connectedGeneration: number | null,43 connectedGeneration: number | null,
...@@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: {...@@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: {
287195
288 const handleRequest = useCallback(196 const handleRequest = useCallback(
289 async (request: PreviewAutomationRequest): Promise<unknown> => {197 async (request: PreviewAutomationRequest): Promise<unknown> => {
290 if (request.threadId !== threadRef.threadId) {198 let tabId = request.tabId ?? null;
291 throw new PreviewAutomationStaleOwnerError({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 requestId: request.requestId,211 requestId: request.requestId,
212 operation: request.operation,
293 environmentId: threadRef.environmentId,213 environmentId: threadRef.environmentId,
294 expectedThreadId: threadRef.threadId,214 threadId: threadRef.threadId,
295 requestedThreadId: request.threadId,215 tabId,
296 });216 bridgeAvailable: Boolean(previewBridge),
297 }217 };
298 const state = readThreadPreviewState(threadRef);218 switch (request.operation) {
299 const tabId = request.tabId ?? state.snapshot?.tabId ?? null;219 case "status":
300 const unavailableTarget = {220 return await currentStatus(threadRef, visible);
301 requestId: request.requestId,221 case "open": {
302 operation: request.operation,222 const input = request.input as PreviewAutomationOpenInput;
303 environmentId: threadRef.environmentId,223 let activeTabId =
304 threadId: threadRef.threadId,224 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
305 tabId,225 tabId = activeTabId;
306 bridgeAvailable: Boolean(previewBridge),226 if (!activeTabId) {
307 };227 const result = await open({
308 switch (request.operation) {228 environmentId: threadRef.environmentId,
309 case "status":229 input: {
310 return currentStatus(threadRef, visible);230 threadId: threadRef.threadId,
311 case "open": {231 ...(input.url ? { url: input.url } : {}),
312 const input = request.input as PreviewAutomationOpenInput;232 },
313 let activeTabId =233 });
314 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;234 if (result._tag === "Failure") {
315 if (!activeTabId) {235 throw squashAtomCommandFailure(result);
316 const result = await open({236 }
317 environmentId: threadRef.environmentId,237 const snapshot = result.value;
318 input: {238 applyPreviewServerSnapshot(threadRef, snapshot);
319 threadId: threadRef.threadId,239 activeTabId = snapshot.tabId;
320 ...(input.url ? { url: input.url } : {}),240 tabId = activeTabId;
321 },241 } else if (input.url && previewBridge) {
322 });242 await previewBridge.navigate(activeTabId, input.url);
323 if (result._tag === "Failure") {
324 throw squashAtomCommandFailure(result);
325 }243 }
326 const snapshot = result.value;244 if (input.show ?? true) {
327 applyPreviewServerSnapshot(threadRef, snapshot);245 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
328 activeTabId = snapshot.tabId;246 }
329 } else if (input.url && previewBridge) {247 await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs);
330 await previewBridge.navigate(activeTabId, input.url);248 return await currentStatus(threadRef, input.show ?? true);
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);
413 }249 }
414 const startedAt = await startBrowserRecording(tabId);250 case "navigate": {
415 return {251 if (!previewBridge || !tabId) {
416 tabId,252 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
417 recording: true,253 }
418 startedAt,254 const input = request.input as PreviewAutomationNavigateInput;
419 };255 const resolution = resolveBrowserNavigationTarget(
420 }256 threadRef.environmentId,
421 case "recordingStop": {257 input.target ?? { kind: "url", url: input.url! },
422 if (!tabId) {258 );
423 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);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);269 case "snapshot":
426 if (!artifact) {270 if (!previewBridge || !tabId) {
427 throw new PreviewAutomationRecordingNotActiveError({271 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
428 requestId: request.requestId,272 }
429 environmentId: threadRef.environmentId,273 return await previewBridge.automation.snapshot(tabId);
430 threadId: threadRef.threadId,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 tabId,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 [open, threadRef, visible],360 [open, threadRef, visible],
...@@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: {...@@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: {
448 () =>370 () =>
449 createPreviewAutomationRequestConsumerAtom({371 createPreviewAutomationRequestConsumerAtom({
450 requestsAtom: automationRequestsAtom,372 requestsAtom: automationRequestsAtom,
373 environmentId: threadRef.environmentId,
451 handleRequest: requestHandler.handle,374 handleRequest: requestHandler.handle,
452 respond: (response) =>375 respond: (response) =>
453 respondToAutomation({376 respondToAutomation({
apps/web/src/components/preview/previewAutomationErrors.ts created+169
...@@ -0,0 +1,169 @@
1import {
2 EnvironmentId,
3 type PreviewAutomationOwner,
4 PreviewAutomationOperation,
5 type PreviewAutomationRequest,
6 type PreviewAutomationResponse,
7 PreviewTabId,
8 ThreadId,
9 TrimmedNonEmptyString,
10} from "@t3tools/contracts";
11import * as Schema from "effect/Schema";
12
13export 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
21export 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
39export 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
59export 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
77export 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
97export 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
115export 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
143export const PreviewAutomationOwnerError = Schema.Union([
144 PreviewAutomationOverlayTimeoutError,
145 PreviewAutomationNavigationTimeoutError,
146 PreviewAutomationStaleOwnerError,
147 PreviewAutomationTargetUnavailableError,
148 PreviewAutomationRecordingNotActiveError,
149 PreviewAutomationOperationError,
150]);
151export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type;
152
153export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError);
154
155export 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,19 +1,33 @@
1import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts";1import {
2import { ThreadId } from "@t3tools/contracts";2 EnvironmentId,
3 type PreviewAutomationRequest,
4 type PreviewAutomationResponse,
5 PreviewTabId,
6 ThreadId,
7} from "@t3tools/contracts";
3import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";9import { describe, expect, it, vi } from "vite-plus/test";
510
11import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
6import {12import {
7 createPreviewAutomationRequestConsumerAtom,13 createPreviewAutomationRequestConsumerAtom,
8 serializePreviewAutomationError,14 serializePreviewAutomationError,
9} from "./previewAutomationRequestConsumer";15} from "./previewAutomationRequestConsumer";
1016
11const request = (requestId: string): PreviewAutomationRequest => ({17const environmentId = EnvironmentId.make("environment-1");
18const threadId = ThreadId.make("thread-1");
19const tabId = PreviewTabId.make("tab-1");
20
21const request = (
22 requestId: string,
23 overrides: Partial<PreviewAutomationRequest> = {},
24): PreviewAutomationRequest => ({
12 requestId,25 requestId,
13 threadId: ThreadId.make("thread-1"),26 threadId,
14 operation: "status",27 operation: "status",
15 input: {},28 input: {},
16 timeoutMs: 15_000,29 timeoutMs: 15_000,
30 ...overrides,
17});31});
1832
19describe("previewAutomationRequestConsumer", () => {33describe("previewAutomationRequestConsumer", () => {
...@@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => {...@@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => {
30 });44 });
31 const consumerAtom = createPreviewAutomationRequestConsumerAtom({45 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
32 requestsAtom,46 requestsAtom,
47 environmentId,
33 handleRequest,48 handleRequest,
34 respond,49 respond,
35 label: "test:preview-automation-consumer",50 label: "test:preview-automation-consumer",
...@@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => {...@@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => {
56 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);71 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
57 const consumerAtom = createPreviewAutomationRequestConsumerAtom({72 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
58 requestsAtom,73 requestsAtom,
74 environmentId,
59 handleRequest: async () => undefined,75 handleRequest: async () => undefined,
60 respond,76 respond,
61 label: "test:preview-automation-initial-request",77 label: "test:preview-automation-initial-request",
...@@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => {...@@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => {
69 registry.dispose();85 registry.dispose();
70 });86 });
7187
72 it("preserves typed automation errors in responses", () => {88 it("preserves tagged automation errors and their structured diagnostics", () => {
73 const error = new Error("No preview tab");89 const error = new PreviewAutomationTargetUnavailableError({
74 error.name = "PreviewAutomationTabNotFoundError";90 requestId: "request-1",
91 operation: "click",
92 environmentId,
93 threadId,
94 tabId,
95 bridgeAvailable: false,
96 });
7597
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 _tag: "PreviewAutomationTabNotFoundError",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 });
81120
82 it("serializes structured automation context without leaking causes", () => {121 it("correlates unexpected failures without exposing cause details", () => {
83 const error = Object.assign(new Error("Preview target unavailable"), {122 const cause = new Error("private bridge token: preview-secret");
84 name: "PreviewAutomationTargetUnavailableError",123 const context = {
85 _tag: "PreviewAutomationTargetUnavailableError",124 requestId: "request-2",
86 responseTag: "PreviewAutomationTabNotFoundError",125 operation: "snapshot" as const,
87 requestId: "request-1",126 environmentId,
88 threadId: "thread-1",127 threadId,
89 cause: new Error("private bridge failure"),128 tabId,
90 });129 };
130 const response = serializePreviewAutomationError(cause, context);
91131
92 expect(serializePreviewAutomationError(error)).toEqual({132 expect(response).toEqual({
93 _tag: "PreviewAutomationTabNotFoundError",133 _tag: "PreviewAutomationExecutionError",
94 message: "Preview target unavailable",134 message:
135 "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).",
95 detail: {136 detail: {
96 requestId: "request-1",137 requestId: "request-2",
138 operation: "snapshot",
139 environmentId: "environment-1",
97 threadId: "thread-1",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,6 +1,16 @@
1import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts";1import type {
2 PreviewAutomationOwner,
3 PreviewAutomationRequest,
4 PreviewAutomationResponse,
5} from "@t3tools/contracts";
2import { AsyncResult, Atom } from "effect/unstable/reactivity";6import { AsyncResult, Atom } from "effect/unstable/reactivity";
37
8import {
9 PreviewAutomationOperationError,
10 type PreviewAutomationOperationContext,
11 serializePreviewAutomationOwnerError,
12} from "./previewAutomationErrors";
13
4type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>;14type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>;
5type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>;15type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>;
616
...@@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR...@@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR
1929
20export function serializePreviewAutomationError(30export function serializePreviewAutomationError(
21 error: unknown,31 error: unknown,
32 context: PreviewAutomationOperationContext,
22): NonNullable<PreviewAutomationResponse["error"]> {33): NonNullable<PreviewAutomationResponse["error"]> {
23 if (error instanceof Error) {34 return serializePreviewAutomationOwnerError(
24 const explicitDetail =35 PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
25 "detail" in error && (error as { detail?: unknown }).detail !== undefined36 );
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 };
66}37}
6738
68export function createPreviewAutomationRequestConsumerAtom<E>(options: {39export function createPreviewAutomationRequestConsumerAtom<E>(options: {
69 readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>;40 readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>;
41 readonly environmentId: PreviewAutomationOwner["environmentId"];
70 readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>;42 readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>;
71 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;43 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;
72 readonly label: string;44 readonly label: string;
...@@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom<E>(options: {...@@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom<E>(options: {
89 options.respond({61 options.respond({
90 requestId: request.requestId,62 requestId: request.requestId,
91 ok: false,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 };