Files
dify/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.spec.tsx

2029 lines
58 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import DSLConfirmModal from './dsl-confirm-modal'
import Header from './header'
import CreateFromDSLModal, { CreateFromDSLModalTab } from './index'
import Tab from './tab'
import TabItem from './tab/item'
import Uploader from './uploader'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock service hooks
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useImportPipelineDSL: () => ({
mutateAsync: mockImportDSL,
}),
useImportPipelineDSLConfirm: () => ({
mutateAsync: mockImportDSLConfirm,
}),
}))
// Mock plugin dependencies hook
const mockHandleCheckPluginDependencies = vi.fn()
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: vi.fn(() => ({ notify: mockNotify })),
}
})
// Test data builders
const createMockFile = (name = 'test.pipeline'): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
const createImportDSLResponse = (overrides = {}) => ({
id: 'import-123',
status: 'completed' as const,
pipeline_id: 'pipeline-456',
dataset_id: 'dataset-789',
current_dsl_version: '1.0.0',
imported_dsl_version: '1.0.0',
...overrides,
})
// Helper function to create QueryClient wrapper
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('CreateFromDSLModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockImportDSL.mockReset()
mockImportDSLConfirm.mockReset()
mockPush.mockReset()
mockNotify.mockReset()
mockHandleCheckPluginDependencies.mockReset()
})
// ============================================
// Rendering Tests
// ============================================
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('app.importFromDSL')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
render(
<CreateFromDSLModal
show={false}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
// Modal with show=false should not display its content visibly
const modal = screen.queryByText('app.importFromDSL')
expect(modal).toBeNull()
})
it('should render file tab by default', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
})
it('should render cancel and import buttons', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument()
expect(screen.getByText('app.newApp.import')).toBeInTheDocument()
})
it('should render uploader when file tab is active', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
})
it('should render URL input when URL tab is active', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('DSL URL')).toBeInTheDocument()
expect(screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')).toBeInTheDocument()
})
})
// ============================================
// Props Testing
// ============================================
describe('Props', () => {
it('should use FROM_FILE as default activeTab', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
// File tab content should be visible
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
})
it('should use provided activeTab prop', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByText('DSL URL')).toBeInTheDocument()
})
it('should use provided dslUrl prop', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/test.pipeline"
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
expect(input).toHaveValue('https://example.com/test.pipeline')
})
it('should call onClose when cancel button is clicked', () => {
const onClose = vi.fn()
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByText('app.newApp.Cancel'))
expect(onClose).toHaveBeenCalled()
})
})
// ============================================
// State Management Tests
// ============================================
describe('State Management', () => {
it('should switch between tabs', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
/>,
{ wrapper: createWrapper() },
)
// Initially file tab is active
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
// Click URL tab
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// URL input should be visible
expect(screen.getByText('DSL URL')).toBeInTheDocument()
})
it('should update URL value when typing', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
expect(input).toHaveValue('https://example.com/test.pipeline')
})
it('should have disabled import button when no file is selected in file tab', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).toBeDisabled()
})
it('should have disabled import button when no URL is entered in URL tab', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).toBeDisabled()
})
it('should enable import button when URL is entered', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).not.toBeDisabled()
})
})
// ============================================
// API Call Tests
// ============================================
describe('API Calls', () => {
it('should call importDSL with URL mode when URL tab is active', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse())
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledWith({
mode: 'yaml-url',
yaml_url: 'https://example.com/test.pipeline',
})
})
})
it('should handle successful import with COMPLETED status', async () => {
const onSuccess = vi.fn()
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed' }))
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onSuccess={onSuccess}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline')
})
})
it('should handle import with COMPLETED_WITH_WARNINGS status', async () => {
const onSuccess = vi.fn()
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed-with-warnings' }))
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onSuccess={onSuccess}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'warning',
}))
})
})
it('should handle import with PENDING status and show error modal', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({
status: 'pending',
imported_dsl_version: '0.9.0',
current_dsl_version: '1.0.0',
}))
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
// Advance timer to show error modal
await act(async () => {
vi.advanceTimersByTime(400)
})
await waitFor(() => {
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
vi.useRealTimers()
})
it('should handle API error', async () => {
mockImportDSL.mockResolvedValue(null)
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should handle FAILED status', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'failed' }))
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should check plugin dependencies after successful import', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse({
status: 'completed',
pipeline_id: 'pipeline-123',
}))
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipeline-123', true)
})
})
})
// ============================================
// Event Handler Tests
// ============================================
describe('Event Handlers', () => {
it('should call onClose when header close button is clicked', () => {
const onClose = vi.fn()
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
/>,
{ wrapper: createWrapper() },
)
// Find and click the close icon in header
const closeIcon = document.querySelector('[class*="cursor-pointer"]')
if (closeIcon) {
fireEvent.click(closeIcon)
expect(onClose).toHaveBeenCalled()
}
})
it('should close modal on ESC key press', () => {
const onClose = vi.fn()
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
/>,
{ wrapper: createWrapper() },
)
// Trigger ESC key event - ahooks useKeyPress listens for 'esc' which maps to Escape key
// Need to dispatch on window/document with the correct event properties
const escEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
})
document.dispatchEvent(escEvent)
expect(onClose).toHaveBeenCalled()
})
it('should not close on ESC when error modal is shown', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'pending' }))
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
// Clear previous calls
onClose.mockClear()
// Show error modal
await act(async () => {
vi.advanceTimersByTime(400)
})
// Now ESC should not close main modal because error modal is shown
const escEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
})
document.dispatchEvent(escEvent)
// onClose should not be called again when error modal is shown
expect(onClose).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should prevent duplicate submissions', async () => {
mockImportDSL.mockImplementation(() => new Promise(resolve =>
setTimeout(() => resolve(createImportDSLResponse()), 1000),
))
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
// Click multiple times rapidly
fireEvent.click(importButton)
fireEvent.click(importButton)
fireEvent.click(importButton)
// Should only be called once due to isCreatingRef
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledTimes(1)
})
})
})
// ============================================
// Memoization Tests
// ============================================
describe('Memoization', () => {
it('should correctly compute buttonDisabled based on currentTab and file/URL', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
// File tab with no file - disabled
let importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).toBeDisabled()
// Switch to URL tab by clicking on it
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// Still disabled (no URL)
importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).toBeDisabled()
// Add URL value - should enable
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com' } })
importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).not.toBeDisabled()
})
})
// ============================================
// Edge Cases Tests
// ============================================
describe('Edge Cases', () => {
it('should handle empty URL gracefully', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Should not call API with empty URL
expect(mockImportDSL).not.toHaveBeenCalled()
})
it('should handle undefined onSuccess gracefully', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse())
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
// onSuccess is undefined
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
// Should not throw
fireEvent.click(importButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalled()
})
})
it('should handle response without pipeline_id', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse({
status: 'completed',
pipeline_id: null,
}))
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await waitFor(() => {
// Should not call handleCheckPluginDependencies when pipeline_id is null
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
})
it('should handle empty file in file tab gracefully', () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Should not call API with no file
expect(mockImportDSL).not.toHaveBeenCalled()
})
it('should return early in onCreate when file tab has no file (direct trigger)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
// Test the early return branch by force-triggering the button even when disabled
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')!
// Remove disabled attribute temporarily to test the early return
importButton.removeAttribute('disabled')
// Dispatch a native click event to bypass any React disabled checks
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
importButton.dispatchEvent(clickEvent)
// Wait for debounce to trigger
await act(async () => {
vi.advanceTimersByTime(400)
})
// Should not call API due to early return in onCreate
expect(mockImportDSL).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should return early in onCreate when URL tab has no URL (direct trigger)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const importButton = screen.getByText('app.newApp.import').closest('button')!
// Remove disabled attribute to test the early return
importButton.removeAttribute('disabled')
// Dispatch a native click event
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
importButton.dispatchEvent(clickEvent)
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(400)
})
// Should not call API due to early return
expect(mockImportDSL).not.toHaveBeenCalled()
vi.useRealTimers()
})
})
// ============================================
// File Import Tests (covers readFile, handleFile, file mode import)
// ============================================
describe('File Import', () => {
it('should read file content when file is selected', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse())
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
// Create a mock file with content
const fileContent = 'test yaml content'
const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' })
// Get the file input and simulate file selection
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
Object.defineProperty(fileInput, 'files', {
value: [mockFile],
configurable: true,
})
fireEvent.change(fileInput)
// Wait for FileReader to complete
await waitFor(() => {
const importButton = screen.getByText('app.newApp.import').closest('button')
expect(importButton).not.toBeDisabled()
})
// Click import button
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Verify API was called with file content
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledWith({
mode: 'yaml-content',
yaml_content: fileContent,
})
})
})
it('should clear file content when file is removed', async () => {
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_FILE}
/>,
{ wrapper: createWrapper() },
)
// First add a file
const mockFile = new File(['content'], 'test.pipeline', { type: 'application/octet-stream' })
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
Object.defineProperty(fileInput, 'files', {
value: [mockFile],
configurable: true,
})
fireEvent.change(fileInput)
// Wait for file to be displayed
await waitFor(() => {
expect(screen.getByText('test.pipeline')).toBeInTheDocument()
})
// Now remove the file by clicking delete button (inside ActionButton)
const actionButton = document.querySelector('[class*="group-hover"]')
const deleteButton = actionButton?.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
// File should be removed - uploader prompt should show again
await waitFor(() => {
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
})
}
})
})
// ============================================
// DSL Confirm Flow Tests (covers onDSLConfirm)
// ============================================
describe('DSL Confirm Flow', () => {
it('should handle DSL confirm success', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const onSuccess = vi.fn()
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({
id: 'import-123',
status: 'pending',
imported_dsl_version: '0.9.0',
current_dsl_version: '1.0.0',
}))
mockImportDSLConfirm.mockResolvedValue({
status: 'completed',
pipeline_id: 'pipeline-456',
dataset_id: 'dataset-789',
})
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onSuccess={onSuccess}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
// Enter URL and submit
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Wait for pending status handling
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
// Advance timer to show error modal
await act(async () => {
vi.advanceTimersByTime(400)
})
// Click confirm button in error modal
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.newApp.Confirm'))
// Verify confirm was called
await waitFor(() => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123')
})
// Verify success handling
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
vi.useRealTimers()
})
it('should handle DSL confirm with no importId', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue(createImportDSLResponse({
id: '', // Empty id
status: 'pending',
}))
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
await act(async () => {
vi.advanceTimersByTime(400)
})
// Click confirm - should return early since importId is empty
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.newApp.Confirm'))
// Confirm should not be called since importId is empty string (falsy)
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should handle DSL confirm API error', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue(createImportDSLResponse({
id: 'import-123',
status: 'pending',
}))
mockImportDSLConfirm.mockResolvedValue(null)
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
fireEvent.click(screen.getByText('app.newApp.import').closest('button')!)
await act(async () => {
vi.advanceTimersByTime(400)
})
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.newApp.Confirm'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
vi.useRealTimers()
})
it('should handle DSL confirm with FAILED status', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue(createImportDSLResponse({
id: 'import-123',
status: 'pending',
}))
mockImportDSLConfirm.mockResolvedValue({
status: 'failed',
pipeline_id: 'pipeline-456',
dataset_id: 'dataset-789',
})
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
fireEvent.click(screen.getByText('app.newApp.import').closest('button')!)
await act(async () => {
vi.advanceTimersByTime(400)
})
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.newApp.Confirm'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
vi.useRealTimers()
})
it('should close error modal when cancel is clicked', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue(createImportDSLResponse({
status: 'pending',
}))
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/test.pipeline' } })
fireEvent.click(screen.getByText('app.newApp.import').closest('button')!)
await act(async () => {
vi.advanceTimersByTime(400)
})
// Error modal should be visible
await waitFor(() => {
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
// There are two Cancel buttons now (one in main modal footer, one in error modal)
// Find the Cancel button in the error modal context
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
// Click the last Cancel button (the one in the error modal)
fireEvent.click(cancelButtons[cancelButtons.length - 1])
vi.useRealTimers()
})
})
})
// ============================================
// Header Component Tests
// ============================================
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title', () => {
render(<Header onClose={vi.fn()} />)
expect(screen.getByText('app.importFromDSL')).toBeInTheDocument()
})
it('should render close icon', () => {
render(<Header onClose={vi.fn()} />)
// Check for close icon container
const closeButton = document.querySelector('[class*="cursor-pointer"]')
expect(closeButton).toBeInTheDocument()
})
})
describe('Event Handlers', () => {
it('should call onClose when close icon is clicked', () => {
const onClose = vi.fn()
render(<Header onClose={onClose} />)
const closeButton = document.querySelector('[class*="cursor-pointer"]')!
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalled()
})
})
})
// ============================================
// Tab Component Tests
// ============================================
describe('Tab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render both tabs', () => {
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={vi.fn()}
/>,
)
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
})
})
describe('Event Handlers', () => {
it('should call setCurrentTab when clicking file tab', () => {
const setCurrentTab = vi.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
fireEvent.click(screen.getByText('app.importFromDSLFile'))
// Tab uses bind() which passes the key as first argument and event as second
expect(setCurrentTab).toHaveBeenCalled()
expect(setCurrentTab.mock.calls[0][0]).toBe(CreateFromDSLModalTab.FROM_FILE)
})
it('should call setCurrentTab when clicking URL tab', () => {
const setCurrentTab = vi.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// Tab uses bind() which passes the key as first argument and event as second
expect(setCurrentTab).toHaveBeenCalled()
expect(setCurrentTab.mock.calls[0][0]).toBe(CreateFromDSLModalTab.FROM_URL)
})
})
})
// ============================================
// Tab Item Component Tests
// ============================================
describe('TabItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render label', () => {
render(
<TabItem
isActive={false}
label="Test Tab"
onClick={vi.fn()}
/>,
)
expect(screen.getByText('Test Tab')).toBeInTheDocument()
})
it('should render active indicator when active', () => {
render(
<TabItem
isActive={true}
label="Test Tab"
onClick={vi.fn()}
/>,
)
// Active indicator is the bottom border div
const indicator = document.querySelector('[class*="bg-util-colors-blue"]')
expect(indicator).toBeInTheDocument()
})
it('should not render active indicator when inactive', () => {
render(
<TabItem
isActive={false}
label="Test Tab"
onClick={vi.fn()}
/>,
)
const indicator = document.querySelector('[class*="bg-util-colors-blue"]')
expect(indicator).toBeNull()
})
it('should have active text color when active', () => {
render(
<TabItem
isActive={true}
label="Test Tab"
onClick={vi.fn()}
/>,
)
const item = screen.getByText('Test Tab')
expect(item.className).toContain('text-text-primary')
})
it('should have inactive text color when inactive', () => {
render(
<TabItem
isActive={false}
label="Test Tab"
onClick={vi.fn()}
/>,
)
const item = screen.getByText('Test Tab')
expect(item.className).toContain('text-text-tertiary')
})
})
describe('Event Handlers', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(
<TabItem
isActive={false}
label="Test Tab"
onClick={onClick}
/>,
)
fireEvent.click(screen.getByText('Test Tab'))
expect(onClick).toHaveBeenCalled()
})
})
})
// ============================================
// Uploader Component Tests
// ============================================
describe('Uploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render upload prompt when no file', () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
expect(screen.getByText('app.dslUploader.browse')).toBeInTheDocument()
})
it('should render file info when file is selected', () => {
const mockFile = createMockFile('test.pipeline')
render(
<Uploader
file={mockFile}
updateFile={vi.fn()}
/>,
)
expect(screen.getByText('test.pipeline')).toBeInTheDocument()
expect(screen.getByText('PIPELINE')).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(
<Uploader
file={undefined}
updateFile={vi.fn()}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('Event Handlers', () => {
it('should call updateFile when browse link is clicked and file is selected', async () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
// Get the hidden input
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
// Create a mock file
const mockFile = createMockFile()
// Simulate file selection
Object.defineProperty(fileInput, 'files', {
value: [mockFile],
})
fireEvent.change(fileInput)
expect(updateFile).toHaveBeenCalledWith(mockFile)
})
it('should call updateFile with undefined when delete button is clicked', () => {
const updateFile = vi.fn()
const mockFile = createMockFile()
render(
<Uploader
file={mockFile}
updateFile={updateFile}
/>,
)
// Find and click delete button - the button contains the delete icon
const deleteButton = document.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(updateFile).toHaveBeenCalledWith()
}
})
it('should handle browse click', () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
const browseLink = screen.getByText('app.dslUploader.browse')
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
// Mock click on input
const clickSpy = vi.spyOn(fileInput, 'click')
fireEvent.click(browseLink)
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Drag and Drop', () => {
it('should show drag state when dragging over', () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
const dropArea = document.querySelector('[class*="border-dashed"]')!
// The drag state is triggered when dragEnter fires on something other than the dragRef
// In the component, setDragging(true) happens when e.target !== dragRef.current
fireEvent.dragEnter(dropArea, {
dataTransfer: { files: [] },
})
// The class should be present since dropArea is not dragRef
expect(dropArea.className).toContain('border-components-dropzone')
})
it('should handle dragOver event', () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
const dashedArea = document.querySelector('[class*="border-dashed"]')
const dropArea = dashedArea?.parentElement
if (!dropArea)
return
// DragOver should prevent default and stop propagation
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
dropArea.dispatchEvent(dragOverEvent)
// Event should be handled without errors
expect(dropArea).toBeInTheDocument()
})
it('should handle dragLeave event and reset dragging state when target is dragRef', async () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
const dropArea = document.querySelector('[class*="border-dashed"]')!
const dropAreaParent = dropArea.parentElement
if (!dropAreaParent)
return
// First trigger dragEnter to set dragging state
fireEvent.dragEnter(dropArea, {
dataTransfer: { files: [] },
})
// Verify dragging state is set - the accent class appears when dragging
await waitFor(() => {
expect(dropArea.className).toContain('border-components-dropzone-border-accent')
})
// The dragRef div appears when dragging is true
const dragRefDiv = document.querySelector('[class*="absolute left-0 top-0"]')
expect(dragRefDiv).toBeInTheDocument()
// When dragLeave happens on the dragRef element, setDragging(false) is called
if (dragRefDiv) {
// Fire dragleave directly on the dragRef element
fireEvent.dragLeave(dragRefDiv)
// After dragLeave on dragRef, dragging should be false and accent class removed
await waitFor(() => {
expect(dropArea.className).not.toContain('border-components-dropzone-border-accent')
})
}
})
it('should not reset dragging when dragLeave target is not dragRef', async () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
const dropArea = document.querySelector('[class*="border-dashed"]')!
const dropAreaParent = dropArea.parentElement
if (!dropAreaParent)
return
// First trigger dragEnter to set dragging state
fireEvent.dragEnter(dropArea, {
dataTransfer: { files: [] },
})
// Verify dragging state is set
await waitFor(() => {
expect(dropArea.className).toContain('border-components-dropzone-border-accent')
})
// Trigger dragLeave on the drop area (not dragRef) - should NOT reset dragging
fireEvent.dragLeave(dropArea, {
dataTransfer: { files: [] },
})
// Dragging should still be true (accent class still present)
// because target is not dragRef
expect(dropArea.className).toContain('border-components-dropzone')
})
it('should handle file drop', async () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
const dashedArea = document.querySelector('[class*="border-dashed"]')
const dropArea = dashedArea?.parentElement
if (!dropArea)
return
const mockFile = createMockFile()
fireEvent.drop(dropArea, {
dataTransfer: {
files: [mockFile],
},
})
expect(updateFile).toHaveBeenCalledWith(mockFile)
})
it('should reject multiple files', async () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
const dashedArea = document.querySelector('[class*="border-dashed"]')
const dropArea = dashedArea?.parentElement
if (!dropArea)
return
const mockFile1 = createMockFile('file1.pipeline')
const mockFile2 = createMockFile('file2.pipeline')
fireEvent.drop(dropArea, {
dataTransfer: {
files: [mockFile1, mockFile2],
},
})
expect(updateFile).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
describe('Edge Cases', () => {
it('should handle drop event without dataTransfer', () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
const dashedArea = document.querySelector('[class*="border-dashed"]')
const dropArea = dashedArea?.parentElement
if (!dropArea)
return
fireEvent.drop(dropArea, {
dataTransfer: null,
})
expect(updateFile).not.toHaveBeenCalled()
})
it('should handle file cancel in selectHandle and restore original file', () => {
const updateFile = vi.fn()
render(
<Uploader
file={undefined}
updateFile={updateFile}
/>,
)
// Get the file input
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
expect(fileInput).toBeInTheDocument()
// Spy on input click before triggering selectHandle
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {
// After click, oncancel should be set
})
// Click browse link to trigger selectHandle
const browseLink = screen.getByText('app.dslUploader.browse')
fireEvent.click(browseLink)
// selectHandle should have triggered click on input
expect(clickSpy).toHaveBeenCalled()
// After selectHandle runs, oncancel should be set
// Trigger cancel - should restore original file (undefined in this case)
if (fileInput.oncancel) {
fileInput.oncancel(new Event('cancel'))
// updateFile should be called with undefined (the original file)
expect(updateFile).toHaveBeenCalledWith(undefined)
}
clickSpy.mockRestore()
})
it('should not set dragging when target equals dragRef', () => {
render(
<Uploader
file={undefined}
updateFile={vi.fn()}
/>,
)
const dropArea = document.querySelector('[class*="border-dashed"]')!
// First trigger drag to show dragRef div
fireEvent.dragEnter(dropArea, {
dataTransfer: { files: [] },
})
// Now the dragRef div should exist
const dragRefDiv = document.querySelector('[class*="absolute left-0 top-0"]')
// When dragEnter happens on dragRef itself, setDragging should NOT be called
if (dragRefDiv) {
const dropAreaParent = dropArea.parentElement
if (dropAreaParent) {
// Trigger dragEnter with target = dragRef - this should NOT set dragging
const dragEnterEvent = new Event('dragenter', { bubbles: true })
Object.defineProperty(dragEnterEvent, 'target', { value: dragRefDiv })
dropAreaParent.dispatchEvent(dragEnterEvent)
}
}
})
it('should handle removeFile when file input exists', () => {
const updateFile = vi.fn()
const mockFile = createMockFile()
render(
<Uploader
file={mockFile}
updateFile={updateFile}
/>,
)
// Find and click delete button
const deleteButton = document.querySelector('button')
expect(deleteButton).toBeInTheDocument()
if (deleteButton) {
fireEvent.click(deleteButton)
// updateFile should be called without arguments
expect(updateFile).toHaveBeenCalledWith()
}
// Verify file input value was cleared
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
expect(fileInput.value).toBe('')
})
})
})
// ============================================
// DSLConfirmModal Component Tests
// ============================================
describe('DSLConfirmModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
it('should render version information', () => {
render(
<DSLConfirmModal
versions={{
importedVersion: '0.9.0',
systemVersion: '1.0.0',
}}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText('0.9.0')).toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
})
it('should render cancel and confirm buttons', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument()
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
it('should render with default empty versions', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
// Should not crash with default empty strings
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
it('should disable confirm button when confirmDisabled is true', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
confirmDisabled={true}
/>,
)
const confirmButton = screen.getByText('app.newApp.Confirm').closest('button')
expect(confirmButton).toBeDisabled()
})
})
describe('Event Handlers', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(
<DSLConfirmModal
onCancel={onCancel}
onConfirm={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.newApp.Cancel'))
expect(onCancel).toHaveBeenCalled()
})
it('should call onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn()
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
)
fireEvent.click(screen.getByText('app.newApp.Confirm'))
expect(onConfirm).toHaveBeenCalled()
})
it('should bind onClose to onCancel via arrow function', () => {
// This test verifies that the Modal's onClose prop calls onCancel
// The implementation is: onClose={() => onCancel()}
const onCancel = vi.fn()
render(
<DSLConfirmModal
onCancel={onCancel}
onConfirm={vi.fn()}
/>,
)
// Trigger the cancel button which also calls onCancel
// This confirms onCancel is properly wired up
fireEvent.click(screen.getByText('app.newApp.Cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when modal is closed via escape key', () => {
const onCancel = vi.fn()
render(
<DSLConfirmModal
onCancel={onCancel}
onConfirm={vi.fn()}
/>,
)
// Pressing Escape triggers Modal's onClose which calls onCancel
const escEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
})
document.dispatchEvent(escEvent)
// onCancel should be called via the onClose={() => onCancel()} callback
expect(onCancel).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should use default versions when not provided', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
// Component should render without crashing
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
it('should use default confirmDisabled when not provided', () => {
render(
<DSLConfirmModal
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
const confirmButton = screen.getByText('app.newApp.Confirm').closest('button')
expect(confirmButton).not.toBeDisabled()
})
})
})
// ============================================
// Integration Tests
// ============================================
describe('CreateFromDSLModal Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockImportDSL.mockReset()
mockImportDSLConfirm.mockReset()
mockPush.mockReset()
mockNotify.mockReset()
mockHandleCheckPluginDependencies.mockReset()
})
it('should complete full import flow with URL', async () => {
const onSuccess = vi.fn()
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse())
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
render(
<CreateFromDSLModal
show={true}
onSuccess={onSuccess}
onClose={onClose}
/>,
{ wrapper: createWrapper() },
)
// Switch to URL tab
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// Enter URL
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/pipeline.yaml' } })
// Click import
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Verify API was called
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalled()
})
// Verify success callbacks after API completes
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline')
})
})
it('should handle version mismatch flow - shows error modal', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const onClose = vi.fn()
mockImportDSL.mockResolvedValue(createImportDSLResponse({
status: 'pending',
imported_dsl_version: '0.8.0',
current_dsl_version: '1.0.0',
}))
render(
<CreateFromDSLModal
show={true}
onClose={onClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
// Enter URL
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/old-pipeline.yaml' } })
// Click import
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
// Wait for API call
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalled()
})
// Wait for onClose to be called
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
// Advance timer to show error modal
await act(async () => {
vi.advanceTimersByTime(400)
})
// Verify error modal is shown
await waitFor(() => {
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
vi.useRealTimers()
})
})