Skip to content

Commit 4de44be

Browse files
committed
fix(preview): name hidden, disabled, and ambiguous click failures
preview_click treated a hidden or disabled button as missing and leaked the locator when it failed. Agents then spent turns guessing at chrome they could already see. Clicks now report hidden, disabled, or ambiguous targets without putting the locator on the wire.
1 parent cd096b9 commit 4de44be

5 files changed

Lines changed: 338 additions & 10 deletions

File tree

apps/desktop/src/preview/Manager.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2839,4 +2839,33 @@ describe("Preview automation diagnostics", () => {
28392839
expect(JSON.stringify(error)).not.toContain(selector);
28402840
expect("locator" in error).toBe(false);
28412841
});
2842+
2843+
it("names hidden, disabled, and ambiguous click failures without leaking the locator", () => {
2844+
const selector = "role=button[name='target-secret']";
2845+
const hidden = new PreviewManager.PreviewAutomationTargetHiddenError({
2846+
operation: "click",
2847+
tabId: "tab_1",
2848+
selectorKind: "locator",
2849+
selectorLength: selector.length,
2850+
});
2851+
const disabled = new PreviewManager.PreviewAutomationTargetDisabledError({
2852+
operation: "click",
2853+
tabId: "tab_1",
2854+
selectorKind: "locator",
2855+
selectorLength: selector.length,
2856+
});
2857+
const ambiguous = new PreviewManager.PreviewAutomationTargetAmbiguousError({
2858+
operation: "click",
2859+
tabId: "tab_1",
2860+
selectorKind: "locator",
2861+
selectorLength: selector.length,
2862+
matchCount: 3,
2863+
});
2864+
expect(hidden.message).toContain("not visible");
2865+
expect(disabled.message).toContain("disabled");
2866+
expect(ambiguous.message).toContain("matched 3 elements");
2867+
expect(hidden.message).not.toContain("secret");
2868+
expect(disabled.message).not.toContain("secret");
2869+
expect(ambiguous.message).not.toContain("secret");
2870+
});
28422871
});

