mirror of
https://github.com/langgenius/dify.git
synced 2026-03-20 23:32:04 +00:00
Compare commits
5 Commits
optional-p
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11f075b35 | ||
|
|
e823e09afb | ||
|
|
039b448b90 | ||
|
|
3a64db3d60 | ||
|
|
7dac2f0d82 |
@@ -6,27 +6,12 @@
|
||||
*
|
||||
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DevelopMain from '@/app/components/develop'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
let storeAppDetail: unknown
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
@@ -85,6 +70,10 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) => (isShow ? <div aria-label="Secret key modal" role="dialog" /> : null),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('DevelopMain page flow', () => {
|
||||
@@ -159,7 +148,7 @@ describe('DevelopMain page flow', () => {
|
||||
})
|
||||
|
||||
it('should open API key modal from the page', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const user = userEvent.setup()
|
||||
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
@@ -171,14 +160,11 @@ describe('DevelopMain page flow', () => {
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Click API Key button in the header
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
|
||||
// SecretKeyModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'Secret key modal' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
enable_explore_banner: false,
|
||||
enable_trial_app: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: vi.fn(),
|
||||
}))
|
||||
@@ -108,6 +117,10 @@ vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/explore/try-app', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-id',
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -76,21 +77,69 @@ vi.mock('@/config', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
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 },
|
||||
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>
|
||||
)}
|
||||
>
|
||||
Trigger Crop
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{animatedUrl && <img data-testid="animated-image" src={animatedUrl} alt="animated preview" />}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../image-uploader/hooks', () => ({
|
||||
@@ -104,6 +153,14 @@ 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
|
||||
@@ -183,10 +240,10 @@ describe('AppIconPicker', () => {
|
||||
it('should switch between emoji and image tabs', async () => {
|
||||
renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
fireEvent.click(screen.getByText(/image/i))
|
||||
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/emoji/i))
|
||||
fireEvent.click(screen.getByText(/emoji/i))
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,51 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../index', () => ({
|
||||
Authorized: ({
|
||||
credentials = [],
|
||||
extraAuthorizationItems = [],
|
||||
isOpen,
|
||||
onItemClick,
|
||||
onOpenChange,
|
||||
renderTrigger,
|
||||
}: {
|
||||
credentials?: Credential[]
|
||||
extraAuthorizationItems?: Credential[]
|
||||
isOpen?: boolean
|
||||
onItemClick?: (id: string) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
renderTrigger?: (isOpen?: boolean) => ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="authorized-trigger" onClick={() => onOpenChange?.(!isOpen)}>
|
||||
{renderTrigger?.(isOpen)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div>
|
||||
{extraAuthorizationItems.map(item => (
|
||||
<button key={item.id} type="button" onClick={() => onItemClick?.(item.id)}>{item.name}</button>
|
||||
))}
|
||||
{credentials.map(item => (
|
||||
<button key={item.id} type="button" onClick={() => onItemClick?.(item.id)}>{item.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
usePluginAuth: () => {
|
||||
const credentialInfo = mockGetPluginCredentialInfo() ?? { credentials: [] }
|
||||
return {
|
||||
canApiKey: true,
|
||||
canOAuth: false,
|
||||
credentials: credentialInfo.credentials ?? [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
|
||||
vi.mock('../authorize/add-api-key-button', () => ({ default: () => null }))
|
||||
vi.mock('../authorize/add-oauth-button', () => ({ default: () => null }))
|
||||
vi.mock('../authorize/api-key-modal', () => ({ default: () => null }))
|
||||
vi.mock('../authorized', () => ({ default: () => null }))
|
||||
vi.mock('../authorized-in-data-source-node', () => ({ default: () => null }))
|
||||
vi.mock('../authorized-in-node', () => ({ default: () => null }))
|
||||
vi.mock('../plugin-auth', () => ({ default: () => null }))
|
||||
vi.mock('../plugin-auth-in-agent', () => ({ default: () => null }))
|
||||
vi.mock('../plugin-auth-in-datasource-node', () => ({ default: () => null }))
|
||||
vi.mock('../hooks/use-plugin-auth', () => ({ usePluginAuth: () => ({}) }))
|
||||
vi.mock('../hooks/use-plugin-auth-action', () => ({}))
|
||||
|
||||
describe('plugin-auth index exports', () => {
|
||||
it('should export all required components and hooks', async () => {
|
||||
const exports = await import('../index')
|
||||
|
||||
@@ -56,6 +56,48 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../authorize', () => ({
|
||||
default: () => (
|
||||
<button type="button">
|
||||
plugin.auth.useApiAuth
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../authorized', () => ({
|
||||
default: ({
|
||||
credentials = [],
|
||||
extraAuthorizationItems = [],
|
||||
isOpen,
|
||||
onItemClick,
|
||||
onOpenChange,
|
||||
renderTrigger,
|
||||
}: {
|
||||
credentials?: Credential[]
|
||||
extraAuthorizationItems?: Credential[]
|
||||
isOpen?: boolean
|
||||
onItemClick?: (id: string) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
renderTrigger?: (isOpen?: boolean) => ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="authorized-trigger" onClick={() => onOpenChange?.(!isOpen)}>
|
||||
{renderTrigger?.(isOpen)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div>
|
||||
{extraAuthorizationItems.map(item => (
|
||||
<button key={item.id} type="button" onClick={() => onItemClick?.(item.isWorkspaceDefault ? '' : item.id)}>{item.name}</button>
|
||||
))}
|
||||
{credentials.map(item => (
|
||||
<button key={item.id} type="button" onClick={() => onItemClick?.(item.id)}>{item.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
@@ -120,7 +162,7 @@ describe('PluginAuthInAgent Component', () => {
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'plugin.auth.useApiAuth' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Authorized with workspace default when authorized', async () => {
|
||||
@@ -130,7 +172,7 @@ describe('PluginAuthInAgent Component', () => {
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /plugin\.auth\.workspaceDefault/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1541,7 +1541,7 @@ describe('AppSelector', () => {
|
||||
|
||||
it('should manage isLoadingMore state during load more', () => {
|
||||
mockHasNextPage = true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
@@ -1769,7 +1769,10 @@ describe('AppSelector', () => {
|
||||
it('should not call fetchNextPage when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
|
||||
let resolveFetchNextPage: (() => void) | undefined
|
||||
mockFetchNextPage.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveFetchNextPage = resolve
|
||||
}))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
@@ -1780,8 +1783,13 @@ describe('AppSelector', () => {
|
||||
|
||||
// Trigger intersection - this starts loading
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetchNextPage?.()
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip handleLoadMore when isFetchingNextPage is true', async () => {
|
||||
@@ -1825,8 +1833,10 @@ describe('AppSelector', () => {
|
||||
it('should return early from handleLoadMore when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
// Make fetchNextPage slow to keep isLoadingMore true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000)))
|
||||
let resolveFetchNextPage: (() => void) | undefined
|
||||
mockFetchNextPage.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveFetchNextPage = resolve
|
||||
}))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
@@ -1843,6 +1853,10 @@ describe('AppSelector', () => {
|
||||
|
||||
// Still only 1 call because isLoadingMore blocks it
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetchNextPage?.()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ const AppPicker: FC<Props> = ({
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loadingRef = useRef(false)
|
||||
const loadingResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0]
|
||||
@@ -63,8 +64,11 @@ const AppPicker: FC<Props> = ({
|
||||
loadingRef.current = true
|
||||
onLoadMore()
|
||||
// Reset loading state
|
||||
setTimeout(() => {
|
||||
if (loadingResetTimeoutRef.current)
|
||||
clearTimeout(loadingResetTimeoutRef.current)
|
||||
loadingResetTimeoutRef.current = setTimeout(() => {
|
||||
loadingRef.current = false
|
||||
loadingResetTimeoutRef.current = null
|
||||
}, 500)
|
||||
}, [hasMore, isLoading, onLoadMore])
|
||||
|
||||
@@ -116,6 +120,10 @@ const AppPicker: FC<Props> = ({
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
if (loadingResetTimeoutRef.current) {
|
||||
clearTimeout(loadingResetTimeoutRef.current)
|
||||
loadingResetTimeoutRef.current = null
|
||||
}
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
}, [isShow, handleIntersection])
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
import type { FC } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
@@ -50,6 +50,7 @@ const AppSelector: FC<Props> = ({
|
||||
const [isShow, onShowChange] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const loadingMoreTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -106,12 +107,24 @@ const AppSelector: FC<Props> = ({
|
||||
}
|
||||
finally {
|
||||
// Add a small delay to ensure state updates are complete
|
||||
setTimeout(() => {
|
||||
if (loadingMoreTimeoutRef.current)
|
||||
clearTimeout(loadingMoreTimeoutRef.current)
|
||||
loadingMoreTimeoutRef.current = setTimeout(() => {
|
||||
setIsLoadingMore(false)
|
||||
loadingMoreTimeoutRef.current = null
|
||||
}, 300)
|
||||
}
|
||||
}, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (loadingMoreTimeoutRef.current) {
|
||||
clearTimeout(loadingMoreTimeoutRef.current)
|
||||
loadingMoreTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
|
||||
import Conversion from '../conversion'
|
||||
@@ -347,6 +348,47 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="modal" role="dialog">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: function MockAppIconPicker({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
onSelect: (selection: { type: 'emoji' | 'image', icon?: string, background?: string, url?: string }) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [pendingSelection, setPendingSelection] = React.useState<{ type: 'emoji' | 'image', icon?: string, background?: string, url?: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div data-testid="app-icon-picker">
|
||||
<button onClick={() => setPendingSelection({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })}>
|
||||
iconPicker.emojiOption
|
||||
</button>
|
||||
<button onClick={() => setPendingSelection({ type: 'image', url: '/icon.png' })}>
|
||||
iconPicker.image
|
||||
</button>
|
||||
<button onClick={() => pendingSelection && onSelect(pendingSelection)}>
|
||||
iconPicker.ok
|
||||
</button>
|
||||
<button onClick={onClose}>
|
||||
iconPicker.cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Silence expected console.error from Dialog/Modal rendering
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -708,10 +750,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
const appIcon = getAppIcon()
|
||||
fireEvent.click(appIcon)
|
||||
|
||||
// Click the first emoji in the grid (search full document since Dialog uses portal)
|
||||
const gridEmojis = document.querySelectorAll('.grid em-emoji')
|
||||
expect(gridEmojis.length).toBeGreaterThan(0)
|
||||
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ }))
|
||||
|
||||
// Click OK to confirm selection
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
@@ -728,9 +767,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
const appIcon = getAppIcon()
|
||||
fireEvent.click(appIcon)
|
||||
|
||||
// Switch to image tab
|
||||
const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ })
|
||||
fireEvent.click(imageTab)
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
|
||||
|
||||
// Picker should still be open
|
||||
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
|
||||
@@ -1031,11 +1068,8 @@ describe('Integration Tests', () => {
|
||||
// Open picker and select an emoji
|
||||
const appIcon = getAppIcon()
|
||||
fireEvent.click(appIcon)
|
||||
const gridEmojis = document.querySelectorAll('.grid em-emoji')
|
||||
if (gridEmojis.length > 0) {
|
||||
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
// Vitest config
|
||||
test: {
|
||||
detectAsyncLeaks: true,
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
|
||||
@@ -98,6 +98,10 @@ afterEach(async () => {
|
||||
await act(async () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Give Headless UI transition scheduler tasks one event-loop turn to settle
|
||||
// so detectAsyncLeaks does not report teardown false positives.
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
// mock foxact/use-clipboard - not available in test environment
|
||||
|
||||
Reference in New Issue
Block a user