Skip to content

Commit 88686c5

Browse files
committed
fix(preview): keep slim snapshots honest and fail closed
Pointer-cursor div/td controls were never selected. An empty <main> fell back to body text, leaking sidebar chrome. A failed capturePage plus an empty CDP screenshot became a 0x0 image. Snapshots now select those controls, keep empty main text empty, and surface a typed capture failure. Tests cover the slim default, include ax, wait scope, and the failed-screenshot path.
1 parent 5a0b0cb commit 88686c5

2 files changed

Lines changed: 151 additions & 13 deletions

File tree

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ vi.mock("electron", () => ({
8787
},
8888
nativeImage: {
8989
createFromPath,
90+
createFromBuffer: (buffer: Buffer) => ({
91+
getSize: () => ({ width: buffer.length > 0 ? 1 : 0, height: buffer.length > 0 ? 1 : 0 }),
92+
toPNG: () => buffer,
93+
resize: () => ({
94+
getSize: () => ({ width: 1, height: 1 }),
95+
toPNG: () => buffer,
96+
}),
97+
}),
9098
},
9199
shell: {
92100
showItemInFolder,
@@ -168,6 +176,7 @@ const makeTestPreviewWebContents = (
168176
getURL: () => "https://example.com",
169177
getTitle: () => "Example",
170178
isLoading: () => false,
179+
isDevToolsOpened: () => false,
171180
getZoomFactor: () => 1,
172181
setZoomFactor: vi.fn(),
173182
on: vi.fn(),
@@ -2840,3 +2849,134 @@ describe("Preview automation diagnostics", () => {
28402849
expect("locator" in error).toBe(false);
28412850
});
28422851
});
2852+
2853+
describe("Preview automation snapshots", () => {
2854+
const pageValue = {
2855+
url: "https://example.com",
2856+
title: "Example",
2857+
loading: false,
2858+
visibleText: "Dashboard",
2859+
interactiveElements: [],
2860+
};
2861+
2862+
const snapshotImage = {
2863+
getSize: () => ({ width: 100, height: 80 }),
2864+
toPNG: () => Buffer.from("png"),
2865+
resize: () => ({
2866+
getSize: () => ({ width: 100, height: 80 }),
2867+
toPNG: () => Buffer.from("png"),
2868+
}),
2869+
};
2870+
2871+
effectIt.effect("omits ax, console, and network unless include asks", () =>
2872+
withManager((manager) =>
2873+
Effect.gen(function* () {
2874+
const sendCommand = vi.fn(async (method: string) => {
2875+
if (method === "Runtime.evaluate") {
2876+
return { result: { value: pageValue } };
2877+
}
2878+
if (method === "Accessibility.getFullAXTree") {
2879+
return { nodes: [{ role: "main" }] };
2880+
}
2881+
return undefined;
2882+
});
2883+
fromId.mockReturnValue({
2884+
...makeTestPreviewWebContents(vi.fn(async () => snapshotImage)),
2885+
debugger: {
2886+
isAttached: () => false,
2887+
attach: vi.fn(),
2888+
sendCommand,
2889+
on: vi.fn(),
2890+
off: vi.fn(),
2891+
},
2892+
} as never);
2893+
2894+
yield* manager.createTab("tab_snapshot");
2895+
yield* manager.registerWebview("tab_snapshot", 42);
2896+
const slim = yield* manager.automationSnapshot("tab_snapshot");
2897+
expect(slim.accessibilityTree).toBeUndefined();
2898+
expect(slim.consoleEntries).toEqual([]);
2899+
expect(slim.networkEntries).toEqual([]);
2900+
const slimMethods = sendCommand.mock.calls.map(([method]) => method);
2901+
expect(slimMethods).not.toContain("Accessibility.getFullAXTree");
2902+
2903+
sendCommand.mockClear();
2904+
const withAx = yield* manager.automationSnapshot("tab_snapshot", ["ax"]);
2905+
expect(withAx.accessibilityTree).toEqual({ nodes: [{ role: "main" }] });
2906+
expect(sendCommand.mock.calls.map(([method]) => method)).toContain(
2907+
"Accessibility.getFullAXTree",
2908+
);
2909+
}),
2910+
),
2911+
);
2912+
2913+
effectIt.effect("fails the snapshot when capturePage and CDP screenshot both miss", () =>
2914+
withManager((manager) =>
2915+
Effect.gen(function* () {
2916+
const sendCommand = vi.fn(async (method: string) => {
2917+
if (method === "Runtime.evaluate") {
2918+
return { result: { value: pageValue } };
2919+
}
2920+
if (method === "Page.captureScreenshot") {
2921+
return {};
2922+
}
2923+
return undefined;
2924+
});
2925+
fromId.mockReturnValue({
2926+
...makeTestPreviewWebContents(
2927+
vi.fn(async () => {
2928+
throw new Error("capturePage failed");
2929+
}),
2930+
),
2931+
debugger: {
2932+
isAttached: () => false,
2933+
attach: vi.fn(),
2934+
sendCommand,
2935+
on: vi.fn(),
2936+
off: vi.fn(),
2937+
},
2938+
} as never);
2939+
2940+
yield* manager.createTab("tab_snapshot_fail");
2941+
yield* manager.registerWebview("tab_snapshot_fail", 42);
2942+
const exit = yield* Effect.exit(manager.automationSnapshot("tab_snapshot_fail"));
2943+
expect(Exit.isFailure(exit)).toBe(true);
2944+
if (Exit.isSuccess(exit)) return;
2945+
expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
2946+
_tag: "PreviewOperationError",
2947+
operation: "automationSnapshot.capturePage",
2948+
});
2949+
}),
2950+
),
2951+
);
2952+
2953+
effectIt.effect("defaults waitFor text search to the main landmark", () =>
2954+
withManager((manager) =>
2955+
Effect.gen(function* () {
2956+
const expressions: string[] = [];
2957+
const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
2958+
if (method === "Runtime.evaluate") {
2959+
expressions.push(String(params?.expression ?? ""));
2960+
return { result: { value: { matched: true } } };
2961+
}
2962+
return undefined;
2963+
});
2964+
fromId.mockReturnValue({
2965+
...makeTestPreviewWebContents(vi.fn(async () => snapshotImage)),
2966+
debugger: {
2967+
isAttached: () => false,
2968+
attach: vi.fn(),
2969+
sendCommand,
2970+
on: vi.fn(),
2971+
off: vi.fn(),
2972+
},
2973+
} as never);
2974+
2975+
yield* manager.createTab("tab_wait");
2976+
yield* manager.registerWebview("tab_wait", 42);
2977+
yield* manager.automationWaitFor("tab_wait", { text: "Dashboard" });
2978+
expect(expressions.some((expression) => expression.includes('"main"'))).toBe(true);
2979+
}),
2980+
),
2981+
);
2982+
});

