mirror of https://github.com/langgenius/dify.git
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:
parent
80e6312807
commit
bfdc39510b
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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])
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }) => (
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getInitialTokenV2, isTokenV1 } from './utils'
|
||||
import { getInitialTokenV2, isTokenV1 } from '../utils'
|
||||
|
||||
describe('utils', () => {
|
||||
describe('isTokenV1', () => {
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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
|
||||
|
|
@ -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">
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue