Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}),
);
});
36 changes: 30 additions & 6 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DesktopPreviewArtifactInputSchema,
DesktopPreviewAutomationClickInputSchema,
DesktopPreviewAutomationEvaluateInputSchema,
DesktopPreviewAutomationFailureSchema,
DesktopPreviewAutomationPressInputSchema,
DesktopPreviewAutomationScrollInputSchema,
DesktopPreviewAutomationStatusSchema,
Expand Down Expand Up @@ -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 = <R>(
effect: Effect.Effect<void, PreviewManager.PreviewManagerError, R>,
) =>
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({
Expand All @@ -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({
Expand All @@ -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({
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>({
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* () {
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ready.bridge.automation.type>[1],
);
if (failure) throw failure;
return;
}
case "press": {
const ready = await requireReadyTab();
Expand All @@ -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<typeof ready.bridge.automation.scroll>[1],
);
if (failure) throw failure;
return;
}
case "evaluate": {
const ready = await requireReadyTab();
Expand All @@ -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<typeof ready.bridge.automation.waitFor>[1],
);
if (failure) throw failure;
return;
}
case "recordingStart": {
const ready = await requireReadyTab();
Expand Down
102 changes: 57 additions & 45 deletions apps/web/src/components/preview/previewAutomationErrors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
DesktopPreviewAutomationFailureSchema,
EnvironmentId,
type PreviewAutomationHost,
PreviewAutomationOperation,
Expand Down Expand Up @@ -79,6 +80,26 @@ export class PreviewAutomationViewportTimeoutError extends Schema.TaggedError<Pr
}
}

export class PreviewAutomationOperationTimeoutError extends Schema.TaggedError<PreviewAutomationOperationTimeoutError>()(
"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>()(
"PreviewAutomationTargetUnavailableError",
{
Expand Down Expand Up @@ -117,6 +138,27 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError
}
}

export class PreviewAutomationInvalidSelectorHostError extends Schema.TaggedError<PreviewAutomationInvalidSelectorHostError>()(
"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>()(
"PreviewAutomationTargetNotEditableHostError",
{
Expand All @@ -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>()(
"PreviewAutomationOperationError",
Expand All @@ -186,18 +196,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedError<PreviewA
static fromCause(
input: PreviewAutomationOperationContext & { readonly cause: unknown },
): PreviewAutomationHostError {
if (isPreviewAutomationHostError(input.cause)) return input.cause;
const diagnostics = targetNotEditableDiagnostics(input.cause);
return diagnostics
? new PreviewAutomationTargetNotEditableHostError({
requestId: input.requestId,
operation: input.operation,
environmentId: input.environmentId,
threadId: input.threadId,
tabId: input.tabId,
...diagnostics,
})
: new PreviewAutomationOperationError(input);
const { cause, ...context } = input;
if (isPreviewAutomationHostError(cause)) return cause;
if (!isDesktopPreviewAutomationFailure(cause)) {
return new PreviewAutomationOperationError(input);
}
if (cause._tag === "PreviewAutomationTimeoutError") {
return new PreviewAutomationOperationTimeoutError({ ...context, timeoutMs: cause.timeoutMs });
}
const { _tag, ...diagnostics } = cause;
return _tag === "PreviewAutomationInvalidSelectorError"
? new PreviewAutomationInvalidSelectorHostError({ ...context, ...diagnostics })
: new PreviewAutomationTargetNotEditableHostError({ ...context, ...diagnostics });
}

get responseTag() {
Expand All @@ -217,9 +227,11 @@ export const PreviewAutomationHostError = Schema.Union([
PreviewAutomationOverlayTimeoutError,
PreviewAutomationNavigationTimeoutError,
PreviewAutomationViewportTimeoutError,
PreviewAutomationOperationTimeoutError,
PreviewAutomationTargetUnavailableError,
PreviewAutomationRecordingNotActiveError,
PreviewAutomationTargetNotEditableHostError,
PreviewAutomationInvalidSelectorHostError,
PreviewAutomationOperationError,
]);
export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type;
Expand Down
Loading
Loading