apps/desktop/src/preview/Manager.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2921,7 +2921,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29212921
};
29222922
const seen = new Set();
29232923
const elements = Array.from(document.querySelectorAll(
2924-
"a[href],button,input,textarea,select,[role],[tabindex],[role=row],tr,[role=gridcell]"
2924+
"a[href],button,input,textarea,select,[role],[tabindex],[role=row],tr,[role=gridcell],div,td"
29252925
)).filter((element) => {
29262926
if (!visible(element) || !clickable(element) || seen.has(element)) return false;
29272927
seen.add(element);
@@ -2945,7 +2945,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29452945
url: location.href,
29462946
title: document.title,
29472947
loading: document.readyState !== "complete",
2948-
visibleText: ((main && main.innerText) || document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
2948+
visibleText: (main ? main.innerText : document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
29492949
interactiveElements: elements
29502950
};
29512951
})()`,
@@ -2964,18 +2964,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29642964
},
29652965
() => wc.capturePage(),
29662966
).pipe(
2967-
Effect.catch(() =>
2967+
Effect.catch((captureFailure) =>
29682968
send("Page.captureScreenshot", { format: "png" }).pipe(
2969-
Effect.map((result) => {
2970-
const data =
2971-
result !== null &&
2972-
typeof result === "object" &&
2973-
"data" in result &&
2974-
typeof result.data === "string"
2975-
? result.data
2976-
: "";
2977-
return nativeImage.createFromBuffer(Buffer.from(data, "base64"));
2978-
}),
2969+
Effect.flatMap((result) =>
2970+
result !== null &&
2971+
typeof result === "object" &&
2972+
"data" in result &&
2973+
typeof result.data === "string"
2974+
? Effect.succeed(nativeImage.createFromBuffer(Buffer.from(result.data, "base64")))
2975+
: Effect.fail(captureFailure),
2976+
),
29792977
),
29802978
),
29812979
);

0 commit comments

Comments
 (0)