authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:07:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:07:09-07:00
log8c03188c1cad4220baac08b1580823b4baeaae70
tree860c9d7b482f366e4f46ea11030c31d1f99ecaa1
parent3e5f9f2cdd73c3afb1ab73634ca91c42098fbaba
signature Signed by PGP key B5690EEEBB952194

[codex] Structure preview automation failures (#3333)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 232 insertions(+), 40 deletions(-)

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