test: add unit and integration tests for share, develop, and goto-anything modules (#32246)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-02-12 10:05:43 +08:00 committed by GitHub
parent 80e6312807
commit bfdc39510b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2880 additions and 555 deletions

View File

@ -0,0 +1,192 @@
/**
* Integration test: API Key management flow
*
* Tests the cross-component interaction:
* ApiServer SecretKeyButton SecretKeyModal
*
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
* with only service-layer mocks. Deep modal interactions (create/delete)
* are covered by unit tests in secret-key-modal.spec.tsx.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ApiServer from '@/app/components/develop/ApiServer'
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
// ---------- mocks ----------
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
const mockIsLoading = vi.fn().mockReturnValue(false)
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({
data: mockApiKeys(),
isLoading: mockIsLoading(),
}),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('API Key management flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApiKeys.mockReturnValue({ data: [] })
mockIsLoading.mockReturnValue(false)
})
it('ApiServer renders URL, status badge, and API Key button', () => {
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
})
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
// Click API Key button (rendered by SecretKeyButton)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should render with real HeadlessUI Dialog
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
})
})
it('modal shows loading state when API keys are being fetched', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
mockIsLoading.mockReturnValue(true)
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Loading indicator should be present
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
})
it('modal can be closed by clicking X icon', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
// Open modal
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Click X icon to close
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
await act(async () => {
await user.click(closeIcon!)
})
await flushUI()
// Modal should close
await waitFor(() => {
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
})
})
it('renders correctly with different API URLs', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { rerender } = render(
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
)
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
// Open modal and verify it works with the same appId
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Close modal, update URL and re-verify
const xIcon = document.body.querySelector('svg.cursor-pointer')
await act(async () => {
await user.click(xIcon!)
})
await flushUI()
rerender(
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
)
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,241 @@
/**
* Integration test: DevelopMain page flow
*
* Tests the full page lifecycle:
* Loading state App loaded Header (ApiServer) + Content (Doc) rendered
*
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DevelopMain from '@/app/components/develop'
import { AppModeEnum, Theme } from '@/types/app'
// ---------- fake timers ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
// ---------- store mock ----------
let storeAppDetail: unknown
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
return selector({ appDetail: storeAppDetail })
},
}))
// ---------- Doc dependencies ----------
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
}))
// ---------- SecretKeyModal dependencies ----------
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('DevelopMain page flow', () => {
beforeEach(() => {
vi.clearAllMocks()
storeAppDetail = undefined
})
it('should show loading indicator when appDetail is not available', () => {
storeAppDetail = undefined
render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// No content should be visible
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
})
it('should render full page when appDetail is loaded', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
render(<DevelopMain appId="app-1" />)
// ApiServer section should be visible
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
// Loading should NOT be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should render Doc component with correct app mode template', () => {
storeAppDetail = {
id: 'app-1',
name: 'Chat App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
const { container } = render(<DevelopMain appId="app-1" />)
// Doc renders an article element with prose classes
const article = container.querySelector('article')
expect(article).toBeInTheDocument()
expect(article?.className).toContain('prose')
})
it('should transition from loading to content when appDetail becomes available', () => {
// Start with no data
storeAppDetail = undefined
const { rerender } = render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// Simulate store update
storeAppDetail = {
id: 'app-1',
name: 'My App',
api_base_url: 'https://api.example.com/v1',
mode: AppModeEnum.COMPLETION,
}
rerender(<DevelopMain appId="app-1" />)
// Content should now be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
})
it('should open API key modal from the page', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.WORKFLOW,
}
render(<DevelopMain appId="app-1" />)
// Click API Key button in the header
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should open
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
})
it('should render correctly for different app modes', () => {
const modes = [
AppModeEnum.CHAT,
AppModeEnum.COMPLETION,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
]
for (const mode of modes) {
storeAppDetail = {
id: 'app-1',
name: `${mode} App`,
api_base_url: 'https://api.test.com/v1',
mode,
}
const { container, unmount } = render(<DevelopMain appId="app-1" />)
// ApiServer should always be present
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
// Doc should render an article
expect(container.querySelector('article')).toBeInTheDocument()
unmount()
}
})
it('should have correct page layout structure', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
render(<DevelopMain appId="app-1" />)
// Main container: flex column with full height
const mainDiv = screen.getByTestId('develop-main')
expect(mainDiv.className).toContain('flex')
expect(mainDiv.className).toContain('flex-col')
expect(mainDiv.className).toContain('h-full')
// Header section with border
const header = mainDiv.querySelector('.border-b')
expect(header).toBeInTheDocument()
// Content section with overflow scroll
const content = mainDiv.querySelector('.overflow-auto')
expect(content).toBeInTheDocument()
})
})

View File

@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => {
beforeEach(() => {
vi.clearAllMocks()
;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => {
vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => {
if (name === 'docs')
return mockDirectCommand
if (name === 'theme')
return mockSubmenuCommand
return null
return undefined
})
;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
mockDirectCommand,
mockSubmenuCommand,
])
@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => {
unregister: vi.fn(),
}
;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode)
vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode)
const handler = slashCommandRegistry.findCommand('test')
// Default behavior should be submenu when mode is not specified

View File

@ -0,0 +1,121 @@
/**
* Integration test: RunBatch CSV upload Run flow
*
* Tests the complete user journey:
* Upload CSV parse enable run click run results finish run again
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import RunBatch from '@/app/components/share/text-generation/run-batch'
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
// Capture the onParsed callback from CSVReader to simulate CSV uploads
let capturedOnParsed: ((data: string[][]) => void) | undefined
vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({
default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => {
capturedOnParsed = onParsed
return <div data-testid="csv-reader">CSV Reader</div>
},
}))
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
default: ({ vars }: { vars: { name: string }[] }) => (
<div data-testid="csv-download">
{vars.map(v => v.name).join(', ')}
</div>
),
}))
describe('RunBatch integration flow', () => {
const vars = [{ name: 'prompt' }, { name: 'context' }]
beforeEach(() => {
capturedOnParsed = undefined
vi.clearAllMocks()
})
it('full lifecycle: upload CSV → run → finish → run again', async () => {
const onSend = vi.fn()
const { rerender } = render(
<RunBatch vars={vars} onSend={onSend} isAllFinished />,
)
// Phase 1 verify child components rendered
expect(screen.getByTestId('csv-reader')).toBeInTheDocument()
expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context')
// Run button should be disabled before CSV is parsed
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
// Phase 2 simulate CSV upload
const csvData = [
['prompt', 'context'],
['Hello', 'World'],
['Goodbye', 'Moon'],
]
await act(async () => {
capturedOnParsed?.(csvData)
})
// Run button should now be enabled
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
// Phase 3 click run
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledTimes(1)
expect(onSend).toHaveBeenCalledWith(csvData)
// Phase 4 simulate results still running
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
expect(runButton).toBeDisabled()
// Phase 5 results finish → can run again
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
onSend.mockClear()
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledTimes(1)
})
it('should remain disabled when CSV not uploaded even if all finished', () => {
const onSend = vi.fn()
render(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
fireEvent.click(runButton)
expect(onSend).not.toHaveBeenCalled()
})
it('should show spinner icon when results are still running', async () => {
const onSend = vi.fn()
const { container } = render(
<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />,
)
// Upload CSV first
await act(async () => {
capturedOnParsed?.([['data']])
})
// Button disabled + spinning icon
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
const icon = container.querySelector('svg')
expect(icon).toHaveClass('animate-spin')
})
})

View File

@ -0,0 +1,218 @@
/**
* Integration test: RunOnce form lifecycle
*
* Tests the complete user journey:
* Init defaults edit fields submit running state stop
*/
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useRef, useState } from 'react'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { Resolution, TransferMethod } from '@/types/app'
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => (
<textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} />
),
}))
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
default: () => <div data-testid="vision-uploader" />,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
}))
// ----- helpers -----
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
key: 'k',
name: 'Name',
type: 'string',
required: true,
...overrides,
})
const visionOff: VisionSettings = {
enabled: false,
number_limits: 0,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 5,
}
const siteInfo: SiteInfo = { title: 'Test' }
/**
* Stateful wrapper that mirrors what text-generation/index.tsx does:
* owns `inputs` state and passes an `inputsRef`.
*/
function Harness({
promptConfig,
visionConfig = visionOff,
onSendSpy,
runControl = null,
}: {
promptConfig: PromptConfig
visionConfig?: VisionSettings
onSendSpy: () => void
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
}) {
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef<Record<string, InputValueTypes>>({})
return (
<RunOnce
siteInfo={siteInfo}
promptConfig={promptConfig}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={(updated) => {
inputsRef.current = updated
setInputs(updated)
}}
onSend={onSendSpy}
visionConfig={visionConfig}
onVisionFilesChange={vi.fn()}
runControl={runControl}
/>
)
}
// ----- tests -----
describe('RunOnce integration flow', () => {
it('full lifecycle: init → edit → submit → running → stop', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
],
}
// Phase 1 render, wait for initialisation
const { rerender } = render(
<Harness promptConfig={config} onSendSpy={onSend} />,
)
await waitFor(() => {
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
})
// Phase 2 fill fields
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
// Phase 3 submit
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
// Phase 4 simulate "running" state
const onStop = vi.fn()
rerender(
<Harness
promptConfig={config}
onSendSpy={onSend}
runControl={{ onStop, isStopping: false }}
/>,
)
const stopBtn = screen.getByTestId('stop-button')
expect(stopBtn).toBeInTheDocument()
fireEvent.click(stopBtn)
expect(onStop).toHaveBeenCalledTimes(1)
// Phase 5 simulate "stopping" state
rerender(
<Harness
promptConfig={config}
onSendSpy={onSend}
runControl={{ onStop, isStopping: true }}
/>,
)
expect(screen.getByTestId('stop-button')).toBeDisabled()
})
it('clear resets all field types and allows re-submit', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
],
}
render(<Harness promptConfig={config} onSendSpy={onSend} />)
await waitFor(() => {
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
})
// Clear all
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
await waitFor(() => {
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
})
// Re-fill and submit
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
it('mixed input types: string + select + json_object', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
variable({
key: 'sel',
name: 'Dropdown',
type: 'select',
options: ['A', 'B'],
default: 'A',
}),
variable({
key: 'json',
name: 'JSON',
type: 'json_object' as PromptVariable['type'],
}),
],
}
render(<Harness promptConfig={config} onSendSpy={onSend} />)
await waitFor(() => {
expect(screen.getByText('Text')).toBeInTheDocument()
expect(screen.getByText('Dropdown')).toBeInTheDocument()
expect(screen.getByText('JSON')).toBeInTheDocument()
})
// Edit text & json
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,9 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import ApiServer from './ApiServer'
import ApiServer from '../ApiServer'
// Mock the secret-key-modal since it involves complex API interactions
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null
@ -38,7 +37,6 @@ describe('ApiServer', () => {
it('should render CopyFeedback component', () => {
render(<ApiServer {...defaultProps} />)
// CopyFeedback renders a button for copying
const copyButtons = screen.getAllByRole('button')
expect(copyButtons.length).toBeGreaterThan(0)
})
@ -90,7 +88,6 @@ describe('ApiServer', () => {
const user = userEvent.setup()
render(<ApiServer {...defaultProps} appId="app-123" />)
// Open modal
const apiKeyButton = screen.getByText('appApi.apiKey')
await act(async () => {
await user.click(apiKeyButton)
@ -98,7 +95,6 @@ describe('ApiServer', () => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByText('Close Modal')
await act(async () => {
await user.click(closeButton)
@ -196,9 +192,7 @@ describe('ApiServer', () => {
describe('SecretKeyButton styling', () => {
it('should have shrink-0 class to prevent shrinking', () => {
render(<ApiServer {...defaultProps} appId="app-123" />)
// The SecretKeyButton wraps a Button component
const button = screen.getByRole('button', { name: /apiKey/i })
// Check parent container has shrink-0
const buttonContainer = button.closest('.shrink-0')
expect(buttonContainer).toBeInTheDocument()
})

View File

@ -1,8 +1,7 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Code, CodeGroup, Embed, Pre } from './code'
import { Code, CodeGroup, Embed, Pre } from '../code'
// Mock the clipboard utility
vi.mock('@/utils/clipboard', () => ({
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
}))
@ -155,6 +154,9 @@ describe('code.tsx components', () => {
<pre><code>fallback</code></pre>
</CodeGroup>,
)
await act(async () => {
vi.runAllTimers()
})
const tab2 = screen.getByRole('tab', { name: 'Tab2' })
await act(async () => {
@ -229,7 +231,6 @@ describe('code.tsx components', () => {
)
expect(screen.getByText('POST')).toBeInTheDocument()
expect(screen.getByText('/api/create')).toBeInTheDocument()
// Separator should be present
const separator = container.querySelector('.rounded-full.bg-zinc-500')
expect(separator).toBeInTheDocument()
})
@ -264,6 +265,9 @@ describe('code.tsx components', () => {
<pre><code>fallback</code></pre>
</CodeGroup>,
)
await act(async () => {
vi.runAllTimers()
})
const copyButton = screen.getByRole('button')
await act(async () => {
@ -285,6 +289,9 @@ describe('code.tsx components', () => {
<pre><code>fallback</code></pre>
</CodeGroup>,
)
await act(async () => {
vi.runAllTimers()
})
const copyButton = screen.getByRole('button')
await act(async () => {
@ -295,7 +302,6 @@ describe('code.tsx components', () => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
// Advance time past the timeout
await act(async () => {
vi.advanceTimersByTime(1500)
})
@ -358,7 +364,6 @@ describe('code.tsx components', () => {
<pre><code>code content</code></pre>
</Pre>,
)
// Should render within a CodeGroup structure
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
@ -382,7 +387,6 @@ describe('code.tsx components', () => {
</Pre>
</CodeGroup>,
)
// The outer code should be rendered (from targetCode)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
})
@ -546,7 +550,6 @@ describe('code.tsx components', () => {
<pre><code>fallback</code></pre>
</CodeGroup>,
)
// Should render copy button even with empty code
expect(screen.getByRole('button')).toBeInTheDocument()
})
@ -569,7 +572,6 @@ line3`
<pre><code>fallback</code></pre>
</CodeGroup>,
)
// Multiline code should be rendered - use a partial match
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line2/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()

View File

@ -0,0 +1,206 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum, Theme } from '@/types/app'
import Doc from '../doc'
// The vitest mdx-stub plugin makes .mdx files parseable; these mocks replace
vi.mock('../template/template.en.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-en" />,
}))
vi.mock('../template/template.zh.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-zh" />,
}))
vi.mock('../template/template.ja.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-ja" />,
}))
vi.mock('../template/template_chat.en.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-en" />,
}))
vi.mock('../template/template_chat.zh.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-zh" />,
}))
vi.mock('../template/template_chat.ja.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-ja" />,
}))
vi.mock('../template/template_advanced_chat.en.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-en" />,
}))
vi.mock('../template/template_advanced_chat.zh.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-zh" />,
}))
vi.mock('../template/template_advanced_chat.ja.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-ja" />,
}))
vi.mock('../template/template_workflow.en.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-en" />,
}))
vi.mock('../template/template_workflow.zh.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-zh" />,
}))
vi.mock('../template/template_workflow.ja.mdx', () => ({
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-ja" />,
}))
const mockLocale = vi.fn().mockReturnValue('en-US')
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale(),
}))
const mockTheme = vi.fn().mockReturnValue(Theme.light)
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme() }),
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
}))
describe('Doc', () => {
const makeAppDetail = (mode: AppModeEnum, variables: Array<{ key: string, name: string }> = []) => ({
mode,
model_config: {
configs: {
prompt_variables: variables,
},
},
})
beforeEach(() => {
vi.clearAllMocks()
mockLocale.mockReturnValue('en-US')
mockTheme.mockReturnValue(Theme.light)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
})
describe('template selection by app mode', () => {
it.each([
[AppModeEnum.CHAT, 'template-chat-en'],
[AppModeEnum.AGENT_CHAT, 'template-chat-en'],
[AppModeEnum.ADVANCED_CHAT, 'template-advanced-chat-en'],
[AppModeEnum.WORKFLOW, 'template-workflow-en'],
[AppModeEnum.COMPLETION, 'template-completion-en'],
])('should render correct EN template for mode %s', (mode, testId) => {
render(<Doc appDetail={makeAppDetail(mode)} />)
expect(screen.getByTestId(testId)).toBeInTheDocument()
})
})
describe('template selection by locale', () => {
it('should render ZH template when locale is zh-Hans', () => {
mockLocale.mockReturnValue('zh-Hans')
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
expect(screen.getByTestId('template-chat-zh')).toBeInTheDocument()
})
it('should render JA template when locale is ja-JP', () => {
mockLocale.mockReturnValue('ja-JP')
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
expect(screen.getByTestId('template-chat-ja')).toBeInTheDocument()
})
it('should fall back to EN template for unsupported locales', () => {
mockLocale.mockReturnValue('fr-FR')
render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />)
expect(screen.getByTestId('template-completion-en')).toBeInTheDocument()
})
it('should render ZH advanced-chat template', () => {
mockLocale.mockReturnValue('zh-Hans')
render(<Doc appDetail={makeAppDetail(AppModeEnum.ADVANCED_CHAT)} />)
expect(screen.getByTestId('template-advanced-chat-zh')).toBeInTheDocument()
})
it('should render JA workflow template', () => {
mockLocale.mockReturnValue('ja-JP')
render(<Doc appDetail={makeAppDetail(AppModeEnum.WORKFLOW)} />)
expect(screen.getByTestId('template-workflow-ja')).toBeInTheDocument()
})
})
describe('null/undefined appDetail', () => {
it('should render nothing when appDetail has no mode', () => {
render(<Doc appDetail={{}} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument()
})
it('should render nothing when appDetail is null', () => {
render(<Doc appDetail={null} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
})
})
describe('TOC toggle', () => {
it('should show collapsed TOC button by default on small screens', () => {
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
})
it('should show expanded TOC on wide screens', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
expect(screen.getByLabelText('Close')).toBeInTheDocument()
})
it('should expand TOC when toggle button is clicked', async () => {
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
const toggleBtn = screen.getByLabelText('Open table of contents')
await act(async () => {
fireEvent.click(toggleBtn)
})
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
})
it('should collapse TOC when close button is clicked', async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
const closeBtn = screen.getByLabelText('Close')
await act(async () => {
fireEvent.click(closeBtn)
})
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
})
})
describe('dark theme', () => {
it('should apply prose-invert class in dark mode', () => {
mockTheme.mockReturnValue(Theme.dark)
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
const article = container.querySelector('article')
expect(article?.className).toContain('prose-invert')
})
it('should not apply prose-invert class in light mode', () => {
mockTheme.mockReturnValue(Theme.light)
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
const article = container.querySelector('article')
expect(article?.className).not.toContain('prose-invert')
})
})
describe('article structure', () => {
it('should render article with prose classes', () => {
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />)
const article = container.querySelector('article')
expect(article).toBeInTheDocument()
expect(article?.className).toContain('prose')
})
it('should render flex layout wrapper', () => {
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
expect(container.querySelector('.flex')).toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react'
import DevelopMain from './index'
import DevelopMain from '../index'
// Mock the app store with a factory function to control state
const mockAppDetailValue: { current: unknown } = { current: undefined }
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: unknown) => unknown) => {
@ -10,7 +9,6 @@ vi.mock('@/app/components/app/store', () => ({
},
}))
// Mock the Doc component since it has complex dependencies
vi.mock('@/app/components/develop/doc', () => ({
default: ({ appDetail }: { appDetail: { name?: string } | null }) => (
<div data-testid="doc-component">
@ -20,7 +18,6 @@ vi.mock('@/app/components/develop/doc', () => ({
),
}))
// Mock the ApiServer component
vi.mock('@/app/components/develop/ApiServer', () => ({
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => (
<div data-testid="api-server">
@ -44,7 +41,6 @@ describe('DevelopMain', () => {
mockAppDetailValue.current = undefined
render(<DevelopMain appId="app-123" />)
// Loading component renders with role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
@ -128,27 +124,27 @@ describe('DevelopMain', () => {
})
it('should have flex column layout', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
render(<DevelopMain appId="app-123" />)
const mainContainer = screen.getByTestId('develop-main')
expect(mainContainer.className).toContain('flex')
expect(mainContainer.className).toContain('flex-col')
})
it('should have relative positioning', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
render(<DevelopMain appId="app-123" />)
const mainContainer = screen.getByTestId('develop-main')
expect(mainContainer.className).toContain('relative')
})
it('should have full height', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
render(<DevelopMain appId="app-123" />)
const mainContainer = screen.getByTestId('develop-main')
expect(mainContainer.className).toContain('h-full')
})
it('should have overflow-hidden', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
render(<DevelopMain appId="app-123" />)
const mainContainer = screen.getByTestId('develop-main')
expect(mainContainer.className).toContain('overflow-hidden')
})
})

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md'
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from '../md'
describe('md.tsx components', () => {
describe('Heading', () => {

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { Tag } from './tag'
import { Tag } from '../tag'
describe('Tag', () => {
describe('rendering', () => {
@ -110,7 +110,6 @@ describe('Tag', () => {
it('should apply small variant styles', () => {
render(<Tag variant="small">GET</Tag>)
const tag = screen.getByText('GET')
// Small variant should not have ring styles
expect(tag.className).not.toContain('rounded-lg')
expect(tag.className).not.toContain('ring-1')
})
@ -189,7 +188,6 @@ describe('Tag', () => {
render(<Tag color="emerald" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-emerald-500')
// Small variant should not have background/ring styles
expect(tag.className).not.toContain('bg-emerald-400/10')
expect(tag.className).not.toContain('ring-emerald-300')
})
@ -223,7 +221,6 @@ describe('Tag', () => {
it('should correctly map PATCH to emerald (default)', () => {
render(<Tag>PATCH</Tag>)
const tag = screen.getByText('PATCH')
// PATCH is not in the valueColorMap, so it defaults to emerald
expect(tag.className).toContain('text-emerald')
})

View File

@ -20,7 +20,7 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => {
}
return (
<div className="relative flex h-full flex-col overflow-hidden">
<div data-testid="develop-main" className="relative flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-2">
<div className="text-lg font-medium text-text-primary"></div>
<ApiServer apiBaseUrl={appDetail.api_base_url} appId={appId} />

View File

@ -1,13 +1,20 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import InputCopy from './input-copy'
import InputCopy from '../input-copy'
// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn().mockReturnValue(true),
}))
async function renderAndFlush(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {
vi.runAllTimers()
})
return result
}
describe('InputCopy', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -20,19 +27,18 @@ describe('InputCopy', () => {
})
describe('rendering', () => {
it('should render the value', () => {
render(<InputCopy value="test-api-key-12345" />)
it('should render the value', async () => {
await renderAndFlush(<InputCopy value="test-api-key-12345" />)
expect(screen.getByText('test-api-key-12345')).toBeInTheDocument()
})
it('should render with empty value by default', () => {
render(<InputCopy />)
// Empty string should be rendered
it('should render with empty value by default', async () => {
await renderAndFlush(<InputCopy />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render children when provided', () => {
render(
it('should render children when provided', async () => {
await renderAndFlush(
<InputCopy value="key">
<span data-testid="custom-child">Custom Content</span>
</InputCopy>,
@ -40,53 +46,52 @@ describe('InputCopy', () => {
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
})
it('should render CopyFeedback component', () => {
render(<InputCopy value="test" />)
// CopyFeedback should render a button
it('should render CopyFeedback component', async () => {
await renderAndFlush(<InputCopy value="test" />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
})
describe('styling', () => {
it('should apply custom className', () => {
const { container } = render(<InputCopy value="test" className="custom-class" />)
it('should apply custom className', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('custom-class')
})
it('should have flex layout', () => {
const { container } = render(<InputCopy value="test" />)
it('should have flex layout', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
})
it('should have items-center alignment', () => {
const { container } = render(<InputCopy value="test" />)
it('should have items-center alignment', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('items-center')
})
it('should have rounded-lg class', () => {
const { container } = render(<InputCopy value="test" />)
it('should have rounded-lg class', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('rounded-lg')
})
it('should have background class', () => {
const { container } = render(<InputCopy value="test" />)
it('should have background class', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-components-input-bg-normal')
})
it('should have hover state', () => {
const { container } = render(<InputCopy value="test" />)
it('should have hover state', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('hover:bg-state-base-hover')
})
it('should have py-2 padding', () => {
const { container } = render(<InputCopy value="test" />)
it('should have py-2 padding', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('py-2')
})
@ -95,7 +100,7 @@ describe('InputCopy', () => {
describe('copy functionality', () => {
it('should copy value when clicked', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="copy-this-value" />)
await renderAndFlush(<InputCopy value="copy-this-value" />)
const copyableArea = screen.getByText('copy-this-value')
await act(async () => {
@ -107,20 +112,19 @@ describe('InputCopy', () => {
it('should update copied state after clicking', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test-value" />)
await renderAndFlush(<InputCopy value="test-value" />)
const copyableArea = screen.getByText('test-value')
await act(async () => {
await user.click(copyableArea)
})
// Copy function should have been called
expect(copy).toHaveBeenCalledWith('test-value')
})
it('should reset copied state after timeout', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test-value" />)
await renderAndFlush(<InputCopy value="test-value" />)
const copyableArea = screen.getByText('test-value')
await act(async () => {
@ -129,32 +133,29 @@ describe('InputCopy', () => {
expect(copy).toHaveBeenCalledWith('test-value')
// Advance time to reset the copied state
await act(async () => {
vi.advanceTimersByTime(1500)
})
// Component should still be functional
expect(screen.getByText('test-value')).toBeInTheDocument()
})
it('should render tooltip on value', () => {
render(<InputCopy value="test-value" />)
// Value should be wrapped in tooltip (tooltip shows on hover, not as visible text)
it('should render tooltip on value', async () => {
await renderAndFlush(<InputCopy value="test-value" />)
const valueText = screen.getByText('test-value')
expect(valueText).toBeInTheDocument()
})
})
describe('tooltip', () => {
it('should render tooltip wrapper', () => {
render(<InputCopy value="test" />)
it('should render tooltip wrapper', async () => {
await renderAndFlush(<InputCopy value="test" />)
const valueText = screen.getByText('test')
expect(valueText).toBeInTheDocument()
})
it('should have cursor-pointer on clickable area', () => {
render(<InputCopy value="test" />)
it('should have cursor-pointer on clickable area', async () => {
await renderAndFlush(<InputCopy value="test" />)
const valueText = screen.getByText('test')
const clickableArea = valueText.closest('div[class*="cursor-pointer"]')
expect(clickableArea).toBeInTheDocument()
@ -162,42 +163,42 @@ describe('InputCopy', () => {
})
describe('divider', () => {
it('should render vertical divider', () => {
const { container } = render(<InputCopy value="test" />)
it('should render vertical divider', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider).toBeInTheDocument()
})
it('should have correct divider dimensions', () => {
const { container } = render(<InputCopy value="test" />)
it('should have correct divider dimensions', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider?.className).toContain('h-4')
expect(divider?.className).toContain('w-px')
})
it('should have shrink-0 on divider', () => {
const { container } = render(<InputCopy value="test" />)
it('should have shrink-0 on divider', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider?.className).toContain('shrink-0')
})
})
describe('value display', () => {
it('should have truncate class for long values', () => {
render(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
it('should have truncate class for long values', async () => {
await renderAndFlush(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
const valueText = screen.getByText('very-long-api-key-value-that-might-overflow')
const container = valueText.closest('div[class*="truncate"]')
expect(container).toBeInTheDocument()
})
it('should have text-secondary color on value', () => {
render(<InputCopy value="test-value" />)
it('should have text-secondary color on value', async () => {
await renderAndFlush(<InputCopy value="test-value" />)
const valueText = screen.getByText('test-value')
expect(valueText.className).toContain('text-text-secondary')
})
it('should have absolute positioning for overlay', () => {
render(<InputCopy value="test" />)
it('should have absolute positioning for overlay', async () => {
await renderAndFlush(<InputCopy value="test" />)
const valueText = screen.getByText('test')
const container = valueText.closest('div[class*="absolute"]')
expect(container).toBeInTheDocument()
@ -205,22 +206,22 @@ describe('InputCopy', () => {
})
describe('inner container', () => {
it('should have grow class on inner container', () => {
const { container } = render(<InputCopy value="test" />)
it('should have grow class on inner container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const innerContainer = container.querySelector('.grow')
expect(innerContainer).toBeInTheDocument()
})
it('should have h-5 height on inner container', () => {
const { container } = render(<InputCopy value="test" />)
it('should have h-5 height on inner container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const innerContainer = container.querySelector('.h-5')
expect(innerContainer).toBeInTheDocument()
})
})
describe('with children', () => {
it('should render children before value', () => {
const { container } = render(
it('should render children before value', async () => {
const { container } = await renderAndFlush(
<InputCopy value="key">
<span data-testid="prefix">Prefix:</span>
</InputCopy>,
@ -229,8 +230,8 @@ describe('InputCopy', () => {
expect(children).toBeInTheDocument()
})
it('should render both children and value', () => {
render(
it('should render both children and value', async () => {
await renderAndFlush(
<InputCopy value="api-key">
<span>Label:</span>
</InputCopy>,
@ -241,55 +242,53 @@ describe('InputCopy', () => {
})
describe('CopyFeedback section', () => {
it('should have margin on CopyFeedback container', () => {
const { container } = render(<InputCopy value="test" />)
it('should have margin on CopyFeedback container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const copyFeedbackContainer = container.querySelector('.mx-1')
expect(copyFeedbackContainer).toBeInTheDocument()
})
})
describe('relative container', () => {
it('should have relative positioning on value container', () => {
const { container } = render(<InputCopy value="test" />)
it('should have relative positioning on value container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const relativeContainer = container.querySelector('.relative')
expect(relativeContainer).toBeInTheDocument()
})
it('should have grow on value container', () => {
const { container } = render(<InputCopy value="test" />)
// Find the relative container that also has grow
it('should have grow on value container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const valueContainer = container.querySelector('.relative.grow')
expect(valueContainer).toBeInTheDocument()
})
it('should have full height on value container', () => {
const { container } = render(<InputCopy value="test" />)
it('should have full height on value container', async () => {
const { container } = await renderAndFlush(<InputCopy value="test" />)
const valueContainer = container.querySelector('.relative.h-full')
expect(valueContainer).toBeInTheDocument()
})
})
describe('edge cases', () => {
it('should handle undefined value', () => {
render(<InputCopy value={undefined} />)
// Should not crash
it('should handle undefined value', async () => {
await renderAndFlush(<InputCopy value={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty string value', () => {
render(<InputCopy value="" />)
it('should handle empty string value', async () => {
await renderAndFlush(<InputCopy value="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long values', () => {
it('should handle very long values', async () => {
const longValue = 'a'.repeat(500)
render(<InputCopy value={longValue} />)
await renderAndFlush(<InputCopy value={longValue} />)
expect(screen.getByText(longValue)).toBeInTheDocument()
})
it('should handle special characters in value', () => {
it('should handle special characters in value', async () => {
const specialValue = 'key-with-special-chars!@#$%^&*()'
render(<InputCopy value={specialValue} />)
await renderAndFlush(<InputCopy value={specialValue} />)
expect(screen.getByText(specialValue)).toBeInTheDocument()
})
})
@ -297,11 +296,10 @@ describe('InputCopy', () => {
describe('multiple clicks', () => {
it('should handle multiple rapid clicks', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test" />)
await renderAndFlush(<InputCopy value="test" />)
const copyableArea = screen.getByText('test')
// Click multiple times rapidly
await act(async () => {
await user.click(copyableArea)
await user.click(copyableArea)

View File

@ -1,8 +1,7 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyButton from './secret-key-button'
import SecretKeyButton from '../secret-key-button'
// Mock the SecretKeyModal since it has complex dependencies
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => (
isShow
@ -30,7 +29,6 @@ describe('SecretKeyButton', () => {
it('should render the key icon', () => {
const { container } = render(<SecretKeyButton />)
// RiKey2Line icon should be rendered as an svg
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
@ -58,7 +56,6 @@ describe('SecretKeyButton', () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
// Open modal
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
@ -66,7 +63,6 @@ describe('SecretKeyButton', () => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
@ -81,20 +77,17 @@ describe('SecretKeyButton', () => {
const button = screen.getByRole('button')
// Open
await act(async () => {
await user.click(button)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
// Open again
await act(async () => {
await user.click(button)
})
@ -205,7 +198,6 @@ describe('SecretKeyButton', () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
// Initially modal should not be visible
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
const button = screen.getByRole('button')
@ -213,7 +205,6 @@ describe('SecretKeyButton', () => {
await user.click(button)
})
// Now modal should be visible
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
@ -231,7 +222,6 @@ describe('SecretKeyButton', () => {
await user.click(closeButton)
})
// Modal should be closed after clicking close
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
@ -251,7 +241,6 @@ describe('SecretKeyButton', () => {
button.focus()
expect(document.activeElement).toBe(button)
// Press Enter to activate
await act(async () => {
await user.keyboard('{Enter}')
})
@ -273,20 +262,17 @@ describe('SecretKeyButton', () => {
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
// Click first button
await act(async () => {
await user.click(buttons[0])
})
expect(screen.getByText('Modal for app-1')).toBeInTheDocument()
// Close first modal
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
// Click second button
await act(async () => {
await user.click(buttons[1])
})

View File

@ -1,15 +1,22 @@
import type { CreateApiKeyResponse } from '@/models/app'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyGenerateModal from './secret-key-generate'
import SecretKeyGenerateModal from '../secret-key-generate'
// Helper to create a valid CreateApiKeyResponse
const createMockApiKey = (token: string): CreateApiKeyResponse => ({
id: 'mock-id',
token,
created_at: '2024-01-01T00:00:00Z',
})
async function renderModal(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {
vi.runAllTimers()
})
return result
}
describe('SecretKeyGenerateModal', () => {
const defaultProps = {
isShow: true,
@ -18,75 +25,78 @@ describe('SecretKeyGenerateModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('rendering when shown', () => {
it('should render the modal when isShow is true', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should render the modal when isShow is true', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render the generate tips text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should render the generate tips text', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
it('should render the OK button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should render the OK button', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal, so query from document.body
it('should render the close icon', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
it('should render InputCopy component', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
it('should render InputCopy component', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
expect(screen.getByText('test-token-123')).toBeInTheDocument()
})
})
describe('rendering when hidden', () => {
it('should not render content when isShow is false', () => {
render(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
it('should not render content when isShow is false', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('newKey prop', () => {
it('should display the token when newKey is provided', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
it('should display the token when newKey is provided', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument()
})
it('should handle undefined newKey', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
// Should not crash and modal should still render
it('should handle undefined newKey', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should handle newKey with empty token', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
it('should handle newKey with empty token', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should display long tokens correctly', () => {
it('should display long tokens correctly', async () => {
const longToken = `sk-${'a'.repeat(100)}`
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
expect(screen.getByText(longToken)).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when X icon is clicked', async () => {
const user = userEvent.setup()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
@ -94,81 +104,60 @@ describe('SecretKeyGenerateModal', () => {
await user.click(closeIcon!)
})
// HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close)
expect(onClose).toHaveBeenCalled()
})
it('should call onClose when OK button is clicked', async () => {
const user = userEvent.setup()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
const okButton = screen.getByRole('button', { name: /ok/i })
await act(async () => {
await user.click(okButton)
})
// HeadlessUI Dialog calls onClose both from button click and modal close
expect(onClose).toHaveBeenCalled()
})
})
describe('className prop', () => {
it('should apply custom className', () => {
render(
it('should apply custom className', async () => {
await renderModal(
<SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />,
)
// Modal renders via portal
const modal = document.body.querySelector('.custom-modal-class')
expect(modal).toBeInTheDocument()
})
it('should apply shrink-0 class', () => {
render(
it('should apply shrink-0 class', async () => {
await renderModal(
<SecretKeyGenerateModal {...defaultProps} className="shrink-0" />,
)
// Modal renders via portal
const modal = document.body.querySelector('.shrink-0')
expect(modal).toBeInTheDocument()
})
})
describe('modal styling', () => {
it('should have px-8 padding', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
it('should have px-8 padding', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const modal = document.body.querySelector('.px-8')
expect(modal).toBeInTheDocument()
})
})
describe('close icon styling', () => {
it('should have cursor-pointer class on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
it('should have cursor-pointer class on close icon', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
it('should have correct dimensions on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]')
expect(closeIcon).toBeInTheDocument()
})
it('should have tertiary text color on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]')
expect(closeIcon).toBeInTheDocument()
})
})
describe('header section', () => {
it('should have flex justify-end on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
it('should have flex justify-end on close container', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@ -176,9 +165,8 @@ describe('SecretKeyGenerateModal', () => {
expect(closeContainer?.className).toContain('justify-end')
})
it('should have negative margin on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
it('should have negative margin on close container', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@ -186,9 +174,8 @@ describe('SecretKeyGenerateModal', () => {
expect(closeContainer?.className).toContain('-mt-6')
})
it('should have bottom margin on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
it('should have bottom margin on close container', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@ -197,46 +184,45 @@ describe('SecretKeyGenerateModal', () => {
})
describe('tips text styling', () => {
it('should have mt-1 margin on tips', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have mt-1 margin on tips', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('mt-1')
})
it('should have correct font size', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have correct font size', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('text-[13px]')
})
it('should have normal font weight', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have normal font weight', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('font-normal')
})
it('should have leading-5 line height', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have leading-5 line height', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('leading-5')
})
it('should have tertiary text color', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have tertiary text color', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('text-text-tertiary')
})
})
describe('InputCopy section', () => {
it('should render InputCopy with token value', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
it('should render InputCopy with token value', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
expect(screen.getByText('test-token')).toBeInTheDocument()
})
it('should have w-full class on InputCopy', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
// The InputCopy component should have w-full
it('should have w-full class on InputCopy', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
const inputText = screen.getByText('test')
const inputContainer = inputText.closest('.w-full')
expect(inputContainer).toBeInTheDocument()
@ -244,58 +230,57 @@ describe('SecretKeyGenerateModal', () => {
})
describe('OK button section', () => {
it('should render OK button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should render OK button', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
expect(button).toBeInTheDocument()
})
it('should have button container with flex layout', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have button container with flex layout', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
const container = button.parentElement
expect(container).toBeInTheDocument()
expect(container?.className).toContain('flex')
})
it('should have shrink-0 on button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have shrink-0 on button', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
expect(button.className).toContain('shrink-0')
})
})
describe('button text styling', () => {
it('should have text-xs font size on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have text-xs font size on button text', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('text-xs')
})
it('should have font-medium on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have font-medium on button text', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('font-medium')
})
it('should have secondary text color on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should have secondary text color on button text', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('text-text-secondary')
})
})
describe('default prop values', () => {
it('should default isShow to false', () => {
// When isShow is explicitly set to false
render(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
it('should default isShow to false', async () => {
await renderModal(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('modal title', () => {
it('should display the correct title', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
it('should display the correct title', async () => {
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
})

View File

@ -1,8 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyModal from './secret-key-modal'
import { afterEach } from 'vitest'
import SecretKeyModal from '../secret-key-modal'
async function renderModal(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {
vi.runAllTimers()
})
return result
}
async function flushTransitions() {
await act(async () => {
vi.runAllTimers()
})
await act(async () => {
vi.runAllTimers()
})
}
// Mock the app context
const mockCurrentWorkspace = vi.fn().mockReturnValue({
id: 'workspace-1',
name: 'Test Workspace',
@ -18,7 +35,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
// Mock the timestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`),
@ -26,7 +42,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
// Mock API services
const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' })
const mockDelAppApikey = vi.fn().mockResolvedValue({})
vi.mock('@/service/apps', () => ({
@ -41,7 +56,6 @@ vi.mock('@/service/datasets', () => ({
delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args),
}))
// Mock React Query hooks for apps
const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] })
const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false)
const mockInvalidateAppApiKeys = vi.fn()
@ -54,7 +68,6 @@ vi.mock('@/service/use-apps', () => ({
useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys,
}))
// Mock React Query hooks for datasets
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
const mockInvalidateDatasetApiKeys = vi.fn()
@ -75,6 +88,7 @@ describe('SecretKeyModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
@ -84,53 +98,57 @@ describe('SecretKeyModal', () => {
mockIsDatasetApiKeysLoading.mockReturnValue(false)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('rendering when shown', () => {
it('should render the modal when isShow is true', () => {
render(<SecretKeyModal {...defaultProps} />)
it('should render the modal when isShow is true', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render the tips text', () => {
render(<SecretKeyModal {...defaultProps} />)
it('should render the tips text', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
})
it('should render the create new key button', () => {
render(<SecretKeyModal {...defaultProps} />)
it('should render the create new key button', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal renders via portal, so we need to query from document.body
it('should render the close icon', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
})
describe('rendering when hidden', () => {
it('should not render content when isShow is false', () => {
render(<SecretKeyModal {...defaultProps} isShow={false} />)
it('should not render content when isShow is false', async () => {
await renderModal(<SecretKeyModal {...defaultProps} isShow={false} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('loading state', () => {
it('should show loading when app API keys are loading', () => {
it('should show loading when app API keys are loading', async () => {
mockIsAppApiKeysLoading.mockReturnValue(true)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show loading when dataset API keys are loading', () => {
it('should show loading when dataset API keys are loading', async () => {
mockIsDatasetApiKeysLoading.mockReturnValue(true)
render(<SecretKeyModal {...defaultProps} />)
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not show loading when data is loaded', () => {
it('should not show loading when data is loaded', async () => {
mockIsAppApiKeysLoading.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
@ -145,49 +163,43 @@ describe('SecretKeyModal', () => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
it('should render API keys when available', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789'
it('should render API keys when available', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
it('should render created time for keys', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render created time for keys', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument()
})
it('should render last used time for keys', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render last used time for keys', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument()
})
it('should render "never" for keys without last_used_at', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render "never" for keys without last_used_at', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('appApi.never')).toBeInTheDocument()
})
it('should render delete button for managers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons
it('should render delete button for managers', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const buttons = screen.getAllByRole('button')
// There should be at least 3 buttons: copy feedback, delete, and create
expect(buttons.length).toBeGreaterThanOrEqual(2)
// Check for delete icon SVG - Modal renders via portal
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
expect(deleteIcon).toBeInTheDocument()
})
it('should not render delete button for non-managers', () => {
it('should not render delete button for non-managers', async () => {
mockIsCurrentWorkspaceManager.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// The specific delete action button should not be present
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = screen.getAllByRole('button')
// Should only have copy and create buttons, not delete
expect(actionButtons.length).toBeGreaterThan(0)
})
it('should render table headers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render table headers', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument()
@ -203,20 +215,18 @@ describe('SecretKeyModal', () => {
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
})
it('should render dataset API keys when no appId', () => {
render(<SecretKeyModal {...defaultProps} />)
// Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789'
it('should render dataset API keys when no appId', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when X icon is clicked', async () => {
const user = userEvent.setup()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
render(<SecretKeyModal {...defaultProps} onClose={onClose} />)
await renderModal(<SecretKeyModal {...defaultProps} onClose={onClose} />)
// Modal renders via portal, so we need to query from document.body
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
@ -224,14 +234,14 @@ describe('SecretKeyModal', () => {
await user.click(closeIcon!)
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalled()
})
})
describe('create new key', () => {
it('should call create API for app when button is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@ -247,8 +257,8 @@ describe('SecretKeyModal', () => {
})
it('should call create API for dataset when no appId', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@ -264,8 +274,8 @@ describe('SecretKeyModal', () => {
})
it('should show generate modal after creating key', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@ -273,14 +283,13 @@ describe('SecretKeyModal', () => {
})
await waitFor(() => {
// The SecretKeyGenerateModal should be shown with the new token
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
})
it('should invalidate app API keys after creating', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@ -293,8 +302,8 @@ describe('SecretKeyModal', () => {
})
it('should invalidate dataset API keys after creating (no appId)', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@ -306,17 +315,17 @@ describe('SecretKeyModal', () => {
})
})
it('should disable create button when no workspace', () => {
it('should disable create button when no workspace', async () => {
mockCurrentWorkspace.mockReturnValue(null)
render(<SecretKeyModal {...defaultProps} />)
await renderModal(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
})
it('should disable create button when not editor', () => {
it('should disable create button when not editor', async () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} />)
await renderModal(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
@ -332,80 +341,74 @@ describe('SecretKeyModal', () => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
it('should render delete button for managers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render delete button for managers', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find buttons that contain SVG (delete/copy buttons)
const actionButtons = screen.getAllByRole('button')
// There should be at least copy, delete, and create buttons
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
})
it('should render API key row with actions', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should render API key row with actions', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Verify the truncated token is rendered
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
it('should have action buttons in the key row', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
it('should have action buttons in the key row', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Check for action button containers - Modal renders via portal
const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]')
expect(actionContainers.length).toBeGreaterThan(0)
})
it('should have delete button visible for managers', async () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find the delete button by looking for the button with the delete icon
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
const deleteButton = deleteIcon?.closest('button')
expect(deleteButton).toBeInTheDocument()
})
it('should show confirm dialog when delete button is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find delete button by action-btn class (second action button after copy)
const actionButtons = document.body.querySelectorAll('button.action-btn')
// The delete button is the second action button (first is copy)
const deleteButton = actionButtons[1]
expect(deleteButton).toBeInTheDocument()
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Confirm dialog should appear
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument()
})
await flushTransitions()
})
it('should call delete API for app when confirmed', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
// Find and click the confirm button
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
vi.runAllTimers()
})
await waitFor(() => {
@ -417,24 +420,25 @@ describe('SecretKeyModal', () => {
})
it('should invalidate app API keys after deleting', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
vi.runAllTimers()
})
await waitFor(() => {
@ -443,33 +447,31 @@ describe('SecretKeyModal', () => {
})
it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Wait for confirm dialog
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
// Click cancel button
const cancelButton = screen.getByText('common.operation.cancel')
await act(async () => {
await user.click(cancelButton)
vi.runAllTimers()
})
// Confirm dialog should close
await waitFor(() => {
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
})
// Delete API should not be called
expect(mockDelAppApikey).not.toHaveBeenCalled()
})
})
@ -484,24 +486,25 @@ describe('SecretKeyModal', () => {
})
it('should call delete API for dataset when no appId', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
vi.runAllTimers()
})
await waitFor(() => {
@ -513,24 +516,25 @@ describe('SecretKeyModal', () => {
})
it('should invalidate dataset API keys after deleting', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
vi.runAllTimers()
})
await waitFor(() => {
@ -540,46 +544,42 @@ describe('SecretKeyModal', () => {
})
describe('token truncation', () => {
it('should truncate token correctly', () => {
it('should truncate token correctly', async () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
]
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Token format: first 3 chars + ... + last 20 chars
// 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890'
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should render modal with expected structure', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal should render and contain the title
it('should render modal with expected structure', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render create button with flex styling', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal renders via portal, so query from document.body
it('should render create button with flex styling', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
const flexContainers = document.body.querySelectorAll('[class*="flex"]')
expect(flexContainers.length).toBeGreaterThan(0)
})
})
describe('empty state', () => {
it('should not render table when no keys', () => {
it('should not render table when no keys', async () => {
mockAppApiKeysData.mockReturnValue({ data: [] })
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
it('should not render table when data is null', () => {
it('should not render table when data is null', async () => {
mockAppApiKeysData.mockReturnValue(null)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
@ -587,23 +587,23 @@ describe('SecretKeyModal', () => {
describe('SecretKeyGenerateModal', () => {
it('should close generate modal on close', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Create a new key to open generate modal
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
vi.runAllTimers()
})
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
// Find and click the close/OK button in generate modal
const okButton = screen.getByText('appApi.actionMsg.ok')
await act(async () => {
await user.click(okButton)
vi.runAllTimers()
})
await waitFor(() => {

View File

@ -1,9 +1,9 @@
import type { ActionItem } from './actions/types'
import type { ActionItem } from '../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import * as React from 'react'
import CommandSelector from './command-selector'
import CommandSelector from '../command-selector'
vi.mock('next/navigation', () => ({
usePathname: () => '/app',
@ -16,7 +16,7 @@ const slashCommandsMock = [{
isAvailable: () => true,
}]
vi.mock('./actions/commands/registry', () => ({
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
getAvailableCommands: () => slashCommandsMock,
},
@ -97,7 +97,6 @@ describe('CommandSelector', () => {
</Command>,
)
// Should show the zen command from mock
expect(screen.getByText('/zen')).toBeInTheDocument()
})
@ -125,7 +124,6 @@ describe('CommandSelector', () => {
</Command>,
)
// Should show @ commands but not /
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('/')).not.toBeInTheDocument()
})

View File

@ -1,6 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { GotoAnythingProvider, useGotoAnythingContext } from '../context'
let pathnameMock: string | null | undefined = '/'
vi.mock('next/navigation', () => ({
@ -8,7 +8,7 @@ vi.mock('next/navigation', () => ({
}))
let isWorkflowPageMock = false
vi.mock('../workflow/constants', () => ({
vi.mock('../../workflow/constants', () => ({
isInWorkflowPage: () => isWorkflowPageMock,
}))

View File

@ -1,27 +1,15 @@
import type { ReactNode } from 'react'
import type { ActionItem, SearchResult } from './actions/types'
import type { ActionItem, SearchResult } from '../actions/types'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import GotoAnything from './index'
import GotoAnything from '../index'
// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
icon?: ReactNode
data?: Record<string, unknown>
}
// Mock react-i18next to return namespace.key format
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const ns = options?.ns || 'common'
return `${ns}.${key}`
},
i18n: { language: 'en' },
}),
}))
const routerPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@ -65,7 +53,7 @@ vi.mock('@/context/i18n', () => ({
}))
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
vi.mock('./context', () => ({
vi.mock('../context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
@ -93,13 +81,13 @@ const createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined)
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
vi.mock('./actions', () => ({
vi.mock('../actions', () => ({
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
}))
vi.mock('./actions/commands', () => ({
vi.mock('../actions/commands', () => ({
SlashCommandProvider: () => null,
}))
@ -110,7 +98,7 @@ type MockSlashCommand = {
} | null
let mockFindCommand: MockSlashCommand = null
vi.mock('./actions/commands/registry', () => ({
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommand,
getAvailableCommands: () => [],
@ -129,7 +117,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: vi.fn(),
}))
vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({
vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
<div data-testid="install-modal">
<span>{props.manifest?.name}</span>
@ -207,23 +195,19 @@ describe('GotoAnything', () => {
const user = userEvent.setup()
render(<GotoAnything />)
// Open modal first time
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
// Type something
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test')
// Close modal
triggerKeyPress('esc')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
// Open modal again - should be empty
triggerKeyPress('ctrl.k')
await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
@ -278,7 +262,6 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test query')
// Should not throw and input should have value
expect(input).toHaveValue('test query')
})
})
@ -303,7 +286,6 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
// Loading state shows in both EmptyState (spinner) and Footer
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
})

View File

@ -0,0 +1,71 @@
import type { App } from '@/types/app'
import { appAction } from '../app'
vi.mock('@/service/apps', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirectionPath: vi.fn((_isAdmin: boolean, app: { id: string }) => `/app/${app.id}`),
}))
vi.mock('../../../app/type-selector', () => ({
AppTypeIcon: () => null,
}))
vi.mock('../../../base/app-icon', () => ({
default: () => null,
}))
describe('appAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(appAction.key).toBe('@app')
expect(appAction.shortcut).toBe('@app')
})
it('returns parsed app results on success', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({
data: [
{ id: 'app-1', name: 'My App', description: 'A great app', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await appAction.search('@app test', 'test', 'en')
expect(fetchAppList).toHaveBeenCalledWith({
url: 'apps',
params: { page: 1, name: 'test' },
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'app-1',
title: 'My App',
type: 'app',
})
})
it('returns empty array when response has no data', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
const results = await appAction.search('@app', '', 'en')
expect(results).toEqual([])
})
it('returns empty array on API failure', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
const results = await appAction.search('@app fail', 'fail', 'en')
expect(results).toEqual([])
})
})

View File

@ -0,0 +1,276 @@
import type { ActionItem, SearchResult } from '../types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { slashCommandRegistry } from '../commands/registry'
import { createActions, matchAction, searchAnything } from '../index'
vi.mock('../app', () => ({
appAction: {
key: '@app',
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../knowledge', () => ({
knowledgeAction: {
key: '@knowledge',
shortcut: '@kb',
title: 'Knowledge',
description: 'Search knowledge',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../plugin', () => ({
pluginAction: {
key: '@plugin',
shortcut: '@plugin',
title: 'Plugins',
description: 'Search plugins',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../commands', () => ({
slashAction: {
key: '/',
shortcut: '/',
title: 'Commands',
description: 'Slash commands',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../workflow-nodes', () => ({
workflowNodesAction: {
key: '@node',
shortcut: '@node',
title: 'Workflow Nodes',
description: 'Search workflow nodes',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../rag-pipeline-nodes', () => ({
ragPipelineNodesAction: {
key: '@node',
shortcut: '@node',
title: 'RAG Pipeline Nodes',
description: 'Search RAG nodes',
search: vi.fn().mockResolvedValue([]),
} satisfies ActionItem,
}))
vi.mock('../commands/registry')
describe('createActions', () => {
it('returns base actions when neither workflow nor rag-pipeline page', () => {
const actions = createActions(false, false)
expect(actions).toHaveProperty('slash')
expect(actions).toHaveProperty('app')
expect(actions).toHaveProperty('knowledge')
expect(actions).toHaveProperty('plugin')
expect(actions).not.toHaveProperty('node')
})
it('includes workflow nodes action on workflow pages', () => {
const actions = createActions(true, false) as Record<string, ActionItem>
expect(actions).toHaveProperty('node')
expect(actions.node.title).toBe('Workflow Nodes')
})
it('includes rag-pipeline nodes action on rag-pipeline pages', () => {
const actions = createActions(false, true) as Record<string, ActionItem>
expect(actions).toHaveProperty('node')
expect(actions.node.title).toBe('RAG Pipeline Nodes')
})
it('rag-pipeline page takes priority over workflow page', () => {
const actions = createActions(true, true) as Record<string, ActionItem>
expect(actions.node.title).toBe('RAG Pipeline Nodes')
})
})
describe('searchAnything', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('delegates to specific action when actionItem is provided', async () => {
const mockResults: SearchResult[] = [
{ id: '1', title: 'App1', type: 'app', data: {} as unknown as App },
]
const action: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockResolvedValue(mockResults),
}
const results = await searchAnything('en', '@app myquery', action)
expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en')
expect(results).toEqual(mockResults)
})
it('strips action prefix from search term', async () => {
const action: ActionItem = {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: 'Search KB',
search: vi.fn().mockResolvedValue([]),
}
await searchAnything('en', '@kb hello', action)
expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en')
})
it('returns empty for queries starting with @ without actionItem', async () => {
const results = await searchAnything('en', '@unknown')
expect(results).toEqual([])
})
it('returns empty for queries starting with / without actionItem', async () => {
const results = await searchAnything('en', '/theme')
expect(results).toEqual([])
})
it('handles action search failure gracefully', async () => {
const action: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockRejectedValue(new Error('network error')),
}
const results = await searchAnything('en', '@app test', action)
expect(results).toEqual([])
})
it('runs global search across all non-slash actions for plain queries', async () => {
const appResults: SearchResult[] = [
{ id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App },
]
const kbResults: SearchResult[] = [
{ id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet },
]
const dynamicActions: Record<string, ActionItem> = {
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn().mockResolvedValue([]) },
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockResolvedValue(appResults) },
knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn().mockResolvedValue(kbResults) },
}
const results = await searchAnything('en', 'my query', undefined, dynamicActions)
expect(dynamicActions.slash.search).not.toHaveBeenCalled()
expect(results).toHaveLength(2)
expect(results).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'a1' }),
expect.objectContaining({ id: 'k1' }),
]))
})
it('handles partial search failures in global search gracefully', async () => {
const dynamicActions: Record<string, ActionItem> = {
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) },
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: '',
search: vi.fn().mockResolvedValue([
{ id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet },
]),
},
}
const results = await searchAnything('en', 'query', undefined, dynamicActions)
expect(results).toHaveLength(1)
expect(results[0].id).toBe('k1')
})
})
describe('matchAction', () => {
const actions: Record<string, ActionItem> = {
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn() },
plugin: { key: '@plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() },
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() },
}
beforeEach(() => {
vi.clearAllMocks()
})
it('matches @app query', () => {
const result = matchAction('@app test', actions)
expect(result?.key).toBe('@app')
})
it('matches @kb shortcut', () => {
const result = matchAction('@kb test', actions)
expect(result?.key).toBe('@knowledge')
})
it('matches @plugin query', () => {
const result = matchAction('@plugin test', actions)
expect(result?.key).toBe('@plugin')
})
it('returns undefined for unmatched query', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
const result = matchAction('random query', actions)
expect(result).toBeUndefined()
})
describe('slash command matching', () => {
it('matches submenu command with full name', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
const result = matchAction('/theme', actions)
expect(result?.key).toBe('/')
})
it('matches submenu command with args', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
const result = matchAction('/theme dark', actions)
expect(result?.key).toBe('/')
})
it('does not match direct-mode commands', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'docs', mode: 'direct', description: '', search: vi.fn() },
])
const result = matchAction('/docs', actions)
expect(result).toBeUndefined()
})
it('does not match partial slash command name', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
const result = matchAction('/the', actions)
expect(result).toBeUndefined()
})
})
})

View File

@ -0,0 +1,93 @@
import type { DataSet } from '@/models/datasets'
import { knowledgeAction } from '../knowledge'
vi.mock('@/service/datasets', () => ({
fetchDatasets: vi.fn(),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}))
vi.mock('../../../base/icons/src/vender/solid/files', () => ({
Folder: () => null,
}))
describe('knowledgeAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(knowledgeAction.key).toBe('@knowledge')
expect(knowledgeAction.shortcut).toBe('@kb')
})
it('returns parsed dataset results on success', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{ id: 'ds-1', name: 'My Knowledge', description: 'A KB', provider: 'vendor', embedding_available: true } as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await knowledgeAction.search('@knowledge query', 'query', 'en')
expect(fetchDatasets).toHaveBeenCalledWith({
url: '/datasets',
params: { page: 1, limit: 10, keyword: 'query' },
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'ds-1',
title: 'My Knowledge',
type: 'knowledge',
})
})
it('generates correct path for external provider', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{ id: 'ds-ext', name: 'External', description: '', provider: 'external', embedding_available: true } as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await knowledgeAction.search('@knowledge', '', 'en')
expect(results[0].path).toBe('/datasets/ds-ext/hitTesting')
})
it('generates correct path for non-external provider', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{ id: 'ds-2', name: 'Internal', description: '', provider: 'vendor', embedding_available: true } as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await knowledgeAction.search('@knowledge', '', 'en')
expect(results[0].path).toBe('/datasets/ds-2/documents')
})
it('returns empty array on API failure', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
expect(results).toEqual([])
})
})

View File

@ -0,0 +1,72 @@
import { pluginAction } from '../plugin'
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => {
if (typeof obj === 'string')
return obj
return obj[locale] || obj.en_US || ''
}),
}))
vi.mock('../../../plugins/card/base/card-icon', () => ({
default: () => null,
}))
vi.mock('../../../plugins/marketplace/utils', () => ({
getPluginIconInMarketplace: vi.fn(() => 'icon-url'),
}))
describe('pluginAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(pluginAction.key).toBe('@plugin')
expect(pluginAction.shortcut).toBe('@plugin')
})
it('returns parsed plugin results on success', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({
data: {
plugins: [
{ name: 'plugin-1', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png' },
],
total: 1,
},
})
const results = await pluginAction.search('@plugin', 'test', 'en_US')
expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
body: { page: 1, page_size: 10, query: 'test', type: 'plugin' },
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'plugin-1',
title: 'My Plugin',
type: 'plugin',
})
})
it('returns empty array when response has unexpected structure', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
const results = await pluginAction.search('@plugin', 'test', 'en')
expect(results).toEqual([])
})
it('returns empty array on API failure', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
const results = await pluginAction.search('@plugin', 'fail', 'en')
expect(results).toEqual([])
})
})

View File

@ -0,0 +1,68 @@
import { executeCommand, registerCommands, unregisterCommands } from '../command-bus'
describe('command-bus', () => {
afterEach(() => {
unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop'])
})
describe('registerCommands / executeCommand', () => {
it('registers and executes a sync command', async () => {
const handler = vi.fn()
registerCommands({ 'test.a': handler })
await executeCommand('test.a', { value: 42 })
expect(handler).toHaveBeenCalledWith({ value: 42 })
})
it('registers and executes an async command', async () => {
const handler = vi.fn().mockResolvedValue(undefined)
registerCommands({ 'async.cmd': handler })
await executeCommand('async.cmd')
expect(handler).toHaveBeenCalled()
})
it('registers multiple commands at once', async () => {
const handlerA = vi.fn()
const handlerB = vi.fn()
registerCommands({ 'test.a': handlerA, 'test.b': handlerB })
await executeCommand('test.a')
await executeCommand('test.b')
expect(handlerA).toHaveBeenCalled()
expect(handlerB).toHaveBeenCalled()
})
it('silently ignores unregistered command names', async () => {
await expect(executeCommand('nonexistent')).resolves.toBeUndefined()
})
it('passes undefined args when not provided', async () => {
const handler = vi.fn()
registerCommands({ 'test.c': handler })
await executeCommand('test.c')
expect(handler).toHaveBeenCalledWith(undefined)
})
})
describe('unregisterCommands', () => {
it('removes commands so they can no longer execute', async () => {
const handler = vi.fn()
registerCommands({ 'test.a': handler })
unregisterCommands(['test.a'])
await executeCommand('test.a')
expect(handler).not.toHaveBeenCalled()
})
it('handles unregistering non-existent commands gracefully', () => {
expect(() => unregisterCommands(['nope'])).not.toThrow()
})
})
})

View File

@ -0,0 +1,212 @@
/**
* Tests for direct-mode commands that share similar patterns:
* docs, account, community, forum
*
* Each command: opens a URL or navigates, has direct mode, and registers a navigation command.
*/
import { accountCommand } from '../account'
import { registerCommands, unregisterCommands } from '../command-bus'
import { communityCommand } from '../community'
import { docsCommand } from '../docs'
import { forumCommand } from '../forum'
vi.mock('../command-bus')
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
language: 'en',
}),
}))
vi.mock('@/context/i18n', () => ({
defaultDocBaseUrl: 'https://docs.dify.ai',
}))
vi.mock('@/i18n-config/language', () => ({
getDocLanguage: (locale: string) => locale === 'en' ? 'en' : locale,
}))
describe('docsCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(docsCommand.name).toBe('docs')
expect(docsCommand.mode).toBe('direct')
expect(docsCommand.execute).toBeDefined()
})
it('execute opens documentation in new tab', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
docsCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('https://docs.dify.ai'),
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('search returns a single doc result', async () => {
const results = await docsCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'doc',
type: 'command',
data: { command: 'navigation.doc', args: {} },
})
})
it('registers navigation.doc command', () => {
docsCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
})
it('unregisters navigation.doc command', () => {
docsCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
})
})
describe('accountCommand', () => {
let originalHref: string
beforeEach(() => {
vi.clearAllMocks()
originalHref = window.location.href
})
afterEach(() => {
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
})
it('has correct metadata', () => {
expect(accountCommand.name).toBe('account')
expect(accountCommand.mode).toBe('direct')
expect(accountCommand.execute).toBeDefined()
})
it('execute navigates to /account', () => {
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
accountCommand.execute?.()
expect(window.location.href).toBe('/account')
})
it('search returns account result', async () => {
const results = await accountCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'account',
type: 'command',
data: { command: 'navigation.account', args: {} },
})
})
it('registers navigation.account command', () => {
accountCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) })
})
it('unregisters navigation.account command', () => {
accountCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account'])
})
})
describe('communityCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(communityCommand.name).toBe('community')
expect(communityCommand.mode).toBe('direct')
expect(communityCommand.execute).toBeDefined()
})
it('execute opens Discord URL', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
communityCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
'https://discord.gg/5AEfbxcd9k',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('search returns community result', async () => {
const results = await communityCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'community',
type: 'command',
data: { command: 'navigation.community' },
})
})
it('registers navigation.community command', () => {
communityCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) })
})
it('unregisters navigation.community command', () => {
communityCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community'])
})
})
describe('forumCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(forumCommand.name).toBe('forum')
expect(forumCommand.mode).toBe('direct')
expect(forumCommand.execute).toBeDefined()
})
it('execute opens forum URL', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
forumCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
'https://forum.dify.ai',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('search returns forum result', async () => {
const results = await forumCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'forum',
type: 'command',
data: { command: 'navigation.forum' },
})
})
it('registers navigation.forum command', () => {
forumCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) })
})
it('unregisters navigation.forum command', () => {
forumCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum'])
})
})

View File

@ -0,0 +1,89 @@
import { registerCommands, unregisterCommands } from '../command-bus'
import { languageCommand } from '../language'
vi.mock('../command-bus')
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/i18n-config/language', () => ({
languages: [
{ value: 'en-US', name: 'English', supported: true },
{ value: 'zh-Hans', name: '简体中文', supported: true },
{ value: 'ja-JP', name: '日本語', supported: true },
{ value: 'unsupported', name: 'Unsupported', supported: false },
],
}))
describe('languageCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(languageCommand.name).toBe('language')
expect(languageCommand.aliases).toEqual(['lang'])
expect(languageCommand.mode).toBe('submenu')
expect(languageCommand.execute).toBeUndefined()
})
describe('search', () => {
it('returns all supported languages when query is empty', async () => {
const results = await languageCommand.search('', 'en')
expect(results).toHaveLength(3) // 3 supported languages
expect(results.every(r => r.type === 'command')).toBe(true)
})
it('filters languages by name query', async () => {
const results = await languageCommand.search('english', 'en')
expect(results).toHaveLength(1)
expect(results[0].id).toBe('lang-en-US')
})
it('filters languages by value query', async () => {
const results = await languageCommand.search('zh', 'en')
expect(results).toHaveLength(1)
expect(results[0].id).toBe('lang-zh-Hans')
})
it('returns command data with i18n.set command', async () => {
const results = await languageCommand.search('', 'en')
results.forEach((r) => {
expect(r.data.command).toBe('i18n.set')
expect(r.data.args).toHaveProperty('locale')
})
})
})
describe('register / unregister', () => {
it('registers i18n.set command', () => {
languageCommand.register?.({ setLocale: vi.fn() })
expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) })
})
it('unregisters i18n.set command', () => {
languageCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set'])
})
it('registered handler calls setLocale with correct locale', async () => {
const setLocale = vi.fn().mockResolvedValue(undefined)
vi.mocked(registerCommands).mockImplementation((map) => {
map['i18n.set']?.({ locale: 'zh-Hans' })
})
languageCommand.register?.({ setLocale })
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
})
})
})

