dify/web/app/components/datasets/create/website/jina-reader/index.spec.tsx

1701 lines
56 KiB
TypeScript

import type { Mock } from 'vitest'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets'
import { sleep } from '@/utils'
import JinaReader from './index'
// Mock external dependencies
vi.mock('@/service/datasets', () => ({
createJinaReaderTask: vi.fn(),
checkJinaReaderTaskStatus: vi.fn(),
}))
vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
// Mock doc link context
vi.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.example.com',
}))
// ============================================================================
// Test Data Factories
// ============================================================================
// Note: limit and max_depth are typed as `number | string` in CrawlOptions
// Tests may use number, string, or empty string values to cover all valid cases
const createDefaultCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
only_main_content: true,
includes: '',
excludes: '',
limit: 10,
max_depth: 2,
use_sitemap: false,
...overrides,
})
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: '# Test Content\n\nThis is test markdown content.',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
const createDefaultProps = (overrides: Partial<Parameters<typeof JinaReader>[0]> = {}) => ({
onPreview: vi.fn(),
checkedCrawlResult: [] as CrawlResultItem[],
onCheckedCrawlResultChange: vi.fn(),
onJobIdChange: vi.fn(),
crawlOptions: createDefaultCrawlOptions(),
onCrawlOptionsChange: vi.fn(),
...overrides,
})
// ============================================================================
// Rendering Tests
// ============================================================================
describe('JinaReader', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle')).toBeInTheDocument()
})
it('should render header with configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.website.configureJinaReader')).toBeInTheDocument()
})
it('should render URL input field', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render run button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render options section', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
})
it('should render doc link to Jina Reader', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
const docLink = screen.getByRole('link')
expect(docLink).toHaveAttribute('href', 'https://jina.ai/reader')
})
it('should not render crawling or result components initially', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Assert
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
// ============================================================================
// Props Testing
// ============================================================================
describe('Props', () => {
it('should call onCrawlOptionsChange when options change', async () => {
// Arrange
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onCrawlOptionsChange = vi.fn()
const props = createDefaultProps({ onCrawlOptionsChange })
// Act
render(<JinaReader {...props} />)
// Find the limit input by its associated label text
const limitLabel = screen.queryByText('datasetCreation.stepOne.website.limit')
if (limitLabel) {
// The limit input is a number input (spinbutton role) within the same container
const limitInput = limitLabel.closest('div')?.parentElement?.querySelector('input[type="number"]')
if (limitInput) {
await user.clear(limitInput)
await user.type(limitInput, '20')
// Assert
expect(onCrawlOptionsChange).toHaveBeenCalled()
}
}
else {
// Options might not be visible, just verify component renders
expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
}
})
it('should execute crawl task when checkedCrawlResult is provided', async () => {
// Arrange
const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' })
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Test',
content: 'Test content',
description: 'Test desc',
url: 'https://example.com',
},
})
const props = createDefaultProps({
checkedCrawlResult: [checkedItem],
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - crawl task should be created even with pre-checked results
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalled()
})
})
it('should use default crawlOptions limit in validation', () => {
// Arrange
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '' }),
})
// Act
render(<JinaReader {...props} />)
// Assert - component renders with empty limit
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// ============================================================================
// State Management Tests
// ============================================================================
describe('State Management', () => {
it('should transition from init to running state when run is clicked', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
let resolvePromise: () => void
const taskPromise = new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
})
mockCreateTask.mockImplementation(() => taskPromise)
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const urlInput = screen.getAllByRole('textbox')[0]
await userEvent.type(urlInput, 'https://example.com')
// Click run and immediately check for crawling state
const runButton = screen.getByRole('button', { name: /run/i })
fireEvent.click(runButton)
// Assert - crawling indicator should appear
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise and wait for component to finish
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should transition to finished state after successful crawl', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Test Page',
content: 'Test content',
description: 'Test description',
url: 'https://example.com',
},
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument()
})
})
it('should update crawl result state during polling', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job-123' })
mockCheckStatus
.mockResolvedValueOnce({
status: 'running',
current: 1,
total: 3,
data: [createCrawlResultItem()],
})
.mockResolvedValueOnce({
status: 'completed',
current: 3,
total: 3,
data: [
createCrawlResultItem({ source_url: 'https://example.com/1' }),
createCrawlResultItem({ source_url: 'https://example.com/2' }),
createCrawlResultItem({ source_url: 'https://example.com/3' }),
],
})
const onCheckedCrawlResultChange = vi.fn()
const onJobIdChange = vi.fn()
const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onJobIdChange).toHaveBeenCalledWith('test-job-123')
})
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalled()
})
})
it('should fold options when step changes from init', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Test',
content: 'Content',
description: 'Desc',
url: 'https://example.com',
},
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Options should be visible initially
expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - options should be folded after crawl starts
await waitFor(() => {
expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument()
})
})
})
// ============================================================================
// Side Effects and Cleanup Tests
// ============================================================================
describe('Side Effects and Cleanup', () => {
it('should call sleep during polling', async () => {
// Arrange
const mockSleep = sleep as Mock
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckStatus
.mockResolvedValueOnce({ status: 'running', current: 1, total: 2, data: [] })
.mockResolvedValueOnce({ status: 'completed', current: 2, total: 2, data: [] })
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockSleep).toHaveBeenCalledWith(2500)
})
})
it('should update controlFoldOptions when step changes', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
let resolvePromise: () => void
const taskPromise = new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
})
mockCreateTask.mockImplementation(() => taskPromise)
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Initially options should be visible
expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - the crawling indicator should appear
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
})
// ============================================================================
// Callback Stability and Memoization Tests
// ============================================================================
describe('Callback Stability', () => {
it('should maintain stable handleSetting callback', () => {
// Arrange
const props = createDefaultProps()
// Act
const { rerender } = render(<JinaReader {...props} />)
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
fireEvent.click(configButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
// Rerender and click again
rerender(<JinaReader {...props} />)
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
})
it('should memoize checkValid callback based on crawlOptions', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValue({ data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' } })
const props = createDefaultProps()
// Act
const { rerender } = render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledTimes(1)
})
// Rerender with same options
rerender(<JinaReader {...props} />)
// Assert - component should still work correctly
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// ============================================================================
// User Interactions and Event Handlers Tests
// ============================================================================
describe('User Interactions', () => {
it('should open account settings when configuration button is clicked', async () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
await userEvent.click(configButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
it('should handle URL input and run button click', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Test',
content: 'Content',
description: 'Desc',
url: 'https://test.com',
},
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith({
url: 'https://test.com',
options: props.crawlOptions,
})
})
})
it('should handle preview action on crawled result', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const onPreview = vi.fn()
const crawlResultData = {
title: 'Preview Test',
content: '# Content',
description: 'Preview desc',
url: 'https://preview.com',
}
mockCreateTask.mockResolvedValueOnce({ data: crawlResultData })
const props = createDefaultProps({ onPreview })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://preview.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - result should be displayed
await waitFor(() => {
expect(screen.getByText('Preview Test')).toBeInTheDocument()
})
// Click on preview button
const previewButton = screen.getByText('datasetCreation.stepOne.website.preview')
await userEvent.click(previewButton)
expect(onPreview).toHaveBeenCalled()
})
it('should handle checkbox changes in options', async () => {
// Arrange
const onCrawlOptionsChange = vi.fn()
const props = createDefaultProps({
onCrawlOptionsChange,
crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }),
})
// Act
render(<JinaReader {...props} />)
// Find and click the checkbox by data-testid
const checkbox = screen.getByTestId('checkbox-crawl-sub-pages')
fireEvent.click(checkbox)
// Assert - onCrawlOptionsChange should be called
expect(onCrawlOptionsChange).toHaveBeenCalled()
})
it('should toggle options visibility when clicking options header', async () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
// Options content should be visible initially
expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
// Click to collapse
const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options')
await userEvent.click(optionsHeader)
// Assert - options should be hidden
expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument()
// Click to expand again
await userEvent.click(optionsHeader)
// Options should be visible again
expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
})
})
// ============================================================================
// API Calls Tests
// ============================================================================
describe('API Calls', () => {
it('should call createJinaReaderTask with correct parameters', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://api-test.com' },
})
const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 })
const props = createDefaultProps({ crawlOptions })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://api-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith({
url: 'https://api-test.com',
options: crawlOptions,
})
})
})
it('should handle direct data response from API', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Direct Result',
content: '# Direct Content',
description: 'Direct desc',
url: 'https://direct.com',
},
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://direct.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([
expect.objectContaining({
title: 'Direct Result',
source_url: 'https://direct.com',
}),
])
})
})
it('should handle job_id response and poll for status', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onJobIdChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'poll-job-123' })
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 2,
total: 2,
data: [
createCrawlResultItem({ source_url: 'https://p1.com' }),
createCrawlResultItem({ source_url: 'https://p2.com' }),
],
})
const props = createDefaultProps({ onJobIdChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://poll-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123')
})
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalledWith('poll-job-123')
})
})
it('should handle failed status from polling', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'fail-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'failed',
message: 'Crawl failed due to network error',
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://fail-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
})
expect(screen.getByText('Crawl failed due to network error')).toBeInTheDocument()
})
it('should handle API error during status check', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'error-job' })
mockCheckStatus.mockRejectedValueOnce({
json: () => Promise.resolve({ message: 'API Error occurred' }),
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://error-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
})
})
it('should limit total to crawlOptions.limit', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'limit-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 100,
total: 100,
data: Array.from({ length: 100 }, (_, i) =>
createCrawlResultItem({ source_url: `https://example.com/${i}` })),
})
const props = createDefaultProps({
onCheckedCrawlResultChange,
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://limit-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalled()
})
})
})
// ============================================================================
// Component Memoization Tests
// ============================================================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - React.memo components have $$typeof Symbol(react.memo)
expect(JinaReader.$$typeof?.toString()).toBe('Symbol(react.memo)')
expect((JinaReader as unknown as { type: unknown }).type).toBeDefined()
})
})
// ============================================================================
// Edge Cases and Error Handling Tests
// ============================================================================
describe('Edge Cases and Error Handling', () => {
it('should show error for empty URL', async () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - Toast should be shown (mocked via Toast component)
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should show error for invalid URL format', async () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'invalid-url')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should show error for URL without protocol', async () => {
// Arrange
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should accept URL with http:// protocol', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'http://example.com' },
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'http://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalled()
})
})
it('should show error when limit is empty', async () => {
// Arrange
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '' }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should show error when limit is null', async () => {
// Arrange
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should show error when limit is undefined', async () => {
// Arrange
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(createJinaReaderTask).not.toHaveBeenCalled()
})
})
it('should handle API throwing an exception', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockRejectedValueOnce(new Error('Network error'))
// Suppress console output during test to avoid noisy logs
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(vi.fn())
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://exception-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
})
consoleSpy.mockRestore()
})
it('should handle status response without status field', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-status-job' })
mockCheckStatus.mockResolvedValueOnce({
// No status field
message: 'Unknown error',
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://no-status-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
})
})
it('should show unknown error when error message is empty', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'empty-error-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'failed',
// No message
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://empty-error-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument()
})
})
it('should handle empty data array from API', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'empty-data-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
data: [],
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://empty-data-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should handle null data from running status', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'null-data-job' })
mockCheckStatus
.mockResolvedValueOnce({
status: 'running',
current: 0,
total: 5,
data: null,
})
.mockResolvedValueOnce({
status: 'completed',
current: 5,
total: 5,
data: [createCrawlResultItem()],
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://null-data-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should return empty array when completed job has undefined data', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'undefined-data-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
// data is undefined - should fallback to empty array
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://undefined-data-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should show zero current progress when crawlResult is not yet available', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://zero-current-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should show 0/10 when crawlResult is undefined
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should show 0/0 progress when limit is zero string', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://zero-total-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should show 0/0 when limit parses to 0
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should complete successfully when result data is undefined', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'undefined-result-data-job' })
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
time_consuming: 1.5,
// data is undefined - should fallback to empty array
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://undefined-result-data-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should complete and show results even if empty
await waitFor(() => {
expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
})
})
it('should use limit as total when crawlResult total is not available', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://no-total-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should use limit (15) as total
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should fallback to limit when crawlResult has zero total', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
mockCheckStatus
.mockResolvedValueOnce({
status: 'running',
current: 0,
total: 0,
data: [],
})
.mockImplementationOnce(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://both-zero-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should show progress indicator
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should construct result item from direct data response', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Direct Title',
content: '# Direct Content',
description: 'Direct desc',
url: 'https://direct-array.com',
},
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://direct-array.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should construct result item from direct response
await waitFor(() => {
expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([
expect.objectContaining({
title: 'Direct Title',
source_url: 'https://direct-array.com',
}),
])
})
})
})
// ============================================================================
// All Prop Variations Tests
// ============================================================================
describe('Prop Variations', () => {
it('should handle different limit values in crawlOptions', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://limit.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 100 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://limit.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ limit: 100 }),
}),
)
})
})
it('should handle different max_depth values', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://depth.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://depth.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ max_depth: 5 }),
}),
)
})
})
it('should handle crawl_sub_pages disabled', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://nosub.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://nosub.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ crawl_sub_pages: false }),
}),
)
})
})
it('should handle use_sitemap enabled', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://sitemap.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://sitemap.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ use_sitemap: true }),
}),
)
})
})
it('should handle includes and excludes patterns', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://patterns.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({
includes: '/docs/*',
excludes: '/api/*',
}),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://patterns.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
includes: '/docs/*',
excludes: '/api/*',
}),
}),
)
})
})
it('should handle pre-selected crawl results', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' })
mockCreateTask.mockResolvedValueOnce({
data: { title: 'New', content: 'C', description: 'D', url: 'https://new.com' },
})
const props = createDefaultProps({
checkedCrawlResult: [existingResult],
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://new.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalled()
})
})
it('should handle string type limit value', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://string-limit.com' },
})
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '25' }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://string-limit.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalled()
})
})
})
// ============================================================================
// Display and UI State Tests
// ============================================================================
describe('Display and UI States', () => {
it('should show crawling progress during running state', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://progress.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should display time consumed after crawl completion', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: { title: 'T', content: 'C', description: 'D', url: 'https://time.com' },
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://time.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
})
})
it('should display crawled results list after completion', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockResolvedValueOnce({
data: {
title: 'Result Page',
content: '# Content',
description: 'Description',
url: 'https://result.com',
},
})
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://result.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('Result Page')).toBeInTheDocument()
})
})
it('should show error message component when crawl fails', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockRejectedValueOnce(new Error('Failed'))
// Suppress console output during test to avoid noisy logs
vi.spyOn(console, 'log').mockImplementation(vi.fn())
const props = createDefaultProps()
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://fail.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('Integration', () => {
it('should complete full crawl workflow with job polling', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
const onCheckedCrawlResultChange = vi.fn()
const onJobIdChange = vi.fn()
const onPreview = vi.fn()
mockCreateTask.mockResolvedValueOnce({ job_id: 'full-workflow-job' })
mockCheckStatus
.mockResolvedValueOnce({
status: 'running',
current: 2,
total: 5,
data: [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
],
})
.mockResolvedValueOnce({
status: 'completed',
current: 5,
total: 5,
time_consuming: 3.5,
data: [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
createCrawlResultItem({ source_url: 'https://page4.com', title: 'Page 4' }),
createCrawlResultItem({ source_url: 'https://page5.com', title: 'Page 5' }),
],
})
const props = createDefaultProps({
onCheckedCrawlResultChange,
onJobIdChange,
onPreview,
})
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://full-workflow.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - job id should be set
await waitFor(() => {
expect(onJobIdChange).toHaveBeenCalledWith('full-workflow-job')
})
// Assert - final results should be displayed
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 5')).toBeInTheDocument()
})
// Assert - checked results should be updated
expect(onCheckedCrawlResultChange).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({ source_url: 'https://page1.com' }),
expect.objectContaining({ source_url: 'https://page5.com' }),
]),
)
})
it('should handle select all and deselect all in results', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const onCheckedCrawlResultChange = vi.fn()
mockCreateTask.mockResolvedValueOnce({
data: { title: 'Single', content: 'C', description: 'D', url: 'https://single.com' },
})
const props = createDefaultProps({ onCheckedCrawlResultChange })
// Act
render(<JinaReader {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://single.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Wait for results
await waitFor(() => {
expect(screen.getByText('Single')).toBeInTheDocument()
})
// Click select all/reset all
const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i)
await userEvent.click(selectAllCheckbox)
// Assert
expect(onCheckedCrawlResultChange).toHaveBeenCalled()
})
})
})