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 @@
33import { useAtomValue } from "@effect/atom-react";
44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
55import {
6 EnvironmentId,
76 type PreviewAutomationNavigateInput,
87 type PreviewAutomationOpenInput,
9 PreviewAutomationOperation,
108 type PreviewAutomationOwner as PreviewAutomationOwnerState,
119 type PreviewAutomationRequest,
1210 type PreviewAutomationStatus,
13 PreviewTabId,
1411 type ScopedThreadRef,
15 ThreadId,
16 TrimmedNonEmptyString,
1712} from "@t3tools/contracts";
18import * as Schema from "effect/Schema";
1913import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
2014
2115import {
......@@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments";
3125import { useAtomCommand } from "~/state/use-atom-command";
3226
3327import { previewBridge } from "./previewBridge";
28import {
29 PreviewAutomationNavigationTimeoutError,
30 PreviewAutomationOperationError,
31 PreviewAutomationOverlayTimeoutError,
32 PreviewAutomationRecordingNotActiveError,
33 PreviewAutomationStaleOwnerError,
34 PreviewAutomationTargetUnavailableError,
35} from "./previewAutomationErrors";
3436import {
3537 createLatestPreviewAutomationRequestHandler,
3638 createPreviewAutomationRequestConsumerAtom,
3739} 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
13341export function observeAutomationOwnerConnectedGeneration(
13442 previousGeneration: number | null,
13543 connectedGeneration: number | null,
......@@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: {
287195
288196 const handleRequest = useCallback(
289197 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 = {
292211 requestId: request.requestId,
212 operation: request.operation,
293213 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);
325243 }
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);
413249 }
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);
424268 }
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 {
431328 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;
433347 }
434 return artifact;
435348 }
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 });
436358 }
437359 },
438360 [open, threadRef, visible],
......@@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: {
448370 () =>
449371 createPreviewAutomationRequestConsumerAtom({
450372 requestsAtom: automationRequestsAtom,
373 environmentId: threadRef.environmentId,
451374 handleRequest: requestHandler.handle,
452375 respond: (response) =>
453376 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 @@
1import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 type PreviewAutomationRequest,
4 type PreviewAutomationResponse,
5 PreviewTabId,
6 ThreadId,
7} from "@t3tools/contracts";
38import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
49import { describe, expect, it, vi } from "vite-plus/test";
510
11import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
612import {
713 createPreviewAutomationRequestConsumerAtom,
814 serializePreviewAutomationError,
915} 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 => ({
1225 requestId,
13 threadId: ThreadId.make("thread-1"),
26 threadId,
1427 operation: "status",
1528 input: {},
1629 timeoutMs: 15_000,
30 ...overrides,
1731});
1832
1933describe("previewAutomationRequestConsumer", () => {
......@@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => {
3044 });
3145 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
3246 requestsAtom,
47 environmentId,
3348 handleRequest,
3449 respond,
3550 label: "test:preview-automation-consumer",
......@@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => {
5671 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
5772 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
5873 requestsAtom,
74 environmentId,
5975 handleRequest: async () => undefined,
6076 respond,
6177 label: "test:preview-automation-initial-request",
......@@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => {
6985 registry.dispose();
7086 });
7187
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 });
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({
77107 _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 },
79118 });
80119 });
81120
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);
91131
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).",
95136 detail: {
96 requestId: "request-1",
137 requestId: "request-2",
138 operation: "snapshot",
139 environmentId: "environment-1",
97140 threadId: "thread-1",
141 tabId: "tab-1",
98142 },
99143 });
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();
100195 });
101196});
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+23-45
......@@ -1,6 +1,16 @@
1import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts";
1import type {
2 PreviewAutomationOwner,
3 PreviewAutomationRequest,
4 PreviewAutomationResponse,
5} from "@t3tools/contracts";
26import { AsyncResult, Atom } from "effect/unstable/reactivity";
37
8import {
9 PreviewAutomationOperationError,
10 type PreviewAutomationOperationContext,
11 serializePreviewAutomationOwnerError,
12} from "./previewAutomationErrors";
13
414type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>;
515type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>;
616
......@@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR
1929
2030export function serializePreviewAutomationError(
2131 error: unknown,
32 context: PreviewAutomationOperationContext,
2233): 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 );
6637}
6738
6839export function createPreviewAutomationRequestConsumerAtom<E>(options: {
6940 readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>;
41 readonly environmentId: PreviewAutomationOwner["environmentId"];
7042 readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>;
7143 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;
7244 readonly label: string;
......@@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom<E>(options: {
8961 options.respond({
9062 requestId: request.requestId,
9163 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 }),
9371 }),
9472 );
9573 };