View File

@ -0,0 +1,267 @@
import type { SlashCommandHandler } from '../types'
import { SlashCommandRegistry } from '../registry'
function createHandler(overrides: Partial<SlashCommandHandler> = {}): SlashCommandHandler {
return {
name: 'test',
description: 'Test command',
search: vi.fn().mockResolvedValue([]),
register: vi.fn(),
unregister: vi.fn(),
...overrides,
}
}
describe('SlashCommandRegistry', () => {
let registry: SlashCommandRegistry
beforeEach(() => {
registry = new SlashCommandRegistry()
})
describe('register & findCommand', () => {
it('registers a handler and retrieves it by name', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
expect(registry.findCommand('docs')).toBe(handler)
})
it('registers aliases so handler is found by any alias', () => {
const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] })
registry.register(handler)
expect(registry.findCommand('language')).toBe(handler)
expect(registry.findCommand('lang')).toBe(handler)
expect(registry.findCommand('l')).toBe(handler)
})
it('calls handler.register with provided deps', () => {
const handler = createHandler({ name: 'theme' })
const deps = { setTheme: vi.fn() }
registry.register(handler, deps)
expect(handler.register).toHaveBeenCalledWith(deps)
})
it('does not call handler.register when no deps provided', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
expect(handler.register).not.toHaveBeenCalled()
})
it('returns undefined for unknown command name', () => {
expect(registry.findCommand('nonexistent')).toBeUndefined()
})
})
describe('unregister', () => {
it('removes handler by name', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
registry.unregister('docs')
expect(registry.findCommand('docs')).toBeUndefined()
})
it('removes all aliases', () => {
const handler = createHandler({ name: 'language', aliases: ['lang'] })
registry.register(handler)
registry.unregister('language')
expect(registry.findCommand('language')).toBeUndefined()
expect(registry.findCommand('lang')).toBeUndefined()
})
it('calls handler.unregister', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
registry.unregister('docs')
expect(handler.unregister).toHaveBeenCalled()
})
it('is a no-op for unknown command', () => {
expect(() => registry.unregister('unknown')).not.toThrow()
})
})
describe('getAllCommands', () => {
it('returns deduplicated handlers', () => {
const h1 = createHandler({ name: 'theme', aliases: ['t'] })
const h2 = createHandler({ name: 'docs' })
registry.register(h1)
registry.register(h2)
const commands = registry.getAllCommands()
expect(commands).toHaveLength(2)
expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' }))
expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' }))
})
it('returns empty array when nothing registered', () => {
expect(registry.getAllCommands()).toEqual([])
})
})
describe('getAvailableCommands', () => {
it('includes commands without isAvailable guard', () => {
registry.register(createHandler({ name: 'docs' }))
expect(registry.getAvailableCommands()).toHaveLength(1)
})
it('includes commands where isAvailable returns true', () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => true }))
expect(registry.getAvailableCommands()).toHaveLength(1)
})
it('excludes commands where isAvailable returns false', () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
expect(registry.getAvailableCommands()).toHaveLength(0)
})
})
describe('search', () => {
it('returns root commands for "/"', async () => {
registry.register(createHandler({ name: 'theme', description: 'Change theme' }))
registry.register(createHandler({ name: 'docs', description: 'Open docs' }))
const results = await registry.search('/')
expect(results).toHaveLength(2)
expect(results[0]).toMatchObject({
id: expect.stringContaining('root-'),
type: 'command',
})
})
it('returns root commands for "/ "', async () => {
registry.register(createHandler({ name: 'theme' }))
const results = await registry.search('/ ')
expect(results).toHaveLength(1)
})
it('delegates to exact-match handler for "/theme dark"', async () => {
const mockResults = [{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }]
const handler = createHandler({
name: 'theme',
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/theme dark')
expect(handler.search).toHaveBeenCalledWith('dark', 'en')
expect(results).toEqual(mockResults)
})
it('delegates to exact-match handler for command without args', async () => {
const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) })
registry.register(handler)
await registry.search('/docs')
expect(handler.search).toHaveBeenCalledWith('', 'en')
})
it('uses partial match when no exact match found', async () => {
const mockResults = [{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} }]
const handler = createHandler({
name: 'theme',
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/the')
expect(results).toEqual(mockResults)
})
it('uses alias partial match', async () => {
const mockResults = [{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} }]
const handler = createHandler({
name: 'language',
aliases: ['lang'],
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/lan')
expect(results).toEqual(mockResults)
})
it('falls back to fuzzy search when nothing matches', async () => {
registry.register(createHandler({ name: 'theme', description: 'Set theme' }))
const results = await registry.search('/hem')
expect(results).toHaveLength(1)
expect(results[0].title).toBe('/theme')
})
it('fuzzy search also matches aliases', async () => {
registry.register(createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }))
const handler = registry.findCommand('language')
await registry.search('/lan')
expect(handler?.search).toHaveBeenCalled()
})
it('returns empty when handler.search throws', async () => {
const handler = createHandler({
name: 'broken',
search: vi.fn().mockRejectedValue(new Error('fail')),
})
registry.register(handler)
const results = await registry.search('/broken')
expect(results).toEqual([])
})
it('excludes unavailable commands from root listing', async () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
registry.register(createHandler({ name: 'docs' }))
const results = await registry.search('/')
expect(results).toHaveLength(1)
expect(results[0].title).toBe('/docs')
})
it('skips unavailable handler in exact match', async () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
const results = await registry.search('/zen')
expect(results).toEqual([])
})
it('passes locale to handler search', async () => {
const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) })
registry.register(handler)
await registry.search('/theme light', 'zh')
expect(handler.search).toHaveBeenCalledWith('light', 'zh')
})
})
describe('getCommandDependencies', () => {
it('returns stored deps', () => {
const deps = { setTheme: vi.fn() }
registry.register(createHandler({ name: 'theme' }), deps)
expect(registry.getCommandDependencies('theme')).toBe(deps)
})
it('returns undefined when no deps stored', () => {
registry.register(createHandler({ name: 'docs' }))
expect(registry.getCommandDependencies('docs')).toBeUndefined()
})
})
})

