diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 18b0b8040e3d..8eeaa06490f7 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -169,4 +169,84 @@ describe("preview IPC methods", () => { }), ).toThrow(); }); + + effectIt.effect("resolves failures that have a public automation tag instead of rejecting", () => + Effect.gen(function* () { + const locator = "role=button[["; + const manager = PreviewManager.PreviewManager.of({ + automationWaitFor: (_tabId: string, input: { readonly text?: string }) => + input.text === "Ready" + ? Effect.void + : Effect.fail( + new PreviewManager.PreviewAutomationTimeoutError({ + tabId: "tab-1", + timeoutMs: 2_000, + }), + ), + automationScroll: () => + Effect.fail( + new PreviewManager.PreviewAutomationInvalidSelectorError({ + operation: "scroll", + tabId: "tab-1", + selectorKind: "locator", + selectorLength: locator.length, + reasonLength: 16, + cause: { invalidSelector: true, message: "Unexpected token" }, + }), + ), + automationType: (_tabId: string, input: { readonly selector?: string }) => + Effect.fail( + input.selector === undefined + ? new PreviewManager.PreviewAutomationTargetNotEditableError({ + tabId: "tab-1", + selectorKind: "focused-element", + }) + : new PreviewManager.PreviewAutomationTargetNotFoundError({ + operation: "type", + tabId: "tab-1", + selectorKind: "selector", + selectorLength: input.selector.length, + }), + ), + } as unknown as PreviewManager.PreviewManager["Service"]); + const provide = Effect.provideService(PreviewManager.PreviewManager, manager); + + expect( + yield* PreviewIpc.automationWaitFor + .handler({ tabId: "tab-1", input: { text: "Ready" } }) + .pipe(provide), + ).toBeUndefined(); + expect( + yield* PreviewIpc.automationWaitFor + .handler({ tabId: "tab-1", input: { text: "Missing", timeoutMs: 2_000 } }) + .pipe(provide), + ).toEqual({ _tag: "PreviewAutomationTimeoutError", timeoutMs: 2_000 }); + expect( + yield* PreviewIpc.automationScroll + .handler({ tabId: "tab-1", input: { locator, deltaY: 100 } }) + .pipe(provide), + ).toEqual({ + _tag: "PreviewAutomationInvalidSelectorError", + selectorKind: "locator", + selectorLength: locator.length, + }); + expect( + yield* PreviewIpc.automationType + .handler({ tabId: "tab-1", input: { text: "hello" } }) + .pipe(provide), + ).toEqual({ + _tag: "PreviewAutomationTargetNotEditableError", + selectorKind: "focused-element", + }); + // Target-not-found has no public response tag, so it still rejects. + const notFound = yield* PreviewIpc.automationType + .handler({ tabId: "tab-1", input: { selector: "#field", text: "hello" } }) + .pipe(provide, Effect.exit); + expect(Exit.isFailure(notFound)).toBe(true); + if (Exit.isSuccess(notFound)) return; + expect(Option.getOrUndefined(Cause.findErrorOption(notFound.cause))).toBeInstanceOf( + PreviewManager.PreviewAutomationTargetNotFoundError, + ); + }), + ); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 36cf7c9abe15..3127f8b5b5f2 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -3,6 +3,7 @@ import { DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, DesktopPreviewAutomationEvaluateInputSchema, + DesktopPreviewAutomationFailureSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, DesktopPreviewAutomationStatusSchema, @@ -422,14 +423,37 @@ export const automationClick = DesktopIpc.makeIpcMethod({ }), }); +/** + * Returns the failures the server classifies by tag as a typed result, because + * a rejected `ipcRenderer.invoke` keeps only the error message. + */ +const resolveAutomationFailures = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.as(undefined), + Effect.catchTags({ + PreviewAutomationTimeoutError: ({ timeoutMs }) => + Effect.succeed({ _tag: "PreviewAutomationTimeoutError", timeoutMs } as const), + PreviewAutomationInvalidSelectorError: (error) => + Effect.succeed({ _tag: "PreviewAutomationInvalidSelectorError", ...error.detail } as const), + PreviewAutomationTargetNotEditableError: ({ selectorKind, selectorLength }) => + Effect.succeed({ + _tag: "PreviewAutomationTargetNotEditableError", + selectorKind, + ...(selectorLength === undefined ? {} : { selectorLength }), + } as const), + }), + ); + export const automationType = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, payload: DesktopPreviewAutomationTypeInputSchema, - result: Schema.Void, + result: Schema.UndefinedOr(DesktopPreviewAutomationFailureSchema), handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { const manager = yield* PreviewManager.PreviewManager; yield* manager.automationType(tabId, input); - }), + }, resolveAutomationFailures), }); export const automationPress = DesktopIpc.makeIpcMethod({ @@ -445,11 +469,11 @@ export const automationPress = DesktopIpc.makeIpcMethod({ export const automationScroll = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, payload: DesktopPreviewAutomationScrollInputSchema, - result: Schema.Void, + result: Schema.UndefinedOr(DesktopPreviewAutomationFailureSchema), handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { const manager = yield* PreviewManager.PreviewManager; yield* manager.automationScroll(tabId, input); - }), + }, resolveAutomationFailures), }); export const automationEvaluate = DesktopIpc.makeIpcMethod({ @@ -465,11 +489,11 @@ export const automationEvaluate = DesktopIpc.makeIpcMethod({ export const automationWaitFor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, payload: DesktopPreviewAutomationWaitForInputSchema, - result: Schema.Void, + result: Schema.UndefinedOr(DesktopPreviewAutomationFailureSchema), handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { const manager = yield* PreviewManager.PreviewManager; yield* manager.automationWaitFor(tabId, input); - }), + }, resolveAutomationFailures), }); export const saveRecording = DesktopIpc.makeIpcMethod({ diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index adf55cad5e91..354480c9ebc5 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1463,6 +1463,40 @@ it.effect("rejects a routed action when its generation is evicted before deliver ), ); +it.effect("reports a host waitFor timeout against the caller deadline", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationTimeoutError", + message: `Preview automation waitFor request ${request.requestId} timed out after 2000ms.`, + detail: { operation: "waitFor", timeoutMs: 2_000 }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "waitFor", + input: { text: "Missing", timeoutMs: 2_000 }, + timeoutMs: 2_000, + }) + .pipe(Effect.flip); + + expect(error.message).toBe("Preview automation waitFor timed out after 2000ms."); + }), + ), +); + it.effect("keeps a host that responds with an operation timeout", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 4a3f9b7c88ac..646416524fae 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -696,10 +696,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "type": { const ready = await requireReadyTab(); - return await ready.bridge.automation.type( + const failure = await ready.bridge.automation.type( ready.runtimeTabId, request.input as Parameters[1], ); + if (failure) throw failure; + return; } case "press": { const ready = await requireReadyTab(); @@ -710,10 +712,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "scroll": { const ready = await requireReadyTab(); - return await ready.bridge.automation.scroll( + const failure = await ready.bridge.automation.scroll( ready.runtimeTabId, request.input as Parameters[1], ); + if (failure) throw failure; + return; } case "evaluate": { const ready = await requireReadyTab(); @@ -724,10 +728,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "waitFor": { const ready = await requireReadyTab(); - return await ready.bridge.automation.waitFor( + const failure = await ready.bridge.automation.waitFor( ready.runtimeTabId, request.input as Parameters[1], ); + if (failure) throw failure; + return; } case "recordingStart": { const ready = await requireReadyTab(); diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index 5e1378599878..c84c415b16a6 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -1,4 +1,5 @@ import { + DesktopPreviewAutomationFailureSchema, EnvironmentId, type PreviewAutomationHost, PreviewAutomationOperation, @@ -79,6 +80,26 @@ export class PreviewAutomationViewportTimeoutError extends Schema.TaggedError()( + "PreviewAutomationOperationTimeoutError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId ?? "unassigned"} timed out after ${this.timeoutMs}ms.`; + } +} + export class PreviewAutomationTargetUnavailableError extends Schema.TaggedError()( "PreviewAutomationTargetUnavailableError", { @@ -117,6 +138,27 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError } } +export class PreviewAutomationInvalidSelectorHostError extends Schema.TaggedError()( + "PreviewAutomationInvalidSelectorHostError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + get responseTag() { + return "PreviewAutomationInvalidSelectorError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} received an invalid selector in tab ${this.tabId ?? "unassigned"}.`; + } +} + export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedError()( "PreviewAutomationTargetNotEditableHostError", { @@ -138,39 +180,7 @@ export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedEr } } -const targetNotEditableDiagnostics = ( - cause: unknown, -): { - readonly selectorKind?: "focused-element" | "locator" | "selector"; - readonly selectorLength?: number; -} | null => { - if ( - typeof cause !== "object" || - cause === null || - !("_tag" in cause) || - cause._tag !== "PreviewAutomationTargetNotEditableError" - ) { - return null; - } - const selectorKind = - "selectorKind" in cause && - (cause.selectorKind === "focused-element" || - cause.selectorKind === "locator" || - cause.selectorKind === "selector") - ? cause.selectorKind - : undefined; - const selectorLength = - "selectorLength" in cause && - typeof cause.selectorLength === "number" && - Number.isInteger(cause.selectorLength) && - cause.selectorLength >= 0 - ? cause.selectorLength - : undefined; - return { - ...(selectorKind === undefined ? {} : { selectorKind }), - ...(selectorLength === undefined ? {} : { selectorLength }), - }; -}; +const isDesktopPreviewAutomationFailure = Schema.is(DesktopPreviewAutomationFailureSchema); export class PreviewAutomationOperationError extends Schema.TaggedError()( "PreviewAutomationOperationError", @@ -186,18 +196,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedError { }); }); + it("maps typed failures the desktop resolves over IPC to the public response tags", () => { + const serialize = (cause: unknown, operation: PreviewAutomationRequest["operation"]) => + serializePreviewAutomationError(cause, { + requestId: "request-desktop", + operation, + environmentId, + threadId, + tabId, + }); + + expect( + serialize({ _tag: "PreviewAutomationTimeoutError", timeoutMs: 2_000 }, "waitFor"), + ).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + detail: { operation: "waitFor", tabId: "tab-1", timeoutMs: 2_000 }, + }); + expect( + serialize( + { + _tag: "PreviewAutomationInvalidSelectorError", + selectorKind: "locator", + selectorLength: 9, + }, + "scroll", + ), + ).toMatchObject({ + _tag: "PreviewAutomationInvalidSelectorError", + detail: { operation: "scroll", selectorKind: "locator", selectorLength: 9 }, + }); + expect( + serialize( + { _tag: "PreviewAutomationTargetNotEditableError", selectorKind: "focused-element" }, + "type", + ), + ).toMatchObject({ + _tag: "PreviewAutomationTargetNotEditableError", + detail: { operation: "type", selectorKind: "focused-element" }, + }); + // A rejected `ipcRenderer.invoke` keeps only the message, and a malformed + // failure cannot be trusted, so both stay untyped. + for (const cause of [ + new Error("Preview condition did not match within 2000ms in tab tab-1"), + { _tag: "PreviewAutomationTimeoutError" }, + ]) { + expect(serialize(cause, "waitFor")).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + }); + } + }); + it("correlates unexpected failures without exposing cause details", () => { const cause = new Error("private bridge token: preview-secret"); const context = { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db2cc2a36cac..862d3b6c197f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1114,6 +1114,31 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +const DesktopPreviewAutomationSelectorFields = { + selectorKind: Schema.Literals(["focused-element", "locator", "selector"]), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}; + +/** + * A typed automation failure that main resolves with instead of rejecting. + * `ipcRenderer.invoke` keeps only a rejection's message (electron#24427), so the + * tag and fields the server classifies have to cross IPC as a result. + */ +export const DesktopPreviewAutomationFailureSchema = Schema.Union([ + Schema.TaggedStruct("PreviewAutomationTimeoutError", { + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.TaggedStruct( + "PreviewAutomationInvalidSelectorError", + DesktopPreviewAutomationSelectorFields, + ), + Schema.TaggedStruct( + "PreviewAutomationTargetNotEditableError", + DesktopPreviewAutomationSelectorFields, + ), +]); +export type DesktopPreviewAutomationFailure = typeof DesktopPreviewAutomationFailureSchema.Type; + /** * A System Settings pane the app can deep-link to. The identifier crosses IPC * rather than a URL, so the renderer can only reach these known destinations. @@ -1341,11 +1366,20 @@ export interface DesktopPreviewBridge { status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; - type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; + type: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; - scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise; + scroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Promise; evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise; - waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise; + waitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Promise; }; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;