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 @@
22
33import { useAtomValue } from "@effect/atom-react";
44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import type {
6 PreviewAutomationNavigateInput,
7 PreviewAutomationOpenInput,
8 PreviewAutomationRequest,
9 PreviewAutomationOwner as PreviewAutomationOwnerState,
10 PreviewAutomationStatus,
11 ScopedThreadRef,
5import {
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,
1217} from "@t3tools/contracts";
18import * as Schema from "effect/Schema";
1319import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
1420
1521import {
......@@ -30,6 +36,100 @@ import {
3036 createPreviewAutomationRequestConsumerAtom,
3137} 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
33133export function observeAutomationOwnerConnectedGeneration(
34134 previousGeneration: number | null,
35135 connectedGeneration: number | null,
......@@ -51,6 +151,7 @@ export function observeAutomationOwnerConnectedGeneration(
51151
52152const waitForDesktopOverlay = async (
53153 threadRef: ScopedThreadRef,
154 requestId: string,
54155 timeoutMs: number,
55156): Promise<void> => {
56157 const deadline = Date.now() + timeoutMs;
......@@ -63,20 +164,26 @@ const waitForDesktopOverlay = async (
63164 }
64165 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
65166 }
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 });
69173};
70174
71175const waitForNavigationReadiness = async (
176 threadRef: ScopedThreadRef,
177 requestId: string,
72178 tabId: string,
73179 readiness: PreviewAutomationNavigateInput["readiness"],
74180 timeoutMs: number,
75181): Promise<void> => {
76 if (!previewBridge || readiness === "none") return;
182 const targetReadiness = readiness ?? "load";
183 if (!previewBridge || targetReadiness === "none") return;
77184 const deadline = Date.now() + timeoutMs;
78185 while (Date.now() <= deadline) {
79 if (readiness === "domContentLoaded") {
186 if (targetReadiness === "domContentLoaded") {
80187 const readyState = await previewBridge.automation.evaluate(tabId, {
81188 expression: "document.readyState",
82189 });
......@@ -87,9 +194,14 @@ const waitForNavigationReadiness = async (
87194 }
88195 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
89196 }
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 });
93205};
94206
95207const currentStatus = async (
......@@ -113,12 +225,6 @@ const currentStatus = async (
113225 };
114226};
115227
116const previewTabNotFoundError = (): Error => {
117 const error = new Error("Preview tab is not initialized.");
118 error.name = "PreviewAutomationTabNotFoundError";
119 return error;
120};
121
122228export function PreviewAutomationOwner(props: {
123229 readonly threadRef: ScopedThreadRef;
124230 readonly visible: boolean;
......@@ -182,12 +288,23 @@ export function PreviewAutomationOwner(props: {
182288 const handleRequest = useCallback(
183289 async (request: PreviewAutomationRequest): Promise<unknown> => {
184290 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 });
188297 }
189298 const state = readThreadPreviewState(threadRef);
190299 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 };
191308 switch (request.operation) {
192309 case "status":
193310 return currentStatus(threadRef, visible);
......@@ -215,11 +332,13 @@ export function PreviewAutomationOwner(props: {
215332 if (input.show ?? true) {
216333 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
217334 }
218 await waitForDesktopOverlay(threadRef, request.timeoutMs);
335 await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs);
219336 return currentStatus(threadRef, input.show ?? true);
220337 }
221338 case "navigate": {
222 if (!previewBridge || !tabId) throw previewTabNotFoundError();
339 if (!previewBridge || !tabId) {
340 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
341 }
223342 const input = request.input as PreviewAutomationNavigateInput;
224343 const resolution = resolveBrowserNavigationTarget(
225344 threadRef.environmentId,
......@@ -227,6 +346,8 @@ export function PreviewAutomationOwner(props: {
227346 );
228347 await previewBridge.navigate(tabId, resolution.resolvedUrl);
229348 await waitForNavigationReadiness(
349 threadRef,
350 request.requestId,
230351 tabId,
231352 input.readiness ?? "load",
232353 input.timeoutMs ?? request.timeoutMs,
......@@ -234,46 +355,62 @@ export function PreviewAutomationOwner(props: {
234355 return currentStatus(threadRef, visible);
235356 }
236357 case "snapshot":
237 if (!previewBridge || !tabId) throw previewTabNotFoundError();
358 if (!previewBridge || !tabId) {
359 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
360 }
238361 return previewBridge.automation.snapshot(tabId);
239362 case "click":
240 if (!previewBridge || !tabId) throw previewTabNotFoundError();
363 if (!previewBridge || !tabId) {
364 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
365 }
241366 return previewBridge.automation.click(
242367 tabId,
243368 request.input as Parameters<typeof previewBridge.automation.click>[1],
244369 );
245370 case "type":
246 if (!previewBridge || !tabId) throw previewTabNotFoundError();
371 if (!previewBridge || !tabId) {
372 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
373 }
247374 return previewBridge.automation.type(
248375 tabId,
249376 request.input as Parameters<typeof previewBridge.automation.type>[1],
250377 );
251378 case "press":
252 if (!previewBridge || !tabId) throw previewTabNotFoundError();
379 if (!previewBridge || !tabId) {
380 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
381 }
253382 return previewBridge.automation.press(
254383 tabId,
255384 request.input as Parameters<typeof previewBridge.automation.press>[1],
256385 );
257386 case "scroll":
258 if (!previewBridge || !tabId) throw previewTabNotFoundError();
387 if (!previewBridge || !tabId) {
388 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
389 }
259390 return previewBridge.automation.scroll(
260391 tabId,
261392 request.input as Parameters<typeof previewBridge.automation.scroll>[1],
262393 );
263394 case "evaluate":
264 if (!previewBridge || !tabId) throw previewTabNotFoundError();
395 if (!previewBridge || !tabId) {
396 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
397 }
265398 return previewBridge.automation.evaluate(
266399 tabId,
267400 request.input as Parameters<typeof previewBridge.automation.evaluate>[1],
268401 );
269402 case "waitFor":
270 if (!previewBridge || !tabId) throw previewTabNotFoundError();
403 if (!previewBridge || !tabId) {
404 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
405 }
271406 return previewBridge.automation.waitFor(
272407 tabId,
273408 request.input as Parameters<typeof previewBridge.automation.waitFor>[1],
274409 );
275410 case "recordingStart": {
276 if (!tabId) throw previewTabNotFoundError();
411 if (!tabId) {
412 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
413 }
277414 const startedAt = await startBrowserRecording(tabId);
278415 return {
279416 tabId,
......@@ -282,9 +419,18 @@ export function PreviewAutomationOwner(props: {
282419 };
283420 }
284421 case "recordingStop": {
285 if (!tabId) throw previewTabNotFoundError();
422 if (!tabId) {
423 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
424 }
286425 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 }
288434 return artifact;
289435 }
290436 }
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+20
......@@ -78,4 +78,24 @@ describe("previewAutomationRequestConsumer", () => {
7878 message: "No preview tab",
7979 });
8080 });
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 });
81101});
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+30-4
......@@ -21,14 +21,40 @@ export function serializePreviewAutomationError(
2121 error: unknown,
2222): NonNullable<PreviewAutomationResponse["error"]> {
2323 if (error instanceof Error) {
24 const detail =
24 const explicitDetail =
2525 "detail" in error && (error as { detail?: unknown }).detail !== undefined
2626 ? (error as { detail?: unknown }).detail
2727 : 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;
2852 return {
29 _tag: error.name.startsWith("PreviewAutomation")
30 ? error.name
31 : "PreviewAutomationExecutionError",
53 _tag:
54 responseTag ??
55 (error.name.startsWith("PreviewAutomation")
56 ? error.name
57 : "PreviewAutomationExecutionError"),
3258 message: error.message,
3359 ...(detail === undefined ? {} : { detail }),
3460 };