From 718fb9fdc40012cf6836c9d97cf6fc8f614dc185 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:04 -0700 Subject: [PATCH] Extract shared image clipboard copy utility (#31600) * Extract image clipboard copying into shared utility * Await image reads before reporting clipboard copy success --- ...tiveChatResolvedView.context-menu.test.tsx | 53 ++++++++++++-- .../native-chat/native-chat-image-copy.ts | 5 +- .../src/lib/clipboard-image-copy.test.ts | 73 +++++++++++++++++++ src/renderer/src/lib/clipboard-image-copy.ts | 6 ++ 4 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/lib/clipboard-image-copy.test.ts create mode 100644 src/renderer/src/lib/clipboard-image-copy.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx index 37a747f5cea..e2a84afc266 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx @@ -144,21 +144,58 @@ describe('NativeChatResolvedView image menu ownership', () => { expect(mocks.writeClipboardImage).not.toHaveBeenCalled() }) - it('copies the original image through the visible bridge chat menu', async () => { + it.each(['thumbnail', 'preview'])( + 'copies the original image from the %s through the visible bridge chat menu', + async (surface) => { + mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) }) + render() + + if (surface === 'preview') { + fireEvent.click(screen.getByRole('button', { name: 'Image #1: View image: original.png' })) + } + const preview = + surface === 'preview' ? screen.getByRole('dialog', { name: 'original.png' }) : null + const image = preview + ? within(preview).getByRole('img') + : screen.getByRole('img', { name: 'original.png' }) + fireEvent.contextMenu(image) + const copy = await screen.findByRole('menuitem', { name: 'Copy image' }) + fireEvent.pointerDown(copy, { button: 0, pointerType: 'mouse' }) + fireEvent.pointerUp(copy, { button: 0, pointerType: 'mouse' }) + fireEvent.click(copy) + + await waitFor(() => + expect(mocks.writeClipboardImage).toHaveBeenCalledWith( + `data:image/png;base64,${Buffer.from('original image').toString('base64')}` + ) + ) + expect(mocks.fetch).toHaveBeenCalledWith('blob:original') + expect(mocks.success).toHaveBeenCalledWith('Image copied') + expect(mocks.error).not.toHaveBeenCalled() + if (preview) { + expect(preview).toBeInTheDocument() + expect(preview).toHaveAttribute('data-state', 'open') + } + } + ) + + it('reports a clipboard write failure without announcing success while the write is pending', async () => { + const write = Promise.withResolvers() mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) }) + mocks.writeClipboardImage.mockReturnValue(write.promise) render() fireEvent.contextMenu(screen.getByRole('img', { name: 'original.png' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' })) - await waitFor(() => - expect(mocks.writeClipboardImage).toHaveBeenCalledWith( - `data:image/png;base64,${Buffer.from('original image').toString('base64')}` - ) - ) - expect(mocks.fetch).toHaveBeenCalledWith('blob:original') - expect(mocks.success).toHaveBeenCalledWith('Image copied') + await waitFor(() => expect(mocks.writeClipboardImage).toHaveBeenCalledOnce()) + expect(mocks.success).not.toHaveBeenCalled() expect(mocks.error).not.toHaveBeenCalled() + + await act(async () => write.reject(new Error('Clipboard unavailable'))) + + expect(mocks.error).toHaveBeenCalledWith("Couldn't copy image", { description: undefined }) + expect(mocks.success).not.toHaveBeenCalled() }) it('removes the portaled menu and captured image when the retained bridge owner hides', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-image-copy.ts b/src/renderer/src/components/native-chat/native-chat-image-copy.ts index 32dd7581e14..50d0d8bd4b3 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-copy.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-copy.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png' +import { copyImageToClipboard } from '@/lib/clipboard-image-copy' import { isWebClientLocation } from '@/lib/web-client-location' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' @@ -60,8 +60,7 @@ export function keepPreviewOpenForChatMenu(event: { export async function copyNativeChatImage(image: NativeChatCopyImage): Promise { try { - const png = await convertImageBlobToPng(await image()) - await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`) + await copyImageToClipboard(await image()) toast.success(translate('components.native-chat.composer.imageCopied', 'Image copied')) } catch (error) { toast.error( diff --git a/src/renderer/src/lib/clipboard-image-copy.test.ts b/src/renderer/src/lib/clipboard-image-copy.test.ts new file mode 100644 index 00000000000..031c84e7bd3 --- /dev/null +++ b/src/renderer/src/lib/clipboard-image-copy.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { copyImageToClipboard } from './clipboard-image-copy' + +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' +const png = new Blob([Uint8Array.from(atob(PNG_BASE64), (character) => character.charCodeAt(0))], { + type: 'image/png' +}) +const writeClipboardImage = vi.fn<(dataUrl: string) => Promise>() +const decode = vi.fn<() => Promise>() + +beforeEach(() => { + writeClipboardImage.mockResolvedValue(undefined) + decode.mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { writeClipboardImage } }) + vi.stubGlobal( + 'Image', + class { + src = '' + naturalWidth = 1 + naturalHeight = 1 + decode = decode + } + ) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:decode-owned') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + writeClipboardImage.mockReset() + decode.mockReset() +}) + +describe('copyImageToClipboard', () => { + it('writes the original PNG bytes and waits for the local clipboard write', async () => { + const write = Promise.withResolvers() + writeClipboardImage.mockReturnValue(write.promise) + let finished = false + + const copying = copyImageToClipboard(png).then(() => { + finished = true + }) + + await vi.waitFor(() => { + expect(writeClipboardImage).toHaveBeenCalledWith(`data:image/png;base64,${PNG_BASE64}`) + }) + expect(finished).toBe(false) + + write.resolve() + await copying + expect(finished).toBe(true) + }) + + it('propagates a decode failure without writing to the clipboard', async () => { + const error = new Error('Invalid image') + decode.mockRejectedValue(error) + + await expect(copyImageToClipboard(png)).rejects.toBe(error) + + expect(writeClipboardImage).not.toHaveBeenCalled() + }) + + it('propagates a local clipboard write failure', async () => { + const error = new Error('Clipboard unavailable') + writeClipboardImage.mockRejectedValue(error) + + await expect(copyImageToClipboard(png)).rejects.toBe(error) + }) +}) diff --git a/src/renderer/src/lib/clipboard-image-copy.ts b/src/renderer/src/lib/clipboard-image-copy.ts new file mode 100644 index 00000000000..18d5bc0080f --- /dev/null +++ b/src/renderer/src/lib/clipboard-image-copy.ts @@ -0,0 +1,6 @@ +import { blobToBase64, convertImageBlobToPng } from './image-blob-png' + +export async function copyImageToClipboard(blob: Blob): Promise { + const png = await convertImageBlobToPng(blob) + await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`) +}