View File

@ -0,0 +1,73 @@
import { registerCommands, unregisterCommands } from '../command-bus'
import { themeCommand } from '../theme'
vi.mock('../command-bus')
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
}),
}))
describe('themeCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(themeCommand.name).toBe('theme')
expect(themeCommand.mode).toBe('submenu')
expect(themeCommand.execute).toBeUndefined()
})
describe('search', () => {
it('returns all theme options when query is empty', async () => {
const results = await themeCommand.search('', 'en')
expect(results).toHaveLength(3)
expect(results.map(r => r.id)).toEqual(['system', 'light', 'dark'])
})
it('returns all theme options with correct type', async () => {
const results = await themeCommand.search('', 'en')
results.forEach((r) => {
expect(r.type).toBe('command')
expect(r.data).toEqual({ command: 'theme.set', args: expect.objectContaining({ value: expect.any(String) }) })
})
})
it('filters results by query matching id', async () => {
const results = await themeCommand.search('dark', 'en')
expect(results).toHaveLength(1)
expect(results[0].id).toBe('dark')
})
})
describe('register / unregister', () => {
it('registers theme.set command with deps', () => {
const deps = { setTheme: vi.fn() }
themeCommand.register?.(deps)
expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) })
})
it('unregisters theme.set command', () => {
themeCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['theme.set'])
})
it('registered handler calls setTheme', async () => {
const setTheme = vi.fn()
vi.mocked(registerCommands).mockImplementation((map) => {
map['theme.set']?.({ value: 'dark' })
})
themeCommand.register?.({ setTheme })
expect(setTheme).toHaveBeenCalledWith('dark')
})
})
})

