dify/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

374 lines
12 KiB
TypeScript

import type { ComponentProps } from 'react'
import type { Area } from 'react-easy-crop'
import type { ImageFile } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import AppIconPicker from '../index'
import 'vitest-canvas-mock'
type LocalFileUploaderOptions = {
disabled?: boolean
limit?: number
onUpload: (imageFile: ImageFile) => void
}
class MockLoadedImage {
width = 320
height = 160
private listeners: Record<string, EventListener[]> = {}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const eventListener =
typeof listener === 'function' ? listener : listener.handleEvent.bind(listener)
if (!this.listeners[type]) this.listeners[type] = []
this.listeners[type].push(eventListener)
}
setAttribute(_name: string, _value: string) {}
set src(_value: string) {
queueMicrotask(() => {
for (const listener of this.listeners.load ?? []) listener(new Event('load'))
})
}
get src() {
return ''
}
}
const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.local_file,
_id: 'test-image-id',
fileId: 'uploaded-image-id',
progress: 100,
url: 'https://example.com/uploaded.png',
...overrides,
})
const createCanvasContextMock = (): CanvasRenderingContext2D =>
({
translate: vi.fn(),
rotate: vi.fn(),
scale: vi.fn(),
drawImage: vi.fn(),
}) as unknown as CanvasRenderingContext2D
const createCanvasElementMock = (
context: CanvasRenderingContext2D | null,
blob: Blob | null = new Blob(['ok'], { type: 'image/png' }),
) =>
({
width: 0,
height: 0,
getContext: vi.fn(() => context),
toBlob: vi.fn((callback: BlobCallback) => callback(blob)),
}) as unknown as HTMLCanvasElement
const mocks = vi.hoisted(() => ({
disableUpload: false,
uploadResult: null as ImageFile | null,
onUpload: null as ((imageFile: ImageFile) => void) | null,
handleLocalFileUpload: vi.fn<(file: File) => void>(),
}))
vi.mock('@/config', () => ({
get DISABLE_UPLOAD_IMAGE_AS_ICON() {
return mocks.disableUpload
},
}))
vi.mock('react-easy-crop', () => ({
default: ({
onCropComplete,
}: {
onCropComplete: (_area: Area, croppedAreaPixels: Area) => void
}) => (
<div data-testid="mock-cropper">
<button
type="button"
data-testid="trigger-crop"
onClick={() =>
onCropComplete(
{ x: 0, y: 0, width: 100, height: 100 },
{ x: 0, y: 0, width: 100, height: 100 },
)
}
>
Trigger Crop
</button>
</div>
),
}))
vi.mock('../../image-uploader/hooks', () => ({
useLocalFileUploader: (options: LocalFileUploaderOptions) => {
mocks.onUpload = options.onUpload
return { handleLocalFileUpload: mocks.handleLocalFileUpload }
},
}))
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn().mockResolvedValue(['grinning', 'sunglasses']),
}))
describe('AppIconPicker', () => {
const originalCreateElement = document.createElement.bind(document)
const originalCreateObjectURL = globalThis.URL.createObjectURL
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
let originalImage: typeof Image
const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => {
vi.spyOn(document, 'createElement').mockImplementation(
(...args: Parameters<Document['createElement']>) => {
if (args[0] === 'canvas') {
const nextCanvas = canvases.shift()
if (!nextCanvas) throw new Error('Unexpected canvas creation')
return nextCanvas as ReturnType<Document['createElement']>
}
return originalCreateElement(...args)
},
)
}
const renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => {
const onSelect = vi.fn()
const onOpenChange = vi.fn()
const { container } = render(
<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />,
)
return { onSelect, onOpenChange, container }
}
beforeEach(() => {
vi.clearAllMocks()
mocks.disableUpload = false
mocks.uploadResult = createImageFile()
mocks.onUpload = null
mocks.handleLocalFileUpload.mockImplementation(() => {
if (mocks.uploadResult) mocks.onUpload?.(mocks.uploadResult)
})
originalImage = globalThis.Image
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
globalThis.Image = originalImage
globalThis.URL.createObjectURL = originalCreateObjectURL
globalThis.URL.revokeObjectURL = originalRevokeObjectURL
})
describe('Rendering', () => {
it('should render emoji and image tabs when upload is enabled', async () => {
renderPicker()
expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /emoji/i }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /image/i }))!.toBeInTheDocument()
expect(screen.getByText(/cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/ok/i))!.toBeInTheDocument()
})
it('should hide the image tab when upload is disabled', () => {
mocks.disableUpload = true
renderPicker()
expect(screen.queryByText(/image/i)).not.toBeInTheDocument()
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should close when cancel is clicked', async () => {
const { onOpenChange } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('should switch between emoji and image tabs', async () => {
renderPicker()
await userEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /emoji/i }))
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
})
it('should call onSelect with emoji data after emoji selection', async () => {
const { onSelect } = renderPicker()
await waitFor(() => {
expect(document.querySelector('em-emoji')?.closest('button'))!.toBeInTheDocument()
})
const firstEmoji = document.querySelector('em-emoji')?.closest('button')
if (!firstEmoji) throw new Error('Could not find emoji option')
await userEvent.click(firstEmoji)
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
type: 'emoji',
icon: expect.any(String),
background: expect.any(String),
}),
)
})
})
it('should close through the dialog open change contract when Escape is pressed', async () => {
const { onOpenChange } = renderPicker()
await userEvent.keyboard('{Escape}')
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything())
})
it('should not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/ok/i))
expect(onSelect).not.toHaveBeenCalled()
})
it('should submit the initial emoji when provided', async () => {
const { onSelect } = renderPicker({ initialEmoji: { icon: 'rabbit', background: '#E4FBCC' } })
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith({
type: 'emoji',
icon: 'rabbit',
background: '#E4FBCC',
})
})
})
})
describe('Image Upload', () => {
it('should return early when image tab is active and no file has been selected', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
await userEvent.click(screen.getByText(/ok/i))
expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled()
expect(onSelect).not.toHaveBeenCalled()
})
it('should upload cropped static image and emit selected image metadata', async () => {
globalThis.Image = MockLoadedImage as unknown as typeof Image
const sourceCanvas = createCanvasElementMock(createCanvasContextMock())
const croppedBlob = new Blob(['cropped-image'], { type: 'image/png' })
const croppedCanvas = createCanvasElementMock(createCanvasContextMock(), croppedBlob)
mockCanvasCreation([sourceCanvas, croppedCanvas])
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const input = screen.queryByTestId('image-input')
if (!input) throw new Error('Could not find image input')
fireEvent.change(input, {
target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] },
})
await waitFor(() => {
expect(screen.getByTestId('mock-cropper'))!.toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('trigger-crop'))
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledTimes(1)
})
const uploadedFile = mocks.handleLocalFileUpload.mock.calls[0]![0]
expect(uploadedFile).toBeInstanceOf(File)
expect(uploadedFile.name).toBe('avatar.png')
expect(uploadedFile.type).toBe('image/png')
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith({
type: 'image',
fileId: 'uploaded-image-id',
url: 'https://example.com/uploaded.png',
})
})
})
it('should upload animated image directly without crop', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
const gifFile = new File([gifBytes], 'animated.gif', { type: 'image/gif' })
const input = screen.queryByTestId('image-input')
if (!input) throw new Error('Could not find image input')
fireEvent.change(input, { target: { files: [gifFile] } })
await waitFor(() => {
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
const preview = screen.queryByTestId('animated-image')
expect(preview)!.toBeInTheDocument()
expect(preview?.getAttribute('src')).toContain('blob:mock-url')
})
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
})
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith({
type: 'image',
fileId: 'uploaded-image-id',
url: 'https://example.com/uploaded.png',
})
})
})
it('should not call onSelect when upload callback returns image without fileId', async () => {
mocks.uploadResult = createImageFile({ fileId: '' })
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
const gifFile = new File([gifBytes], 'no-file-id.gif', { type: 'image/gif' })
const input = screen.queryByTestId('image-input')
if (!input) throw new Error('Could not find image input')
fireEvent.change(input, { target: { files: [gifFile] } })
await waitFor(() => {
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
})
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
})
expect(onSelect).not.toHaveBeenCalled()
})
})
})