Extract shared image clipboard copy utility (#31600)

* Extract image clipboard copying into shared utility

* Await image reads before reporting clipboard copy success
This commit is contained in:
Jinjing
2026-10-21 17:24:04 -07:00
committed by GitHub
parent a3c9958973
commit 718fb9fdc4
4 changed files with 126 additions and 11 deletions
@@ -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(<RetainedBridgeChat />)
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<void>()
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
mocks.writeClipboardImage.mockReturnValue(write.promise)
render(<RetainedBridgeChat />)
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 () => {
@@ -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<void> {
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(
@@ -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<void>>()
const decode = vi.fn<() => Promise<void>>()
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<void>()
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)
})
})
@@ -0,0 +1,6 @@
import { blobToBase64, convertImageBlobToPng } from './image-blob-png'
export async function copyImageToClipboard(blob: Blob): Promise<void> {
const png = await convertImageBlobToPng(blob)
await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`)
}