1import { beforeEach, expect, it, vi } from "vite-plus/test";
2import type * as Electron from "electron";
3import { copyContextMenuImage } from "./ContextMenuImage.ts";
4
5const native = vi.hoisted(() => ({
6 fetch: vi.fn(),
7 createFromBuffer: vi.fn(),
8 write: vi.fn(),
9 image: { isEmpty: vi.fn(() => false), toPNG: vi.fn(() => Buffer.from("png")) },
10}));
11
12vi.mock("electron", () => ({
13 net: { fetch: native.fetch },
14 nativeImage: { createFromBuffer: native.createFromBuffer },
15 clipboard: { write: native.write },
16 ClipboardItem: class {
17 readonly blobs: Record<string, Blob>;
18 constructor(blobs: Record<string, Blob>) {
19 this.blobs = blobs;
20 }
21 },
22}));
23
24const mainFrame = { isDestroyed: vi.fn(() => false) };
25const frame = { isDestroyed: vi.fn(() => false) };
26const contents = {
27 isDestroyed: vi.fn(() => false),
28 copyImageAt: vi.fn(),
29 executeJavaScriptInIsolatedWorld: vi.fn(),
30 mainFrame,
31};
32const params = {
33 x: 12,
34 y: 34,
35 srcURL: "https://example.com/image.png",
36 frame,
37};
38const sourcePng = Buffer.from(
39 "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAFklEQVR4nGP4TyFgGDVg1IBRA4aLAQBdePwur/3haQAAAABJRU5ErkJggg==",
40 "base64",
41);
42
43const copyImage = (overrides: Partial<typeof params> = {}) =>
44 copyContextMenuImage(
45 contents as unknown as Electron.WebContents,
46 { ...params, ...overrides } as unknown as Electron.ContextMenuParams,
47 );
48
49beforeEach(() => {
50 vi.clearAllMocks();
51 contents.isDestroyed.mockReturnValue(false);
52 frame.isDestroyed.mockReturnValue(false);
53 native.image.isEmpty.mockReturnValue(false);
54 native.createFromBuffer.mockReturnValue(native.image);
55 native.fetch.mockResolvedValue(new Response(Uint8Array.from(sourcePng)));
56 native.write.mockResolvedValue(undefined);
57 contents.executeJavaScriptInIsolatedWorld.mockRejectedValue(
58 new Error("Image could not be decoded"),
59 );
60});
61
62it("preserves the clipboard when the context-menu frame is missing", async () => {
63 await copyContextMenuImage(
64 contents as unknown as Electron.WebContents,
65 { ...params, frame: null } as unknown as Electron.ContextMenuParams,
66 );
67 expect(contents.copyImageAt).not.toHaveBeenCalled();
68 expect(native.fetch).not.toHaveBeenCalled();
69 expect(native.write).not.toHaveBeenCalled();
70});
71
72it("keeps native image copying for the main frame", async () => {
73 await copyImage({ frame: mainFrame });
74 expect(contents.copyImageAt).toHaveBeenCalledWith(12, 34);
75 expect(native.fetch).not.toHaveBeenCalled();
76});
77
78it.each(["https://example.com/image.png", `data:image/png;base64,${sourcePng.toString("base64")}`])(
79 "copies an iframe image from %s",
80 async (srcURL) => {
81 await copyImage({ srcURL });
82 if (srcURL.startsWith("https:")) {
83 expect(native.fetch).toHaveBeenCalledWith(srcURL, {
84 credentials: "omit",
85 redirect: "error",
86 signal: expect.any(AbortSignal),
87 });
88 } else {
89 expect(native.fetch).not.toHaveBeenCalled();
90 }
91 expect(contents.copyImageAt).not.toHaveBeenCalled();
92 expect(native.createFromBuffer).toHaveBeenCalledWith(sourcePng);
93 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
94 expect(blob.type).toBe("image/png");
95 expect(Buffer.from(await blob.arrayBuffer())).toEqual(Buffer.from("png"));
96 },
97);
98
99it.each([
100 ["WebP", "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vz0AAA="],
101 ["GIF", "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs="],
102])("copies %s using Chromium when nativeImage cannot decode it", async (_, base64) => {
103 native.fetch.mockResolvedValue(new Response(Uint8Array.from(Buffer.from(base64, "base64"))));
104 native.image.isEmpty.mockReturnValue(true);
105 contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
106 await copyImage();
107 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
108 expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
109 expect(native.image.toPNG).not.toHaveBeenCalled();
110});
111
112it("preserves the clipboard if the source frame closes during Chromium decoding", async () => {
113 native.image.isEmpty.mockReturnValue(true);
114 contents.executeJavaScriptInIsolatedWorld.mockImplementation(() => {
115 frame.isDestroyed.mockReturnValue(true);
116 return Promise.resolve(Uint8Array.from(sourcePng).buffer);
117 });
118 await copyImage();
119 expect(native.write).not.toHaveBeenCalled();
120});
121
122it("copies a GIF larger than the favicon pixel limit", async () => {
123 const gif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs=", "base64");
124 gif.writeUInt16LE(2048, 6);
125 gif.writeUInt16LE(1024, 8);
126 native.fetch.mockResolvedValue(new Response(Uint8Array.from(gif)));
127 native.image.isEmpty.mockReturnValue(true);
128 contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
129 await copyImage();
130 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
131 expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
132});
133
134it.each([
135 "file:///private/image.png",
136 "blob:https://example.com/image",
137 "https://user:pass@example.com/image.png",
138])("rejects unsupported or credential-bearing URLs: %s", async (srcURL) => {
139 await expect(copyImage({ srcURL })).rejects.toThrow("Unsupported image URL");
140 expect(native.fetch).not.toHaveBeenCalled();
141 expect(native.write).not.toHaveBeenCalled();
142});
143
144it("cancels responses whose declared size exceeds the limit", async () => {
145 const cancel = vi.fn();
146 native.fetch.mockResolvedValue(
147 new Response(new ReadableStream({ cancel }), {
148 headers: { "content-length": String(32 * 1024 * 1024 + 1) },
149 }),
150 );
151 await expect(copyImage()).rejects.toThrow("size limit");
152 expect(cancel).toHaveBeenCalled();
153 expect(native.createFromBuffer).not.toHaveBeenCalled();
154});
155
156it("cancels oversized streams without a content-length", async () => {
157 const cancel = vi.fn();
158 native.fetch.mockResolvedValue(
159 new Response(
160 new ReadableStream({
161 start(controller) {
162 controller.enqueue(new Uint8Array(32 * 1024 * 1024));
163 controller.enqueue(new Uint8Array(1));
164 },
165 cancel,
166 }),
167 ),
168 );
169 await expect(copyImage()).rejects.toThrow("size limit");
170 expect(cancel).toHaveBeenCalled();
171 expect(native.write).not.toHaveBeenCalled();
172});
173
174it("preserves the clipboard when the response is not an image", async () => {
175 native.image.isEmpty.mockReturnValue(true);
176 await expect(copyImage()).rejects.toThrow("could not be decoded");
177 expect(native.write).not.toHaveBeenCalled();
178});
179
180it("rejects large decoded dimensions before image allocation", async () => {
181 const oversizedPng = Buffer.from(sourcePng);
182 oversizedPng.writeUInt32BE(4096, 16);
183 oversizedPng.writeUInt32BE(4096, 20);
184 native.fetch.mockResolvedValue(new Response(Uint8Array.from(oversizedPng)));
185 await expect(copyImage()).rejects.toThrow("size limit");
186 expect(native.createFromBuffer).not.toHaveBeenCalled();
187 expect(native.image.toPNG).not.toHaveBeenCalled();
188 expect(native.write).not.toHaveBeenCalled();
189});
190
191it("preserves the clipboard when the encoded PNG exceeds the limit", async () => {
192 native.image.toPNG.mockReturnValueOnce(Buffer.alloc(32 * 1024 * 1024 + 1));
193 await expect(copyImage()).rejects.toThrow("size limit");
194 expect(native.write).not.toHaveBeenCalled();
195});
196
197it("does not copy after the source frame closes during the request", async () => {
198 native.fetch.mockImplementation(() => {
199 frame.isDestroyed.mockReturnValue(true);
200 return Promise.resolve(new Response(Uint8Array.from(sourcePng)));
201 });
202 await copyImage();
203 expect(native.write).not.toHaveBeenCalled();
204});