View File

@ -0,0 +1,84 @@
import { registerCommands, unregisterCommands } from '../command-bus'
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
vi.mock('../command-bus')
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/workflow/constants', () => ({
isInWorkflowPage: vi.fn(() => true),
}))
describe('zenCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(zenCommand.name).toBe('zen')
expect(zenCommand.mode).toBe('direct')
expect(zenCommand.execute).toBeDefined()
})
it('exports ZEN_TOGGLE_EVENT constant', () => {
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
})
describe('isAvailable', () => {
it('delegates to isInWorkflowPage', async () => {
const { isInWorkflowPage } = vi.mocked(
await import('@/app/components/workflow/constants'),
)
isInWorkflowPage.mockReturnValue(true)
expect(zenCommand.isAvailable?.()).toBe(true)
isInWorkflowPage.mockReturnValue(false)
expect(zenCommand.isAvailable?.()).toBe(false)
})
})
describe('execute', () => {
it('dispatches custom zen-toggle event', () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
zenCommand.execute?.()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
)
dispatchSpy.mockRestore()
})
})
describe('search', () => {
it('returns single zen mode result', async () => {
const results = await zenCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'zen',
type: 'command',
data: { command: 'workflow.zen', args: {} },
})
})
})
describe('register / unregister', () => {
it('registers workflow.zen command', () => {
zenCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'workflow.zen': expect.any(Function) })
})
it('unregisters workflow.zen command', () => {
zenCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['workflow.zen'])
})
})
})

