Files
dify/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx
2026-03-18 14:17:15 +00:00

397 lines
13 KiB
TypeScript

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 * as React from 'react'
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('@/app/components/base/emoji-picker/Inner', () => ({
default: function MockEmojiPickerInner({ onSelect, className }: { onSelect: (emoji: string, background: string) => void, className?: string }) {
return (
<div className={className}>
<input placeholder="search" />
<button
type="button"
data-testid="emoji-container-grinning"
onClick={() => onSelect('😀', '#FFEAD5')}
>
grinning
</button>
</div>
)
},
}))
vi.mock('../ImageInput', () => ({
default: function MockImageInput({ onImageInput, className }: {
onImageInput: (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => void
className?: string
}) {
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
const [animatedUrl, setAnimatedUrl] = React.useState<string | null>(null)
const [showCropper, setShowCropper] = React.useState(false)
return (
<div className={className}>
<div>drop image here</div>
<input
data-testid="image-input"
type="file"
onChange={(event) => {
const file = event.target.files?.[0]
if (!file)
return
setSelectedFile(file)
if (file.type === 'image/gif') {
const nextUrl = URL.createObjectURL(file)
setAnimatedUrl(nextUrl)
setShowCropper(false)
onImageInput(false, file)
return
}
setAnimatedUrl(null)
setShowCropper(true)
}}
/>
{showCropper && selectedFile && (
<div data-testid="mock-cropper">
<button
type="button"
data-testid="trigger-crop"
onClick={() => onImageInput(true, 'blob:crop-temp-url', { x: 0, y: 0, width: 100, height: 100 }, selectedFile.name)}
>
Trigger Crop
</button>
</div>
)}
{animatedUrl && <img data-testid="animated-image" src={animatedUrl} alt="animated preview" />}
</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']),
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="mock-modal" className={className}>
{children}
</div>
),
}))
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 = () => {
const onSelect = vi.fn()
const onClose = vi.fn()
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} />)
return { onSelect, onClose, 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(await screen.findByText(/emoji/i)).toBeInTheDocument()
expect(screen.getByText(/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 call onClose when cancel is clicked', async () => {
const { onClose } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should switch between emoji and image tabs', async () => {
renderPicker()
fireEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
fireEvent.click(screen.getByText(/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(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0)
})
const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0]
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 not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/ok/i))
expect(onSelect).not.toHaveBeenCalled()
})
})
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()
})
})
})