diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index e7770dc629dd..8b5722d6c3ca 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -79,6 +79,24 @@ describe("preview IPC methods", () => { }), ); + effectIt.effect("returns typed click outcomes across the preview IPC handler", () => + Effect.gen(function* () { + const result = { + _tag: "NotSent", + reason: "target-disabled", + } as const; + const manager = PreviewManager.PreviewManager.of({ + automationClick: () => Effect.succeed(result), + } as unknown as PreviewManager.PreviewManager["Service"]); + + expect( + yield* PreviewIpc.automationClick + .handler({ tabId: "tab-1", input: { locator: "role=button[name='Send']" } }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)), + ).toEqual(result); + }), + ); + it("keeps the public automation status tab id limit", () => { const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); const tabId = "t".repeat(129); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..9122d8b5947f 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -2,6 +2,7 @@ import { DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationClickResultSchema, DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, @@ -302,10 +303,10 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({ export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, - result: Schema.Void, + result: DesktopPreviewAutomationClickResultSchema, handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.automationClick(tabId, input); + return yield* manager.automationClick(tabId, input); }), }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..29e7228ba03f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2,6 +2,7 @@ import { it as effectIt } from "@effect/vitest"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NodeVM from "node:vm"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -3747,4 +3748,132 @@ describe("Preview automation diagnostics", () => { expect(JSON.stringify(error)).not.toContain(selector); expect("locator" in error).toBe(false); }); + + it("does not invent an ambiguous target match count", () => { + const error = new PreviewManager.PreviewAutomationTargetLookupError({ + operation: "click", + tabId: "tab_1", + selectorKind: "locator", + selectorLength: 12, + failureKind: "ambiguous", + }); + + expect(error.message).toBe( + "Preview automation click matched multiple elements for locator (12 characters)", + ); + }); + + effectIt.effect("returns typed click lookup failures without dispatching input", () => + withManager((manager) => + Effect.gen(function* () { + const selector = "role=button[name='target-secret']"; + let lookupResult: unknown = { notFound: true, failureKind: "missing" }; + let lookupExpression = ""; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method !== "Runtime.evaluate") return undefined; + const expression = String(params?.["expression"] ?? ""); + if (expression.includes("const parsed = injected.parseSelector")) { + lookupExpression = expression; + return { result: { value: lookupResult } }; + } + return { result: { value: true } }; + }); + fromId.mockReturnValue({ + ...makeTestPreviewWebContents( + vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-click-frame"), + toPNG: () => Buffer.from("unused-click-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })), + ), + isDevToolsOpened: () => false, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_lookup"); + yield* manager.registerWebview("tab_lookup", 42); + + const missing = yield* manager.automationClick("tab_lookup", { locator: selector }); + lookupResult = { notFound: true, failureKind: "hidden" }; + const hidden = yield* manager.automationClick("tab_lookup", { locator: selector }); + lookupResult = { notFound: true, failureKind: "disabled" }; + const disabled = yield* manager.automationClick("tab_lookup", { locator: selector }); + lookupResult = { notFound: true, failureKind: "ambiguous", matchCount: 3 }; + const ambiguous = yield* manager.automationClick("tab_lookup", { locator: selector }); + const snapshot = yield* manager.automationSnapshot("tab_lookup"); + + expect([missing, hidden, disabled, ambiguous]).toEqual([ + { _tag: "NotSent", reason: "target-missing" }, + { _tag: "NotSent", reason: "target-hidden" }, + { _tag: "NotSent", reason: "target-disabled" }, + { _tag: "NotSent", reason: "target-ambiguous", matchCount: 3 }, + ]); + expect(snapshot.actionTimeline.filter((action) => action.action === "click")).toEqual([ + expect.objectContaining({ status: "failed" }), + expect.objectContaining({ status: "failed" }), + expect.objectContaining({ status: "failed" }), + expect.objectContaining({ status: "failed" }), + ]); + expect(sendCommand).not.toHaveBeenCalledWith("Input.dispatchMouseEvent", expect.anything()); + + const element = { + scrollIntoView: vi.fn(), + getBoundingClientRect: () => ({ left: 20, top: 10, width: 40, height: 20 }), + }; + const runLookup = ( + matches: ReadonlyArray, + state: { readonly visible: boolean; readonly enabled: boolean }, + ) => { + const querySelectorAll = vi.fn(() => matches); + const elementState = vi.fn((_element: typeof element, name: keyof typeof state) => ({ + matches: state[name], + })); + const result = NodeVM.runInNewContext(lookupExpression, { + document: {}, + globalThis: { + __t3PlaywrightInjected: { + parseSelector: vi.fn(() => ({ parts: [] })), + querySelectorAll, + elementState, + }, + }, + }); + return { result, querySelectorAll, elementState }; + }; + + const missingLookup = runLookup([], { visible: true, enabled: true }); + expect(missingLookup.result).toEqual({ notFound: true, failureKind: "missing" }); + expect(missingLookup.querySelectorAll).toHaveBeenCalledOnce(); + + const ambiguousLookup = runLookup([element, element, element], { + visible: true, + enabled: true, + }); + expect(ambiguousLookup.result).toEqual({ + notFound: true, + failureKind: "ambiguous", + matchCount: 3, + }); + expect(ambiguousLookup.querySelectorAll).toHaveBeenCalledOnce(); + + const hiddenLookup = runLookup([element], { visible: false, enabled: true }); + expect(hiddenLookup.result).toEqual({ notFound: true, failureKind: "hidden" }); + expect(hiddenLookup.elementState).toHaveBeenCalledWith(element, "visible"); + + const disabledLookup = runLookup([element], { visible: true, enabled: false }); + expect(disabledLookup.result).toEqual({ notFound: true, failureKind: "disabled" }); + expect(disabledLookup.elementState).toHaveBeenCalledWith(element, "enabled"); + + const visibleLookup = runLookup([element], { visible: true, enabled: true }); + expect(visibleLookup.result).toEqual({ x: 40, y: 20 }); + expect(element.scrollIntoView).toHaveBeenCalledWith({ block: "center", inline: "center" }); + }), + ), + ); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8ee312110d86..a2c0f7730e94 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -8,6 +8,7 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAutomationClickResult, DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, @@ -3541,7 +3542,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function locator, ); const point = yield* evaluateWithDebugger< - { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } + | { x: number; y: number } + | { invalidSelector: true; message: string } + | { + notFound: true; + failureKind: "missing" | "hidden" | "disabled"; + } + | { + notFound: true; + failureKind: "ambiguous"; + matchCount: number; + } >( tabId, send, @@ -3549,11 +3560,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try { const injected = globalThis.__t3PlaywrightInjected; const parsed = injected.parseSelector(${locatorJson}); - const element = injected.querySelector(parsed, document, true); - if (!element) return { notFound: true }; + const matches = injected.querySelectorAll(parsed, document); + if (matches.length === 0) return { notFound: true, failureKind: "missing" }; + if (matches.length > 1) { + return { notFound: true, failureKind: "ambiguous", matchCount: matches.length }; + } + const element = matches[0]; const visible = injected.elementState(element, "visible"); const enabled = injected.elementState(element, "enabled"); - if (!visible.matches || !enabled.matches) return { notFound: true }; + if (!visible.matches) return { notFound: true, failureKind: "hidden" }; + if (!enabled.matches) return { notFound: true, failureKind: "disabled" }; element.scrollIntoView({ block: "center", inline: "center" }); const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; @@ -3573,10 +3589,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); } if ("notFound" in point) { - return yield* new PreviewAutomationTargetNotFoundError({ + return yield* new PreviewAutomationTargetLookupError({ operation: "click", tabId, ...automationSelectorDiagnostics(input), + failureKind: point.failureKind, + ...(point.failureKind === "ambiguous" ? { matchCount: point.matchCount } : {}), }); } return point; @@ -3655,8 +3673,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationClickInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "click", (send) => - performAutomationClick(tabId, input, send), + return yield* Effect.gen(function* () { + yield* withControlSession(tabId, wc, "click", (send) => + performAutomationClick(tabId, input, send), + ); + return { _tag: "Dispatched" } satisfies DesktopPreviewAutomationClickResult; + }).pipe( + Effect.catchTags({ + PreviewAutomationTargetLookupError: ( + error, + ): Effect.Effect< + DesktopPreviewAutomationClickResult, + PreviewAutomationTargetLookupError + > => { + if (error.failureKind === "ambiguous") { + if (error.matchCount === undefined) return Effect.fail(error); + return Effect.succeed({ + _tag: "NotSent", + reason: "target-ambiguous", + matchCount: error.matchCount, + }); + } + return Effect.succeed({ + _tag: "NotSent", + reason: `target-${error.failureKind}`, + }); + }, + }), ); }); @@ -4269,6 +4312,34 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas } } +export class PreviewAutomationTargetLookupError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetLookupError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + failureKind: Schema.Literals(["missing", "hidden", "disabled", "ambiguous"]), + matchCount: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))), + }, +) { + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + if (this.failureKind === "hidden") { + return `Preview automation ${this.operation} found ${target}, but it is not visible`; + } + if (this.failureKind === "disabled") { + return `Preview automation ${this.operation} found ${target}, but it is disabled`; + } + if (this.failureKind === "ambiguous") { + return this.matchCount === undefined + ? `Preview automation ${this.operation} matched multiple elements for ${target}` + : `Preview automation ${this.operation} matched ${this.matchCount} elements for ${target}`; + } + return `Preview automation ${this.operation} could not find ${target}`; + } +} + export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass()( "PreviewAutomationTargetNotEditableError", { @@ -4387,6 +4458,7 @@ export const PreviewManagerError = Schema.Union([ PreviewAutomationDebuggerAttachedError, PreviewAutomationEvaluationError, PreviewAutomationTargetNotFoundError, + PreviewAutomationTargetLookupError, PreviewAutomationTargetNotEditableError, PreviewAutomationCoordinatesOutsideViewportError, PreviewAutomationInvalidSelectorError, @@ -4473,7 +4545,7 @@ export class PreviewManager extends Context.Service< readonly automationClick: ( tabId: string, input: PreviewAutomationClickInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly automationType: ( tabId: string, input: PreviewAutomationTypeInput, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fa2880f9c364..9468da95eb04 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -97,6 +97,51 @@ it.effect("returns bounded structural preview snapshot failures", () => ).pipe(Effect.provide(TestLayer)), ); +it.effect("returns a typed click lookup reason through the MCP tool", () => + Effect.scoped( + Effect.gen(function* () { + const locator = "role=button[name='request-secret']"; + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const events = yield* broker.connect({ + clientId: "mcp-click-failure-client", + environmentId, + }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "mcp-click-failure-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationTargetLookupError", + message: "The preview click target is disabled.", + detail: { failureKind: "disabled" }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const result = yield* server + .callTool({ name: "preview_click", arguments: { locator } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: "text", + text: `Preview automation click found locator (${locator.length} characters), but it is disabled.`, + }, + ]); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308e..24c2688f2fff 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -6,6 +6,7 @@ import { PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, + PreviewAutomationTargetLookupError, PreviewAutomationTargetNotEditableError, PreviewTabId, ProviderInstanceId, @@ -404,6 +405,55 @@ it.effect("classifies a remote non-editable target without collapsing it to exec ); }); +it.effect("preserves a remote click lookup reason without exposing the locator", () => { + const locator = "role=button[name='request-secret']"; + const remoteError = { + _tag: "PreviewAutomationTargetLookupError", + message: "The preview click target is not visible.", + detail: { failureKind: "hidden" }, + } as const; + + return 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: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "click", + input: { locator }, + tabId: PreviewTabId.make("tab-1"), + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationTargetLookupError); + expect(error).toMatchObject({ + operation: "click", + failureKind: "hidden", + selectorKind: "locator", + selectorLength: locator.length, + remoteTag: "PreviewAutomationTargetLookupError", + }); + expect(error.message).toBe( + `Preview automation click found locator (${locator.length} characters), but it is not visible.`, + ); + expect(error.message).not.toContain("request-secret"); + expect(error.cause).toBe(remoteError); + }), + ); +}); + it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3e9bfaac26ff..23928e18dc89 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -10,6 +10,8 @@ import { PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, + PreviewAutomationTargetLookupError, + PreviewAutomationTargetLookupFailureKind, PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, PreviewAutomationUnsupportedClientError, @@ -183,6 +185,14 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } +const PreviewAutomationTargetLookupRemoteDetail = Schema.Struct({ + failureKind: PreviewAutomationTargetLookupFailureKind, + matchCount: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), +}); +const isPreviewAutomationTargetLookupRemoteDetail = Schema.is( + PreviewAutomationTargetLookupRemoteDetail, +); + const classifyResponseError = ( context: PreviewAutomationRequestErrorContext, error: NonNullable, @@ -255,6 +265,16 @@ const classifyResponseError = ( : { selectorLength: remoteSelectorLength ?? context.selectorLength }), }); } + case "PreviewAutomationTargetLookupError": { + if (!isPreviewAutomationTargetLookupRemoteDetail(error.detail)) break; + if (error.detail.failureKind === "ambiguous" && error.detail.matchCount === undefined) break; + return new PreviewAutomationTargetLookupError({ + ...context, + ...remoteDiagnostics, + failureKind: error.detail.failureKind, + ...(error.detail.matchCount === undefined ? {} : { matchCount: error.detail.matchCount }), + }); + } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; @@ -278,11 +298,12 @@ const classifyResponseError = ( ...remoteDiagnostics, }); default: - return new PreviewAutomationExecutionError({ - ...context, - ...remoteDiagnostics, - }); + break; } + return new PreviewAutomationExecutionError({ + ...context, + ...remoteDiagnostics, + }); }; export const make = Effect.gen(function* PreviewAutomationBrokerMake() { diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..b46447ba7278 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -52,6 +52,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; import { + confirmPreviewAutomationClickTarget, PreviewAutomationOperationError, PreviewAutomationOverlayTimeoutError, PreviewAutomationRecordingNotActiveError, @@ -595,9 +596,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "click": { const ready = await requireReadyTab(); - return await ready.bridge.automation.click( - ready.runtimeTabId, - request.input as Parameters[1], + return confirmPreviewAutomationClickTarget( + await ready.bridge.automation.click( + ready.runtimeTabId, + request.input as Parameters[1], + ), + { + requestId: request.requestId, + operation: "click", + environmentId, + threadId: request.threadId, + tabId: ready.tabId, + }, ); } case "type": { diff --git a/apps/web/src/components/preview/previewAutomationErrors.test.ts b/apps/web/src/components/preview/previewAutomationErrors.test.ts new file mode 100644 index 000000000000..df831819c933 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationErrors.test.ts @@ -0,0 +1,92 @@ +import { + type DesktopPreviewAutomationClickResult, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + confirmPreviewAutomationClickTarget, + PreviewAutomationOperationError, + PreviewAutomationTargetLookupHostError, +} from "./previewAutomationErrors"; + +type NotSentClickResult = Extract; + +describe("confirmPreviewAutomationClickTarget", () => { + const context = { + requestId: "request-1", + operation: "click" as const, + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + }; + + const lookupError = ( + result: + | { readonly _tag: "NotSent"; readonly reason: "target-missing" } + | { readonly _tag: "NotSent"; readonly reason: "target-hidden" } + | { readonly _tag: "NotSent"; readonly reason: "target-disabled" } + | { + readonly _tag: "NotSent"; + readonly reason: "target-ambiguous"; + readonly matchCount: number; + }, + ) => { + try { + confirmPreviewAutomationClickTarget(result, context); + throw new Error("Expected click target confirmation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewAutomationTargetLookupHostError); + return error as PreviewAutomationTargetLookupHostError; + } + }; + + it("maps typed IPC outcomes to visible, disabled, ambiguous, and missing reasons", () => { + const hidden = lookupError({ _tag: "NotSent", reason: "target-hidden" }); + const disabled = lookupError({ _tag: "NotSent", reason: "target-disabled" }); + const ambiguous = lookupError({ + _tag: "NotSent", + reason: "target-ambiguous", + matchCount: 3, + }); + const missing = lookupError({ _tag: "NotSent", reason: "target-missing" }); + + expect(hidden.message).toContain("not visible"); + expect(disabled.message).toContain("disabled"); + expect(ambiguous.message).toContain("matched 3 elements"); + expect(missing.message).toContain("not found"); + expect(hidden.message).not.toContain("secret"); + expect(disabled.message).not.toContain("secret"); + expect(ambiguous.message).not.toContain("secret"); + }); + + it("fails every NotSent outcome and preserves successful results", () => { + const results = [ + { _tag: "NotSent", reason: "tab-not-visible" }, + { _tag: "NotSent", reason: "timeout", timeoutMs: 5_000 }, + { _tag: "NotSent", reason: "target-missing" }, + { _tag: "NotSent", reason: "target-hidden" }, + { _tag: "NotSent", reason: "target-disabled" }, + { _tag: "NotSent", reason: "target-ambiguous", matchCount: 3 }, + ] satisfies ReadonlyArray; + + for (const result of results) { + expect(() => confirmPreviewAutomationClickTarget(result, context)).toThrow(); + } + + for (const result of results.slice(0, 2)) { + try { + confirmPreviewAutomationClickTarget(result, context); + throw new Error("Expected click target confirmation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewAutomationOperationError); + expect((error as PreviewAutomationOperationError).cause).toEqual(result); + } + } + + const dispatched = { _tag: "Dispatched" } as const; + expect(confirmPreviewAutomationClickTarget(dispatched, context)).toBe(dispatched); + expect(confirmPreviewAutomationClickTarget(undefined, context)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index dcf35de53f2d..376f833ce534 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -1,4 +1,5 @@ import { + type DesktopPreviewAutomationClickResult, EnvironmentId, type PreviewAutomationHost, PreviewAutomationOperation, @@ -134,6 +135,70 @@ export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedEr } } +const PreviewAutomationTargetHostFields = { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), +}; + +export class PreviewAutomationTargetLookupHostError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetLookupHostError", + { + ...PreviewAutomationTargetHostFields, + failureKind: Schema.Literals(["missing", "hidden", "disabled", "ambiguous"]), + matchCount: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + }, +) { + get responseTag() { + return "PreviewAutomationTargetLookupError" as const; + } + + override get message(): string { + if (this.failureKind === "hidden") return "The preview click target is not visible."; + if (this.failureKind === "disabled") return "The preview click target is disabled."; + if (this.failureKind === "ambiguous") { + return this.matchCount === undefined + ? "The preview click target matched multiple elements." + : `The preview click target matched ${this.matchCount} elements.`; + } + return "The preview click target was not found."; + } +} + +export function confirmPreviewAutomationClickTarget( + result: DesktopPreviewAutomationClickResult | void, + context: PreviewAutomationOperationContext & { readonly operation: "click" }, +): DesktopPreviewAutomationClickResult | void { + if (result?._tag !== "NotSent") return result; + switch (result.reason) { + case "target-missing": + throw new PreviewAutomationTargetLookupHostError({ + ...context, + failureKind: "missing", + }); + case "target-hidden": + throw new PreviewAutomationTargetLookupHostError({ + ...context, + failureKind: "hidden", + }); + case "target-disabled": + throw new PreviewAutomationTargetLookupHostError({ + ...context, + failureKind: "disabled", + }); + case "target-ambiguous": + throw new PreviewAutomationTargetLookupHostError({ + ...context, + failureKind: "ambiguous", + matchCount: result.matchCount, + }); + default: + throw new PreviewAutomationOperationError({ ...context, cause: result }); + } +} + const targetNotEditableDiagnostics = ( cause: unknown, ): { @@ -211,6 +276,7 @@ export const PreviewAutomationHostError = Schema.Union([ PreviewAutomationViewportTimeoutError, PreviewAutomationTargetUnavailableError, PreviewAutomationRecordingNotActiveError, + PreviewAutomationTargetLookupHostError, PreviewAutomationTargetNotEditableHostError, PreviewAutomationOperationError, ]); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index af3a95c32c78..d7990a2e47fa 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -10,6 +10,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { + confirmPreviewAutomationClickTarget, PreviewAutomationRecordingNotActiveError, PreviewAutomationTargetUnavailableError, PreviewAutomationViewportTimeoutError, @@ -291,6 +292,37 @@ describe("previewAutomationRequestConsumer", () => { }); }); + it("maps hidden click targets to a named execution failure without leaking the locator", () => { + const context = { + requestId: "request-click", + operation: "click" as const, + environmentId, + threadId, + tabId, + }; + let error: unknown; + try { + confirmPreviewAutomationClickTarget({ _tag: "NotSent", reason: "target-hidden" }, context); + } catch (cause) { + error = cause; + } + + const response = serializePreviewAutomationError(error, context); + expect(response).toEqual({ + _tag: "PreviewAutomationTargetLookupError", + message: "The preview click target is not visible.", + detail: { + requestId: "request-click", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + failureKind: "hidden", + }, + }); + expect(JSON.stringify(response)).not.toContain("target-secret"); + }); + it("maps desktop non-editable targets to the public typed response", () => { expect( serializePreviewAutomationError( diff --git a/packages/contracts/src/ipc.test.ts b/packages/contracts/src/ipc.test.ts index 20db75368a9a..87f22907ad07 100644 --- a/packages/contracts/src/ipc.test.ts +++ b/packages/contracts/src/ipc.test.ts @@ -1,7 +1,10 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { DesktopEnvironmentBootstrapSchema } from "./ipc.ts"; +import { + DesktopEnvironmentBootstrapSchema, + DesktopPreviewAutomationClickResultSchema, +} from "./ipc.ts"; describe("DesktopEnvironmentBootstrapSchema", () => { const decode = Schema.decodeUnknownSync(DesktopEnvironmentBootstrapSchema); @@ -36,3 +39,23 @@ describe("DesktopEnvironmentBootstrapSchema", () => { ).toBeNull(); }); }); + +describe("DesktopPreviewAutomationClickResultSchema", () => { + const decode = Schema.decodeUnknownSync(DesktopPreviewAutomationClickResultSchema); + + it.each([ + { _tag: "Dispatched" }, + { _tag: "NotSent", reason: "tab-not-visible" }, + { _tag: "NotSent", reason: "timeout", timeoutMs: 50 }, + { _tag: "NotSent", reason: "target-missing" }, + { _tag: "NotSent", reason: "target-hidden" }, + { _tag: "NotSent", reason: "target-disabled" }, + { _tag: "NotSent", reason: "target-ambiguous", matchCount: 2 }, + ] as const)("decodes $reason", (result) => { + expect(decode(result)).toEqual(result); + }); + + it("rejects an ambiguous target without a positive match count", () => { + expect(() => decode({ _tag: "NotSent", reason: "target-ambiguous", matchCount: 0 })).toThrow(); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88dc2b26b280..f447d346f0c1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1001,6 +1001,32 @@ export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ input: PreviewAutomationClickInput, }); +export const DesktopPreviewAutomationClickResultSchema = Schema.Union([ + Schema.TaggedStruct("Dispatched", {}), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("tab-not-visible"), + }), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("timeout"), + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("target-missing"), + }), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("target-hidden"), + }), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("target-disabled"), + }), + Schema.TaggedStruct("NotSent", { + reason: Schema.Literal("target-ambiguous"), + matchCount: Schema.Int.check(Schema.isGreaterThan(0)), + }), +]); +export type DesktopPreviewAutomationClickResult = + typeof DesktopPreviewAutomationClickResultSchema.Type; + export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, input: PreviewAutomationTypeInput, @@ -1194,7 +1220,10 @@ export interface DesktopPreviewBridge { automation: { status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; - click: (tabId: string, input: PreviewAutomationClickInput) => Promise; + click: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index e33615fa4c05..78048300538a 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -800,6 +800,46 @@ export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorC } } +export const PreviewAutomationTargetLookupFailureKind = Schema.Literals([ + "missing", + "hidden", + "disabled", + "ambiguous", +]); +export type PreviewAutomationTargetLookupFailureKind = + typeof PreviewAutomationTargetLookupFailureKind.Type; + +export class PreviewAutomationTargetLookupError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetLookupError", + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + failureKind: PreviewAutomationTargetLookupFailureKind, + matchCount: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + override get message(): string { + const target = + this.selectorKind === undefined || this.selectorLength === undefined + ? "target" + : `${this.selectorKind} (${this.selectorLength} characters)`; + if (this.failureKind === "hidden") { + return `Preview automation ${this.operation} found ${target}, but it is not visible.`; + } + if (this.failureKind === "disabled") { + return `Preview automation ${this.operation} found ${target}, but it is disabled.`; + } + if (this.failureKind === "ambiguous") { + return this.matchCount === undefined + ? `Preview automation ${this.operation} matched multiple elements for ${target}.` + : `Preview automation ${this.operation} matched ${this.matchCount} elements for ${target}.`; + } + return `Preview automation ${this.operation} could not find ${target}.`; + } +} + export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( "PreviewAutomationResultTooLargeError", { @@ -866,6 +906,7 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationTargetNotEditableError, + PreviewAutomationTargetLookupError, PreviewAutomationResultTooLargeError, PreviewAutomationClientDisconnectedError, PreviewAutomationRequestQueueClosedError,