View File

@ -1,15 +1,5 @@
import { render, screen } from '@testing-library/react'
import EmptyState from './empty-state'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, shortcuts?: string }) => {
if (options?.shortcuts !== undefined)
return `${key}:${options.shortcuts}`
return `${options?.ns || 'common'}.${key}`
},
}),
}))
import EmptyState from '../empty-state'
describe('EmptyState', () => {
describe('loading variant', () => {
@ -86,10 +76,10 @@ describe('EmptyState', () => {
const Actions = {
app: { key: '@app', shortcut: '@app' },
plugin: { key: '@plugin', shortcut: '@plugin' },
} as unknown as Record<string, import('../actions/types').ActionItem>
} as unknown as Record<string, import('../../actions/types').ActionItem>
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}')).toBeInTheDocument()
})
})
@ -150,8 +140,7 @@ describe('EmptyState', () => {
it('should use empty object as default Actions', () => {
render(<EmptyState variant="no-results" searchMode="general" />)
// Should show empty shortcuts
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}')).toBeInTheDocument()
})
})
})

View File

@ -1,17 +1,5 @@
import { render, screen } from '@testing-library/react'
import Footer from './footer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => {
if (options?.count !== undefined)
return `${key}:${options.count}`
if (options?.scope)
return `${key}:${options.scope}`
return `${options?.ns || 'common'}.${key}`
},
}),
}))
import Footer from '../footer'
describe('Footer', () => {
describe('left content', () => {
@ -27,7 +15,7 @@ describe('Footer', () => {
/>,
)
expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument()
})
it('should show scope when not in general mode', () => {
@ -41,7 +29,7 @@ describe('Footer', () => {
/>,
)
expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
})
it('should NOT show scope when in general mode', () => {

View File

@ -0,0 +1,82 @@
import type { SearchResult } from '../../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import ResultItem from '../result-item'
function renderInCommandRoot(ui: React.ReactElement) {
return render(<Command>{ui}</Command>)
}
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
id: 'test-1',
title: 'Test Result',
type: 'app',
data: {},
...overrides,
} as SearchResult
}
describe('ResultItem', () => {
it('renders title', () => {
renderInCommandRoot(
<ResultItem result={createResult({ title: 'My App' })} onSelect={vi.fn()} />,
)
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('renders description when provided', () => {
renderInCommandRoot(
<ResultItem
result={createResult({ description: 'A great app' })}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('A great app')).toBeInTheDocument()
})
it('does not render description when absent', () => {
const result = createResult()
delete (result as Record<string, unknown>).description
renderInCommandRoot(
<ResultItem result={result} onSelect={vi.fn()} />,
)
expect(screen.getByText('Test Result')).toBeInTheDocument()
expect(screen.getByText('app')).toBeInTheDocument()
})
it('renders result type label', () => {
renderInCommandRoot(
<ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />,
)
expect(screen.getByText('plugin')).toBeInTheDocument()
})
it('renders icon when provided', () => {
const icon = <span data-testid="custom-icon">icon</span>
renderInCommandRoot(
<ResultItem result={createResult({ icon })} onSelect={vi.fn()} />,
)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('calls onSelect when clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderInCommandRoot(
<ResultItem result={createResult()} onSelect={onSelect} />,
)
await user.click(screen.getByText('Test Result'))
expect(onSelect).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,86 @@
import type { SearchResult } from '../../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import ResultList from '../result-list'
function renderInCommandRoot(ui: React.ReactElement) {
return render(<Command>{ui}</Command>)
}
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
id: 'test-1',
title: 'Result 1',
type: 'app',
data: {},
...overrides,
} as SearchResult
}
describe('ResultList', () => {
it('renders grouped results with headings', () => {
const grouped: Record<string, SearchResult[]> = {
app: [createResult({ id: 'a1', title: 'App One', type: 'app' })],
plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })],
}
renderInCommandRoot(
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
)
expect(screen.getByText('App One')).toBeInTheDocument()
expect(screen.getByText('Plugin One')).toBeInTheDocument()
})
it('renders multiple results in the same group', () => {
const grouped: Record<string, SearchResult[]> = {
app: [
createResult({ id: 'a1', title: 'App One', type: 'app' }),
createResult({ id: 'a2', title: 'App Two', type: 'app' }),
],
}
renderInCommandRoot(
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
)
expect(screen.getByText('App One')).toBeInTheDocument()
expect(screen.getByText('App Two')).toBeInTheDocument()
})
it('calls onSelect with the correct result when clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' })
renderInCommandRoot(
<ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />,
)
await user.click(screen.getByText('Click Me'))
expect(onSelect).toHaveBeenCalledWith(result)
})
it('renders empty when no grouped results provided', () => {
const { container } = renderInCommandRoot(
<ResultList groupedResults={{}} onSelect={vi.fn()} />,
)
const groups = container.querySelectorAll('[cmdk-group]')
expect(groups).toHaveLength(0)
})
it('uses i18n keys for known group types', () => {
const grouped: Record<string, SearchResult[]> = {
command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })],
}
renderInCommandRoot(
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
)
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
})
})