apps/desktop/src/preview/Manager.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2990,19 +2990,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29902990
locator,
29912991
);
29922992
const point = yield* evaluateWithDebugger<
2993-
{ x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
2993+
| { x: number; y: number }
2994+
| { invalidSelector: true; message: string }
2995+
| {
2996+
notFound: true;
2997+
failureKind: "missing" | "hidden" | "disabled" | "ambiguous";
2998+
matchCount?: number;
2999+
}
29943000
>(
29953001
tabId,
29963002
send,
29973003
`(() => {
29983004
try {
29993005
const injected = globalThis.__t3PlaywrightInjected;
30003006
const parsed = injected.parseSelector(${locatorJson});
3001-
const element = injected.querySelector(parsed, document, true);
3002-
if (!element) return { notFound: true };
3007+
let element;
3008+
try {
3009+
element = injected.querySelector(parsed, document, true);
3010+
} catch (error) {
3011+
const message = String(error);
3012+
if (message.toLowerCase().includes("strict mode")) {
3013+
const matches = injected.querySelectorAll
3014+
? injected.querySelectorAll(parsed, document)
3015+
: [];
3016+
return { notFound: true, failureKind: "ambiguous", matchCount: matches.length || 2 };
3017+
}
3018+
throw error;
3019+
}
3020+
if (!element) return { notFound: true, failureKind: "missing" };
30033021
const visible = injected.elementState(element, "visible");
30043022
const enabled = injected.elementState(element, "enabled");
3005-
if (!visible.matches || !enabled.matches) return { notFound: true };
3023+
if (!visible.matches) return { notFound: true, failureKind: "hidden" };
3024+
if (!enabled.matches) return { notFound: true, failureKind: "disabled" };
30063025
element.scrollIntoView({ block: "center", inline: "center" });
30073026
const rect = element.getBoundingClientRect();
30083027
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
@@ -3022,10 +3041,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
30223041
});
30233042
}
30243043
if ("notFound" in point) {
3025-
return yield* new PreviewAutomationTargetNotFoundError({
3044+
return yield* raiseAutomationTargetLookupError({
30263045
operation: "click",
30273046
tabId,
30283047
...automationSelectorDiagnostics(input),
3048+
failureKind: point.failureKind,
3049+
...(point.matchCount === undefined ? {} : { matchCount: point.matchCount }),
30293050
});
30303051
}
30313052
return point;
@@ -3668,21 +3689,85 @@ export class PreviewAutomationEvaluationError extends Schema.TaggedErrorClass<Pr
36683689
}
36693690
}
36703691

3692+
const PreviewAutomationTargetLookupFields = {
3693+
operation: Schema.String,
3694+
tabId: Schema.String,
3695+
selectorKind: PreviewAutomationSelectorKind,
3696+
selectorLength: Schema.optionalKey(Schema.Number),
3697+
};
3698+
36713699
export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotFoundError>()(
36723700
"PreviewAutomationTargetNotFoundError",
3701+
PreviewAutomationTargetLookupFields,
3702+
) {
3703+
override get message(): string {
3704+
const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
3705+
return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`;
3706+
}
3707+
}
3708+
3709+
export class PreviewAutomationTargetHiddenError extends Schema.TaggedErrorClass<PreviewAutomationTargetHiddenError>()(
3710+
"PreviewAutomationTargetHiddenError",
3711+
PreviewAutomationTargetLookupFields,
3712+
) {
3713+
override get message(): string {
3714+
const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
3715+
return `Preview automation ${this.operation} found ${target} in tab ${this.tabId}, but it is not visible`;
3716+
}
3717+
}
3718+
3719+
export class PreviewAutomationTargetDisabledError extends Schema.TaggedErrorClass<PreviewAutomationTargetDisabledError>()(
3720+
"PreviewAutomationTargetDisabledError",
3721+
PreviewAutomationTargetLookupFields,
3722+
) {
3723+
override get message(): string {
3724+
const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
3725+
return `Preview automation ${this.operation} found ${target} in tab ${this.tabId}, but it is disabled`;
3726+
}
3727+
}
3728+
3729+
export class PreviewAutomationTargetAmbiguousError extends Schema.TaggedErrorClass<PreviewAutomationTargetAmbiguousError>()(
3730+
"PreviewAutomationTargetAmbiguousError",
36733731
{
3674-
operation: Schema.String,
3675-
tabId: Schema.String,
3676-
selectorKind: PreviewAutomationSelectorKind,
3677-
selectorLength: Schema.optionalKey(Schema.Number),
3732+
...PreviewAutomationTargetLookupFields,
3733+
matchCount: Schema.Number,
36783734
},
36793735
) {
36803736
override get message(): string {
36813737
const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
3682-
return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`;
3738+
return `Preview automation ${this.operation} matched ${this.matchCount} elements for ${target} in tab ${this.tabId}`;
36833739
}
36843740
}
36853741

3742+
const raiseAutomationTargetLookupError = (input: {
3743+
readonly operation: string;
3744+
readonly tabId: string;
3745+
readonly selectorKind: PreviewAutomationSelectorKind;
3746+
readonly selectorLength?: number;
3747+
readonly failureKind?: "missing" | "hidden" | "disabled" | "ambiguous";
3748+
readonly matchCount?: number;
3749+
}) => {
3750+
const shared = {
3751+
operation: input.operation,
3752+
tabId: input.tabId,
3753+
selectorKind: input.selectorKind,
3754+
...(input.selectorLength === undefined ? {} : { selectorLength: input.selectorLength }),
3755+
};
3756+
if (input.failureKind === "hidden") {
3757+
return new PreviewAutomationTargetHiddenError(shared);
3758+
}
3759+
if (input.failureKind === "disabled") {
3760+
return new PreviewAutomationTargetDisabledError(shared);
3761+
}
3762+
if (input.failureKind === "ambiguous") {
3763+
return new PreviewAutomationTargetAmbiguousError({
3764+
...shared,
3765+
matchCount: input.matchCount ?? 0,
3766+
});
3767+
}
3768+
return new PreviewAutomationTargetNotFoundError(shared);
3769+
};
3770+
36863771
export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()(
36873772
"PreviewAutomationTargetNotEditableError",
36883773
{
@@ -3798,6 +3883,9 @@ export const PreviewManagerError = Schema.Union([
37983883
PreviewAutomationDebuggerAttachedError,
37993884
PreviewAutomationEvaluationError,
38003885
PreviewAutomationTargetNotFoundError,
3886+
PreviewAutomationTargetHiddenError,
3887+
PreviewAutomationTargetDisabledError,
3888+
PreviewAutomationTargetAmbiguousError,
38013889
PreviewAutomationTargetNotEditableError,
38023890
PreviewAutomationCoordinatesOutsideViewportError,
38033891
PreviewAutomationInvalidSelectorError,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "vite-plus/test";
3+
4+
import { PreviewAutomationOperationError } from "./previewAutomationErrors";
5+
6+
describe("PreviewAutomationOperationError", () => {
7+
const context = {
8+
requestId: "request-1",
9+
operation: "click" as const,
10+
environmentId: EnvironmentId.make("environment-1"),
11+
threadId: ThreadId.make("thread-1"),
12+
tabId: "tab-1",
13+
};
14+
15+
it("maps typed not-found failures to a visible/disabled/ambiguous reason", () => {
16+
const hidden = PreviewAutomationOperationError.fromCause({
17+
...context,
18+
cause: { _tag: "PreviewAutomationTargetHiddenError" },
19+
});
20+
const disabled = PreviewAutomationOperationError.fromCause({
21+
...context,
22+
cause: { _tag: "PreviewAutomationTargetDisabledError" },
23+
});
24+
const ambiguous = PreviewAutomationOperationError.fromCause({
25+
...context,
26+
cause: { _tag: "PreviewAutomationTargetAmbiguousError", matchCount: 3 },
27+
});
28+
const legacyHidden = PreviewAutomationOperationError.fromCause({
29+
...context,
30+
cause: {
31+
_tag: "PreviewAutomationTargetNotFoundError",
32+
failureKind: "hidden",
33+
},
34+
});
35+
expect(hidden.message).toContain("not visible");
36+
expect(disabled.message).toContain("disabled");
37+
expect(ambiguous.message).toContain("matched 3 elements");
38+
expect(legacyHidden.message).toContain("not visible");
39+
expect(hidden.message).not.toContain("secret");
40+
expect(disabled.message).not.toContain("secret");
41+
expect(ambiguous.message).not.toContain("secret");
42+
});
43+
});

apps/web/src/components/preview/previewAutomationErrors.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,103 @@ export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedEr
134134
}
135135
}
136136

137+
const PreviewAutomationTargetHostFields = {
138+
requestId: TrimmedNonEmptyString,
139+
operation: PreviewAutomationOperation,
140+
environmentId: EnvironmentId,
141+
threadId: ThreadId,
142+
tabId: Schema.NullOr(PreviewTabId),
143+
cause: Schema.Defect(),
144+
};
145+
146+
const readTargetLookupKind = (
147+
cause: unknown,
148+
): "missing" | "hidden" | "disabled" | "ambiguous" | null => {
149+
if (typeof cause !== "object" || cause === null || !("_tag" in cause)) return null;
150+
if (cause._tag === "PreviewAutomationTargetHiddenError") return "hidden";
151+
if (cause._tag === "PreviewAutomationTargetDisabledError") return "disabled";
152+
if (cause._tag === "PreviewAutomationTargetAmbiguousError") return "ambiguous";
153+
if (cause._tag !== "PreviewAutomationTargetNotFoundError") return null;
154+
if (
155+
"failureKind" in cause &&
156+
(cause.failureKind === "hidden" ||
157+
cause.failureKind === "disabled" ||
158+
cause.failureKind === "ambiguous")
159+
) {
160+
return cause.failureKind;
161+
}
162+
return "missing";
163+
};
164+
165+
const readAmbiguousMatchCount = (cause: unknown): number => {
166+
if (
167+
typeof cause === "object" &&
168+
cause !== null &&
169+
"matchCount" in cause &&
170+
typeof cause.matchCount === "number" &&
171+
Number.isInteger(cause.matchCount) &&
172+
cause.matchCount >= 0
173+
) {
174+
return cause.matchCount;
175+
}
176+
return 0;
177+
};
178+
179+
export class PreviewAutomationTargetNotFoundHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotFoundHostError>()(
180+
"PreviewAutomationTargetNotFoundHostError",
181+
PreviewAutomationTargetHostFields,
182+
) {
183+
get responseTag() {
184+
return "PreviewAutomationExecutionError" as const;
185+
}
186+
187+
override get message(): string {
188+
return `Preview automation ${this.operation} request ${this.requestId} could not find a target in tab ${this.tabId ?? "unassigned"}.`;
189+
}
190+
}
191+
192+
export class PreviewAutomationTargetHiddenHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetHiddenHostError>()(
193+
"PreviewAutomationTargetHiddenHostError",
194+
PreviewAutomationTargetHostFields,
195+
) {
196+
get responseTag() {
197+
return "PreviewAutomationExecutionError" as const;
198+
}
199+
200+
override get message(): string {
201+
return `Preview automation ${this.operation} request ${this.requestId} found a target in tab ${this.tabId ?? "unassigned"}, but it is not visible.`;
202+
}
203+
}
204+
205+
export class PreviewAutomationTargetDisabledHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetDisabledHostError>()(
206+
"PreviewAutomationTargetDisabledHostError",
207+
PreviewAutomationTargetHostFields,
208+
) {
209+
get responseTag() {
210+
return "PreviewAutomationExecutionError" as const;
211+
}
212+
213+
override get message(): string {
214+
return `Preview automation ${this.operation} request ${this.requestId} found a target in tab ${this.tabId ?? "unassigned"}, but it is disabled.`;
215+
}
216+
}
217+
218+
export class PreviewAutomationTargetAmbiguousHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetAmbiguousHostError>()(
219+
"PreviewAutomationTargetAmbiguousHostError",
220+
{
221+
...PreviewAutomationTargetHostFields,
222+
matchCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
223+
},
224+
) {
225+
get responseTag() {
226+
return "PreviewAutomationExecutionError" as const;
227+
}
228+
229+
override get message(): string {
230+
return `Preview automation ${this.operation} request ${this.requestId} matched ${this.matchCount} elements in tab ${this.tabId ?? "unassigned"}.`;
231+
}
232+
}
233+
137234
const targetNotEditableDiagnostics = (
138235
cause: unknown,
139236
): {
@@ -183,6 +280,26 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
183280
input: PreviewAutomationOperationContext & { readonly cause: unknown },
184281
): PreviewAutomationHostError {
185282
if (isPreviewAutomationHostError(input.cause)) return input.cause;
283+
const lookupKind = readTargetLookupKind(input.cause);
284+
if (lookupKind) {
285+
const shared = {
286+
requestId: input.requestId,
287+
operation: input.operation,
288+
environmentId: input.environmentId,
289+
threadId: input.threadId,
290+
tabId: input.tabId,
291+
cause: input.cause,
292+
};
293+
if (lookupKind === "hidden") return new PreviewAutomationTargetHiddenHostError(shared);
294+
if (lookupKind === "disabled") return new PreviewAutomationTargetDisabledHostError(shared);
295+
if (lookupKind === "ambiguous") {
296+
return new PreviewAutomationTargetAmbiguousHostError({
297+
...shared,
298+
matchCount: readAmbiguousMatchCount(input.cause),
299+
});
300+
}
301+
return new PreviewAutomationTargetNotFoundHostError(shared);
302+
}
186303
const diagnostics = targetNotEditableDiagnostics(input.cause);
187304
return diagnostics
188305
? new PreviewAutomationTargetNotEditableHostError({
@@ -211,6 +328,10 @@ export const PreviewAutomationHostError = Schema.Union([
211328
PreviewAutomationViewportTimeoutError,
212329
PreviewAutomationTargetUnavailableError,
213330
PreviewAutomationRecordingNotActiveError,
331+
PreviewAutomationTargetNotFoundHostError,
332+
PreviewAutomationTargetHiddenHostError,
333+
PreviewAutomationTargetDisabledHostError,
334+
PreviewAutomationTargetAmbiguousHostError,
214335
PreviewAutomationTargetNotEditableHostError,
215336
PreviewAutomationOperationError,
216337
]);

0 commit comments

Comments
 (0)