diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index e3d08ea131b..2be14363624 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -3,19 +3,13 @@ import { useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { - EnvironmentId, type PreviewAutomationNavigateInput, type PreviewAutomationOpenInput, - PreviewAutomationOperation, type PreviewAutomationOwner as PreviewAutomationOwnerState, type PreviewAutomationRequest, type PreviewAutomationStatus, - PreviewTabId, type ScopedThreadRef, - ThreadId, - TrimmedNonEmptyString, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; import { @@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; import { createLatestPreviewAutomationRequestHandler, createPreviewAutomationRequestConsumerAtom, } from "./previewAutomationRequestConsumer"; -export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationOverlayTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationNavigationTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - readiness: Schema.Literals(["domContentLoaded", "load"]), - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - 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.`; - } -} - -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, - }, -) { - get responseTag() { - return "PreviewAutomationUnavailableError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; - } -} - -export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetUnavailableError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - bridgeAvailable: Schema.Boolean, - }, -) { - get responseTag() { - return "PreviewAutomationTabNotFoundError"; - } - - override get message(): string { - 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"}).`; - } -} - -export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( - "PreviewAutomationRecordingNotActiveError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - }, -) { - get responseTag() { - return "PreviewAutomationExecutionError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; - } -} - export function observeAutomationOwnerConnectedGeneration( previousGeneration: number | null, connectedGeneration: number | null, @@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: { const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ + let tabId = request.tabId ?? null; + try { + if (request.threadId !== threadRef.threadId) { + throw new PreviewAutomationStaleOwnerError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + expectedThreadId: threadRef.threadId, + requestedThreadId: request.threadId, + }); + } + const state = readThreadPreviewState(threadRef); + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { requestId: request.requestId, + operation: request.operation, environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); - } - const state = readThreadPreviewState(threadRef); - const tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - switch (request.operation) { - case "status": - return currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - if (!activeTabId) { - const result = await open({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); - return currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); + return await currentStatus(threadRef, input.show ?? true); } - const startedAt = await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + case "navigate": { + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, visible); } - const artifact = await stopBrowserRecording(tabId); - if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + case "snapshot": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.click( + tabId, + request.input as Parameters[1], + ); + case "type": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.type( + tabId, + request.input as Parameters[1], + ); + case "press": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.press( + tabId, + request.input as Parameters[1], + ); + case "scroll": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.scroll( + tabId, + request.input as Parameters[1], + ); + case "evaluate": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.evaluate( + tabId, + request.input as Parameters[1], + ); + case "waitFor": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.waitFor( + tabId, + request.input as Parameters[1], + ); + case "recordingStart": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const startedAt = await startBrowserRecording(tabId); + return { tabId, - }); + recording: true, + startedAt, + }; + } + case "recordingStop": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const artifact = await stopBrowserRecording(tabId); + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + }); + } + return artifact; } - return artifact; } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + cause, + }); } }, [open, threadRef, visible], @@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: { () => createPreviewAutomationRequestConsumerAtom({ requestsAtom: automationRequestsAtom, + environmentId: threadRef.environmentId, handleRequest: requestHandler.handle, respond: (response) => respondToAutomation({ diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts new file mode 100644 index 00000000000..c4ca445458c --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -0,0 +1,169 @@ +import { + EnvironmentId, + type PreviewAutomationOwner, + PreviewAutomationOperation, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface PreviewAutomationOperationContext { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly operation: PreviewAutomationRequest["operation"]; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly threadId: PreviewAutomationRequest["threadId"]; + readonly tabId: Exclude | null; +} + +export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationOverlayTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationNavigationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + readiness: Schema.Literals(["domContentLoaded", "load"]), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + 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.`; + } +} + +export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( + "PreviewAutomationStaleOwnerError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + expectedThreadId: ThreadId, + requestedThreadId: ThreadId, + }, +) { + get responseTag() { + return "PreviewAutomationUnavailableError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + } +} + +export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetUnavailableError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + bridgeAvailable: Schema.Boolean, + }, +) { + get responseTag() { + return "PreviewAutomationTabNotFoundError" as const; + } + + override get message(): string { + 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"}).`; + } +} + +export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingNotActiveError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + }, +) { + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + } +} + +export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( + "PreviewAutomationOperationError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + cause: Schema.Defect(), + }, +) { + static fromCause( + input: PreviewAutomationOperationContext & { readonly cause: unknown }, + ): PreviewAutomationOwnerError { + return isPreviewAutomationOwnerError(input.cause) + ? input.cause + : new PreviewAutomationOperationError(input); + } + + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; + } +} + +export const PreviewAutomationOwnerError = Schema.Union([ + PreviewAutomationOverlayTimeoutError, + PreviewAutomationNavigationTimeoutError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationOperationError, +]); +export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; + +export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); + +export function serializePreviewAutomationOwnerError( + error: PreviewAutomationOwnerError, +): NonNullable { + const detail = Object.fromEntries( + Object.entries(error).filter( + ([key]) => + key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", + ), + ); + return { + _tag: error.responseTag, + message: error.message, + ...(Object.keys(detail).length === 0 ? {} : { detail }), + }; +} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index 5cc89c00e9c..905a014d5af 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -1,19 +1,33 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, +} from "@t3tools/contracts"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom, serializePreviewAutomationError, } from "./previewAutomationRequestConsumer"; -const request = (requestId: string): PreviewAutomationRequest => ({ +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const tabId = PreviewTabId.make("tab-1"); + +const request = ( + requestId: string, + overrides: Partial = {}, +): PreviewAutomationRequest => ({ requestId, - threadId: ThreadId.make("thread-1"), + threadId, operation: "status", input: {}, timeoutMs: 15_000, + ...overrides, }); describe("previewAutomationRequestConsumer", () => { @@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => { }); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest, respond, label: "test:preview-automation-consumer", @@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => { const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest: async () => undefined, respond, label: "test:preview-automation-initial-request", @@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => { registry.dispose(); }); - it("preserves typed automation errors in responses", () => { - const error = new Error("No preview tab"); - error.name = "PreviewAutomationTabNotFoundError"; + it("preserves tagged automation errors and their structured diagnostics", () => { + const error = new PreviewAutomationTargetUnavailableError({ + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + bridgeAvailable: false, + }); - expect(serializePreviewAutomationError(error)).toEqual({ + expect( + serializePreviewAutomationError(error, { + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + }), + ).toEqual({ _tag: "PreviewAutomationTabNotFoundError", - message: "No preview tab", + message: + "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", + detail: { + requestId: "request-1", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + bridgeAvailable: false, + }, }); }); - it("serializes structured automation context without leaking causes", () => { - const error = Object.assign(new Error("Preview target unavailable"), { - name: "PreviewAutomationTargetUnavailableError", - _tag: "PreviewAutomationTargetUnavailableError", - responseTag: "PreviewAutomationTabNotFoundError", - requestId: "request-1", - threadId: "thread-1", - cause: new Error("private bridge failure"), - }); + it("correlates unexpected failures without exposing cause details", () => { + const cause = new Error("private bridge token: preview-secret"); + const context = { + requestId: "request-2", + operation: "snapshot" as const, + environmentId, + threadId, + tabId, + }; + const response = serializePreviewAutomationError(cause, context); - expect(serializePreviewAutomationError(error)).toEqual({ - _tag: "PreviewAutomationTabNotFoundError", - message: "Preview target unavailable", + expect(response).toEqual({ + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", detail: { - requestId: "request-1", + requestId: "request-2", + operation: "snapshot", + environmentId: "environment-1", threadId: "thread-1", + tabId: "tab-1", }, }); + expect(JSON.stringify(response)).not.toContain("preview-secret"); + }); + + it("sanitizes unexpected handler failures at the response boundary", async () => { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const responses: PreviewAutomationResponse[] = []; + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest: async () => { + throw new Error("desktop IPC secret: do-not-return"); + }, + respond: async (response) => { + responses.push(response); + }, + label: "test:preview-automation-failure-boundary", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + + registry.set( + requestsAtom, + AsyncResult.success( + request("request-failed", { + operation: "click", + tabId, + }), + ), + ); + + await vi.waitFor(() => expect(responses).toHaveLength(1)); + expect(responses[0]).toEqual({ + requestId: "request-failed", + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", + detail: { + requestId: "request-failed", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + }, + }, + }); + expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); + registry.dispose(); }); }); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts index 5cf5590335f..37983b0255e 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -1,6 +1,16 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; +import type { + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + PreviewAutomationOperationError, + type PreviewAutomationOperationContext, + serializePreviewAutomationOwnerError, +} from "./previewAutomationErrors"; + type AutomationRequestResult = AsyncResult.AsyncResult; type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise; @@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR export function serializePreviewAutomationError( error: unknown, + context: PreviewAutomationOperationContext, ): NonNullable { - if (error instanceof Error) { - const explicitDetail = - "detail" in error && (error as { detail?: unknown }).detail !== undefined - ? (error as { detail?: unknown }).detail - : undefined; - const structuralDetail = - "_tag" in error && - typeof (error as { _tag?: unknown })._tag === "string" && - (error as { _tag: string })._tag.startsWith("PreviewAutomation") - ? Object.fromEntries( - Object.entries(error).filter( - ([key]) => - key !== "_tag" && - key !== "cause" && - key !== "name" && - key !== "message" && - key !== "stack" && - key !== "detail" && - key !== "responseTag", - ), - ) - : undefined; - const detail = explicitDetail ?? structuralDetail; - const responseTag = - "responseTag" in error && - typeof (error as { responseTag?: unknown }).responseTag === "string" && - (error as { responseTag: string }).responseTag.startsWith("PreviewAutomation") - ? (error as { responseTag: string }).responseTag - : undefined; - return { - _tag: - responseTag ?? - (error.name.startsWith("PreviewAutomation") - ? error.name - : "PreviewAutomationExecutionError"), - message: error.message, - ...(detail === undefined ? {} : { detail }), - }; - } - return { - _tag: "PreviewAutomationExecutionError", - message: String(error), - }; + return serializePreviewAutomationOwnerError( + PreviewAutomationOperationError.fromCause({ ...context, cause: error }), + ); } export function createPreviewAutomationRequestConsumerAtom(options: { readonly requestsAtom: Atom.Atom>; + readonly environmentId: PreviewAutomationOwner["environmentId"]; readonly handleRequest: (request: PreviewAutomationRequest) => Promise; readonly respond: (response: PreviewAutomationResponse) => Promise; readonly label: string; @@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom(options: { options.respond({ requestId: request.requestId, ok: false, - error: serializePreviewAutomationError(error), + error: serializePreviewAutomationError(error, { + requestId: request.requestId, + operation: request.operation, + environmentId: options.environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, + }), }), ); };