mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 15:28:42 +08:00
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Signed-off-by: -LAN- <laipz8200@outlook.com> Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: Poojan <poojan@infocusp.com> Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: heyszt <270985384@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com> Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com> Co-authored-by: User <user@example.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com> Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: wangxiaolei <fatelei@gmail.com> Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: tda <95275462+tda1017@users.noreply.github.com> Co-authored-by: root <root@DESKTOP-KQLO90N> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com> Co-authored-by: hj24 <mambahj24@gmail.com> Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Co-authored-by: Stephen Zhou <hi@hyoban.cc> Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com> Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com> Co-authored-by: 99 <wh2099@pm.me> Co-authored-by: Br1an <932039080@qq.com> Co-authored-by: L1nSn0w <l1nsn0w@qq.com> Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai> Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com> Co-authored-by: 盐粒 Yanli <yanli@dify.ai> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: weiguang li <codingpunk@gmail.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Stable Genius <stablegenius043@gmail.com> Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com> Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import ImagePreview from '../image-preview'
|
|
|
|
type _HotkeyHandler = () => void
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
notify: vi.fn(),
|
|
downloadUrl: vi.fn(),
|
|
windowOpen: vi.fn<(...args: unknown[]) => Window | null>(),
|
|
clipboardWrite: vi.fn<(items: ClipboardItem[]) => Promise<void>>(),
|
|
}))
|
|
|
|
vi.mock('@/app/components/base/toast', () => ({
|
|
default: {
|
|
notify: (...args: Parameters<typeof mocks.notify>) => mocks.notify(...args),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/utils/download', () => ({
|
|
downloadUrl: (...args: Parameters<typeof mocks.downloadUrl>) => mocks.downloadUrl(...args),
|
|
}))
|
|
|
|
const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement
|
|
const getCloseButton = () => screen.getByTestId('image-preview-close-button') as HTMLDivElement
|
|
const getCopyButton = () => screen.getByTestId('image-preview-copy-button') as HTMLDivElement
|
|
const getZoomOutButton = () => screen.getByTestId('image-preview-zoom-out-button') as HTMLDivElement
|
|
const getZoomInButton = () => screen.getByTestId('image-preview-zoom-in-button') as HTMLDivElement
|
|
const getDownloadButton = () => screen.getByTestId('image-preview-download-button') as HTMLDivElement
|
|
const getOpenInTabButton = () => screen.getByTestId('image-preview-open-in-tab-button') as HTMLDivElement
|
|
|
|
const base64Image = 'aGVsbG8='
|
|
const dataImage = `data:image/png;base64,${base64Image}`
|
|
|
|
describe('ImagePreview', () => {
|
|
const originalClipboardItem = globalThis.ClipboardItem
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
if (!navigator.clipboard) {
|
|
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
|
value: {
|
|
write: vi.fn(),
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
const clipboardTarget = navigator.clipboard as { write: (items: ClipboardItem[]) => Promise<void> }
|
|
// In some test environments `write` lives on the prototype rather than
|
|
// the clipboard instance itself; locate the actual owner so vi.spyOn
|
|
// patches the right object.
|
|
const writeOwner = Object.prototype.hasOwnProperty.call(clipboardTarget, 'write')
|
|
? clipboardTarget
|
|
: (Object.getPrototypeOf(clipboardTarget) as { write: (items: ClipboardItem[]) => Promise<void> })
|
|
vi.spyOn(writeOwner, 'write').mockImplementation((items: ClipboardItem[]) => {
|
|
return mocks.clipboardWrite(items)
|
|
})
|
|
|
|
globalThis.ClipboardItem = class {
|
|
constructor(public readonly data: Record<string, Blob>) { }
|
|
} as unknown as typeof ClipboardItem
|
|
vi.spyOn(window, 'open').mockImplementation((...args: Parameters<Window['open']>) => {
|
|
return mocks.windowOpen(...args)
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.ClipboardItem = originalClipboardItem
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render preview in portal with image from url', () => {
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const overlay = getOverlay()
|
|
expect(overlay).toBeInTheDocument()
|
|
expect(overlay?.parentElement).toBe(document.body)
|
|
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png')
|
|
})
|
|
|
|
it('should convert plain base64 string into data image src', () => {
|
|
render(
|
|
<ImagePreview
|
|
url={base64Image}
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', dataImage)
|
|
})
|
|
})
|
|
|
|
describe('Hotkeys', () => {
|
|
it('should trigger esc/left/right handlers from keyboard', async () => {
|
|
const user = userEvent.setup()
|
|
const onCancel = vi.fn()
|
|
const onPrev = vi.fn()
|
|
const onNext = vi.fn()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={onCancel}
|
|
onPrev={onPrev}
|
|
onNext={onNext}
|
|
/>,
|
|
)
|
|
|
|
await user.keyboard('{Escape}{ArrowLeft}{ArrowRight}')
|
|
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
expect(onPrev).toHaveBeenCalledTimes(1)
|
|
expect(onNext).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should zoom in and out from keyboard up/down hotkeys', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
const image = screen.getByRole('img', { name: 'Preview Image' })
|
|
|
|
await user.keyboard('{ArrowUp}')
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
|
|
})
|
|
|
|
await user.keyboard('{ArrowDown}')
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onCancel when close button is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const onCancel = vi.fn()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={onCancel}
|
|
/>,
|
|
)
|
|
|
|
const closeButton = getCloseButton()
|
|
await user.click(closeButton)
|
|
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should zoom in and out with wheel interactions', async () => {
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
const overlay = getOverlay()
|
|
const image = screen.getByRole('img', { name: 'Preview Image' })
|
|
|
|
act(() => {
|
|
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }))
|
|
})
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
|
|
})
|
|
|
|
act(() => {
|
|
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 100 }))
|
|
})
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
|
|
})
|
|
})
|
|
|
|
it('should update position while dragging when zoomed in and stop dragging on mouseup', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const overlay = getOverlay()
|
|
const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement
|
|
const imageParent = image.parentElement
|
|
if (!imageParent)
|
|
throw new Error('Image parent element not found')
|
|
|
|
vi.spyOn(image, 'getBoundingClientRect').mockReturnValue({
|
|
width: 200,
|
|
height: 120,
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 120,
|
|
right: 200,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
} as DOMRect)
|
|
vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue({
|
|
width: 100,
|
|
height: 100,
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 100,
|
|
right: 100,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
} as DOMRect)
|
|
|
|
const zoomInButton = getZoomInButton()
|
|
await user.click(zoomInButton)
|
|
|
|
act(() => {
|
|
overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }))
|
|
})
|
|
await waitFor(() => {
|
|
expect(image.style.transition).toBe('none')
|
|
})
|
|
|
|
act(() => {
|
|
overlay.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 200, clientY: -100 }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(70px, -22px)' })
|
|
})
|
|
|
|
act(() => {
|
|
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
})
|
|
await waitFor(() => {
|
|
expect(image.style.transition).toContain('transform 0.2s ease-in-out')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Action Buttons', () => {
|
|
it('should open valid url in new tab', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const openInTabButton = getOpenInTabButton()
|
|
await user.click(openInTabButton)
|
|
|
|
expect(mocks.windowOpen).toHaveBeenCalledWith('https://example.com/image.png', '_blank')
|
|
})
|
|
|
|
it('should open data image by writing to popup window document', async () => {
|
|
const user = userEvent.setup()
|
|
const write = vi.fn()
|
|
mocks.windowOpen.mockReturnValue({
|
|
document: {
|
|
write,
|
|
},
|
|
} as unknown as Window)
|
|
|
|
render(
|
|
<ImagePreview
|
|
url={dataImage}
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const openInTabButton = getOpenInTabButton()
|
|
await user.click(openInTabButton)
|
|
|
|
expect(mocks.windowOpen).toHaveBeenCalledWith()
|
|
expect(write).toHaveBeenCalledWith(`<img src="${dataImage}" alt="Preview Image" />`)
|
|
})
|
|
|
|
it('should show error toast when opening unsupported url', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="file:///tmp/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const openInTabButton = getOpenInTabButton()
|
|
await user.click(openInTabButton)
|
|
|
|
expect(mocks.notify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: 'Unable to open image: file:///tmp/image.png',
|
|
})
|
|
})
|
|
|
|
it('should fall back to download and show info toast when clipboard copy fails', async () => {
|
|
const user = userEvent.setup()
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
|
mocks.clipboardWrite.mockRejectedValue(new Error('copy failed'))
|
|
|
|
render(
|
|
<ImagePreview
|
|
url={dataImage}
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const copyButton = getCopyButton()
|
|
await user.click(copyButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: dataImage, fileName: 'Preview Image.png' })
|
|
})
|
|
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'info',
|
|
}))
|
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
it('should copy image and show success toast', async () => {
|
|
const user = userEvent.setup()
|
|
mocks.clipboardWrite.mockResolvedValue()
|
|
render(
|
|
<ImagePreview
|
|
url={dataImage}
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
const copyButton = getCopyButton()
|
|
await user.click(copyButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1)
|
|
})
|
|
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'success',
|
|
}))
|
|
})
|
|
|
|
it('should call download action for valid url', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
const downloadButton = getDownloadButton()
|
|
await user.click(downloadButton)
|
|
|
|
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
|
url: 'https://example.com/image.png',
|
|
fileName: 'Preview Image',
|
|
target: '_blank',
|
|
})
|
|
})
|
|
|
|
it('should show error toast for invalid download url', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="invalid://image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
const downloadButton = getDownloadButton()
|
|
await user.click(downloadButton)
|
|
|
|
expect(mocks.notify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: 'Unable to open image: invalid://image.png',
|
|
})
|
|
})
|
|
|
|
it('should zoom with dedicated zoom buttons', async () => {
|
|
const user = userEvent.setup()
|
|
render(
|
|
<ImagePreview
|
|
url="https://example.com/image.png"
|
|
title="Preview Image"
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
const image = screen.getByRole('img', { name: 'Preview Image' })
|
|
|
|
const zoomInButton = getZoomInButton()
|
|
const zoomOutButton = getZoomOutButton()
|
|
await user.click(zoomInButton)
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
|
|
})
|
|
|
|
await user.click(zoomOutButton)
|
|
await waitFor(() => {
|
|
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
|
|
})
|
|
})
|
|
})
|
|
})
|