mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
feat(native-chat): copy chat images from the right-click menu (#24161)
* feat(native-chat): copy chat images from the right-click menu
Fixes #23904
* test(native-chat): cover Copy image and the open preview in Electron
* fix(native-chat): keep the image preview open by click target, confirm copies, pass PNGs through
The image preview stayed open through a chat-menu click by reading which
element had focus, which depends on the menu's exit-animation timing.
It now ignores an outside interaction whose target is the chat menu, the
same target check other Orca surfaces use for portaled menus. Both
previews drop their color and padding overrides on the dialog surface,
which the design system reserves to the dialog primitive.
A successful Copy image now shows "Image copied"; before, nothing told
the user the copy had finished.
An image that is already a PNG is copied as-is after the size checks,
instead of being decoded and re-encoded, which cost time and could grow
a screenshot past the clipboard size limit.
* fix(native-chat): pass an image through as PNG only when its bytes are PNG
Copy image skipped re-encoding any image whose type said PNG, but a chat
image's type comes from its file extension. A WebP or GIF saved under a
.png name was then sent unconverted, the main process could not decode
it, and the user saw "Image copied" with nothing on the clipboard. The
pass-through now checks the PNG file signature instead.
* test(native-chat): format combined image-copy and session-ID mocks
* Release chat image menu when the retained pane hides
* Validate inherited style directives in their owning scan
* Copy displayed chat images and refuse hidden menu capture
Use full-size sent-image sources, read HTTP images when copying is chosen,
and rasterize browser-decodable formats through the existing converter.
Preserve the current preview surface and prevent a hidden preview from
retaining another copy action.
Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
* Revert "Validate inherited style directives in their owning scan"
This reverts commit fa2d91a3cd.
* Use standard dialog surfaces for chat image previews
Keep image previews open while their chat menu is used, and inherit the standard dialog padding, background and border.
Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
* Use dialog-owned spacing in image previews
Inherit the standard dialog gap together with its padding and surface.
Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
Neil
parent
f6448a1e26
commit
e717ea6b4c
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
|
||||
@@ -29,6 +29,15 @@ function renderPreview(attachment: NativeChatComposerImageAttachment): void {
|
||||
render(<NativeChatImageAttachmentPreview attachment={attachment} onRemove={vi.fn()} />)
|
||||
}
|
||||
|
||||
async function clickOn(target: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
|
||||
target.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, button: 0 }))
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
describe('NativeChatImageAttachmentPreview', () => {
|
||||
it('shows the clipboard thumbnail and a spinner while pending', () => {
|
||||
mocks.useLocalImageSrc.mockReturnValue(undefined)
|
||||
@@ -56,6 +65,65 @@ describe('NativeChatImageAttachmentPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('offers the full-size file, not the clipboard thumbnail, to the chat copy menu', () => {
|
||||
mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1')
|
||||
renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' })
|
||||
|
||||
const thumbnail = screen.getByRole('button', { name: 'View image: example.png' })
|
||||
expect(thumbnail.getAttribute('data-native-chat-copy-image-src')).toBe('blob:on-disk-1')
|
||||
})
|
||||
|
||||
it('offers nothing to copy until the file is readable or in the web client', () => {
|
||||
mocks.useLocalImageSrc.mockReturnValue(undefined)
|
||||
renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' })
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'View image: example.png' })
|
||||
.hasAttribute('data-native-chat-copy-image-src')
|
||||
).toBe(false)
|
||||
|
||||
mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-2')
|
||||
renderPreview({ id: 'a2', path: '/tmp/logo.SVG' })
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'View image: logo.SVG' })
|
||||
.hasAttribute('data-native-chat-copy-image-src')
|
||||
).toBe(true)
|
||||
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
renderPreview({ id: 'a3', path: '/tmp/shot.png' })
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'View image: shot.png' })
|
||||
.hasAttribute('data-native-chat-copy-image-src')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays open through a click on the chat context menu, but not elsewhere outside', async () => {
|
||||
mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1')
|
||||
renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View image: example.png' }))
|
||||
expect(
|
||||
within(screen.getByRole('dialog'))
|
||||
.getByRole('img')
|
||||
.getAttribute('data-native-chat-copy-image-src')
|
||||
).toBe('blob:on-disk-1')
|
||||
const menu = document.body.appendChild(document.createElement('div'))
|
||||
menu.setAttribute('data-native-chat-context-menu', '')
|
||||
const copyItem = menu.appendChild(document.createElement('div'))
|
||||
const elsewhere = document.body.appendChild(document.createElement('div'))
|
||||
// Radix arms its outside-pointer listener a tick after the dialog opens.
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
|
||||
await clickOn(copyItem)
|
||||
expect(screen.queryByRole('dialog')).toBeTruthy()
|
||||
|
||||
await clickOn(elsewhere)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
menu.remove()
|
||||
elsewhere.remove()
|
||||
})
|
||||
|
||||
it('shows a pasted image that could not come back by name, says it was not kept, and lets it be removed', () => {
|
||||
mocks.useLocalImageSrc.mockReturnValue(undefined)
|
||||
const onRemove = vi.fn()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/compone
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { basename } from '@/lib/path'
|
||||
import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc'
|
||||
import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy'
|
||||
import { isNativeChatPastedImagePath } from './native-chat-image-paste'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import { chatImageAccess } from '@/lib/local-file-access'
|
||||
@@ -141,6 +142,8 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
|
||||
'Saving pasted image…'
|
||||
)
|
||||
const label = isPending ? pendingLabel : filename
|
||||
// The thumbnail may be the downscaled clipboard preview; copy only the file.
|
||||
const copySrc = copyableNativeChatImageSrc(localSrc)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,6 +157,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
|
||||
}
|
||||
aria-busy={isPending}
|
||||
title={label}
|
||||
data-native-chat-copy-image-src={copySrc}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -175,7 +179,10 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
|
||||
<RemoveAttachmentButton onRemove={() => onRemove(attachment.id)} />
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[90vw] flex-col gap-3 border-border bg-background p-3 sm:max-w-4xl">
|
||||
<DialogContent
|
||||
onInteractOutside={keepPreviewOpenForChatMenu}
|
||||
className="flex max-h-[90vh] max-w-[90vw] flex-col sm:max-w-4xl"
|
||||
>
|
||||
<DialogTitle className="truncate text-sm">{label}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{translate('components.native-chat.composer.imagePreview', 'Full-size image preview')}
|
||||
@@ -185,6 +192,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
|
||||
<img
|
||||
src={fullSizeSrc}
|
||||
alt={label}
|
||||
data-native-chat-copy-image-src={copySrc}
|
||||
className="max-h-[75vh] max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ImageBlobPng from '@/lib/image-blob-png'
|
||||
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetch: vi.fn(),
|
||||
convertImageBlobToPng: vi.fn(),
|
||||
writeClipboardImage: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-native-chat-retained-session', () => ({
|
||||
useNativeChatRetainedSession: (): NativeChatLiveSession => ({
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
sessionId: 'session-menu',
|
||||
agent: 'claude',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
olderHistoryGeneration: 0,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase: 'ready'
|
||||
})
|
||||
}))
|
||||
vi.mock('./NativeChatComposer', () => ({
|
||||
NativeChatComposer: () => (
|
||||
<>
|
||||
<NativeChatImageAttachmentPreview
|
||||
attachment={{ id: 'image-menu', path: '/tmp/original.png', previewUrl: 'data:thumbnail' }}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
<p>Chat text</p>
|
||||
</>
|
||||
)
|
||||
}))
|
||||
vi.mock('@/components/editor/useLocalImageSrc', () => ({
|
||||
useLocalImageSrc: () => 'blob:original'
|
||||
}))
|
||||
vi.mock('@/lib/image-blob-png', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ImageBlobPng>()),
|
||||
convertImageBlobToPng: mocks.convertImageBlobToPng
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: mocks.success, error: mocks.error } }))
|
||||
|
||||
const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
|
||||
const { useAppStore } = await import('../../store')
|
||||
|
||||
function RetainedBridgeChat({ visible = true }: { visible?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="retained-owner"
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
inert={!visible}
|
||||
>
|
||||
<NativeChatResolvedView
|
||||
paneKey="tab-menu:leaf-menu"
|
||||
agent="claude"
|
||||
sessionId="session-menu"
|
||||
transcriptPath={null}
|
||||
isVisible={visible}
|
||||
isFocusedGroup={false}
|
||||
targetPtyId={null}
|
||||
terminalTabId="tab-menu"
|
||||
ownsTabWideLaunchDraft={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
|
||||
vi.stubGlobal('IntersectionObserver', undefined)
|
||||
vi.stubGlobal('fetch', mocks.fetch)
|
||||
vi.stubGlobal('api', { ui: { writeClipboardImage: mocks.writeClipboardImage } })
|
||||
mocks.convertImageBlobToPng.mockImplementation(async (blob: Blob) => blob)
|
||||
mocks.writeClipboardImage.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('NativeChatResolvedView image menu ownership', () => {
|
||||
it('refuses image capture from a portaled preview while its retained owner is hidden', async () => {
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
|
||||
const view = render(<RetainedBridgeChat />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View image: original.png' }))
|
||||
const preview = screen.getByRole('dialog')
|
||||
expect(screen.getByTestId('retained-owner').contains(preview)).toBe(false)
|
||||
|
||||
view.rerender(<RetainedBridgeChat visible={false} />)
|
||||
fireEvent.contextMenu(within(preview).getByRole('img'))
|
||||
|
||||
expect(mocks.fetch).not.toHaveBeenCalled()
|
||||
view.rerender(<RetainedBridgeChat />)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(mocks.writeClipboardImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the image preview on the first Close click while its copy menu is open', async () => {
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
|
||||
render(<RetainedBridgeChat />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View image: original.png' }))
|
||||
const preview = screen.getByRole('dialog', { name: 'original.png' })
|
||||
fireEvent.contextMenu(within(preview).getByRole('img'))
|
||||
await screen.findByRole('menuitem', { name: 'Copy image' })
|
||||
const close = within(preview).getByRole('button', { name: 'Close' })
|
||||
|
||||
fireEvent.pointerDown(close, { button: 0 })
|
||||
fireEvent.pointerUp(close, { button: 0 })
|
||||
fireEvent.click(close)
|
||||
|
||||
await waitFor(() => expect(preview).not.toBeInTheDocument())
|
||||
expect(mocks.writeClipboardImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the original image through the visible bridge chat menu', async () => {
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
|
||||
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')
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the portaled menu and captured image when the retained bridge owner hides', async () => {
|
||||
const capturedImage = Promise.withResolvers<Blob>()
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: () => capturedImage.promise })
|
||||
const view = render(<RetainedBridgeChat />)
|
||||
const owner = screen.getByTestId('retained-owner')
|
||||
const chatRoot = owner.querySelector('[data-native-chat-root]')
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'original.png' }))
|
||||
const copyAction = await screen.findByRole('menuitem', { name: 'Copy image' })
|
||||
const portaledMenu = screen.getByRole('menu')
|
||||
expect(owner.contains(portaledMenu)).toBe(false)
|
||||
expect(portaledMenu.closest('[inert]')).toBeNull()
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('blob:original')
|
||||
|
||||
// Hide without outside focus/pointer events, which can mask missing caller visibility.
|
||||
view.rerender(<RetainedBridgeChat visible={false} />)
|
||||
expect(owner.querySelector('[data-native-chat-root]')).toBe(chatRoot)
|
||||
await waitFor(() => expect(portaledMenu).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('menuitem', { name: 'Copy image', hidden: true })).toBeNull()
|
||||
|
||||
await act(async () => capturedImage.resolve(new Blob(['hidden image'])))
|
||||
fireEvent.click(copyAction)
|
||||
view.rerender(<RetainedBridgeChat />)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
fireEvent.contextMenu(screen.getByText('Chat text'))
|
||||
await screen.findByRole('menu')
|
||||
expect(screen.queryByRole('menuitem', { name: 'Copy image' })).toBeNull()
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(mocks.convertImageBlobToPng).not.toHaveBeenCalled()
|
||||
expect(mocks.writeClipboardImage).not.toHaveBeenCalled()
|
||||
expect(mocks.success).not.toHaveBeenCalled()
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,7 @@ export function NativeChatResolvedView({
|
||||
})
|
||||
const contextMenu = useNativeChatContextMenu({
|
||||
rootRef,
|
||||
enabled: isVisible,
|
||||
onSwitchToTerminal,
|
||||
splitShortcutLabels: {
|
||||
right: formatShortcutLabel('terminal.splitRight', keybindings),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { fireEvent, screen, within } from '@testing-library/react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
@@ -89,6 +90,63 @@ describe('NativeChatImageAttachments', () => {
|
||||
expect(FakeIntersectionObserver.instances[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers the full-size file of a sent image to the chat copy menu', async () => {
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(NativeChatImageAttachments, {
|
||||
blocks: [{ type: 'image-ref' as const, path: '/repo/image.png' }],
|
||||
runtimeContext: runtimeContext('wt-1')
|
||||
})
|
||||
)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(container.querySelector('button')?.getAttribute('data-native-chat-copy-image-src')).toBe(
|
||||
'blob:owner-1'
|
||||
)
|
||||
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'data:image/png;base64,AA==',
|
||||
'blob:sent-original',
|
||||
'http://example.test/original.png',
|
||||
'https://example.test/original.svg'
|
||||
])(
|
||||
'offers the original displayed source %s in the thumbnail and full-size preview',
|
||||
async (src) => {
|
||||
const container = document.body.appendChild(document.createElement('div'))
|
||||
const root = createRoot(container)
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(NativeChatImageAttachments, {
|
||||
blocks: [{ type: 'image-ref', url: src, alt: 'Sent image' }],
|
||||
runtimeContext: runtimeContext('wt-1')
|
||||
})
|
||||
)
|
||||
await flushPromises()
|
||||
})
|
||||
const thumbnail = within(container).getByRole('button', { name: 'View image: Sent image' })
|
||||
expect(thumbnail.getAttribute('data-native-chat-copy-image-src')).toBe(src)
|
||||
fireEvent.click(thumbnail)
|
||||
const preview = screen.getByRole('dialog', { name: 'Sent image' })
|
||||
expect(
|
||||
within(preview).getByRole('img').getAttribute('data-native-chat-copy-image-src')
|
||||
).toBe(src)
|
||||
expect(window.api.fs.readFile).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(preview).getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves same-image errors but retries when the runtime owner changes', async () => {
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
releaseLocalImageSrcByKey
|
||||
} from '@/components/editor/useLocalImageSrc'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy'
|
||||
import { isNativeChatPastedImagePath } from './native-chat-image-paste'
|
||||
import { chatImageAccess } from '@/lib/local-file-access'
|
||||
|
||||
@@ -119,6 +120,7 @@ function TranscriptImagePreview({
|
||||
TRANSCRIPT_IMAGE_ACCESS
|
||||
)
|
||||
const displaySrc = external && leaseActive ? source : localSrc
|
||||
const copySrc = copyableNativeChatImageSrc(displaySrc)
|
||||
const label =
|
||||
block.alt?.trim() ||
|
||||
(block.path && isNativeChatPastedImagePath(block.path)
|
||||
@@ -171,6 +173,7 @@ function TranscriptImagePreview({
|
||||
type="button"
|
||||
aria-label={`${viewImageLabel}: ${label}`}
|
||||
title={label}
|
||||
data-native-chat-copy-image-src={copySrc}
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -183,7 +186,10 @@ function TranscriptImagePreview({
|
||||
/>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[90vw] flex-col gap-3 border-border bg-background p-3 sm:max-w-4xl">
|
||||
<DialogContent
|
||||
onInteractOutside={keepPreviewOpenForChatMenu}
|
||||
className="flex max-h-[90vh] max-w-[90vw] flex-col sm:max-w-4xl"
|
||||
>
|
||||
<DialogTitle className="truncate text-sm">{label}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{translate('components.native-chat.composer.imagePreview', 'Full-size image preview')}
|
||||
@@ -193,6 +199,7 @@ function TranscriptImagePreview({
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={label}
|
||||
data-native-chat-copy-image-src={copySrc}
|
||||
onError={() => setDialogErrorSrc(displaySrc)}
|
||||
className="max-h-[75vh] max-w-full object-contain"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image'
|
||||
|
||||
/** The chat context menu offers "Copy image" for elements carrying this attribute. */
|
||||
const COPY_IMAGE_SRC_SELECTOR = '[data-native-chat-copy-image-src]'
|
||||
|
||||
/** Full-size source to copy for an image, or undefined when it has none. */
|
||||
export function copyableNativeChatImageSrc(src: string | undefined): string | undefined {
|
||||
// The web client's image clipboard write is a no-op.
|
||||
return isWebClientLocation() ? undefined : src
|
||||
}
|
||||
|
||||
export type NativeChatCopyImage = () => Promise<Blob>
|
||||
|
||||
async function readImageBlob(src: string): Promise<Blob> {
|
||||
const response = await fetch(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image request failed (HTTP ${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
if (blob.type.split(';', 1)[0] === 'text/html') {
|
||||
throw new Error('The image URL returned a web page')
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
/** Captures revocable sources now; remote URLs are read only when copying is selected. */
|
||||
export function readNativeChatCopyImage(
|
||||
target: EventTarget | null
|
||||
): NativeChatCopyImage | undefined {
|
||||
const src =
|
||||
target instanceof Element
|
||||
? target.closest(COPY_IMAGE_SRC_SELECTOR)?.getAttribute('data-native-chat-copy-image-src')
|
||||
: undefined
|
||||
if (!src) {
|
||||
return undefined
|
||||
}
|
||||
if (/^https?:/i.test(src)) {
|
||||
return () => readImageBlob(src)
|
||||
}
|
||||
const image = readImageBlob(src)
|
||||
// Why: a read the user never copies must not surface as an unhandled rejection.
|
||||
image.catch(() => {})
|
||||
return () => image
|
||||
}
|
||||
|
||||
/** Keeps an image preview open through clicks on the chat context menu. */
|
||||
export function keepPreviewOpenForChatMenu(event: {
|
||||
target: EventTarget | null
|
||||
preventDefault: () => void
|
||||
}): void {
|
||||
// Why: the menu portals outside the preview, so Radix counts a click on it as outside.
|
||||
if (event.target instanceof Element && event.target.closest('[data-native-chat-context-menu]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
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)}`)
|
||||
toast.success(translate('components.native-chat.composer.imageCopied', 'Image copied'))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
translate('components.native-chat.composer.copyImageFailed', "Couldn't copy image"),
|
||||
{
|
||||
description:
|
||||
error instanceof Error && error.message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR
|
||||
? translate(
|
||||
'components.native-chat.composer.copyImageTooLarge',
|
||||
'The image is too large to copy.'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
import React, { createRef, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ImageBlobPng from '@/lib/image-blob-png'
|
||||
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../../shared/clipboard-image'
|
||||
import {
|
||||
emptyNativeChatContextMenuActions,
|
||||
useNativeChatContextMenu,
|
||||
@@ -14,6 +16,9 @@ import {
|
||||
type ItemProps = { onSelect?: () => void; children?: ReactNode }
|
||||
|
||||
const items = vi.hoisted(() => ({ list: [] as ItemProps[] }))
|
||||
const imageCopy = vi.hoisted(() => ({
|
||||
convertImageBlobToPng: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children?: ReactNode }) => children,
|
||||
@@ -37,6 +42,7 @@ vi.mock('lucide-react', () => {
|
||||
Clipboard: Icon,
|
||||
Copy: Icon,
|
||||
GitFork: Icon,
|
||||
Image: Icon,
|
||||
Maximize2: Icon,
|
||||
MessageSquarePlus: Icon,
|
||||
Minimize2: Icon,
|
||||
@@ -68,6 +74,10 @@ vi.mock('@/components/ui/tooltip', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/image-blob-png', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ImageBlobPng>()),
|
||||
convertImageBlobToPng: imageCopy.convertImageBlobToPng
|
||||
}))
|
||||
|
||||
vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({
|
||||
TabWorkspaceLayoutMenuSection: () => 'Move Tab to Split'
|
||||
@@ -113,6 +123,66 @@ function Harness({
|
||||
return menu
|
||||
}
|
||||
|
||||
function ImageHarness({
|
||||
enabled = true,
|
||||
src = 'blob:full-size'
|
||||
}: {
|
||||
enabled?: boolean
|
||||
src?: string
|
||||
}) {
|
||||
const rootRef = createRef<HTMLDivElement>()
|
||||
const { menu, onContextMenuCapture } = useNativeChatContextMenu({
|
||||
rootRef,
|
||||
enabled,
|
||||
actions: { ...emptyNativeChatContextMenuActions, onPaste: vi.fn() }
|
||||
})
|
||||
return (
|
||||
<div ref={rootRef} onContextMenuCapture={onContextMenuCapture}>
|
||||
<button type="button" data-native-chat-copy-image-src={src}>
|
||||
<img alt="shot" src="data:thumbnail" />
|
||||
</button>
|
||||
<p>text</p>
|
||||
{menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function copyImageItem(): ItemProps | undefined {
|
||||
return items.list.findLast((candidate) => childrenText(candidate.children) === 'Copy image')
|
||||
}
|
||||
|
||||
function stubClipboardImageWrite(): ReturnType<typeof vi.fn> {
|
||||
const writeClipboardImage = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('api', { ui: { writeClipboardImage } })
|
||||
return writeClipboardImage
|
||||
}
|
||||
|
||||
async function rightClickImageAndCopy({
|
||||
blobFor = (src: string) => new Blob([src]),
|
||||
revokeBeforeSelect = false
|
||||
}: { blobFor?: (src: string) => Blob; revokeBeforeSelect?: boolean } = {}): Promise<{
|
||||
writeClipboardImage: ReturnType<typeof vi.fn>
|
||||
}> {
|
||||
const writeClipboardImage = stubClipboardImageWrite()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (src: string) => ({ ok: true, blob: async () => blobFor(src) }))
|
||||
)
|
||||
render(<ImageHarness />)
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' }))
|
||||
if (revokeBeforeSelect) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
}
|
||||
await act(async () => copyImageItem()?.onSelect?.())
|
||||
return { writeClipboardImage }
|
||||
}
|
||||
|
||||
function pngOfText(): void {
|
||||
imageCopy.convertImageBlobToPng.mockImplementation(
|
||||
async (blob: Blob) => new Blob([`png:${await blob.text()}`], { type: 'image/png' })
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNativeChatContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
items.list = []
|
||||
@@ -121,6 +191,141 @@ describe('useNativeChatContextMenu', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
imageCopy.convertImageBlobToPng.mockReset()
|
||||
toasts.error.mockReset()
|
||||
toasts.success.mockReset()
|
||||
})
|
||||
|
||||
it('copies the right-clicked image as a PNG of its full-size source', async () => {
|
||||
pngOfText()
|
||||
|
||||
const { writeClipboardImage } = await rightClickImageAndCopy()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(writeClipboardImage).toHaveBeenCalledWith(
|
||||
`data:image/png;base64,${Buffer.from('png:blob:full-size').toString('base64')}`
|
||||
)
|
||||
)
|
||||
expect(toasts.success).toHaveBeenCalledWith('Image copied')
|
||||
expect(toasts.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers no image copy when the right-click is not on an image', () => {
|
||||
render(<ImageHarness />)
|
||||
fireEvent.contextMenu(screen.getByText('text'))
|
||||
|
||||
expect(copyImageItem()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('copies an image whose blob URL was revoked after the menu opened', async () => {
|
||||
pngOfText()
|
||||
|
||||
const { writeClipboardImage } = await rightClickImageAndCopy({ revokeBeforeSelect: true })
|
||||
|
||||
await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce())
|
||||
expect(toasts.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the read image when the pane hides with the menu open', () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, blob: async () => new Blob(['image']) }))
|
||||
)
|
||||
const { rerender } = render(<ImageHarness />)
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' }))
|
||||
expect(copyImageItem()).toBeDefined()
|
||||
|
||||
rerender(<ImageHarness enabled={false} />)
|
||||
// Inspect a render after the hide effect has settled.
|
||||
items.list = []
|
||||
rerender(<ImageHarness enabled={false} />)
|
||||
|
||||
expect(copyImageItem()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['http://example.test/original.png', 'https://example.test/original.svg'])(
|
||||
'reads %s only after Copy image is selected',
|
||||
async (src) => {
|
||||
pngOfText()
|
||||
const writeClipboardImage = stubClipboardImageWrite()
|
||||
const fetchImage = vi.fn().mockResolvedValue({ ok: true, blob: async () => new Blob([src]) })
|
||||
vi.stubGlobal('fetch', fetchImage)
|
||||
render(<ImageHarness src={src} />)
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' }))
|
||||
expect(copyImageItem()).toBeDefined()
|
||||
expect(fetchImage).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => copyImageItem()?.onSelect?.())
|
||||
|
||||
await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce())
|
||||
expect(fetchImage).toHaveBeenCalledOnce()
|
||||
expect(fetchImage).toHaveBeenCalledWith(src)
|
||||
expect(writeClipboardImage).toHaveBeenCalledOnce()
|
||||
expect(toasts.success).toHaveBeenCalledWith('Image copied')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['HTTP failure', () => Promise.resolve({ ok: false, status: 404, blob: vi.fn() })],
|
||||
['network failure', () => Promise.reject(new TypeError('Failed to fetch'))],
|
||||
[
|
||||
'HTML response',
|
||||
() =>
|
||||
Promise.resolve({ ok: true, blob: async () => new Blob(['page'], { type: 'text/html' }) })
|
||||
]
|
||||
])(
|
||||
'reports one error for %s with no clipboard write or file fallback',
|
||||
async (_label, response) => {
|
||||
const writeClipboardImage = stubClipboardImageWrite()
|
||||
const fetchImage = vi.fn(response)
|
||||
vi.stubGlobal('fetch', fetchImage)
|
||||
render(<ImageHarness src="https://example.test/original.png" />)
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' }))
|
||||
|
||||
await act(async () => copyImageItem()?.onSelect?.())
|
||||
|
||||
await waitFor(() => expect(toasts.error).toHaveBeenCalledOnce())
|
||||
expect(fetchImage).toHaveBeenCalledOnce()
|
||||
expect(imageCopy.convertImageBlobToPng).not.toHaveBeenCalled()
|
||||
expect(writeClipboardImage).not.toHaveBeenCalled()
|
||||
expect(toasts.error).toHaveBeenCalledOnce()
|
||||
expect(toasts.success).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('captures a data image before selection and keeps it when the source disappears', async () => {
|
||||
pngOfText()
|
||||
const writeClipboardImage = stubClipboardImageWrite()
|
||||
const fetchImage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: async () => new Blob(['inline']) })
|
||||
vi.stubGlobal('fetch', fetchImage)
|
||||
render(<ImageHarness src="data:image/png;base64,aW5saW5l" />)
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' }))
|
||||
expect(fetchImage).toHaveBeenCalledOnce()
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Source gone')))
|
||||
|
||||
await act(async () => copyImageItem()?.onSelect?.())
|
||||
|
||||
await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce())
|
||||
expect(toasts.success).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports an image too large to copy instead of copying nothing silently', async () => {
|
||||
const actual = await vi.importActual<typeof ImageBlobPng>('@/lib/image-blob-png')
|
||||
imageCopy.convertImageBlobToPng.mockImplementation(actual.convertImageBlobToPng)
|
||||
|
||||
const { writeClipboardImage } = await rightClickImageAndCopy({
|
||||
blobFor: () => new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(toasts.error).toHaveBeenCalled())
|
||||
expect(writeClipboardImage).not.toHaveBeenCalled()
|
||||
expect(toasts.success).not.toHaveBeenCalled()
|
||||
expect(toasts.error).toHaveBeenCalledWith("Couldn't copy image", {
|
||||
description: 'The image is too large to copy.'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the bridge switch-to-terminal action when supplied', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
GitFork,
|
||||
Image as ImageIcon,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Minimize2,
|
||||
@@ -29,6 +30,11 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
copyNativeChatImage,
|
||||
readNativeChatCopyImage,
|
||||
type NativeChatCopyImage
|
||||
} from './native-chat-image-copy'
|
||||
import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut'
|
||||
import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspaceLayoutMenuSection'
|
||||
import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem'
|
||||
@@ -38,6 +44,7 @@ type NativeChatContextMenuState = {
|
||||
open: boolean
|
||||
point: { x: number; y: number }
|
||||
selectedText: string
|
||||
image?: NativeChatCopyImage
|
||||
}
|
||||
|
||||
type UseNativeChatContextMenuArgs = {
|
||||
@@ -121,6 +128,7 @@ export function useNativeChatContextMenu({
|
||||
selectedText: ''
|
||||
})
|
||||
const shortcutLabel = nativeChatToggleShortcutLabel(isMacPlatform())
|
||||
const { image } = state
|
||||
|
||||
const rememberCurrentSelection = useCallback(() => {
|
||||
const selectedText = getNativeChatSelectedText(rootRef.current)
|
||||
@@ -139,7 +147,9 @@ export function useNativeChatContextMenu({
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState((current) => (current.open ? { ...current, open: false } : current))
|
||||
setState((current) =>
|
||||
current.open ? { ...current, open: false, image: undefined } : current
|
||||
)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
@@ -147,15 +157,19 @@ export function useNativeChatContextMenu({
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
menuOpenedAtRef.current = Date.now()
|
||||
const selectedText = getNativeChatSelectedText(rootRef.current) || lastSelectedTextRef.current
|
||||
setState({
|
||||
open: true,
|
||||
point: { x: event.clientX, y: event.clientY },
|
||||
selectedText
|
||||
selectedText,
|
||||
image: readNativeChatCopyImage(event.target)
|
||||
})
|
||||
},
|
||||
[rootRef]
|
||||
[enabled, rootRef]
|
||||
)
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
@@ -179,11 +193,25 @@ export function useNativeChatContextMenu({
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
data-native-chat-context-menu=""
|
||||
className="w-56"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Drop the read image once the menu has faded out, so a large file isn't held.
|
||||
setState((prev) => ({ ...prev, image: undefined }))
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => void copyNativeChatImage(image)}>
|
||||
<ImageIcon />
|
||||
{translate('components.native-chat.composer.copyImage', 'Copy image')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={state.selectedText.trim().length === 0}
|
||||
onSelect={() => void window.api.ui.writeClipboardText(state.selectedText)}
|
||||
|
||||
@@ -18006,6 +18006,10 @@
|
||||
"viewAttachment": "View image",
|
||||
"imagePreview": "Full-size image preview",
|
||||
"imagePreviewUnavailable": "Preview unavailable",
|
||||
"copyImage": "Copy image",
|
||||
"copyImageFailed": "Couldn't copy image",
|
||||
"copyImageTooLarge": "The image is too large to copy.",
|
||||
"imageCopied": "Image copied",
|
||||
"workspaceAttachmentMismatch": "Files can only be attached to their source workspace.",
|
||||
"attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.",
|
||||
"attachmentUnreadable": "Couldn't read the dropped files.",
|
||||
|
||||
@@ -17689,6 +17689,10 @@
|
||||
"localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.",
|
||||
"removeAttachment": "Quitar archivo adjunto",
|
||||
"pastedImageLabel": "Imagen pegada",
|
||||
"copyImage": "Copiar imagen",
|
||||
"copyImageFailed": "No se pudo copiar la imagen",
|
||||
"copyImageTooLarge": "La imagen es demasiado grande para copiarla.",
|
||||
"imageCopied": "Imagen copiada",
|
||||
"draftNotSaved": "Este borrador aún no se ha podido guardar. Orca lo sigue intentando.",
|
||||
"imageNotBroughtBack": "No se pudo recuperar la imagen {{name}} con este borrador. Vuelve a adjuntarla o quítala.",
|
||||
"pastedImageNotBroughtBack": "No se pudo recuperar esta imagen pegada con este borrador. Quítala y vuelve a pegarla si aún la necesitas.",
|
||||
|
||||
@@ -17844,6 +17844,10 @@
|
||||
"viewAttachment": "Voir l'image",
|
||||
"imagePreview": "Aperçu de l'image en taille réelle",
|
||||
"imagePreviewUnavailable": "Aperçu indisponible",
|
||||
"copyImage": "Copier l’image",
|
||||
"copyImageFailed": "Impossible de copier l’image",
|
||||
"copyImageTooLarge": "L’image est trop volumineuse pour être copiée.",
|
||||
"imageCopied": "Image copiée",
|
||||
"workspaceAttachmentMismatch": "Les fichiers ne peuvent être joints qu'à leur espace de travail source.",
|
||||
"attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.",
|
||||
"attachmentUnreadable": "Impossible de lire les fichiers supprimés.",
|
||||
|
||||
@@ -17778,6 +17778,10 @@
|
||||
"viewAttachment": "画像を見る",
|
||||
"imagePreview": "フルサイズの画像プレビュー",
|
||||
"imagePreviewUnavailable": "プレビューは利用できません",
|
||||
"copyImage": "画像をコピー",
|
||||
"copyImageFailed": "画像をコピーできませんでした",
|
||||
"copyImageTooLarge": "画像が大きすぎるためコピーできません。",
|
||||
"imageCopied": "画像をコピーしました",
|
||||
"workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。",
|
||||
"attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。",
|
||||
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。",
|
||||
|
||||
@@ -17778,6 +17778,10 @@
|
||||
"viewAttachment": "이미지 보기",
|
||||
"imagePreview": "전체 크기 이미지 미리보기",
|
||||
"imagePreviewUnavailable": "미리보기를 사용할 수 없습니다.",
|
||||
"copyImage": "이미지 복사",
|
||||
"copyImageFailed": "이미지를 복사할 수 없습니다",
|
||||
"copyImageTooLarge": "이미지가 너무 커서 복사할 수 없습니다.",
|
||||
"imageCopied": "이미지를 복사했습니다",
|
||||
"workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.",
|
||||
"attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.",
|
||||
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.",
|
||||
|
||||
@@ -17743,6 +17743,10 @@
|
||||
"viewAttachment": "查看图片",
|
||||
"imagePreview": "全尺寸图像预览",
|
||||
"imagePreviewUnavailable": "预览不可用",
|
||||
"copyImage": "复制图片",
|
||||
"copyImageFailed": "无法复制图片",
|
||||
"copyImageTooLarge": "图片太大,无法复制。",
|
||||
"imageCopied": "已复制图片",
|
||||
"workspaceAttachmentMismatch": "文件只能附加到其源工作区。",
|
||||
"attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。",
|
||||
"attachmentUnreadable": "无法读取删除的文件。",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLIPBOARD_IMAGE_MAX_PIXELS,
|
||||
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
|
||||
CLIPBOARD_IMAGE_TOO_LARGE_ERROR
|
||||
} from '../../../shared/clipboard-image'
|
||||
import { convertImageBlobToPng } from './image-blob-png'
|
||||
|
||||
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13])
|
||||
const WEBP_BYTES = new TextEncoder().encode('RIFF\u0000\u0000\u0000WEBPVP8 ')
|
||||
const originalCanvasGetContext = Object.getOwnPropertyDescriptor(
|
||||
HTMLCanvasElement.prototype,
|
||||
'getContext'
|
||||
)
|
||||
|
||||
function stubDecodedSize(
|
||||
width: number,
|
||||
height: number,
|
||||
decode = vi.fn().mockResolvedValue(undefined)
|
||||
) {
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
naturalWidth = width
|
||||
naturalHeight = height
|
||||
decode = decode
|
||||
}
|
||||
)
|
||||
return decode
|
||||
}
|
||||
|
||||
function stubCanvas(png: Blob | null = new Blob([PNG_BYTES], { type: 'image/png' })) {
|
||||
const dimensions = { width: 0, height: 0 }
|
||||
const drawImage = vi.fn()
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: () => ({ drawImage }),
|
||||
configurable: true
|
||||
})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
|
||||
this: HTMLCanvasElement,
|
||||
callback
|
||||
) {
|
||||
dimensions.width = this.width
|
||||
dimensions.height = this.height
|
||||
callback(png)
|
||||
})
|
||||
return { dimensions, drawImage, png }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:decode-owned')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
if (originalCanvasGetContext) {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalCanvasGetContext)
|
||||
}
|
||||
})
|
||||
|
||||
describe('convertImageBlobToPng', () => {
|
||||
it('validates dimensions and returns PNG signature bytes unchanged', async () => {
|
||||
const decode = stubDecodedSize(800, 600)
|
||||
const png = new Blob([PNG_BYTES], { type: 'image/png' })
|
||||
|
||||
await expect(convertImageBlobToPng(png)).resolves.toBe(png)
|
||||
|
||||
expect(decode).toHaveBeenCalledOnce()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-PNG mislabeled as PNG', () => new Blob([WEBP_BYTES], { type: 'image/png' })],
|
||||
[
|
||||
'SVG',
|
||||
() =>
|
||||
new Blob(['<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"></svg>'], {
|
||||
type: 'image/svg+xml'
|
||||
})
|
||||
]
|
||||
])(
|
||||
'decodes %s with an image element and encodes its natural dimensions',
|
||||
async (_name, source) => {
|
||||
stubDecodedSize(800, 600)
|
||||
const { dimensions, drawImage, png } = stubCanvas()
|
||||
|
||||
await expect(convertImageBlobToPng(source())).resolves.toBe(png)
|
||||
|
||||
expect(dimensions.width).toBe(800)
|
||||
expect(dimensions.height).toBe(600)
|
||||
expect(drawImage).toHaveBeenCalledOnce()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects excessive source bytes before creating a decode URL', async () => {
|
||||
await expect(
|
||||
convertImageBlobToPng(new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)]))
|
||||
).rejects.toThrow(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects excessive PNG dimensions and releases its decode URL', async () => {
|
||||
stubDecodedSize(CLIPBOARD_IMAGE_MAX_PIXELS + 1, 1)
|
||||
|
||||
await expect(
|
||||
convertImageBlobToPng(new Blob([PNG_BYTES], { type: 'image/png' }))
|
||||
).rejects.toThrow(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
})
|
||||
|
||||
it('releases its temporary URL on a decode error', async () => {
|
||||
stubDecodedSize(800, 600, vi.fn().mockRejectedValue(new Error('invalid image')))
|
||||
|
||||
await expect(convertImageBlobToPng(new Blob(['invalid']))).rejects.toThrow('invalid image')
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
})
|
||||
|
||||
it('releases its temporary URL when no canvas context is available', async () => {
|
||||
stubDecodedSize(800, 600)
|
||||
|
||||
await expect(convertImageBlobToPng(new Blob([WEBP_BYTES]))).rejects.toThrow(
|
||||
'Clipboard image could not be decoded'
|
||||
)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
})
|
||||
|
||||
it.each([null, new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)])])(
|
||||
'releases its temporary URL when PNG encoding fails or exceeds the byte cap',
|
||||
async (png) => {
|
||||
stubDecodedSize(800, 600)
|
||||
stubCanvas(png)
|
||||
|
||||
await expect(convertImageBlobToPng(new Blob([WEBP_BYTES]))).rejects.toThrow(
|
||||
png ? CLIPBOARD_IMAGE_TOO_LARGE_ERROR : 'Clipboard image could not be encoded as PNG'
|
||||
)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
assertClipboardImageByteLengthWithinLimit,
|
||||
assertClipboardImageDimensionsWithinLimit
|
||||
} from '../../../shared/clipboard-image'
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : ''
|
||||
const commaIndex = result.indexOf(',')
|
||||
resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1))
|
||||
}
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
|
||||
// Why: a file's type comes from its extension, so a WebP named .png must still be converted.
|
||||
async function hasPngSignature(blob: Blob): Promise<boolean> {
|
||||
const head = new Uint8Array(await blob.slice(0, PNG_SIGNATURE.length).arrayBuffer())
|
||||
return PNG_SIGNATURE.every((byte, index) => head[index] === byte)
|
||||
}
|
||||
|
||||
export async function convertImageBlobToPng(blob: Blob): Promise<Blob> {
|
||||
assertClipboardImageByteLengthWithinLimit(blob.size)
|
||||
const url = URL.createObjectURL(blob)
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode()
|
||||
const width = image.naturalWidth
|
||||
const height = image.naturalHeight
|
||||
assertClipboardImageDimensionsWithinLimit({ width, height })
|
||||
// Why: re-encoding a PNG costs time and can grow it past the clipboard size limit.
|
||||
if (await hasPngSignature(blob)) {
|
||||
return blob
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context || canvas.width <= 0 || canvas.height <= 0) {
|
||||
throw new Error('Clipboard image could not be decoded')
|
||||
}
|
||||
context.drawImage(image, 0, 0)
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((png) => {
|
||||
if (!png) {
|
||||
reject(new Error('Clipboard image could not be encoded as PNG'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
assertClipboardImageByteLengthWithinLimit(png.size)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(png)
|
||||
}, 'image/png')
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ClipboardImageThumbnail
|
||||
} from '../../../../shared/clipboard-image'
|
||||
import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text'
|
||||
import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png'
|
||||
import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback'
|
||||
import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls'
|
||||
|
||||
@@ -25,56 +26,10 @@ export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
|
||||
|
||||
export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : ''
|
||||
const commaIndex = result.indexOf(',')
|
||||
resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1))
|
||||
}
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function assertClipboardImageBlobWithinLimit(blob: Blob): void {
|
||||
assertClipboardImageByteLengthWithinLimit(blob.size)
|
||||
}
|
||||
|
||||
export async function convertImageBlobToPng(blob: Blob): Promise<Blob> {
|
||||
assertClipboardImageBlobWithinLimit(blob)
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
try {
|
||||
assertClipboardImageDimensionsWithinLimit(bitmap)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context || canvas.width <= 0 || canvas.height <= 0) {
|
||||
throw new Error('Clipboard image could not be decoded')
|
||||
}
|
||||
context.drawImage(bitmap, 0, 0)
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((png) => {
|
||||
if (!png) {
|
||||
reject(new Error('Clipboard image could not be encoded as PNG'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
assertClipboardImageBlobWithinLimit(png)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(png)
|
||||
}, 'image/png')
|
||||
})
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function readClipboardImageBlob(): Promise<Blob | null> {
|
||||
const clipboard = navigator.clipboard as
|
||||
| (Clipboard & { read?: () => Promise<ClipboardItem[]> })
|
||||
|
||||
@@ -604,7 +604,7 @@ describe('web UI preload API', () => {
|
||||
|
||||
it('rejects oversized decoded clipboard images before canvas conversion', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
const close = vi.fn()
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
const readAsDataURL = vi.fn(() => {
|
||||
throw new Error('FileReader should not receive oversized decoded image data')
|
||||
})
|
||||
@@ -630,12 +630,13 @@ describe('web UI preload API', () => {
|
||||
const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api')
|
||||
installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' }))
|
||||
vi.stubGlobal(
|
||||
'createImageBitmap',
|
||||
vi.fn().mockResolvedValue({
|
||||
close,
|
||||
height: 1,
|
||||
width: MAX_CLIPBOARD_IMAGE_PIXELS + 1
|
||||
})
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
naturalHeight = 1
|
||||
naturalWidth = MAX_CLIPBOARD_IMAGE_PIXELS + 1
|
||||
decode = vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'FileReader',
|
||||
@@ -648,7 +649,8 @@ describe('web UI preload API', () => {
|
||||
await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow(
|
||||
'Clipboard image is too large'
|
||||
)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||
revokeObjectURL.mockRestore()
|
||||
expect(readAsDataURL).not.toHaveBeenCalled()
|
||||
expect(runtimeCalls).toEqual([])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
|
||||
|
||||
test('Copy image puts the full-size image on the clipboard and keeps the preview open', async ({
|
||||
orcaPage,
|
||||
electronApp,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage)
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
// Why two sizes: the chip previews the pasted image while the copy must read the saved file,
|
||||
// so an 800x600 copy proves the file was read, not the 64x64 preview.
|
||||
const [png, pastedPng] = await electronApp.evaluate(({ nativeImage }) =>
|
||||
[800, 64].map((width) => {
|
||||
const height = (width * 3) / 4
|
||||
return nativeImage
|
||||
.createFromBitmap(Buffer.alloc(width * height * 4, 200), { width, height })
|
||||
.toPNG()
|
||||
.toString('base64')
|
||||
})
|
||||
)
|
||||
// Why the repo: previews only read files under allowed roots; the test output dir isn't one.
|
||||
const imageName = `copy-image-proof-${randomUUID()}.png`
|
||||
const imagePath = path.join(testRepoPath, imageName)
|
||||
await writeFile(imagePath, Buffer.from(png, 'base64'))
|
||||
try {
|
||||
// Substitute only the clipboard IPC; never touch the user's system clipboard.
|
||||
await electronApp.evaluate(
|
||||
({ ipcMain, nativeImage }, { imagePath }) => {
|
||||
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
|
||||
ipcMain.handle('clipboard:saveImageAsTempFile', () => imagePath)
|
||||
ipcMain.removeHandler('clipboard:writeImage')
|
||||
ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => {
|
||||
const { width, height } = nativeImage.createFromDataURL(dataUrl).getSize()
|
||||
process.env.ORCA_E2E_COPIED_IMAGE_SIZE = `${width}x${height}`
|
||||
})
|
||||
},
|
||||
{ imagePath }
|
||||
)
|
||||
const takeCopiedImageSize = (): Promise<string | null> =>
|
||||
electronApp.evaluate(() => {
|
||||
const size = process.env.ORCA_E2E_COPIED_IMAGE_SIZE ?? null
|
||||
delete process.env.ORCA_E2E_COPIED_IMAGE_SIZE
|
||||
return size
|
||||
})
|
||||
await orcaPage.evaluate(async ({ paneKey, worktreeId }) => {
|
||||
const settings = await window.api.settings.set({ experimentalNativeChat: true })
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
store.setState({ settings })
|
||||
const state = store.getState()
|
||||
state.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'done', agentType: 'omp', prompt: '' },
|
||||
'OMP',
|
||||
undefined,
|
||||
{ worktreeId }
|
||||
)
|
||||
const [tabId] = paneKey.split(':')
|
||||
const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId
|
||||
)
|
||||
if (!tab) {
|
||||
throw new Error('Terminal tab unavailable')
|
||||
}
|
||||
state.toggleTabViewMode(tab.id)
|
||||
}, descriptor)
|
||||
const composer = orcaPage.getByRole('textbox', { name: 'Send a message…', exact: true })
|
||||
await expect(composer).toBeVisible()
|
||||
await composer.evaluate((element, png) => {
|
||||
const data = new DataTransfer()
|
||||
const bytes = Uint8Array.from(atob(png), (character) => character.charCodeAt(0))
|
||||
data.items.add(new File([bytes], 'pasted.png', { type: 'image/png' }))
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data })
|
||||
)
|
||||
}, pastedPng)
|
||||
const thumbnail = orcaPage.getByRole('button', { name: `View image: ${imageName}` })
|
||||
await expect(thumbnail).toHaveAttribute('data-native-chat-copy-image-src', /^blob:/)
|
||||
const copyImage = orcaPage.getByRole('menuitem', { name: 'Copy image' })
|
||||
|
||||
await thumbnail.click({ button: 'right' })
|
||||
await copyImage.click()
|
||||
await expect.poll(takeCopiedImageSize).toBe('800x600')
|
||||
|
||||
await thumbnail.click()
|
||||
const preview = orcaPage.getByRole('dialog')
|
||||
const fullSize = preview.getByRole('img', { name: imageName })
|
||||
await fullSize.click({ button: 'right' })
|
||||
await copyImage.click()
|
||||
await expect.poll(takeCopiedImageSize).toBe('800x600')
|
||||
// Why: the preview's dismissal fires on that click, so once the menu has fully left the page
|
||||
// a dismissed preview would already read data-state="closed".
|
||||
await expect(orcaPage.getByRole('menu')).toHaveCount(0)
|
||||
await expect(preview).toHaveAttribute('data-state', 'open')
|
||||
|
||||
await fullSize.click({ button: 'right' })
|
||||
await expect(copyImage).toBeVisible()
|
||||
await preview.getByRole('button', { name: 'Close' }).click()
|
||||
await expect(preview).toHaveCount(0)
|
||||
} finally {
|
||||
await rm(imagePath, { force: true })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user