mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
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:
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Integration test: API Key management flow
|
||||
*
|
||||
* Tests the cross-component interaction:
|
||||
* ApiServer → SecretKeyButton → SecretKeyModal
|
||||
*
|
||||
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
|
||||
* with only service-layer mocks. Deep modal interactions (create/delete)
|
||||
* are covered by unit tests in secret-key-modal.spec.tsx.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ApiServer from '@/app/components/develop/ApiServer'
|
||||
|
||||
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mocks ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsLoading = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({
|
||||
data: mockApiKeys(),
|
||||
isLoading: mockIsLoading(),
|
||||
}),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('API Key management flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiKeys.mockReturnValue({ data: [] })
|
||||
mockIsLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('ApiServer renders URL, status badge, and API Key button', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Click API Key button (rendered by SecretKeyButton)
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should render with real HeadlessUI Dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('modal shows loading state when API keys are being fetched', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
mockIsLoading.mockReturnValue(true)
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading indicator should be present
|
||||
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('modal can be closed by clicking X icon', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Open modal
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click X icon to close
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with different API URLs', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
const { rerender } = render(
|
||||
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
|
||||
|
||||
// Open modal and verify it works with the same appId
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal, update URL and re-verify
|
||||
const xIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
await act(async () => {
|
||||
await user.click(xIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
rerender(
|
||||
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration test: DevelopMain page flow
|
||||
*
|
||||
* Tests the full page lifecycle:
|
||||
* Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered
|
||||
*
|
||||
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DevelopMain from '@/app/components/develop'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
// ---------- fake timers ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- store mock ----------
|
||||
|
||||
let storeAppDetail: unknown
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ appDetail: storeAppDetail })
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------- Doc dependencies ----------
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
|
||||
}))
|
||||
|
||||
// ---------- SecretKeyModal dependencies ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('DevelopMain page flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeAppDetail = undefined
|
||||
})
|
||||
|
||||
it('should show loading indicator when appDetail is not available', () => {
|
||||
storeAppDetail = undefined
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
// No content should be visible
|
||||
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render full page when appDetail is loaded', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer section should be visible
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
|
||||
// Loading should NOT be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Doc component with correct app mode template', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Chat App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
const { container } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Doc renders an article element with prose classes
|
||||
const article = container.querySelector('article')
|
||||
expect(article).toBeInTheDocument()
|
||||
expect(article?.className).toContain('prose')
|
||||
})
|
||||
|
||||
it('should transition from loading to content when appDetail becomes available', () => {
|
||||
// Start with no data
|
||||
storeAppDetail = undefined
|
||||
const { rerender } = render(<DevelopMain appId="app-1" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Simulate store update
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
}
|
||||
rerender(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Content should now be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API key modal from the page', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Click API Key button in the header
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render correctly for different app modes', () => {
|
||||
const modes = [
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: `${mode} App`,
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode,
|
||||
}
|
||||
|
||||
const { container, unmount } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer should always be present
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
|
||||
// Doc should render an article
|
||||
expect(container.querySelector('article')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have correct page layout structure', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Main container: flex column with full height
|
||||
const mainDiv = screen.getByTestId('develop-main')
|
||||
expect(mainDiv.className).toContain('flex')
|
||||
expect(mainDiv.className).toContain('flex-col')
|
||||
expect(mainDiv.className).toContain('h-full')
|
||||
|
||||
// Header section with border
|
||||
const header = mainDiv.querySelector('.border-b')
|
||||
expect(header).toBeInTheDocument()
|
||||
|
||||
// Content section with overflow scroll
|
||||
const content = mainDiv.querySelector('.overflow-auto')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
121
web/__tests__/share/text-generation-run-batch-flow.test.tsx
Normal file
121
web/__tests__/share/text-generation-run-batch-flow.test.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Integration test: RunBatch CSV upload → Run flow
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Upload CSV → parse → enable run → click run → results finish → run again
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RunBatch from '@/app/components/share/text-generation/run-batch'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
// Capture the onParsed callback from CSVReader to simulate CSV uploads
|
||||
let capturedOnParsed: ((data: string[][]) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({
|
||||
default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => {
|
||||
capturedOnParsed = onParsed
|
||||
return <div data-testid="csv-reader">CSV Reader</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
|
||||
default: ({ vars }: { vars: { name: string }[] }) => (
|
||||
<div data-testid="csv-download">
|
||||
{vars.map(v => v.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RunBatch – integration flow', () => {
|
||||
const vars = [{ name: 'prompt' }, { name: 'context' }]
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOnParsed = undefined
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('full lifecycle: upload CSV → run → finish → run again', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished />,
|
||||
)
|
||||
|
||||
// Phase 1 – verify child components rendered
|
||||
expect(screen.getByTestId('csv-reader')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context')
|
||||
|
||||
// Run button should be disabled before CSV is parsed
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 2 – simulate CSV upload
|
||||
const csvData = [
|
||||
['prompt', 'context'],
|
||||
['Hello', 'World'],
|
||||
['Goodbye', 'Moon'],
|
||||
]
|
||||
await act(async () => {
|
||||
capturedOnParsed?.(csvData)
|
||||
})
|
||||
|
||||
// Run button should now be enabled
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Phase 3 – click run
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
expect(onSend).toHaveBeenCalledWith(csvData)
|
||||
|
||||
// Phase 4 – simulate results still running
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 5 – results finish → can run again
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
onSend.mockClear()
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remain disabled when CSV not uploaded even if all finished', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show spinner icon when results are still running', async () => {
|
||||
const onSend = vi.fn()
|
||||
const { container } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />,
|
||||
)
|
||||
|
||||
// Upload CSV first
|
||||
await act(async () => {
|
||||
capturedOnParsed?.([['data']])
|
||||
})
|
||||
|
||||
// Button disabled + spinning icon
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('animate-spin')
|
||||
})
|
||||
})
|
||||
218
web/__tests__/share/text-generation-run-once-flow.test.tsx
Normal file
218
web/__tests__/share/text-generation-run-once-flow.test.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Integration test: RunOnce form lifecycle
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Init defaults → edit fields → submit → running state → stop
|
||||
*/
|
||||
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
|
||||
import type { PromptConfig, PromptVariable } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => (
|
||||
<textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
|
||||
default: () => <div data-testid="vision-uploader" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
|
||||
}))
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
|
||||
key: 'k',
|
||||
name: 'Name',
|
||||
type: 'string',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const visionOff: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 0,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
image_file_size_limit: 5,
|
||||
}
|
||||
|
||||
const siteInfo: SiteInfo = { title: 'Test' }
|
||||
|
||||
/**
|
||||
* Stateful wrapper that mirrors what text-generation/index.tsx does:
|
||||
* owns `inputs` state and passes an `inputsRef`.
|
||||
*/
|
||||
function Harness({
|
||||
promptConfig,
|
||||
visionConfig = visionOff,
|
||||
onSendSpy,
|
||||
runControl = null,
|
||||
}: {
|
||||
promptConfig: PromptConfig
|
||||
visionConfig?: VisionSettings
|
||||
onSendSpy: () => void
|
||||
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
|
||||
}) {
|
||||
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
|
||||
const inputsRef = useRef<Record<string, InputValueTypes>>({})
|
||||
|
||||
return (
|
||||
<RunOnce
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
inputs={inputs}
|
||||
inputsRef={inputsRef}
|
||||
onInputsChange={(updated) => {
|
||||
inputsRef.current = updated
|
||||
setInputs(updated)
|
||||
}}
|
||||
onSend={onSendSpy}
|
||||
visionConfig={visionConfig}
|
||||
onVisionFilesChange={vi.fn()}
|
||||
runControl={runControl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- tests -----
|
||||
|
||||
describe('RunOnce – integration flow', () => {
|
||||
it('full lifecycle: init → edit → submit → running → stop', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
|
||||
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
|
||||
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
|
||||
],
|
||||
}
|
||||
|
||||
// Phase 1 – render, wait for initialisation
|
||||
const { rerender } = render(
|
||||
<Harness promptConfig={config} onSendSpy={onSend} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Phase 2 – fill fields
|
||||
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
|
||||
|
||||
// Phase 3 – submit
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 4 – simulate "running" state
|
||||
const onStop = vi.fn()
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const stopBtn = screen.getByTestId('stop-button')
|
||||
expect(stopBtn).toBeInTheDocument()
|
||||
fireEvent.click(stopBtn)
|
||||
expect(onStop).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 5 – simulate "stopping" state
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('stop-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clear resets all field types and allows re-submit', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
|
||||
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
|
||||
})
|
||||
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
|
||||
})
|
||||
|
||||
// Re-fill and submit
|
||||
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mixed input types: string + select + json_object', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
|
||||
variable({
|
||||
key: 'sel',
|
||||
name: 'Dropdown',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
default: 'A',
|
||||
}),
|
||||
variable({
|
||||
key: 'json',
|
||||
name: 'JSON',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSON')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit text & json
|
||||
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
|
||||
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user