View File

@ -1,12 +1,6 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SearchInput from './search-input'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`,
}),
}))
import SearchInput from '../search-input'
vi.mock('@remixicon/react', () => ({
RiSearchLine: ({ className }: { className?: string }) => (

View File

@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingModal } from './use-goto-anything-modal'
import { useGotoAnythingModal } from '../use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
@ -94,20 +94,17 @@ describe('useGotoAnythingModal', () => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
// Should remain closed because focus is in input area
expect(result.current.show).toBe(false)
})
it('should close modal when escape is pressed and modal is open', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Press escape
act(() => {
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
})
@ -125,7 +122,6 @@ describe('useGotoAnythingModal', () => {
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
})
// Should remain closed, and preventDefault should not be called
expect(result.current.show).toBe(false)
expect(preventDefaultMock).not.toHaveBeenCalled()
})
@ -146,13 +142,11 @@ describe('useGotoAnythingModal', () => {
it('should close modal when handleClose is called', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Close via handleClose
act(() => {
result.current.handleClose()
})
@ -219,14 +213,12 @@ describe('useGotoAnythingModal', () => {
it('should not call requestAnimationFrame when modal closes', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// First open
act(() => {
result.current.setShow(true)
})
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
// Then close
act(() => {
result.current.setShow(false)
})
@ -236,7 +228,6 @@ describe('useGotoAnythingModal', () => {
})
it('should focus input when modal opens and inputRef.current exists', () => {
// Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
@ -245,11 +236,9 @@ describe('useGotoAnythingModal', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// Create a mock input element with focus method
const mockFocus = vi.fn()
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
// Manually set the inputRef
Object.defineProperty(result.current.inputRef, 'current', {
value: mockInput,
writable: true,
@ -261,12 +250,10 @@ describe('useGotoAnythingModal', () => {
expect(mockFocus).toHaveBeenCalled()
// Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
it('should not throw when inputRef.current is null when modal opens', () => {
// Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
@ -275,16 +262,12 @@ describe('useGotoAnythingModal', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// inputRef.current is already null by default
// Should not throw
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
})

View File

@ -1,10 +1,10 @@
import type * as React from 'react'
import type { Plugin } from '../../plugins/types'
import type { CommonNodeType } from '../../workflow/types'
import type { Plugin } from '../../../plugins/types'
import type { CommonNodeType } from '../../../workflow/types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingNavigation } from './use-goto-anything-navigation'
import { useGotoAnythingNavigation } from '../use-goto-anything-navigation'
const mockRouterPush = vi.fn()
const mockSelectWorkflowNode = vi.fn()
@ -26,7 +26,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
}))
vi.mock('../actions/commands/registry', () => ({
vi.mock('../../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommandResult,
},
@ -117,7 +117,6 @@ describe('useGotoAnythingNavigation', () => {
})
expect(options.onClose).not.toHaveBeenCalled()
// Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
})
@ -177,7 +176,6 @@ describe('useGotoAnythingNavigation', () => {
result.current.handleCommandSelect('/unknown')
})
// Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
})
})
@ -333,13 +331,11 @@ describe('useGotoAnythingNavigation', () => {
it('should clear activePlugin when set to undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
// First set a plugin
act(() => {
result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin)
})
expect(result.current.activePlugin).toBeDefined()
// Then clear it
act(() => {
result.current.setActivePlugin(undefined)
})
@ -356,7 +352,6 @@ describe('useGotoAnythingNavigation', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(options))
// Should not throw
act(() => {
result.current.handleCommandSelect('@app')
})
@ -364,8 +359,6 @@ describe('useGotoAnythingNavigation', () => {
act(() => {
vi.runAllTimers()
})
// No error should occur
})
it('should handle missing slash action', () => {
@ -375,7 +368,6 @@ describe('useGotoAnythingNavigation', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(options))
// Should not throw
act(() => {
result.current.handleNavigate({
id: 'cmd-1',
@ -384,8 +376,6 @@ describe('useGotoAnythingNavigation', () => {
data: { command: 'test-command' },
})
})
// No error should occur
})
})
})

View File

@ -1,6 +1,6 @@
import type { SearchResult } from '../actions/types'
import type { SearchResult } from '../../actions/types'
import { renderHook } from '@testing-library/react'
import { useGotoAnythingResults } from './use-goto-anything-results'
import { useGotoAnythingResults } from '../use-goto-anything-results'
type MockQueryResult = {
data: Array<{ id: string, type: string, title: string }> | undefined
@ -30,7 +30,7 @@ vi.mock('@/context/i18n', () => ({
const mockMatchAction = vi.fn()
const mockSearchAnything = vi.fn()
vi.mock('../actions', () => ({
vi.mock('../../actions', () => ({
matchAction: (...args: unknown[]) => mockMatchAction(...args),
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
}))
@ -139,7 +139,6 @@ describe('useGotoAnythingResults', () => {
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
// Different types, same id = different keys, so both should remain
expect(result.current.dedupedResults).toHaveLength(2)
})
})

View File

@ -1,6 +1,6 @@
import type { ActionItem } from '../actions/types'
import type { ActionItem } from '../../actions/types'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingSearch } from './use-goto-anything-search'
import { useGotoAnythingSearch } from '../use-goto-anything-search'
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
let mockMatchActionResult: Partial<ActionItem> | undefined
@ -9,11 +9,11 @@ vi.mock('ahooks', () => ({
useDebounce: <T>(value: T) => value,
}))
vi.mock('../context', () => ({
vi.mock('../../context', () => ({
useGotoAnythingContext: () => mockContextValue,
}))
vi.mock('../actions', () => ({
vi.mock('../../actions', () => ({
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const base = {
slash: { key: '/', shortcut: '/' },
@ -233,13 +233,11 @@ describe('useGotoAnythingSearch', () => {
it('should reset cmdVal to "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
// First change cmdVal
act(() => {
result.current.setCmdVal('app-1')
})
expect(result.current.cmdVal).toBe('app-1')
// Then clear
act(() => {
result.current.clearSelection()
})
@ -294,7 +292,6 @@ describe('useGotoAnythingSearch', () => {
result.current.setSearchQuery(' test ')
})
// Since we mock useDebounce to return value directly
expect(result.current.searchQueryDebouncedValue).toBe('test')
})
})

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getInitialTokenV2, isTokenV1 } from './utils'
import { getInitialTokenV2, isTokenV1 } from '../utils'
describe('utils', () => {
describe('isTokenV1', () => {

View File

@ -1,19 +1,26 @@
import type { SiteInfo } from '@/models/share'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import InfoModal from './info-modal'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import InfoModal from '../info-modal'
// Only mock react-i18next for translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
cleanup()
})
async function renderModal(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {
vi.runAllTimers()
})
return result
}
describe('InfoModal', () => {
const mockOnClose = vi.fn()
@ -29,8 +36,8 @@ describe('InfoModal', () => {
})
describe('rendering', () => {
it('should not render when isShow is false', () => {
render(
it('should not render when isShow is false', async () => {
await renderModal(
<InfoModal
isShow={false}
onClose={mockOnClose}
@ -41,8 +48,8 @@ describe('InfoModal', () => {
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
it('should render when isShow is true', () => {
render(
it('should render when isShow is true', async () => {
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -53,8 +60,8 @@ describe('InfoModal', () => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render app title', () => {
render(
it('should render app title', async () => {
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -65,13 +72,13 @@ describe('InfoModal', () => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render copyright when provided', () => {
it('should render copyright when provided', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Dify Inc.',
}
render(
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -82,13 +89,13 @@ describe('InfoModal', () => {
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
})
it('should render current year in copyright', () => {
it('should render current year in copyright', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Test Company',
}
render(
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -100,13 +107,13 @@ describe('InfoModal', () => {
expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument()
})
it('should render custom disclaimer when provided', () => {
it('should render custom disclaimer when provided', async () => {
const siteInfoWithDisclaimer: SiteInfo = {
...baseSiteInfo,
custom_disclaimer: 'This is a custom disclaimer',
}
render(
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -117,8 +124,8 @@ describe('InfoModal', () => {
expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument()
})
it('should not render copyright section when not provided', () => {
render(
it('should not render copyright section when not provided', async () => {
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -130,8 +137,8 @@ describe('InfoModal', () => {
expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument()
})
it('should render with undefined data', () => {
render(
it('should render with undefined data', async () => {
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -139,18 +146,17 @@ describe('InfoModal', () => {
/>,
)
// Modal should still render but without content
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
it('should render with image icon type', () => {
it('should render with image icon type', async () => {
const siteInfoWithImage: SiteInfo = {
...baseSiteInfo,
icon_type: 'image',
icon_url: 'https://example.com/icon.png',
}
render(
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -163,8 +169,8 @@ describe('InfoModal', () => {
})
describe('close functionality', () => {
it('should call onClose when close button is clicked', () => {
render(
it('should call onClose when close button is clicked', async () => {
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}
@ -172,7 +178,6 @@ describe('InfoModal', () => {
/>,
)
// Find the close icon (RiCloseLine) which has text-text-tertiary class
const closeIcon = document.querySelector('[class*="text-text-tertiary"]')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
@ -183,14 +188,14 @@ describe('InfoModal', () => {
})
describe('both copyright and disclaimer', () => {
it('should render both when both are provided', () => {
it('should render both when both are provided', async () => {
const siteInfoWithBoth: SiteInfo = {
...baseSiteInfo,
copyright: 'My Company',
custom_disclaimer: 'Disclaimer text here',
}
render(
await renderModal(
<InfoModal
isShow={true}
onClose={mockOnClose}

View File

@ -1,16 +1,8 @@
import type { SiteInfo } from '@/models/share'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MenuDropdown from './menu-dropdown'
import MenuDropdown from '../menu-dropdown'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('next/navigation', () => ({
@ -20,7 +12,6 @@ vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}))
// Mock web-app-context
const mockShareCode = 'test-share-code'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => {
@ -32,7 +23,6 @@ vi.mock('@/context/web-app-context', () => ({
},
}))
// Mock webapp-auth service
const mockWebAppLogout = vi.fn().mockResolvedValue(undefined)
vi.mock('@/service/webapp-auth', () => ({
webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
@ -57,7 +47,6 @@ describe('MenuDropdown', () => {
it('should render the trigger button', () => {
render(<MenuDropdown data={baseSiteInfo} />)
// The trigger button contains a settings icon (RiEqualizer2Line)
const triggerButton = screen.getByRole('button')
expect(triggerButton).toBeInTheDocument()
})
@ -65,8 +54,7 @@ describe('MenuDropdown', () => {
it('should not show dropdown content initially', () => {
render(<MenuDropdown data={baseSiteInfo} />)
// Dropdown content should not be visible initially
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
it('should show dropdown content when clicked', async () => {
@ -76,7 +64,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
})
@ -87,7 +75,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
})
})
})
@ -105,7 +93,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument()
expect(screen.getByText('share.chat.privacyPolicyMiddle')).toBeInTheDocument()
})
})
@ -116,7 +104,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument()
expect(screen.queryByText('share.chat.privacyPolicyMiddle')).not.toBeInTheDocument()
})
})
@ -133,7 +121,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
const link = screen.getByText('chat.privacyPolicyMiddle').closest('a')
const link = screen.getByText('share.chat.privacyPolicyMiddle').closest('a')
expect(link).toHaveAttribute('href', privacyUrl)
expect(link).toHaveAttribute('target', '_blank')
})
@ -148,7 +136,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
})
@ -159,7 +147,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.logout')).not.toBeInTheDocument()
})
})
@ -170,10 +158,10 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
const logoutButton = screen.getByText('userProfile.logout')
const logoutButton = screen.getByText('common.userProfile.logout')
await act(async () => {
fireEvent.click(logoutButton)
})
@ -193,10 +181,10 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
})
const aboutButton = screen.getByText('userProfile.about')
const aboutButton = screen.getByText('common.userProfile.about')
fireEvent.click(aboutButton)
await waitFor(() => {
@ -213,13 +201,13 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
rerender(<MenuDropdown data={baseSiteInfo} forceClose={true} />)
await waitFor(() => {
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
})
})
@ -239,16 +227,14 @@ describe('MenuDropdown', () => {
const triggerButton = screen.getByRole('button')
// Open
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
// Close
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
})
})

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import NoData from './index'
import NoData from '../index'
describe('NoData', () => {
beforeEach(() => {

View File

@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import RunBatch from './index'
import RunBatch from '../index'
vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks/use-breakpoints')>()
@ -15,14 +15,14 @@ vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
let latestOnParsed: ((data: string[][]) => void) | undefined
let receivedCSVDownloadProps: Record<string, unknown> | undefined
vi.mock('./csv-reader', () => ({
vi.mock('../csv-reader', () => ({
default: (props: { onParsed: (data: string[][]) => void }) => {
latestOnParsed = props.onParsed
return <div data-testid="csv-reader" />
},
}))
vi.mock('./csv-download', () => ({
vi.mock('../csv-download', () => ({
default: (props: { vars: { name: string }[] }) => {
receivedCSVDownloadProps = props
return <div data-testid="csv-download" />

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import CSVDownload from './index'
import CSVDownload from '../index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined

View File

@ -1,13 +1,20 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import CSVReader from './index'
import CSVReader from '../index'
let mockAcceptedFile: { name: string } | null = null
let capturedHandlers: Record<string, (payload: any) => void> = {}
type CSVReaderHandlers = {
onUploadAccepted?: (payload: { data: string[][] }) => void
onDragOver?: (event: DragEvent) => void
onDragLeave?: (event: DragEvent) => void
}
let capturedHandlers: CSVReaderHandlers = {}
vi.mock('react-papaparse', () => ({
useCSVReader: () => ({
CSVReader: ({ children, ...handlers }: any) => {
CSVReader: ({ children, ...handlers }: { children: (ctx: { getRootProps: () => Record<string, string>, acceptedFile: { name: string } | null }) => React.ReactNode } & CSVReaderHandlers) => {
capturedHandlers = handlers
return (
<div data-testid="csv-reader-wrapper">

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import ResDownload from './index'
import ResDownload from '../index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined

View File

@ -1,4 +1,4 @@
import type { InputValueTypes } from '../types'
import type { InputValueTypes } from '../../types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
@ -6,7 +6,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Resolution, TransferMethod } from '@/types/app'
import RunOnce from './index'
import RunOnce from '../index'
vi.mock('@/hooks/use-breakpoints', () => {
const MediaType = {
@ -39,7 +39,6 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', (
}
})
// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => (
<div data-testid="file-uploader-mock">
@ -272,7 +271,6 @@ describe('RunOnce', () => {
selectInput: 'Option A',
})
})
// The Select component should be rendered
expect(screen.getByText('Select Input')).toBeInTheDocument()
})
})
@ -463,7 +461,6 @@ describe('RunOnce', () => {
key: 'textInput',
name: 'Text Input',
type: 'string',
// max_length is not set
}),
],
}

View File

@ -32,11 +32,6 @@
"count": 2
}
},
"__tests__/goto-anything/slash-command-modes.test.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"__tests__/i18n-upload-features.test.ts": {
"no-console": {
"count": 3
@ -5588,11 +5583,6 @@
"count": 2
}
},
"app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/share/text-generation/run-batch/csv-reader/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1

View File

@ -4,6 +4,17 @@ import viteConfig from './vite.config'
const isCI = !!process.env.CI
export default mergeConfig(viteConfig, defineConfig({
plugins: [
{
// Stub .mdx files so components importing them can be unit-tested
name: 'mdx-stub',
enforce: 'pre',
transform(_, id) {
if (id.endsWith('.mdx'))
return { code: 'export default () => null', map: null }
},
},
],
test: {
environment: 'jsdom',
globals: true,