dify/web/features/new-rag/__tests__/documents-page.spec.tsx
2026-07-29 11:58:14 +00:00

5772 lines
200 KiB
TypeScript

import type {
DocumentProcessingTask,
LogicalDocument,
Source,
} from '@dify/contracts/knowledge-fs/types.gen'
import { hashKey } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithNuqs as render } from '@/test/nuqs-testing'
import { DocumentsPage } from '../documents-page'
import { TaskEventObserver } from '../task-event-observer'
type InfiniteOptions = {
enabled?: boolean
getNextPageParam: (lastPage: { nextCursor?: string }) => string | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
queryKind: 'documents' | 'sources' | 'tasks'
queryKey?: readonly unknown[]
refetchInterval?: (query: { state: { data?: unknown } }) => false | number
}
const documentsQuery = vi.hoisted(() => ({
data: undefined as
| { pages: Array<{ items: LogicalDocument[]; nextCursor?: string }> }
| undefined,
error: null as unknown,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
isRefetching: false,
refetch: vi.fn(),
}))
const tasksQuery = vi.hoisted(() => ({
data: undefined as
| { pages: Array<{ items: DocumentProcessingTask[]; nextCursor?: string }> }
| undefined,
dataUpdatedAt: 0,
dataUpdateCount: 0,
error: null as unknown,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn(),
}))
const sourcesQuery = vi.hoisted(() => ({
data: undefined as { pages: Array<{ items: Source[]; nextCursor?: string }> } | undefined,
error: null as unknown,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn(),
}))
const cancelMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const retryMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const uploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const bulkUploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const queryCacheListeners = vi.hoisted(
() =>
new Set<
(event: { action: { type: string }; query: { queryHash: string }; type: string }) => void
>(),
)
const queryClient = vi.hoisted(() => ({
cancelQueries: vi.fn(),
getQueryCache: vi.fn(() => ({
subscribe: (
listener: (event: {
action: { type: string }
query: { queryHash: string }
type: string
}) => void,
) => {
queryCacheListeners.add(listener)
return () => queryCacheListeners.delete(listener)
},
})),
getQueryState: vi.fn(() => ({ dataUpdateCount: tasksQuery.dataUpdateCount })),
invalidateQueries: vi.fn(),
}))
const streamProcessingTaskEvents = vi.hoisted(() => vi.fn())
const getTaskSnapshot = vi.hoisted(() => vi.fn())
const permissionStateMock = vi.hoisted(() => ({
datasetAtom: Symbol('datasetDefaultPermissionKeysAtom'),
datasetKeys: ['dataset.acl.edit'],
error: null as unknown,
errorAtom: Symbol('workspacePermissionKeysErrorAtom'),
fetching: false,
fetchingAtom: Symbol('workspacePermissionKeysFetchingAtom'),
loading: false,
loadingAtom: Symbol('workspacePermissionKeysLoadingAtom'),
retry: vi.fn(),
retryAtom: Symbol('retryWorkspacePermissionKeysAtom'),
refreshAfterDenial: vi.fn(),
refreshAfterDenialAtom: Symbol('refreshWorkspacePermissionKeysAfterMutationDenialAtom'),
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}))
vi.mock('@/context/permission-state', () => ({
datasetDefaultPermissionKeysAtom: permissionStateMock.datasetAtom,
refreshWorkspacePermissionKeysAfterMutationDenialAtom: permissionStateMock.refreshAfterDenialAtom,
retryWorkspacePermissionKeysAtom: permissionStateMock.retryAtom,
workspacePermissionKeysErrorAtom: permissionStateMock.errorAtom,
workspacePermissionKeysFetchingAtom: permissionStateMock.fetchingAtom,
workspacePermissionKeysLoadingAtom: permissionStateMock.loadingAtom,
}))
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
return {
...original,
useAtomValue: (atom: unknown) => {
if (atom === permissionStateMock.datasetAtom) return permissionStateMock.datasetKeys
if (atom === permissionStateMock.errorAtom) return permissionStateMock.error
if (atom === permissionStateMock.fetchingAtom) return permissionStateMock.fetching
if (atom === permissionStateMock.loadingAtom) return permissionStateMock.loading
return original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0])
},
useSetAtom: (atom: unknown) =>
atom === permissionStateMock.retryAtom
? permissionStateMock.retry
: atom === permissionStateMock.refreshAfterDenialAtom
? permissionStateMock.refreshAfterDenial
: original.useSetAtom(atom as Parameters<typeof original.useSetAtom>[0]),
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock }))
const documentsInfiniteOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({ ...options, queryKind: 'documents' })),
)
const tasksInfiniteOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({
...options,
queryKey: ['knowledge-fs', 'tasks'],
queryKind: 'tasks',
})),
)
const sourcesInfiniteOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({ ...options, queryKind: 'sources' })),
)
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useInfiniteQuery: (options: InfiniteOptions) => {
if (options.queryKind === 'documents') return documentsQuery
if (options.queryKind === 'sources') return sourcesQuery
return tasksQuery
},
useMutation: (options: {
mutationKind: 'bulk-upload' | 'cancel' | 'reindex' | 'retry' | 'upload'
}) => {
if (options.mutationKind === 'cancel') return cancelMutation
if (options.mutationKind === 'retry') return retryMutation
if (options.mutationKind === 'reindex') return reindexMutation
if (options.mutationKind === 'bulk-upload') return bulkUploadMutation
return uploadMutation
},
useQueryClient: () => queryClient,
}
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId: getTaskSnapshot,
},
},
consoleQuery: {
knowledgeFs: {
deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId: {
mutationOptions: () => ({ mutationKind: 'cancel' }),
},
getKnowledgeSpacesByIdLogicalDocuments: {
infiniteOptions: documentsInfiniteOptions,
key: () => ['knowledge-fs', 'documents'],
},
getKnowledgeSpacesByIdProcessingTasks: {
infiniteOptions: tasksInfiniteOptions,
key: () => ['knowledge-fs', 'tasks'],
},
getKnowledgeSpacesByIdSources: {
infiniteOptions: sourcesInfiniteOptions,
key: () => ['knowledge-fs', 'sources'],
},
postKnowledgeSpacesByIdDocuments: {
mutationOptions: () => ({ mutationKind: 'upload' }),
},
postKnowledgeSpacesByIdDocumentsBulk: {
mutationOptions: () => ({ mutationKind: 'bulk-upload' }),
},
postKnowledgeSpacesByIdDocumentsBulkReindex: {
mutationOptions: () => ({ mutationKind: 'reindex' }),
},
postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry: {
mutationOptions: () => ({ mutationKind: 'retry' }),
},
},
},
}))
vi.mock('../services/processing-task-events', () => ({ streamProcessingTaskEvents }))
const document = (overrides: Partial<LogicalDocument> = {}): LogicalDocument => ({
active: {
activatedAt: '2026-07-20T10:01:00Z',
contentHash: 'hash',
createdAt: '2026-07-20T10:00:00Z',
documentAssetId: 'asset-1',
documentAssetVersion: 1,
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
mimeType: 'application/pdf',
revision: 2,
sizeBytes: 1024,
state: 'active',
},
activeRevision: 2,
createdAt: '2026-07-20T10:00:00Z',
id: 'document-1',
knowledgeSpaceId: 'space-1',
rowVersion: 1,
sourceId: 'source-1',
status: 'ready',
title: 'sso-enterprise.pdf',
updatedAt: '2026-07-20T10:01:00Z',
userMetadata: { sourceName: 'Notion support SOP' },
...overrides,
})
const task = (overrides: Partial<DocumentProcessingTask> = {}): DocumentProcessingTask => ({
createdAt: '2026-07-20T10:00:00Z',
documentId: 'document-1',
documentRevision: 2,
id: 'task-1',
knowledgeSpaceId: 'space-1',
progressPercent: 45,
stage: 'parsed',
state: 'running',
updatedAt: '2026-07-20T10:01:00Z',
...overrides,
})
function streamFailedTaskThenWait(taskId: string) {
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 80,
stage: 'parsed' as const,
state: 'failed' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: `${taskId}:2026-07-20T10:03:00Z`,
}
yield {
data: { errorCode: 'PARSER_FAILED', state: 'failed' as const },
event: 'terminal' as const,
id: `${taskId}:terminal`,
}
return
}
await new Promise<void>(() => {})
})
}
function notifyTaskQuerySuccess() {
for (const listener of queryCacheListeners)
listener({
action: { type: 'success' },
query: { queryHash: hashKey(['knowledge-fs', 'tasks']) },
type: 'updated',
})
}
const source = (overrides: Partial<Source> = {}): Source => ({
createdAt: '2026-07-20T10:00:00Z',
id: 'source-1',
knowledgeSpaceId: 'space-1',
metadata: {},
name: 'Notion support SOP',
status: 'active',
type: 'connector',
updatedAt: '2026-07-20T10:00:00Z',
uri: 'notion://support',
...overrides,
})
describe('DocumentsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
queryCacheListeners.clear()
queryClient.cancelQueries.mockResolvedValue(undefined)
queryClient.invalidateQueries.mockResolvedValue(undefined)
documentsQuery.data = { pages: [{ items: [] }] }
documentsQuery.error = null
documentsQuery.hasNextPage = false
documentsQuery.isFetchNextPageError = false
documentsQuery.isFetching = false
documentsQuery.isFetchingNextPage = false
documentsQuery.isPending = false
documentsQuery.isRefetching = false
documentsQuery.refetch.mockResolvedValue({ error: null })
tasksQuery.data = { pages: [{ items: [] }] }
tasksQuery.dataUpdatedAt = 0
tasksQuery.dataUpdateCount = 0
tasksQuery.error = null
tasksQuery.hasNextPage = false
tasksQuery.isFetchNextPageError = false
tasksQuery.isFetching = false
tasksQuery.isFetchingNextPage = false
tasksQuery.isPending = false
tasksQuery.refetch.mockResolvedValue({ error: null })
sourcesQuery.data = { pages: [{ items: [source()] }] }
sourcesQuery.error = null
sourcesQuery.hasNextPage = false
sourcesQuery.isFetchNextPageError = false
sourcesQuery.isFetching = false
sourcesQuery.isFetchingNextPage = false
sourcesQuery.isPending = false
sourcesQuery.refetch.mockResolvedValue({ error: null })
permissionStateMock.datasetKeys = ['dataset.acl.edit']
permissionStateMock.error = null
permissionStateMock.fetching = false
permissionStateMock.loading = false
permissionStateMock.retry.mockResolvedValue({
data: {
dataset: { default_permission_keys: ['dataset.acl.edit'] },
},
error: null,
})
permissionStateMock.refreshAfterDenial.mockResolvedValue({
data: {
dataset: { default_permission_keys: ['dataset.acl.edit'] },
},
error: null,
})
getTaskSnapshot.mockResolvedValue(task({ state: 'succeeded' }))
cancelMutation.mutateAsync.mockResolvedValue(task({ state: 'canceled' }))
retryMutation.mutateAsync.mockResolvedValue(task({ state: 'queued' }))
reindexMutation.mutateAsync.mockResolvedValue({
bulkJobId: 'reindex-1',
items: [
{
asset: {
createdAt: '2026-07-20T10:00:00Z',
filename: 'one.pdf',
id: 'asset-1',
knowledgeSpaceId: 'space-1',
mimeType: 'application/pdf',
objectKey: 'documents/one.pdf',
parserStatus: 'pending',
sha256: 'sha',
sizeBytes: 1024,
version: 1,
},
compilationJob: { id: 'job-1', stage: 'queued' },
status: 'queued',
statusUrl: '/tasks/job-1',
},
],
total: 1,
})
uploadMutation.mutateAsync.mockResolvedValue({
asset: {
createdAt: '2026-07-20T10:00:00Z',
filename: 'one.md',
id: 'asset-1',
knowledgeSpaceId: 'space-1',
mimeType: 'text/markdown',
objectKey: 'documents/one.md',
parserStatus: 'pending',
sha256: 'sha',
sizeBytes: 3,
version: 1,
},
compilationJob: { id: 'job-1', stage: 'queued' },
documentRevision: 1,
logicalDocument: { id: 'document-1', revision: 1 },
logicalDocumentId: 'document-1',
statusUrl: '/tasks/job-1',
})
bulkUploadMutation.mutateAsync.mockResolvedValue({
accepted: 2,
bulkJobId: 'upload-1',
excluded: 0,
items: [],
total: 2,
})
streamProcessingTaskEvents.mockImplementation(async function* () {})
})
it('loads real logical documents and processing tasks through generated contracts', () => {
documentsQuery.isPending = true
tasksQuery.isPending = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const documentOptions = documentsInfiniteOptions.mock.lastCall?.[0]
const taskOptions = tasksInfiniteOptions.mock.lastCall?.[0]
const sourceOptions = sourcesInfiniteOptions.mock.lastCall?.[0]
expect(documentOptions?.input(null)).toEqual({
params: { id: 'space-1' },
query: { limit: 50 },
})
expect(documentOptions?.input('next')).toEqual({
params: { id: 'space-1' },
query: { cursor: 'next', limit: 50 },
})
expect(documentOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(taskOptions?.input(null)).toEqual({
params: { id: 'space-1' },
query: { limit: 100 },
})
expect(taskOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(sourceOptions?.input(null)).toEqual({
params: { id: 'space-1' },
query: { limit: 100 },
})
expect(sourceOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('restores document search and status filters from the URL', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [
{
items: [
document({ id: 'ready-document', title: 'Ready handbook.pdf' }),
document({ id: 'failed-document', title: 'Failed report.pdf' }),
],
},
],
}
tasksQuery.data = {
pages: [
{
items: [task({ documentId: 'failed-document', id: 'failed-task', state: 'failed' })],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />, {
searchParams: '?query=report&status=failed',
})
const metadata = screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })
expect(metadata).toBeEnabled()
await user.click(metadata)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
const rowActions = screen.getByRole('button', {
name: /dataset\.newKnowledge\.documentActions/,
})
expect(rowActions).toBeEnabled()
await user.click(rowActions)
await user.click(
await screen.findByRole('menuitem', { name: 'dataset.newKnowledge.downloadDocuments' }),
)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.documentActionsUnavailable')
expect(screen.getByRole('searchbox')).toHaveValue('report')
expect(screen.getByRole('combobox')).toHaveValue('failed')
expect(screen.getByText('Failed report.pdf')).toBeInTheDocument()
expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument()
})
it('renders revisions, sources, stable cursor-ordered rows, and every designed status', () => {
documentsQuery.data = {
pages: [
{
items: [document({ id: 'ready', title: 'Ready.pdf', userMetadata: {} })],
},
{
items: [
document({
active: null,
activeRevision: undefined,
id: 'queued',
status: 'pending',
title: 'Queued.docx',
updatedAt: '2026-07-20T10:05:00Z',
}),
document({ id: 'failed', status: 'failed', title: 'Failed.html' }),
document({
id: 'disabled',
sourceId: 'disabled-source',
title: 'Disabled.xlsx',
userMetadata: { sourceName: 'Archive' },
}),
document({ id: 'processing', status: 'pending', title: 'Processing.md' }),
],
},
],
}
tasksQuery.data = {
pages: [
{
items: [
task({ documentId: 'processing', id: 'processing-task' }),
task({ documentId: 'queued', id: 'queued-task', state: 'queued' }),
],
},
],
}
sourcesQuery.data = {
pages: [
{
items: [source(), source({ id: 'disabled-source', name: 'Archive', status: 'disabled' })],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const rows = screen.getAllByRole('row').slice(1)
expect(within(rows[0]!).getByText('Ready.pdf')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Ready.pdf' })).toHaveAttribute(
'href',
'/datasets/new/space-1/documents/ready',
)
expect(screen.getAllByText('Notion support SOP').length).toBeGreaterThan(0)
expect(screen.getAllByText('v2').length).toBeGreaterThan(0)
for (const status of ['ready', 'queued', 'processing', 'failed', 'disabled'])
expect(
screen.getAllByText(`dataset.newKnowledge.documentStatus.${status}`).length,
).toBeGreaterThan(0)
expect(screen.getByText('dataset.newKnowledge.lastReadyRevisionHint')).toBeInTheDocument()
})
it('treats a document with an unresolved source as unavailable for writes', () => {
documentsQuery.data = {
pages: [{ items: [document({ sourceId: 'missing-source', title: 'Orphaned.pdf' })] }],
}
sourcesQuery.data = { pages: [{ items: [] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getAllByText('dataset.newKnowledge.documentStatus.disabled')).toHaveLength(2)
expect(screen.getByRole('checkbox', { name: 'Orphaned.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('renders the designed empty state with an available upload action', () => {
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const emptyState = screen.getByText('dataset.newKnowledge.documentsEmptyTitle').parentElement
expect(emptyState).not.toBeNull()
expect(screen.getByText('dataset.newKnowledge.documentsEmptyDescription')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.documentsDropHint')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeEnabled()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.metadata' }),
).not.toBeInTheDocument()
const dataTransfer = { dropEffect: 'none' }
const dragOver = new Event('dragover', { bubbles: true, cancelable: true })
Object.defineProperty(dragOver, 'dataTransfer', { value: dataTransfer })
expect(fireEvent(emptyState!, dragOver)).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
})
it('removes the empty-state drop affordance when uploads are unavailable', () => {
permissionStateMock.datasetKeys = ['dataset.acl.readonly']
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const emptyState = screen.getByText('dataset.newKnowledge.documentsEmptyTitle').parentElement
expect(emptyState).not.toBeNull()
expect(screen.queryByText('dataset.newKnowledge.documentsDropHint')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }),
).toHaveAttribute('aria-describedby', 'documents-readonly-reason')
const dataTransfer = {
dropEffect: 'copy',
files: [new File(['one'], 'one.md', { type: 'text/markdown' })],
}
const dragOver = new Event('dragover', { bubbles: true, cancelable: true })
Object.defineProperty(dragOver, 'dataTransfer', { value: dataTransfer })
expect(fireEvent(emptyState!, dragOver)).toBe(false)
expect(dataTransfer.dropEffect).toBe('none')
expect(
fireEvent.drop(emptyState!, {
dataTransfer,
}),
).toBe(false)
expect(uploadMutation.mutateAsync).not.toHaveBeenCalled()
})
it('keeps processing tasks reachable while the document list is empty', async () => {
const user = userEvent.setup()
tasksQuery.data = { pages: [{ items: [task({ id: 'orphaned-running-task' })] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
})
it('keeps every write action unavailable for read-only users', async () => {
const user = userEvent.setup()
permissionStateMock.datasetKeys = ['dataset.acl.readonly']
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('dataset.newKnowledge.documentPermissionRestricted')).toBeVisible()
expect(screen.queryByLabelText('dataset.newKnowledge.uploadDocuments')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-describedby',
'documents-readonly-reason',
)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
expect(
within(screen.getByRole('dialog')).getByText(
'dataset.newKnowledge.documentPermissionRestricted',
),
).toBeInTheDocument()
})
it('keeps permission lookup failures distinct from read-only access and retries them', async () => {
const user = userEvent.setup()
permissionStateMock.error = new Error('permission service unavailable')
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent('dataset.newKnowledge.permissionLoadFailed')
expect(
screen.queryByText('dataset.newKnowledge.documentPermissionRestricted'),
).not.toBeInTheDocument()
const addDocument = screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })
expect(addDocument).toBeDisabled()
expect(addDocument).toHaveAccessibleDescription('dataset.newKnowledge.permissionLoadFailed')
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionLoadFailed',
)
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.permissionLoadFailed',
}),
)
expect(permissionStateMock.retry).toHaveBeenCalledOnce()
})
it('explains pending permission checks inside the task drawer', async () => {
const user = userEvent.setup()
permissionStateMock.loading = true
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const pendingPermission = screen.getByText('dataset.newKnowledge.permission · common.loading')
expect(pendingPermission).toHaveTextContent('dataset.newKnowledge.permission · common.loading')
expect(pendingPermission).toHaveAttribute('role', 'status')
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }),
).toHaveAttribute('aria-describedby', 'documents-permission-pending')
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-describedby',
'documents-permission-pending',
)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(
within(screen.getByRole('dialog')).getByText(
'dataset.newKnowledge.permission · common.loading',
),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
})
it('uploads one or multiple files through the pinned document contracts', async () => {
const user = userEvent.setup()
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const input = screen.getByLabelText('dataset.newKnowledge.uploadDocuments')
expect(input).toHaveAttribute('hidden')
expect(input).toHaveAttribute('tabindex', '-1')
expect(input).toHaveAttribute(
'accept',
'.csv,.doc,.docx,.epub,.htm,.html,.json,.jsonl,.md,.pdf,.ppt,.pptx,.rtf,.text,.txt,.xls,.xlsx',
)
await user.upload(input, new File(['one'], 'one.md', { type: 'text/markdown' }))
expect(uploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { file: expect.any(File) },
params: { id: 'space-1' },
})
await user.upload(input, [
new File(['two'], 'two.md', { type: 'text/markdown' }),
new File(['three'], 'three.txt', { type: 'text/plain' }),
])
expect(bulkUploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { files: [expect.any(File), expect.any(File)] },
params: { id: 'space-1' },
})
expect(queryClient.invalidateQueries).toHaveBeenCalled()
const documentInvalidation = queryClient.invalidateQueries.mock.calls.find(
([options]) => options.queryKey[1] === 'documents',
)?.[0]
expect(
documentInvalidation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdLogicalDocuments'],
{ input: { params: { id: 'space-1' } }, type: 'infinite' },
],
}),
).toBe(true)
expect(
documentInvalidation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdLogicalDocuments'],
{ input: { params: { id: 'space-2' } }, type: 'infinite' },
],
}),
).toBe(false)
})
it('excludes unsupported files locally while uploading valid files', async () => {
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const validFile = new File(['one'], 'one.md', { type: 'text/markdown' })
const unsupportedFile = new File(['two'], 'two.exe', {
type: 'application/octet-stream',
})
fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), {
target: { files: [validFile, unsupportedFile] },
})
await waitFor(() =>
expect(uploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { file: validFile },
params: { id: 'space-1' },
}),
)
expect(bulkUploadMutation.mutateAsync).not.toHaveBeenCalled()
expect(toastMock.warning).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadPartial:{"accepted":1,"details":"two.exe (dataset.newKnowledge.documentUploadExclusion.fileType)","excluded":1}',
)
})
it('rejects oversized files before invoking an upload contract', async () => {
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const oversizedFile = new File(['one'], 'oversized.pdf', { type: 'application/pdf' })
Object.defineProperty(oversizedFile, 'size', { value: 16 * 1024 * 1024 })
fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), {
target: { files: [oversizedFile] },
})
expect(uploadMutation.mutateAsync).not.toHaveBeenCalled()
expect(bulkUploadMutation.mutateAsync).not.toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadRejected:{"details":"oversized.pdf (dataset.newKnowledge.documentUploadExclusion.fileSize)"}',
)
})
it('reports partial and fully excluded bulk uploads from the contract result', async () => {
const user = userEvent.setup()
bulkUploadMutation.mutateAsync
.mockResolvedValueOnce({
accepted: 1,
bulkJobId: 'upload-partial',
excluded: 1,
items: [
{
filename: 'too-large.pdf',
index: 1,
mimeType: 'application/pdf',
reason: 'file_too_large',
sizeBytes: 10_000,
status: 'excluded',
},
],
total: 2,
})
.mockResolvedValueOnce({
accepted: 0,
bulkJobId: 'upload-rejected',
excluded: 2,
items: [
{
filename: 'one.md',
index: 0,
mimeType: 'text/markdown',
reason: 'quota_exceeded',
sizeBytes: 3,
status: 'excluded',
},
{
filename: 'two.md',
index: 1,
mimeType: 'text/markdown',
reason: 'quota_exceeded',
sizeBytes: 3,
status: 'excluded',
},
],
total: 2,
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const input = screen.getByLabelText('dataset.newKnowledge.uploadDocuments')
await user.upload(input, [
new File(['one'], 'one.md', { type: 'text/markdown' }),
new File(['large'], 'too-large.pdf', { type: 'application/pdf' }),
])
expect(toastMock.warning).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadPartial:{"accepted":1,"details":"too-large.pdf (dataset.newKnowledge.documentUploadExclusion.fileSize)","excluded":1}',
)
queryClient.invalidateQueries.mockClear()
await user.upload(input, [
new File(['one'], 'one.md', { type: 'text/markdown' }),
new File(['two'], 'two.md', { type: 'text/markdown' }),
])
expect(toastMock.error).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadRejected:{"details":"one.md (dataset.newKnowledge.documentUploadExclusion.quota); two.md (dataset.newKnowledge.documentUploadExclusion.quota)"}',
)
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
})
it('distinguishes a permission failure and exposes retry for other load errors', async () => {
const user = userEvent.setup()
documentsQuery.data = undefined
documentsQuery.error = { status: 403 }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionDescription',
)
expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument()
documentsQuery.error = new Error('temporary')
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
)
expect(documentsQuery.refetch).toHaveBeenCalledOnce()
})
it('reports a failed background document refresh while preserving cached rows', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
documentsQuery.error = new Error('background refresh failed')
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('sso-enterprise.pdf')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsErrorDescription',
)
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
const actions = screen.getByRole('group', {
name: 'dataset.newKnowledge.bulkDocumentActions',
})
expect(
within(actions).getByRole('button', {
name: 'dataset.newKnowledge.reindexDocuments',
}),
).toHaveAttribute('aria-describedby', 'document-reindex-unavailable')
expect(
within(actions).getByText(
'dataset.newKnowledge.reindexDocuments · dataset.newKnowledge.documentsErrorDescription',
),
).toBeVisible()
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
)
expect(documentsQuery.refetch).toHaveBeenCalledOnce()
})
it('blocks cached document content when a background refresh loses permission', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.error = { status: 403 }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionDescription',
)
expect(screen.queryByText('sso-enterprise.pdf')).not.toBeInTheDocument()
expect(screen.queryByLabelText('dataset.newKnowledge.uploadDocuments')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toBeInTheDocument()
})
it('keeps cached refresh retries busy while their queries are fetching', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.error = new Error('document refresh failed')
documentsQuery.isRefetching = true
tasksQuery.error = new Error('task refresh failed')
tasksQuery.isFetching = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
).toHaveAttribute('aria-disabled', 'true')
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
).toHaveAttribute('aria-disabled', 'true')
})
it('keeps permission retries busy while permission keys are refetching', () => {
permissionStateMock.error = new Error('permission refresh failed')
permissionStateMock.fetching = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.permissionLoadFailed',
}),
).toHaveAttribute('aria-disabled', 'true')
})
it('does not disable a failed dependency retry for an unrelated background refresh', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.error = new Error('task refresh failed')
sourcesQuery.isFetching = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
).not.toHaveAttribute('aria-disabled')
})
it('keeps a blocking dependency retry stable while its first page refetches', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = undefined
tasksQuery.error = new Error('task first page failed')
sourcesQuery.error = new Error('source background refresh failed')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
const retry = screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
})
await user.click(retry)
expect(tasksQuery.refetch).toHaveBeenCalledOnce()
tasksQuery.error = null
tasksQuery.isFetching = true
tasksQuery.isPending = true
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(retry).toBeInTheDocument()
expect(retry).toHaveFocus()
expect(retry).toHaveAttribute('aria-disabled', 'true')
tasksQuery.data = { pages: [{ items: [] }] }
tasksQuery.isFetching = false
tasksQuery.isPending = false
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('sso-enterprise.pdf')).toBeInTheDocument()
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.sourcesErrorDescription',
}),
).toHaveFocus(),
)
sourcesQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus(),
)
})
it('closes a task drawer permanently and restores focus when document permission is revoked', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
documentsQuery.error = { status: 403 }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
const permissionAlert = screen.getByRole('alert')
expect(permissionAlert).toHaveFocus()
expect(tasksInfiniteOptions.mock.lastCall?.[0].enabled).toBe(false)
expect(sourcesInfiniteOptions.mock.lastCall?.[0].enabled).toBe(false)
expect(queryClient.cancelQueries).toHaveBeenCalledWith({
predicate: expect.any(Function),
queryKey: ['knowledge-fs', 'tasks'],
})
expect(queryClient.cancelQueries).toHaveBeenCalledWith({
predicate: expect.any(Function),
queryKey: ['knowledge-fs', 'sources'],
})
const taskCancellation = queryClient.cancelQueries.mock.calls.find(
([options]) => options.queryKey[1] === 'tasks',
)?.[0]
expect(
taskCancellation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdProcessingTasks'],
{ input: { params: { id: 'space-1' } }, type: 'infinite' },
],
}),
).toBe(true)
expect(
taskCancellation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdProcessingTasks'],
{ input: { params: { id: 'space-2' } }, type: 'infinite' },
],
}),
).toBe(false)
documentsQuery.error = null
tasksQuery.refetch.mockClear()
sourcesQuery.refetch.mockClear()
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(tasksQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: false })
expect(sourcesQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: false })
})
it('does not repeat the child query that already recovered from permission denial', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [] }] }
tasksQuery.error = { status: 403 }
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
tasksQuery.error = null
tasksQuery.refetch.mockClear()
sourcesQuery.refetch.mockClear()
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(tasksQuery.refetch).not.toHaveBeenCalled()
expect(sourcesQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: false })
})
it('moves focus from document controls to the permission alert after dynamic revocation', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
const searchbox = screen.getByRole('searchbox', {
name: 'dataset.newKnowledge.searchDocuments',
})
await user.click(searchbox)
expect(searchbox).toHaveFocus()
documentsQuery.error = { status: 403 }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus())
documentsQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus(),
)
})
it('moves focus from bulk actions to the permission alert after dynamic revocation', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
const reindex = within(
screen.getByRole('group', { name: 'dataset.newKnowledge.bulkDocumentActions' }),
).getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' })
act(() => reindex.focus())
documentsQuery.error = { status: 403 }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus())
})
it('stops background pagination and failed-task polling while document permission is denied', async () => {
vi.useFakeTimers()
documentsQuery.data = {
pages: [{ items: [document({ sourceId: 'unresolved-source' })], nextCursor: 'next' }],
}
documentsQuery.error = { status: 403 }
documentsQuery.hasNextPage = true
tasksQuery.data = {
pages: [{ items: [task({ state: 'failed' })], nextCursor: 'task-next' }],
}
tasksQuery.hasNextPage = true
sourcesQuery.data = { pages: [{ items: [], nextCursor: 'source-next' }] }
sourcesQuery.hasNextPage = true
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />, {
searchParams: '?query=sso',
})
try {
await act(async () => vi.advanceTimersByTime(10_000))
expect(documentsQuery.fetchNextPage).not.toHaveBeenCalled()
expect(tasksQuery.fetchNextPage).not.toHaveBeenCalled()
expect(sourcesQuery.fetchNextPage).not.toHaveBeenCalled()
expect(getTaskSnapshot).not.toHaveBeenCalled()
expect(streamProcessingTaskEvents).not.toHaveBeenCalled()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('filters and searches documents while continuing through cursor pages', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ title: 'Product handbook.pdf' })], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'later page',
)
expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(screen.queryByText('dataset.newKnowledge.noMatchingDocuments')).not.toBeInTheDocument()
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.partialDocumentResults')).toBeInTheDocument()
})
it('blocks selection until filtered cursor pages are complete', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ title: 'Product handbook.pdf' })], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'product',
)
expect(screen.getByRole('checkbox', { name: 'Product handbook.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAllDocuments' }),
).toHaveAttribute('aria-disabled', 'true')
})
it('surfaces dependent source pagination failures and retries the failed page', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
sourcesQuery.isFetchNextPageError = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.sourcesErrorDescription',
)
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.sourcesErrorDescription',
}),
)
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
const documentRow = screen.getByRole('row', { name: /sso-enterprise\.pdf/ })
expect(
within(documentRow).getByText('dataset.newKnowledge.documentStatus.ready'),
).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeEnabled()
})
it('retries a failed task cursor page from the task drawer', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.isFetchNextPageError = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
)
expect(tasksQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(tasksQuery.refetch).not.toHaveBeenCalled()
})
it('returns focus to the drawer close button after a query retry succeeds', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.error = new Error('task refresh failed')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
const panel = screen.getByRole('dialog')
await user.click(
within(panel).getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
)
tasksQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(within(panel).getByRole('button', { name: 'common.operation.close' })).toHaveFocus(),
)
})
it('moves focus between drawer query retries as each error recovers', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document()], nextCursor: 'document-next' }],
}
documentsQuery.hasNextPage = true
documentsQuery.isFetchNextPageError = true
tasksQuery.data = {
pages: [{ items: [task({ documentId: 'missing-document' })] }],
}
tasksQuery.error = new Error('task refresh failed')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
const panel = screen.getByRole('dialog')
await user.click(
within(panel).getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
)
tasksQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const documentRetry = within(panel).getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
})
await waitFor(() => expect(documentRetry).toHaveFocus())
await user.click(documentRetry)
documentsQuery.isFetchNextPageError = false
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(within(panel).getByRole('button', { name: 'common.operation.close' })).toHaveFocus(),
)
})
it('continues remote task pagination from an empty cached drawer after the automatic cap', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({
items: [],
nextCursor: 'next',
})),
}
tasksQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const taskTrigger = screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":0} · dataset.newKnowledge.taskHistoryIncomplete',
})
expect(taskTrigger).toHaveTextContent('0+')
await user.click(taskTrigger)
expect(
within(screen.getByRole('dialog')).queryByText('dataset.newKnowledge.noBackgroundTasks'),
).not.toBeInTheDocument()
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'dataset.newKnowledge.loadMore',
}),
)
expect(tasksQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('loads document titles for tasks whose documents are on a later cursor page', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: Array.from({ length: 20 }, (_, index) => ({
items: index === 0 ? [document()] : [],
nextCursor: 'next',
})),
}
documentsQuery.hasNextPage = true
tasksQuery.data = {
pages: [
{
items: [task({ documentId: 'later-document', id: 'later-task', state: 'succeeded' })],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
const panel = screen.getByRole('dialog')
expect(within(panel).queryByText('later-document')).not.toBeInTheDocument()
expect(
within(panel).getByText(
'dataset.newKnowledge.processDocument:{"name":"dataset.newKnowledge.documentColumn"}',
),
).toBeInTheDocument()
await user.click(within(panel).getByRole('button', { name: 'dataset.newKnowledge.loadMore' }))
expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('defers task title pagination until the task drawer is opened', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document()], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
tasksQuery.data = {
pages: [
{
items: [task({ documentId: 'later-document', id: 'later-task', state: 'succeeded' })],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(documentsQuery.fetchNextPage).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
await waitFor(() => expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce())
})
it('reports and retries task title pagination failures inside the drawer', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document()], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
documentsQuery.isFetchNextPageError = true
tasksQuery.data = {
pages: [
{
items: [task({ documentId: 'later-document', id: 'later-task', state: 'succeeded' })],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
const panel = screen.getByRole('dialog')
expect(within(panel).getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsErrorDescription',
)
await user.click(
within(panel).getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
)
expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('keeps an unresolved source pending when a background source refresh fails', () => {
documentsQuery.data = {
pages: [{ items: [document({ sourceId: 'unresolved-source' })] }],
}
sourcesQuery.error = new Error('source refresh failed')
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const documentRow = screen.getByRole('row', { name: /sso-enterprise\.pdf/ })
expect(
within(documentRow).queryByText('dataset.newKnowledge.documentStatus.disabled'),
).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('keeps loaded task actions available when background task pagination fails', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
tasksQuery.error = new Error('poll failed')
tasksQuery.isFetchingNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('sso-enterprise.pdf')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.tasksErrorDescription',
)
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
)
expect(tasksQuery.refetch).toHaveBeenCalledOnce()
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
})
it('reports and retries cached task refresh failures from the document empty state', async () => {
const user = userEvent.setup()
tasksQuery.error = new Error('task refresh failed')
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.tasksErrorDescription',
)
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.tasksErrorDescription',
}),
)
expect(tasksQuery.refetch).toHaveBeenCalledOnce()
})
it('renders document identity while dependency cursor pages continue loading', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [], nextCursor: 'task-next' }] }
tasksQuery.hasNextPage = true
sourcesQuery.data = { pages: [{ items: [], nextCursor: 'source-next' }] }
sourcesQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('sso-enterprise.pdf')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeDisabled()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(tasksQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('keeps document status and selection pending until the first task page arrives', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = undefined
tasksQuery.isPending = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const documentRow = screen.getByRole('row', { name: /sso-enterprise\.pdf/ })
expect(
within(documentRow).queryByText('dataset.newKnowledge.documentStatus.ready'),
).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('region', { name: 'dataset.newKnowledge.documents' })).toHaveAttribute(
'aria-busy',
'true',
)
})
it('keeps document state pending while the next task cursor page is loading', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [], nextCursor: 'next' }] }
tasksQuery.hasNextPage = true
tasksQuery.isFetchingNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const documentRow = screen.getByRole('row', { name: /sso-enterprise\.pdf/ })
expect(
within(documentRow).queryByText('dataset.newKnowledge.documentStatus.ready'),
).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('keeps selection disabled after a filtered document page fails', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ title: 'Product handbook.pdf' })], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
documentsQuery.isFetchNextPageError = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'product',
)
expect(screen.getByRole('checkbox', { name: 'Product handbook.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAllDocuments' }),
).toHaveAttribute('aria-disabled', 'true')
await user.click(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
)
expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('keeps a failed document page retry busy while the next page is fetching', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document()], nextCursor: 'next' }],
}
documentsQuery.hasNextPage = true
documentsQuery.isFetchNextPageError = true
documentsQuery.isFetchingNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'sso',
)
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
).toHaveAttribute('aria-disabled', 'true')
})
it('does not disable a document page retry for unrelated dependency pagination', () => {
documentsQuery.data = { pages: [{ items: [document()], nextCursor: 'next' }] }
documentsQuery.hasNextPage = true
documentsQuery.isFetchNextPageError = true
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'task-next' })),
}
tasksQuery.hasNextPage = true
tasksQuery.isFetchingNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
}),
).not.toHaveAttribute('aria-disabled')
})
it('moves pagination focus from load more to retry and then to final results', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()], nextCursor: 'next' }] }
documentsQuery.hasNextPage = true
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.loadMore' }))
documentsQuery.isFetchNextPageError = true
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const retry = screen.getByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsErrorDescription',
})
await waitFor(() => expect(retry).toHaveFocus())
await user.click(retry)
documentsQuery.data = {
pages: [
{ items: [document()] },
{ items: [document({ id: 'last-document', title: 'Last.pdf' })] },
],
}
documentsQuery.hasNextPage = false
documentsQuery.isFetchNextPageError = false
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(screen.getByRole('table').parentElement).toHaveFocus())
})
it('re-indexes selected documents and disables backend-dependent actions', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ id: 'one', title: 'One.pdf' }), document({ id: 'two' })] }],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'One.pdf' }))
const actions = screen.getByRole('group', {
name: 'dataset.newKnowledge.bulkDocumentActions',
})
expect(
screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' }).closest('section'),
).toHaveClass('pb-[calc(7rem+env(safe-area-inset-bottom,0px))]')
expect(
within(actions).getByText('dataset.newKnowledge.documentsSelected:{"count":1}'),
).toBeInTheDocument()
const reindex = within(actions).getByRole('button', {
name: 'dataset.newKnowledge.reindexDocuments',
})
expect(reindex).toBeEnabled()
const orderedActions = within(actions).getAllByRole('button')
expect(orderedActions[0]).toHaveAccessibleName('dataset.newKnowledge.reindexDocuments')
expect(orderedActions[1]).toHaveAccessibleName('dataset.newKnowledge.clearDocumentSelection')
expect(actions.firstElementChild).toBe(orderedActions[0])
expect(orderedActions[1]!.nextElementSibling).toHaveTextContent(
'dataset.newKnowledge.documentsSelected:{"count":1}',
)
expect(
within(actions).getByText(
'dataset.newKnowledge.downloadDocuments / dataset.newKnowledge.deleteDocuments · dataset.cornerLabel.unavailable',
),
).toBeVisible()
const download = within(actions).getByRole('button', {
name: 'dataset.newKnowledge.downloadDocuments',
})
const remove = within(actions).getByRole('button', {
name: 'dataset.newKnowledge.deleteDocuments',
})
expect(download).toBeDisabled()
expect(remove).toBeDisabled()
expect(toastMock.info).not.toHaveBeenCalled()
await user.dblClick(reindex)
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({
body: { documentIds: ['one'] },
params: { id: 'space-1' },
})
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus(),
)
})
it('explains why re-indexing becomes unavailable while results refresh', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
tasksQuery.data = undefined
tasksQuery.isPending = true
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const actions = screen.getByRole('group', {
name: 'dataset.newKnowledge.bulkDocumentActions',
})
expect(
within(actions).getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }),
).toHaveAttribute('aria-describedby', 'document-reindex-unavailable')
expect(
within(actions).getByText('dataset.newKnowledge.reindexDocuments · common.loading'),
).toBeVisible()
tasksQuery.data = { pages: [{ items: [] }] }
tasksQuery.isPending = false
sourcesQuery.data = { pages: [{ items: [] }] }
sourcesQuery.error = new Error('source refresh failed')
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
within(actions).getByText(
'dataset.newKnowledge.reindexDocuments · dataset.newKnowledge.sourcesErrorDescription',
),
).toBeVisible()
})
it('keeps missing documents selected after a partial bulk re-index result', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [
{
items: [
document({ id: 'one', title: 'One.pdf' }),
document({ id: 'missing', title: 'Missing.pdf' }),
],
},
],
}
reindexMutation.mutateAsync.mockResolvedValue({
bulkJobId: 'reindex-partial',
items: [
{
asset: {
createdAt: '2026-07-20T10:00:00Z',
filename: 'one.pdf',
id: 'asset-1',
knowledgeSpaceId: 'space-1',
mimeType: 'application/pdf',
objectKey: 'documents/one.pdf',
parserStatus: 'pending',
sha256: 'sha',
sizeBytes: 1024,
version: 1,
},
compilationJob: { id: 'job-1', stage: 'queued' },
status: 'queued',
statusUrl: '/tasks/job-1',
},
{ documentId: 'missing', status: 'not_found' },
],
total: 2,
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'One.pdf' }))
await user.click(screen.getByRole('checkbox', { name: 'Missing.pdf' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }))
expect(screen.getByRole('checkbox', { name: 'One.pdf' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Missing.pdf' })).toBeChecked()
expect(toastMock.warning).toHaveBeenCalledWith(
'dataset.newKnowledge.documentsReindexPartial:{"missing":1,"queued":1}',
)
})
it('clears stale selection and refreshes after every re-index target is missing', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({ id: 'missing' })] }] }
reindexMutation.mutateAsync.mockResolvedValue({
bulkJobId: 'reindex-missing',
items: [{ documentId: 'missing', status: 'not_found' }],
total: 1,
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }))
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).not.toBeChecked()
expect(
screen.queryByRole('group', { name: 'dataset.newKnowledge.bulkDocumentActions' }),
).not.toBeInTheDocument()
expect(queryClient.invalidateQueries).toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith(
'dataset.newKnowledge.documentsReindexPartial:{"missing":1,"queued":0}',
)
})
it('counts only attention tasks and opens the designed task panel', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = {
pages: [
{
items: [
task({ id: 'running' }),
task({ id: 'failed', state: 'failed', errorMessage: 'Parser failed' }),
task({ id: 'done', state: 'succeeded' }),
],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const trigger = screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":2}',
})
expect(trigger).toHaveTextContent('2')
expect(trigger).toHaveAttribute('data-has-error', 'true')
await user.click(trigger)
const panel = screen.getByRole('dialog', { name: 'dataset.newKnowledge.backgroundTasks' })
expect(
within(panel).getByText('dataset.newKnowledge.backgroundTasksDescription'),
).toBeInTheDocument()
expect(within(panel).getByText('Parser failed')).toBeInTheDocument()
expect(
within(panel).getByText((_, element) =>
Boolean(
element?.tagName === 'P' &&
element.textContent?.includes('dataset.newKnowledge.processingTaskState.running') &&
element.textContent.includes('·'),
),
),
).toBeInTheDocument()
})
it('gives duplicate task actions distinct accessible names', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [
{
items: [
document({ id: 'document-a', title: 'Alpha.pdf' }),
document({ id: 'document-b', title: 'Beta.pdf' }),
],
},
],
}
tasksQuery.data = {
pages: [
{
items: [
task({ documentId: 'document-a', id: 'task-a' }),
task({ documentId: 'document-b', id: 'task-b' }),
],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":2}',
}),
)
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.interruptTask · Alpha.pdf · task-a',
}),
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.interruptTask · Beta.pdf · task-b',
}),
).toBeInTheDocument()
})
it('shows task loading instead of an empty result while the task query is pending', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
tasksQuery.isPending = true
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const panel = screen.getByRole('dialog', { name: 'dataset.newKnowledge.backgroundTasks' })
expect(within(panel).getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(
within(panel).queryByText('dataset.newKnowledge.noBackgroundTasks'),
).not.toBeInTheDocument()
})
it('prevents duplicate interrupt requests and refreshes both resources', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'running' })] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
const interrupt = screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' })
await user.dblClick(interrupt)
expect(cancelMutation.mutateAsync).toHaveBeenCalledOnce()
expect(cancelMutation.mutateAsync).toHaveBeenCalledWith({
params: { documentId: 'document-1', id: 'space-1', taskId: 'running' },
})
await act(async () => resolveCancel?.(task({ id: 'running', state: 'canceled' })))
expect(queryClient.invalidateQueries).toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus()
})
it('locks cancel and retry together while either task mutation is pending', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'shared-task' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
tasksQuery.data = {
pages: [{ items: [task({ id: 'shared-task', state: 'failed' })] }],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const retry = screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' })
expect(retry).toHaveAttribute('aria-disabled', 'true')
await user.click(retry)
expect(retryMutation.mutateAsync).not.toHaveBeenCalled()
await act(async () => resolveCancel?.(task({ id: 'shared-task', state: 'canceled' })))
})
it('retries a failed task once and reports mutation errors in the panel', async () => {
const user = userEvent.setup()
retryMutation.mutateAsync.mockRejectedValueOnce(new Error('retry failed'))
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'failed', state: 'failed' })] }] }
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
expect(retryMutation.mutateAsync).toHaveBeenCalledOnce()
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.taskActionFailed',
)
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'failed',
state: 'running',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.queryByText('dataset.newKnowledge.taskActionFailed')).not.toBeInTheDocument()
})
it('does not restore an action failure after its drawer cycle closes', async () => {
const user = userEvent.setup()
let rejectCancel: ((reason?: unknown) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectCancel = reject
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await act(async () => rejectCancel?.(new Error('cancel failed after close')))
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.queryByText('dataset.newKnowledge.taskActionFailed')).not.toBeInTheDocument()
})
it('does not move focus when an action from an older drawer cycle succeeds', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'old-action' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
tasksQuery.data = {
pages: [{ items: [task({ id: 'old-action' }), task({ id: 'new-action' })] }],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":2}',
}),
)
const nextAction = screen.getByRole('button', {
name: 'dataset.newKnowledge.interruptTask · sso-enterprise.pdf · new-action',
})
act(() => nextAction.focus())
expect(nextAction).toHaveFocus()
await act(async () => resolveCancel?.(task({ id: 'old-action', state: 'canceled' })))
expect(nextAction).toHaveFocus()
})
it('does not move focus when the user leaves a pending task action', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'pending-action' }), task({ id: 'focus-target' })] }],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":2}',
}),
)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.interruptTask · sso-enterprise.pdf · pending-action',
}),
)
const nextAction = screen.getByRole('button', {
name: 'dataset.newKnowledge.interruptTask · sso-enterprise.pdf · focus-target',
})
act(() => nextAction.focus())
await act(async () => resolveCancel?.(task({ id: 'pending-action', state: 'canceled' })))
expect(nextAction).toHaveFocus()
})
it('ignores a delayed action success after the task lifecycle advances', async () => {
const user = userEvent.setup()
let resolveRetry: ((value: DocumentProcessingTask) => void) | undefined
retryMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveRetry = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'advanced-success', state: 'failed' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
tasksQuery.data = {
pages: [{ items: [task({ id: 'advanced-success', state: 'running' })] }],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => resolveRetry?.(task({ id: 'advanced-success', state: 'queued' })))
expect(
screen.getByText(/dataset\.newKnowledge\.processingTaskState\.running/),
).toBeInTheDocument()
})
it('ignores a delayed task action after document permission is revoked', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'revoked-action' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
queryClient.invalidateQueries.mockClear()
documentsQuery.error = { status: 403 }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => resolveCancel?.(task({ id: 'revoked-action', state: 'canceled' })))
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
documentsQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
).toBeInTheDocument()
})
it('keeps a successful task action across a transient permission query failure', async () => {
const user = userEvent.setup()
let resolveCancel: ((value: DocumentProcessingTask) => void) | undefined
cancelMutation.mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'permission-query-action' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
permissionStateMock.error = new Error('permission query unavailable')
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
queryClient.invalidateQueries.mockClear()
await act(async () =>
resolveCancel?.(task({ id: 'permission-query-action', state: 'canceled' })),
)
expect(queryClient.invalidateQueries).toHaveBeenCalled()
expect(
screen.getByText(/dataset\.newKnowledge\.processingTaskState\.canceled/),
).toBeInTheDocument()
})
it('drops a delayed action failure after the task lifecycle advances', async () => {
const user = userEvent.setup()
let rejectRetry: ((reason?: unknown) => void) | undefined
retryMutation.mutateAsync.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectRetry = reject
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'advanced', state: 'failed' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
tasksQuery.data = { pages: [{ items: [task({ id: 'advanced', state: 'running' })] }] }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => rejectRetry?.(new Error('late retry failure')))
tasksQuery.data = { pages: [{ items: [task({ id: 'advanced', state: 'failed' })] }] }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.queryByText('dataset.newKnowledge.taskActionFailed')).not.toBeInTheDocument()
})
it('does not offer retry for a canceled task', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'canceled', state: 'canceled' })] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasks',
}),
)
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).not.toBeInTheDocument()
})
it('clears an old failure across retry and shows the next terminal error', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = {
pages: [
{
items: [
task({ id: 'retried-failure', state: 'failed', errorMessage: 'Old parser error' }),
],
},
],
}
retryMutation.mutateAsync.mockResolvedValue(
task({
id: 'retried-failure',
progressPercent: 0,
state: 'queued',
updatedAt: '2026-07-20T10:02:00Z',
}),
)
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 20,
stage: 'parsed' as const,
state: 'failed' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'retried-failure:2026-07-20T10:03:00Z',
}
yield {
data: { errorCode: 'NEW_PARSER_ERROR', state: 'failed' as const },
event: 'terminal' as const,
id: 'retried-failure:terminal',
}
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await waitFor(() => expect(screen.queryByText('Old parser error')).not.toBeInTheDocument())
expect(await screen.findByText('NEW_PARSER_ERROR')).toBeInTheDocument()
})
it('ignores a delayed retry response older than the current failed list snapshot', async () => {
const user = userEvent.setup()
let resolveRetry: ((snapshot: DocumentProcessingTask) => void) | undefined
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = {
pages: [
{
items: [
task({
errorCode: 'OLD_FAILURE',
id: 'delayed-local-retry',
state: 'failed',
updatedAt: '2026-07-20T10:01:00Z',
}),
],
},
],
}
retryMutation.mutateAsync.mockReturnValue(
new Promise<DocumentProcessingTask>((resolve) => {
resolveRetry = resolve
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
tasksQuery.data = {
pages: [
{
items: [
task({
errorCode: 'NEWER_FAILURE',
id: 'delayed-local-retry',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () =>
resolveRetry?.(
task({
id: 'delayed-local-retry',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:02:00Z',
}),
),
)
expect(await screen.findByText('NEWER_FAILURE')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
})
it('accepts an exact active retry snapshot with the terminal timestamp', async () => {
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'same-time-retry' })] }] }
getTaskSnapshot.mockResolvedValue(
task({
id: 'same-time-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 80,
stage: 'parsed' as const,
state: 'failed' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'same-time-retry:2026-07-20T10:03:00Z',
}
yield {
data: { errorCode: 'PARSER_FAILED', state: 'failed' as const },
event: 'terminal' as const,
id: 'same-time-retry:terminal',
}
return
}
await new Promise<void>(() => {})
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
).toHaveTextContent('1'),
)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2))
expect(getTaskSnapshot).toHaveBeenCalledWith(
{
params: { documentId: 'document-1', id: 'space-1', taskId: 'same-time-retry' },
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
it('restarts an aborted equal-timestamp reconciliation after permission returns', async () => {
const signals: AbortSignal[] = []
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'permission-reconciliation' })] }] }
getTaskSnapshot.mockImplementation(
(_input: unknown, options: { signal: AbortSignal }) =>
new Promise<DocumentProcessingTask>((_resolve, reject) => {
signals.push(options.signal)
options.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
streamFailedTaskThenWait('permission-reconciliation')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [
{
items: [task({ id: 'permission-reconciliation', updatedAt: '2026-07-20T10:03:00Z' })],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
documentsQuery.error = { status: 403 }
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(signals[1]?.aborted).toBe(true))
documentsQuery.error = null
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(3))
expect(signals[2]?.aborted).toBe(false)
})
it('restores complete terminal error details from an equal list snapshot', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'listed-terminal-error' })] }] }
getTaskSnapshot.mockRejectedValue(new Response(null, { status: 403 }))
streamFailedTaskThenWait('listed-terminal-error')
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
tasksQuery.data = {
pages: [
{
items: [
task({
errorCode: 'PARSER_FAILED',
errorMessage: 'Parser failed at line 400 with complete server context',
id: 'listed-terminal-error',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const error = await screen.findByText('Parser failed at line 400 with complete server context')
expect(error).toHaveClass('whitespace-pre-wrap', 'wrap-break-word')
expect(error).not.toHaveClass('truncate')
})
it('hides cached documents and closes tasks when terminal reconciliation loses read access', async () => {
const user = userEvent.setup()
let releaseStream!: () => void
const streamGate = new Promise<void>((resolve) => {
releaseStream = resolve
})
let resolveDocumentsRefetch!: (result: { error: null }) => void
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.data = { pages: [{ items: [task({ id: 'forbidden-reconciliation' })] }] }
getTaskSnapshot.mockRejectedValue(new Response(null, { status: 403 }))
streamProcessingTaskEvents.mockImplementation(async function* () {
await streamGate
yield {
data: {
progressPercent: 80,
stage: 'parsed' as const,
state: 'failed' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'forbidden-reconciliation:progress',
}
yield {
data: { errorCode: 'PARSER_FAILED', state: 'failed' as const },
event: 'terminal' as const,
id: 'forbidden-reconciliation:terminal',
}
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
await act(async () => releaseStream())
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionDescription',
),
)
expect(screen.queryByText('sso-enterprise.pdf')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(documentsQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: true })
await act(async () => resolveDocumentsRefetch({ error: null }))
expect(await screen.findByText('sso-enterprise.pdf')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('blocks failed polling for a task version denied during terminal reconciliation', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'denied-terminal-version' })] }] }
getTaskSnapshot.mockRejectedValue(new Response(null, { status: 403 }))
streamFailedTaskThenWait('denied-terminal-version')
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledOnce()
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('hides cached documents and closes tasks when the task event stream loses read access', async () => {
const user = userEvent.setup()
let rejectStream!: () => void
const streamGate = new Promise<void>((_resolve, reject) => {
rejectStream = () => reject(new Response(null, { status: 403 }))
})
let resolveDocumentsRefetch!: (result: { error: null }) => void
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.data = { pages: [{ items: [task({ id: 'forbidden-stream' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
await streamGate
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
await act(async () => rejectStream())
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionDescription',
),
)
expect(screen.queryByText('sso-enterprise.pdf')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(documentsQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: true })
await act(async () => resolveDocumentsRefetch({ error: null }))
expect(await screen.findByText('sso-enterprise.pdf')).toBeInTheDocument()
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
})
it('does not restart a denied stream version while the task list lags behind progress', async () => {
let resolveDocumentsRefetch!: (result: { error: null }) => void
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.data = {
pages: [
{
items: [task({ id: 'lagging-denied-stream', updatedAt: '2026-07-20T10:01:00Z' })],
},
],
}
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'lagging-denied-stream:version-2',
}
throw new Response(null, { status: 403 })
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionDescription',
),
)
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
await act(async () => resolveDocumentsRefetch({ error: null }))
expect(await screen.findByText('sso-enterprise.pdf')).toBeInTheDocument()
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
})
it('observes a same-version retry after the task list confirms a denied stream terminal', async () => {
let resolveDocumentsRefetch!: (result: { error: null }) => void
const deniedVersion = '2026-07-20T10:02:00Z'
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.dataUpdateCount = 1
tasksQuery.data = {
pages: [
{
items: [task({ id: 'same-version-terminal-retry' })],
},
],
}
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: deniedVersion,
},
event: 'progress' as const,
id: 'same-version-terminal-retry:version-2',
}
throw new Response(null, { status: 403 })
}
await new Promise<void>(() => {})
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(documentsQuery.refetch).toHaveBeenCalled())
await act(async () => resolveDocumentsRefetch({ error: null }))
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'same-version-terminal-retry',
state: 'failed',
updatedAt: deniedVersion,
}),
],
},
],
}
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'same-version-terminal-retry',
state: 'running',
updatedAt: deniedVersion,
}),
],
},
],
}
tasksQuery.dataUpdateCount = 3
act(() => notifyTaskQuerySuccess())
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2))
})
it('restarts an active override after a structurally shared terminal confirmation', async () => {
const user = userEvent.setup()
let resolveDocumentsRefetch!: (result: { error: null }) => void
const taskVersion = '2026-07-20T10:02:00Z'
const sharedTerminalData = {
pages: [
{
items: [
task({
id: 'shared-terminal-confirmation',
state: 'failed',
updatedAt: taskVersion,
}),
],
},
],
}
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.data = sharedTerminalData
tasksQuery.dataUpdateCount = 1
retryMutation.mutateAsync.mockResolvedValue(
task({
id: 'shared-terminal-confirmation',
state: 'dispatch_pending',
updatedAt: taskVersion,
}),
)
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) throw new Response(null, { status: 403 })
await new Promise<void>(() => {})
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await waitFor(() => expect(documentsQuery.refetch).toHaveBeenCalled())
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
await act(async () => resolveDocumentsRefetch({ error: null }))
tasksQuery.data = sharedTerminalData
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2))
})
it('single-flights backed-off recovery polling until a denied stream gets a newer version', async () => {
vi.useFakeTimers()
let resolveDocumentsRefetch!: (result: { error: null }) => void
let resolveTaskRefetch!: (result: { error: null }) => void
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch.mockImplementation(
() =>
new Promise((resolve) => {
resolveDocumentsRefetch = resolve
}),
)
tasksQuery.dataUpdateCount = 1
tasksQuery.data = {
pages: [
{
items: [task({ id: 'recovering-stream', updatedAt: '2026-07-20T10:01:00Z' })],
},
],
}
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'recovering-stream:version-2',
}
throw new Response(null, { status: 403 })
}
await new Promise<void>(() => {})
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
await act(async () => resolveDocumentsRefetch({ error: null }))
tasksQuery.refetch.mockClear()
tasksQuery.data = {
pages: [
{
items: [task({ id: 'recovering-stream', updatedAt: '2026-07-20T10:02:00Z' })],
},
],
}
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await act(async () => {})
tasksQuery.refetch
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveTaskRefetch = resolve
}),
)
.mockResolvedValue({ error: null })
await act(async () => vi.advanceTimersByTime(5000))
expect(tasksQuery.refetch).toHaveBeenCalledWith({ cancelRefetch: false })
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
await act(async () => vi.advanceTimersByTime(120000))
expect(tasksQuery.refetch).toHaveBeenCalledOnce()
await act(async () => resolveTaskRefetch({ error: null }))
for (const delay of [10000, 20000, 30000, 30000, 30000, 30000])
await act(async () => vi.advanceTimersByTime(delay))
expect(tasksQuery.refetch).toHaveBeenCalledTimes(7)
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [task({ id: 'recovering-stream', updatedAt: '2026-07-20T10:03:00Z' })],
},
],
}
tasksQuery.dataUpdateCount = 3
act(() => notifyTaskQuerySuccess())
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2)
tasksQuery.refetch.mockClear()
await act(async () => vi.advanceTimersByTime(60000))
expect(tasksQuery.refetch).not.toHaveBeenCalled()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('focuses the permission alert when an auxiliary retry becomes a document denial', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
documentsQuery.refetch
.mockResolvedValueOnce({ error: new Error('verification unavailable') })
.mockImplementationOnce(() => new Promise(() => {}))
tasksQuery.data = { pages: [{ items: [task({ id: 'retry-document-denial' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
throw new Response(null, { status: 403 })
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
const retry = await screen.findByRole('button', {
name: 'common.operation.retry · dataset.newKnowledge.documentsPermissionDescription',
})
await user.click(retry)
documentsQuery.error = { status: 403 }
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus())
expect(screen.queryByText('sso-enterprise.pdf')).not.toBeInTheDocument()
})
it('replaces an in-flight terminal reconciliation and aborts it on unmount', async () => {
vi.useFakeTimers()
const signals: AbortSignal[] = []
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'coalesced-reconciliation' })] }] }
getTaskSnapshot.mockImplementation(
(_input: unknown, options: { signal: AbortSignal }) =>
new Promise<DocumentProcessingTask>((_resolve, reject) => {
signals.push(options.signal)
options.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
streamFailedTaskThenWait('coalesced-reconciliation')
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'coalesced-reconciliation',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
expect(signals[0]?.aborted).toBe(true)
expect(signals[1]?.aborted).toBe(false)
rendered.unmount()
expect(signals[1]?.aborted).toBe(true)
await act(async () => vi.advanceTimersByTime(60000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('retries a terminal reconciliation after its request deadline', async () => {
vi.useFakeTimers()
const signals: AbortSignal[] = []
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'timed-reconciliation' })] }] }
getTaskSnapshot
.mockImplementationOnce(
(_input: unknown, options: { signal: AbortSignal }) =>
new Promise<DocumentProcessingTask>((_resolve, reject) => {
signals.push(options.signal)
options.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
.mockResolvedValueOnce(
task({
errorCode: 'TERMINAL_CONFIRMED',
id: 'timed-reconciliation',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('timed-reconciliation')
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledOnce()
await act(async () => vi.advanceTimersByTime(3000))
expect(signals[0]?.aborted).toBe(true)
await act(async () => vi.advanceTimersByTime(1000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('replaces an older reconciliation backoff with the latest generation retry', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'latest-reconciliation' })] }] }
getTaskSnapshot
.mockRejectedValueOnce(new Error('first snapshot unavailable'))
.mockRejectedValueOnce(new Error('second snapshot unavailable'))
.mockResolvedValueOnce(
task({
id: 'latest-reconciliation',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('latest-reconciliation')
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [task({ id: 'latest-reconciliation', updatedAt: '2026-07-20T10:03:00Z' })],
},
],
}
tasksQuery.dataUpdateCount += 1
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
await act(async () => vi.advanceTimersByTime(1000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(3)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('accepts a later active list snapshot after exact reconciliation saw the terminal state', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'later-same-time-retry' })] }] }
getTaskSnapshot
.mockResolvedValueOnce(
task({
errorCode: 'PARSER_FAILED',
id: 'later-same-time-retry',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
.mockResolvedValueOnce(
task({
id: 'later-same-time-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('later-same-time-retry')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'later-same-time-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(screen.queryByText('PARSER_FAILED')).not.toBeInTheDocument()
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2))
})
it('uses a later active list snapshot when exact reconciliation fails', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'failed-reconciliation-retry' })] }] }
getTaskSnapshot.mockRejectedValueOnce(new Error('snapshot unavailable')).mockResolvedValueOnce(
task({
id: 'failed-reconciliation-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('failed-reconciliation-retry')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'failed-reconciliation-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(screen.queryByText('PARSER_FAILED')).not.toBeInTheDocument()
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2))
})
it('does not treat a stale active row from a completed cursor page as a retry', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = {
pages: [
{
items: [task({ id: 'stale-cursor-active', updatedAt: '2026-07-20T10:03:00Z' })],
},
],
}
getTaskSnapshot.mockResolvedValue(
task({
errorCode: 'PARSER_FAILED',
id: 'stale-cursor-active',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('stale-cursor-active')
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [
{
items: [task({ id: 'stale-cursor-active', updatedAt: '2026-07-20T10:03:00Z' })],
},
{ items: [task({ id: 'another-page', state: 'succeeded' })] },
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
expect(screen.getByText('PARSER_FAILED')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).toBeInTheDocument()
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
})
it('ignores a terminal reconciliation that resolves after a local retry', async () => {
const user = userEvent.setup()
let resolveSnapshot: ((snapshot: DocumentProcessingTask) => void) | undefined
let reconciliationSignal: AbortSignal | undefined
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'local-retry-race' })] }] }
getTaskSnapshot.mockImplementation(
(_input: unknown, options: { signal: AbortSignal }) =>
new Promise<DocumentProcessingTask>((resolve) => {
reconciliationSignal = options.signal
resolveSnapshot = resolve
}),
)
retryMutation.mutateAsync.mockResolvedValue(
task({
id: 'local-retry-race',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamFailedTaskThenWait('local-retry-race')
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
expect(reconciliationSignal?.aborted).toBe(true)
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
await act(async () => {
resolveSnapshot?.(
task({
errorCode: 'PARSER_FAILED',
id: 'local-retry-race',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
})
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(screen.queryByText('PARSER_FAILED')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).not.toBeInTheDocument()
})
it('consumes the terminal error after final progress without duplicate side effects', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'failed-stream' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 80,
stage: 'parsed' as const,
state: 'failed' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'failed-stream:2026-07-20T10:03:00Z',
}
yield {
data: { errorCode: 'PARSER_FAILED', state: 'failed' as const },
event: 'terminal' as const,
id: 'failed-stream:terminal',
}
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
await screen.findByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
expect(toastMock.error).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledOnce()
})
it('applies task events and clears the attention badge after completion', async () => {
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'running' })] }] }
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 100,
stage: 'published' as const,
state: 'succeeded' as const,
updatedAt: '2026-07-20T10:06:00Z',
},
event: 'progress' as const,
id: 'running:2026-07-20T10:06:00Z',
}
yield {
data: { state: 'succeeded' as const },
event: 'terminal' as const,
id: 'running:terminal',
}
return
}
await new Promise<void>(() => {})
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toHaveTextContent('1'),
)
expect(streamProcessingTaskEvents).toHaveBeenCalledWith(
expect.objectContaining({
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
taskId: 'running',
}),
)
expect(queryClient.invalidateQueries).toHaveBeenCalled()
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'running',
progressPercent: 90,
state: 'running',
updatedAt: '2026-07-20T10:05:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toHaveTextContent('1')
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'running',
progressPercent: 100,
state: 'succeeded',
updatedAt: '2026-07-20T10:06:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toHaveTextContent('1'),
)
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'running',
progressPercent: 0,
state: 'queued',
updatedAt: '2026-07-20T10:07:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
).toHaveTextContent('1'),
)
})
it('accepts an external retry newer than the terminal event version', async () => {
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'externally-retried' })] }] }
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 100,
stage: 'published' as const,
state: 'succeeded' as const,
updatedAt: '2026-07-20T10:04:00Z',
},
event: 'progress' as const,
id: 'externally-retried:2026-07-20T10:04:00Z',
}
yield {
data: { state: 'succeeded' as const },
event: 'terminal' as const,
id: 'externally-retried:terminal',
}
return
}
await new Promise<void>(() => {})
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }),
).not.toHaveTextContent('1'),
)
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'externally-retried',
progressPercent: 0,
state: 'queued',
updatedAt: '2026-07-20T10:05:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
).toHaveTextContent('1'),
)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2), {
timeout: 2000,
})
})
it('ignores a stale terminal event after polling observes an external retry', async () => {
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'retry-race' })] }] }
let releaseTerminal: (() => void) | undefined
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
await new Promise<void>((resolve) => {
releaseTerminal = resolve
})
yield {
data: {
progressPercent: 100,
stage: 'published' as const,
state: 'succeeded' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'retry-race:progress',
}
yield {
data: { state: 'succeeded' as const },
event: 'terminal' as const,
id: 'retry-race:terminal',
}
return
}
await new Promise<void>(() => {})
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(releaseTerminal).toBeTypeOf('function'))
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'retry-race',
progressPercent: 0,
state: 'queued',
updatedAt: '2026-07-20T10:05:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => releaseTerminal?.())
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
).toHaveTextContent('1'),
)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2), {
timeout: 2000,
})
})
it('keeps one event stream and its resume cursor when polling updates task versions', async () => {
let releaseStream: (() => void) | undefined
streamProcessingTaskEvents.mockImplementation(async function* () {
await new Promise<void>((resolve) => {
releaseStream = resolve
})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'streaming' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [
{
items: [task({ id: 'streaming', updatedAt: '2026-07-20T10:02:00Z' })],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
await act(async () => releaseStream?.())
})
it('keeps task event progress monotonic and yields to a newer server snapshot', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document({})] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'running' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 80,
stage: 'nodes_generated' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:03:00Z',
},
event: 'progress' as const,
id: 'running:newer',
}
yield {
data: {
progressPercent: 20,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'running:stale',
}
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await waitFor(() =>
expect(
screen.getByText(/dataset\.newKnowledge\.processingTaskState\.running.*"progress":80/),
).toBeInTheDocument(),
)
await act(async () => {})
expect(
screen.queryByText(/dataset\.newKnowledge\.processingTaskState\.running.*"progress":20/),
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'running',
progressPercent: 100,
state: 'succeeded',
updatedAt: '2026-07-20T10:04:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' })).toBeInTheDocument()
})
it('lets an equal-timestamp terminal list row replace an active stream override', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'equal-terminal', state: 'queued' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 60,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:01:00Z',
},
event: 'progress' as const,
id: 'equal-terminal:running',
}
await new Promise<void>(() => {})
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledOnce())
tasksQuery.data = {
pages: [{ items: [task({ id: 'equal-terminal', state: 'failed' })] }],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' })).toBeEnabled()
})
it('moves focus to the drawer close button when an external update removes a task action', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'external-terminal' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {
await new Promise<void>(() => {})
})
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
const action = screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' })
act(() => action.focus())
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'external-terminal',
state: 'succeeded',
updatedAt: '2026-07-20T10:02:00Z',
}),
],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus(),
)
})
it('limits rendered task history while retaining an active retry for an old document', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const history = Array.from({ length: 150 }, (_, index) =>
task({
id: `history-${index}`,
state: 'succeeded',
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
)
history[0] = task({
createdAt: '2026-07-01T10:00:00Z',
id: 'old-active-retry',
state: 'running',
updatedAt: '2026-07-01T10:00:00Z',
})
history[1] = task({
createdAt: '2026-07-01T10:01:00Z',
id: 'old-failed-retry',
state: 'failed',
updatedAt: '2026-08-01T10:00:00Z',
})
tasksQuery.data = { pages: [{ items: history }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":2}',
}),
)
expect(screen.getAllByRole('listitem')).toHaveLength(100)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).toBeInTheDocument()
})
it('keeps a failed task actionable when one hundred active tasks fill the drawer', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
...Array.from({ length: 100 }, (_, index) =>
task({
id: `active-${index}`,
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
task({
id: 'failed-outside-active-limit',
state: 'failed',
updatedAt: '2026-07-01T10:00:00Z',
}),
],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":101}',
}),
)
expect(screen.getAllByRole('listitem')).toHaveLength(100)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).toBeInTheDocument()
})
it('keeps an active task actionable when one hundred failed tasks fill the drawer', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
...Array.from({ length: 100 }, (_, index) =>
task({
id: `failed-${index}`,
state: 'failed',
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
task({
id: 'active-outside-failed-limit',
state: 'running',
updatedAt: '2026-07-01T10:00:00Z',
}),
],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":101}',
}),
)
expect(screen.getAllByRole('listitem')).toHaveLength(100)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'dataset.newKnowledge.loadMore',
}),
)
expect(screen.getAllByRole('listitem')).toHaveLength(101)
})
it('moves focus to the drawer close button when the final task batch is revealed', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: Array.from({ length: 101 }, (_, index) =>
task({
id: `history-${index}`,
state: 'succeeded',
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.tasks' }))
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'dataset.newKnowledge.loadMore',
}),
)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus(),
)
})
it('preserves focus when the user leaves a pending final-page control', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const firstPage = [
task({ id: 'active-focus-target' }),
...Array.from({ length: 99 }, (_, index) =>
task({
id: `history-${index}`,
state: 'succeeded',
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
]
tasksQuery.data = { pages: [{ items: firstPage, nextCursor: 'final-page' }] }
tasksQuery.hasNextPage = true
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1} · dataset.newKnowledge.taskHistoryIncomplete',
}),
)
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'dataset.newKnowledge.loadMore',
}),
)
const action = screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' })
act(() => action.focus())
tasksQuery.data = {
pages: [{ items: firstPage }, { items: [task({ id: 'last-task', state: 'succeeded' })] }],
}
tasksQuery.hasNextPage = false
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(action).toHaveFocus()
})
it('rotates a bounded task event stream pool without polling every cursor page', async () => {
vi.useFakeTimers()
streamProcessingTaskEvents.mockImplementation(async function* () {
await new Promise<void>(() => {})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: Array.from({ length: 20 }, (_, index) =>
task({
id: `active-${index}`,
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
},
],
}
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(6)
await act(async () => vi.advanceTimersByTime(5000))
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(12)
const taskOptions = tasksInfiniteOptions.mock.lastCall?.[0]
expect(taskOptions?.refetchInterval).toBeUndefined()
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":20}',
}),
).toBeInTheDocument()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('resumes a task event stream from its cursor after rotation', async () => {
vi.useFakeTimers()
const resumeCalls: Array<{ lastEventId?: string; taskId: string }> = []
streamProcessingTaskEvents.mockImplementation(async function* ({
lastEventId,
taskId,
}: {
lastEventId?: string
taskId: string
}) {
resumeCalls.push({ lastEventId, taskId })
if (taskId === 'active-0' && !lastEventId) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:30:00Z',
},
event: 'progress' as const,
id: 'active-0:cursor',
}
}
await new Promise<void>(() => {})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: Array.from({ length: 20 }, (_, index) =>
task({
id: `active-${index}`,
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
},
],
}
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
for (let index = 0; index < 3; index += 1) {
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => {})
}
expect(resumeCalls.filter(({ taskId }) => taskId === 'active-0')).toEqual([
{ lastEventId: undefined, taskId: 'active-0' },
{ lastEventId: 'active-0:cursor', taskId: 'active-0' },
])
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('drops an event cursor after the task list reports a terminal state', async () => {
const streamCalls: Array<{ lastEventId?: string; taskId: string }> = []
streamProcessingTaskEvents.mockImplementation(async function* ({
lastEventId,
taskId,
}: {
lastEventId?: string
taskId: string
}) {
streamCalls.push({ lastEventId, taskId })
if (streamCalls.length === 1) {
yield {
data: {
progressPercent: 60,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'restarted:old-cursor',
}
}
await new Promise<void>(() => {})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'restarted' })] }] }
const { rerender } = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(streamCalls).toHaveLength(1))
tasksQuery.data = {
pages: [
{
items: [task({ id: 'restarted', state: 'failed', updatedAt: '2026-07-20T10:03:00Z' })],
},
],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
tasksQuery.data = {
pages: [{ items: [task({ id: 'restarted', updatedAt: '2026-07-20T10:04:00Z' })] }],
}
rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(streamCalls).toHaveLength(2))
expect(streamCalls).toEqual([
{ lastEventId: undefined, taskId: 'restarted' },
{ lastEventId: undefined, taskId: 'restarted' },
])
})
it('keeps a retry cursor while the base task list still has the old terminal row', async () => {
vi.useFakeTimers()
const retryStreamCalls: Array<string | undefined> = []
streamProcessingTaskEvents.mockImplementation(async function* ({
lastEventId,
taskId,
}: {
lastEventId?: string
taskId: string
}) {
if (taskId === 'active-5') {
retryStreamCalls.push(lastEventId)
if (!lastEventId) {
yield {
data: {
progressPercent: 55,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'active-5:retry-cursor',
}
}
}
await new Promise<void>(() => {})
})
retryMutation.mutateAsync.mockResolvedValue(
task({ id: 'active-5', state: 'queued', updatedAt: '2026-07-20T10:01:00Z' }),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
...Array.from({ length: 5 }, (_, index) => task({ id: `active-${index}` })),
task({ id: 'active-5', state: 'failed' }),
task({ id: 'active-6' }),
],
},
],
}
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
fireEvent.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":7}',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await act(async () => {})
expect(retryStreamCalls).toEqual([undefined])
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => {})
expect(retryStreamCalls).toEqual([undefined, 'active-5:retry-cursor'])
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('keeps the latest event cursor when the observer effect restarts', async () => {
const streamCursors: Array<string | undefined> = []
streamProcessingTaskEvents.mockImplementation(async function* ({
lastEventId,
}: {
lastEventId?: string
}) {
streamCursors.push(lastEventId)
if (streamCursors.length === 1) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'task-1:cursor',
}
}
await new Promise<void>(() => {})
})
const onLastEventIdChange = vi.fn()
const firstOnEvent = vi.fn(() => true)
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={firstOnEvent}
onLastEventIdChange={onLastEventIdChange}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
await act(async () => {})
expect(onLastEventIdChange).toHaveBeenCalledWith('task-1', 'task-1:cursor')
rendered.rerender(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
lastEventId="task-1:cursor"
onEvent={vi.fn(() => true)}
onLastEventIdChange={onLastEventIdChange}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
await act(async () => {})
expect(streamCursors).toEqual([undefined, 'task-1:cursor'])
})
it('does not retain a reconnect timer for an already-aborted observer', async () => {
vi.useFakeTimers()
let finishStream!: () => void
streamProcessingTaskEvents.mockImplementation(async function* () {
await new Promise<void>((resolve) => {
finishStream = resolve
})
})
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn(() => true)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
try {
await act(async () => {})
rendered.unmount()
await act(async () => finishStream())
expect(vi.getTimerCount()).toBe(0)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('blocks the latest streamed task version when a reconnect loses permission', async () => {
vi.useFakeTimers()
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:02:00Z',
},
event: 'progress' as const,
id: 'task-1:version-2',
}
return
}
throw new Response(null, { status: 403 })
})
const onPermissionDenied = vi.fn()
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn(() => true)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={onPermissionDenied}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
try {
await act(async () => {})
await act(async () => vi.advanceTimersByTime(1000))
expect(onPermissionDenied).toHaveBeenCalledWith('task-1', '2026-07-20T10:02:00Z')
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('keeps an accepted task version after rejecting a stale event before a denied reconnect', async () => {
vi.useFakeTimers()
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) {
for (const updatedAt of ['2026-07-20T10:02:00Z', '2026-07-20T10:00:00Z']) {
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt,
},
event: 'progress' as const,
id: `task-1:${updatedAt}`,
}
}
return
}
throw new Response(null, { status: 403 })
})
const onPermissionDenied = vi.fn()
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={onPermissionDenied}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
try {
await act(async () => {})
await act(async () => vi.advanceTimersByTime(1000))
expect(onPermissionDenied).toHaveBeenCalledWith('task-1', '2026-07-20T10:02:00Z')
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('syncs a newer task prop before a reconnect is denied', async () => {
vi.useFakeTimers()
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) return
throw new Response(null, { status: 403 })
})
const onPermissionDenied = vi.fn()
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn(() => true)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={onPermissionDenied}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
try {
await act(async () => {})
rendered.rerender(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn(() => true)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={onPermissionDenied}
taskId="task-1"
taskVersion="2026-07-20T10:03:00Z"
/>,
)
rendered.rerender(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={vi.fn(() => true)}
onLastEventIdChange={vi.fn()}
onPermissionDenied={onPermissionDenied}
taskId="task-1"
taskVersion="2026-07-20T10:02:00Z"
/>,
)
await act(async () => vi.advanceTimersByTime(1000))
expect(onPermissionDenied).toHaveBeenCalledWith('task-1', '2026-07-20T10:03:00Z')
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('uses a newer task prop for a terminal-only reconnect', async () => {
vi.useFakeTimers()
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
if (streamCount === 1) return
yield {
data: { errorCode: 'PARSER_FAILED', state: 'failed' as const },
event: 'terminal' as const,
id: 'task-1:terminal',
}
})
const onEvent = vi.fn(() => true)
const rendered = render(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={onEvent}
onLastEventIdChange={vi.fn()}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:01:00Z"
/>,
)
try {
await act(async () => {})
rendered.rerender(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={onEvent}
onLastEventIdChange={vi.fn()}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:03:00Z"
/>,
)
rendered.rerender(
<TaskEventObserver
documentId="document-1"
knowledgeSpaceId="space-1"
onEvent={onEvent}
onLastEventIdChange={vi.fn()}
onPermissionDenied={vi.fn()}
taskId="task-1"
taskVersion="2026-07-20T10:02:00Z"
/>,
)
await act(async () => vi.advanceTimersByTime(1000))
expect(onEvent).toHaveBeenCalledWith(
'task-1',
'2026-07-20T10:03:00Z',
expect.objectContaining({ event: 'terminal' }),
)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('rejects stale active progress and backs off repeated stale reconnects', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'stale-active-progress',
state: 'queued',
updatedAt: '2026-07-20T10:05:00Z',
}),
],
},
],
}
let streamCount = 0
streamProcessingTaskEvents.mockImplementation(async function* () {
streamCount += 1
yield {
data: {
progressPercent: streamCount < 3 ? 40 : 60,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: streamCount < 3 ? '2026-07-20T10:04:00Z' : '2026-07-20T10:06:00Z',
},
event: 'progress' as const,
id: `stale-active-progress:${streamCount}`,
}
await new Promise<void>(() => {})
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
expect(
within(screen.getByRole('row', { name: /sso-enterprise\.pdf/ })).getByText(
'dataset.newKnowledge.documentStatus.queued',
),
).toBeInTheDocument()
await act(async () => vi.advanceTimersByTime(1000))
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2)
await act(async () => vi.advanceTimersByTime(1000))
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(2)
await act(async () => vi.advanceTimersByTime(1000))
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(3)
expect(
within(screen.getByRole('row', { name: /sso-enterprise\.pdf/ })).getByText(
'dataset.newKnowledge.documentStatus.processing',
),
).toBeInTheDocument()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('keeps rotation membership stable when progress updates task timestamps', async () => {
vi.useFakeTimers()
const callsByTask = new Map<string, number>()
streamProcessingTaskEvents.mockImplementation(async function* ({ taskId }: { taskId: string }) {
callsByTask.set(taskId, (callsByTask.get(taskId) ?? 0) + 1)
yield {
data: {
progressPercent: 50,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: '2026-07-20T12:00:00Z',
},
event: 'progress' as const,
id: `${taskId}:progress`,
}
await new Promise<void>(() => {})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: Array.from({ length: 12 }, (_, index) =>
task({
createdAt: new Date(Date.UTC(2026, 6, 20, 9, index)).toISOString(),
id: `stable-${index}`,
updatedAt: new Date(Date.UTC(2026, 6, 20, 10, index)).toISOString(),
}),
),
},
],
}
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => {})
expect(callsByTask).toHaveLength(12)
expect([...callsByTask.values()]).toEqual(Array.from({ length: 12 }, () => 1))
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('preserves the latest progress version when a rotated stream returns terminal-only', async () => {
vi.useFakeTimers()
const streamCounts = new Map<string, number>()
streamProcessingTaskEvents.mockImplementation(async function* ({ taskId }: { taskId: string }) {
const streamCount = (streamCounts.get(taskId) ?? 0) + 1
streamCounts.set(taskId, streamCount)
if (taskId === 'rotated-terminal' && streamCount === 1) {
yield {
data: {
progressPercent: 70,
stage: 'nodes_generated' as const,
state: 'running' as const,
updatedAt: '2026-07-20T10:05:00Z',
},
event: 'progress' as const,
id: 'rotated-terminal:progress',
}
} else if (taskId === 'rotated-terminal') {
yield {
data: { errorCode: 'ROTATED_FAILURE', state: 'failed' as const },
event: 'terminal' as const,
id: 'rotated-terminal:terminal',
}
return
}
await new Promise<void>(() => {})
})
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
task({ id: 'rotated-terminal', updatedAt: '2026-07-20T10:01:00Z' }),
...Array.from({ length: 12 }, (_, index) =>
task({ id: `rotation-peer-${index}`, updatedAt: '2026-07-20T10:01:00Z' }),
),
],
},
],
}
getTaskSnapshot.mockResolvedValue(
task({
errorCode: 'ROTATED_FAILURE',
id: 'rotated-terminal',
state: 'failed',
updatedAt: '2026-07-20T10:05:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => {})
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => {})
expect(streamCounts.get('rotated-terminal')).toBe(2)
expect(toastMock.error).toHaveBeenCalledWith('dataset.newKnowledge.taskFailedNotification')
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('relies on bounded reconnect backoff instead of invalidating queries on stream closure', async () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'closed-stream' })] }] }
streamProcessingTaskEvents.mockImplementation(async function* () {})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalledOnce())
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
})
it('polls failed tasks through a bounded exact-snapshot pool for external retries', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'external-retry', state: 'failed' })] }],
}
getTaskSnapshot.mockResolvedValue(
task({
id: 'external-retry',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('keeps one failed-task poll in flight and ignores it after a local retry', async () => {
vi.useFakeTimers()
let resolvePoll: ((snapshot: DocumentProcessingTask) => void) | undefined
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'slow-failed-poll', state: 'failed' })] }],
}
getTaskSnapshot.mockReturnValue(
new Promise<DocumentProcessingTask>((resolve) => {
resolvePoll = resolve
}),
)
retryMutation.mutateAsync.mockResolvedValue(
task({
id: 'slow-failed-poll',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
fireEvent.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await act(async () => {})
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
await act(async () => {
resolvePoll?.(
task({
errorCode: 'OLD_FAILURE',
id: 'slow-failed-poll',
state: 'failed',
updatedAt: '2026-07-20T10:01:00Z',
}),
)
})
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(screen.queryByText('OLD_FAILURE')).not.toBeInTheDocument()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('ignores a delayed permanent poll rejection after a local retry', async () => {
vi.useFakeTimers()
let rejectPoll: ((error: unknown) => void) | undefined
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'rejected-failed-poll', state: 'failed' })] }],
}
getTaskSnapshot
.mockReturnValueOnce(
new Promise<DocumentProcessingTask>((_resolve, reject) => {
rejectPoll = reject
}),
)
.mockResolvedValueOnce(
task({
id: 'rejected-failed-poll',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:05:00Z',
}),
)
retryMutation.mutateAsync.mockResolvedValue(
task({
id: 'rejected-failed-poll',
progressPercent: 0,
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
fireEvent.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await act(async () => {})
await act(async () => rejectPoll?.(new Response(null, { status: 403 })))
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'rejected-failed-poll',
state: 'failed',
updatedAt: '2026-07-20T10:04:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('does not repoll the same failed snapshot after document read recovery', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'forbidden-failed-poll', state: 'failed' })] }],
}
getTaskSnapshot.mockRejectedValue(new Response(null, { status: 403 }))
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [{ items: [task({ id: 'forbidden-failed-poll', state: 'failed' })] }],
}
tasksQuery.dataUpdateCount += 1
act(() => notifyTaskQuerySuccess())
await act(async () => vi.advanceTimersByTime(60000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('accepts a same-version active list lifecycle after a failed poll is denied', async () => {
vi.useFakeTimers()
const taskVersion = '2026-07-20T10:01:00Z'
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.dataUpdateCount = 1
tasksQuery.data = {
pages: [
{
items: [
task({ id: 'denied-failed-poll-retry', state: 'failed', updatedAt: taskVersion }),
],
},
],
}
getTaskSnapshot.mockRejectedValueOnce(new Response(null, { status: 403 }))
streamProcessingTaskEvents.mockImplementation(async function* () {
await new Promise<void>(() => {})
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [
task({ id: 'denied-failed-poll-retry', state: 'running', updatedAt: taskVersion }),
],
},
],
}
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await act(async () => {})
expect(streamProcessingTaskEvents).toHaveBeenCalledOnce()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('advances failed-task polling when a snapshot request never settles', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: Array.from({ length: 7 }, (_, index) =>
task({ id: `failed-${index}`, state: 'failed' }),
),
},
],
}
getTaskSnapshot.mockImplementation((input: { params: { taskId: string } }) => {
if (input.params.taskId !== 'failed-0') return new Promise<DocumentProcessingTask>(() => {})
return Promise.resolve(
task({
id: 'failed-0',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:02:00Z',
}),
)
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(6)
await act(async () => vi.advanceTimersByTime(3000))
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(12)
expect(getTaskSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ taskId: 'failed-0' }) }),
expect.anything(),
)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('forgets permanent poll failures after a task leaves the cursor result', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'removed-failed-task', state: 'failed' })] }],
}
getTaskSnapshot.mockRejectedValue(new Response(null, { status: 403 }))
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = { pages: [{ items: [] }] }
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => {})
tasksQuery.data = {
pages: [{ items: [task({ id: 'removed-failed-task', state: 'failed' })] }],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('ignores an older active snapshot while polling a newer failed task', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
task({
errorCode: 'LATEST_FAILURE',
id: 'stale-active-poll',
state: 'failed',
updatedAt: '2026-07-20T10:03:00.123789Z',
}),
],
},
],
}
getTaskSnapshot.mockResolvedValue(
task({
id: 'stale-active-poll',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00.123456Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
expect(streamProcessingTaskEvents).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByText('LATEST_FAILURE')).toBeInTheDocument()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('accepts a terminal-only stream after an exact snapshot observes an external retry', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
task({
errorCode: 'OLD_FAILURE',
id: 'terminal-only-external-retry',
state: 'failed',
updatedAt: '2026-07-20T10:01:00Z',
}),
],
},
],
}
getTaskSnapshot
.mockResolvedValueOnce(
task({
id: 'terminal-only-external-retry',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
.mockResolvedValueOnce(
task({
errorCode: 'NEW_FAILURE',
id: 'terminal-only-external-retry',
state: 'failed',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: { errorCode: 'NEW_FAILURE', state: 'failed' as const },
event: 'terminal' as const,
id: 'terminal-only-external-retry:terminal',
}
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
await act(async () => {})
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
fireEvent.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(screen.getByText('NEW_FAILURE')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('unblocks failed-task polling after an active list snapshot without a terminal pin', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'list-retry-after-block', state: 'failed' })] }],
}
getTaskSnapshot
.mockRejectedValueOnce(new Response(null, { status: 403 }))
.mockResolvedValueOnce(
task({
id: 'list-retry-after-block',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:05:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'list-retry-after-block',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => {})
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'list-retry-after-block',
state: 'failed',
updatedAt: '2026-07-20T10:04:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('retries exact polling when a permanently blocked failed task advances version', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'new-failed-generation',
state: 'failed',
updatedAt: '2026-07-20T10:01:00Z',
}),
],
},
],
}
getTaskSnapshot
.mockRejectedValueOnce(new Response(null, { status: 403 }))
.mockResolvedValueOnce(
task({
id: 'new-failed-generation',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:03:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
tasksQuery.data = {
pages: [
{
items: [
task({
id: 'new-failed-generation',
state: 'failed',
updatedAt: '2026-07-20T10:02:00Z',
}),
],
},
],
}
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('aborts an in-flight failed-task poll on unmount', async () => {
vi.useFakeTimers()
let pollSignal: AbortSignal | undefined
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [{ items: [task({ id: 'aborted-failed-poll', state: 'failed' })] }],
}
getTaskSnapshot.mockImplementation(
(_input: unknown, options: { signal: AbortSignal }) =>
new Promise<DocumentProcessingTask>((_resolve, reject) => {
pollSignal = options.signal
options.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(pollSignal?.aborted).toBe(false)
rendered.unmount()
expect(pollSignal?.aborted).toBe(true)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('renders large document results in bounded batches', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [
{
items: Array.from({ length: 150 }, (_, index) =>
document({ id: `document-${index}`, title: `Document ${index}` }),
),
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getAllByRole('row')).toHaveLength(101)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.loadMore' }))
expect(screen.getAllByRole('row')).toHaveLength(151)
expect(screen.getByRole('table').parentElement).toHaveFocus()
})
it('eagerly exhausts task and source cursor pages for accurate labels', () => {
documentsQuery.data = {
pages: [{ items: [document({ sourceId: 'source-on-later-page' })] }],
}
tasksQuery.data = { pages: [{ items: [], nextCursor: 'next' }] }
tasksQuery.hasNextPage = true
sourcesQuery.data = { pages: [{ items: [], nextCursor: 'source-next' }] }
sourcesQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(tasksQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('keeps filtered results partial while task-dependent statuses have more pages', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'next' })),
}
tasksQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.selectOptions(screen.getByRole('combobox'), 'failed')
expect(screen.queryByText('dataset.newKnowledge.noMatchingDocuments')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.partialDocumentResults')).toBeInTheDocument()
})
it('keeps source-name searches partial while unresolved sources have more pages', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [
{
items: [document({ sourceId: 'later-source', userMetadata: {} })],
},
],
}
sourcesQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'next' })),
}
sourcesQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'source title from next page',
)
expect(screen.queryByText('dataset.newKnowledge.noMatchingDocuments')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.partialDocumentResults')).toBeInTheDocument()
})
it('bounds automatic cursor exhaustion and leaves all further loading explicit', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: Array.from({ length: 20 }, (_, index) => ({
items: index === 0 ? [document({ sourceId: 'source-on-later-page' })] : [],
nextCursor: 'next',
})),
}
documentsQuery.hasNextPage = true
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'next' })),
}
tasksQuery.hasNextPage = true
sourcesQuery.data = {
pages: Array.from({ length: 20 }, (_, index) => ({
items: index === 0 ? [source()] : [],
nextCursor: 'next',
})),
}
sourcesQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByRole('searchbox', { name: 'dataset.newKnowledge.searchDocuments' }),
'sso',
)
expect(documentsQuery.fetchNextPage).not.toHaveBeenCalled()
expect(screen.getByText('dataset.newKnowledge.partialDocumentResults')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-describedby',
'partial-document-results',
)
const loadMore = screen.getByRole('button', { name: 'dataset.newKnowledge.loadMore' })
expect(loadMore).toBeInTheDocument()
await user.clear(screen.getByRole('searchbox'))
await user.type(screen.getByRole('searchbox'), 'missing')
expect(screen.queryByText('dataset.newKnowledge.noMatchingDocuments')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.partialDocumentResults')).toBeInTheDocument()
await user.click(loadMore)
expect(documentsQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(tasksQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('keeps explicit result pagination busy while a dependency page is loading', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'next' })),
}
tasksQuery.hasNextPage = true
tasksQuery.isFetchingNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.loadMore' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('keeps known documents actionable while older task and source pages remain', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: Array.from({ length: 20 }, () => ({ items: [], nextCursor: 'next' })),
}
tasksQuery.hasNextPage = true
sourcesQuery.data = {
pages: Array.from({ length: 20 }, (_, index) => ({
items: index === 0 ? [source()] : [],
nextCursor: 'next',
})),
}
sourcesQuery.hasNextPage = true
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).not.toHaveAttribute(
'aria-disabled',
)
expect(
within(screen.getByRole('row', { name: /sso-enterprise\.pdf/ })).getByText(
'dataset.newKnowledge.documentStatus.ready',
),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.loadMore' }),
).toBeInTheDocument()
})
it('locks cached documents when the task query returns a permission denial', () => {
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
tasksQuery.error = { status: 403 }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentsPermissionTitle',
)
expect(screen.queryByText('sso-enterprise.pdf')).not.toBeInTheDocument()
expect(screen.queryByLabelText('dataset.newKnowledge.uploadDocuments')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}' }),
).not.toBeInTheDocument()
})
it('retires a failed task action before background invalidation settles', async () => {
const user = userEvent.setup()
retryMutation.mutateAsync.mockRejectedValueOnce(new Error('retry failed'))
queryClient.invalidateQueries.mockImplementation(() => new Promise<void>(() => {}))
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task({ id: 'failed', state: 'failed' })] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
expect(await screen.findByText('dataset.newKnowledge.taskActionFailed')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' })).toHaveAttribute(
'aria-busy',
'false',
)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).not.toHaveAttribute('aria-disabled', 'true')
})
it('trusts same-version stream progress after a local retry', async () => {
const user = userEvent.setup()
const retryVersion = '2026-07-20T10:01:00Z'
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [task({ id: 'same-version-retry', state: 'failed', updatedAt: retryVersion })],
},
],
}
retryMutation.mutateAsync.mockResolvedValue(
task({ id: 'same-version-retry', state: 'queued', updatedAt: retryVersion }),
)
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 20,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: retryVersion,
},
event: 'progress' as const,
id: 'same-version-retry:running',
}
await new Promise<void>(() => {})
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
await waitFor(() => expect(streamProcessingTaskEvents).toHaveBeenCalled())
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'dataset.newKnowledge.retryTask' })).toBeNull()
})
it('retries a permission-blocked failed-task poll after permission returns', async () => {
vi.useFakeTimers()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [task({ id: 'permission-blocked-poll', state: 'failed' })],
},
],
}
getTaskSnapshot
.mockRejectedValueOnce(new Response(null, { status: 403 }))
.mockResolvedValueOnce(
task({
id: 'permission-blocked-poll',
state: 'dispatch_pending',
updatedAt: '2026-07-20T10:02:00Z',
}),
)
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
try {
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledOnce()
documentsQuery.error = { status: 403 }
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
documentsQuery.error = null
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await act(async () => vi.advanceTimersByTime(5000))
expect(getTaskSnapshot).toHaveBeenCalledTimes(2)
} finally {
rendered.unmount()
vi.useRealTimers()
}
})
it('restores document focus when a focused bulk toolbar is cleared', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.clearDocumentSelection' }),
)
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus(),
)
rendered.unmount()
})
it('announces upload and re-index operations as busy', async () => {
const user = userEvent.setup()
uploadMutation.mutateAsync.mockImplementation(() => new Promise(() => {}))
const emptyPage = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadDocuments'),
new File(['one'], 'one.md', { type: 'text/markdown' }),
)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }),
).toHaveAttribute('aria-busy', 'true')
emptyPage.unmount()
reindexMutation.mutateAsync.mockImplementation(() => new Promise(() => {}))
documentsQuery.data = { pages: [{ items: [document()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }))
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }),
).toHaveAttribute('aria-busy', 'true')
})
it('locks uploads after a write mutation reveals revoked permission', async () => {
const user = userEvent.setup()
uploadMutation.mutateAsync.mockRejectedValueOnce(new Response(null, { status: 403 }))
permissionStateMock.refreshAfterDenial.mockResolvedValueOnce({
data: { dataset: { default_permission_keys: ['dataset.acl.readonly'] } },
error: null,
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
const addDocument = screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })
await user.click(addDocument)
expect(addDocument).toHaveFocus()
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadDocuments'),
new File(['one'], 'one.md', { type: 'text/markdown' }),
)
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledOnce())
expect(toastMock.error).not.toHaveBeenCalledWith('dataset.newKnowledge.documentUploadFailed')
const restriction = screen.getByText('dataset.newKnowledge.documentPermissionRestricted')
expect(restriction).toBeInTheDocument()
expect(restriction).toHaveAttribute('role', 'status')
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeDisabled()
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus()
permissionStateMock.datasetKeys = ['dataset.acl.readonly']
permissionStateMock.fetching = true
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
permissionStateMock.fetching = false
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeDisabled()
permissionStateMock.datasetKeys = ['dataset.acl.edit']
permissionStateMock.fetching = true
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
permissionStateMock.fetching = false
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }),
).toBeEnabled(),
)
})
it('preserves a newer focus target when an upload later loses write permission', async () => {
const user = userEvent.setup()
let rejectUpload!: (error: unknown) => void
uploadMutation.mutateAsync.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectUpload = reject
}),
)
permissionStateMock.refreshAfterDenial.mockResolvedValueOnce({
data: { dataset: { default_permission_keys: ['dataset.acl.readonly'] } },
error: null,
})
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }))
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadDocuments'),
new File(['one'], 'one.md', { type: 'text/markdown' }),
)
const tasksButton = screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
})
tasksButton.focus()
expect(tasksButton).toHaveFocus()
await act(async () => rejectUpload(new Response(null, { status: 403 })))
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledOnce())
expect(tasksButton).toHaveFocus()
})
it('locks re-indexing after a write mutation reveals revoked permission', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
reindexMutation.mutateAsync.mockRejectedValueOnce(new Response(null, { status: 403 }))
permissionStateMock.refreshAfterDenial.mockResolvedValueOnce({
data: { dataset: { default_permission_keys: ['dataset.acl.readonly'] } },
error: null,
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }))
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledOnce())
expect(toastMock.error).not.toHaveBeenCalledWith('dataset.newKnowledge.documentsReindexFailed')
expect(
screen.queryByRole('group', { name: 'dataset.newKnowledge.bulkDocumentActions' }),
).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'sso-enterprise.pdf' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus()
})
it('locks task actions after a write mutation reveals revoked permission', async () => {
const user = userEvent.setup()
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
cancelMutation.mutateAsync.mockRejectedValueOnce(new Response(null, { status: 403 }))
permissionStateMock.refreshAfterDenial.mockResolvedValueOnce({
data: { dataset: { default_permission_keys: ['dataset.acl.readonly'] } },
error: null,
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledOnce())
expect(
within(screen.getByRole('dialog')).getByText(
'dataset.newKnowledge.documentPermissionRestricted',
),
).toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.taskActionFailed')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus()
permissionStateMock.error = new Error('permission refresh failed')
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
const dialog = screen.getByRole('dialog')
expect(
within(dialog).getByText('dataset.newKnowledge.documentPermissionRestricted'),
).toBeInTheDocument()
expect(within(dialog).getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionLoadFailed',
)
})
it('does not let an older permission refresh release the latest write lock', async () => {
const user = userEvent.setup()
let rejectUpload: ((reason: unknown) => void) | undefined
let rejectCancel: ((reason: unknown) => void) | undefined
let resolveFirstRefresh: ((value: unknown) => void) | undefined
let resolveSecondRefresh: ((value: unknown) => void) | undefined
uploadMutation.mutateAsync.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectUpload = reject
}),
)
cancelMutation.mutateAsync.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectCancel = reject
}),
)
permissionStateMock.refreshAfterDenial
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstRefresh = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecondRefresh = resolve
}),
)
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = { pages: [{ items: [task()] }] }
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.interruptTask' }))
fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), {
target: { files: [new File(['one'], 'one.md', { type: 'text/markdown' })] },
})
expect(cancelMutation.mutateAsync).toHaveBeenCalledOnce()
expect(uploadMutation.mutateAsync).toHaveBeenCalledOnce()
await act(async () => rejectUpload?.(new Response(null, { status: 403 })))
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledOnce())
await act(async () => rejectCancel?.(new Response(null, { status: 403 })))
await waitFor(() => expect(permissionStateMock.refreshAfterDenial).toHaveBeenCalledTimes(2))
await act(async () =>
resolveFirstRefresh?.({
data: { dataset: { default_permission_keys: ['dataset.acl.edit'] } },
error: null,
}),
)
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
await act(async () =>
resolveSecondRefresh?.({
data: { dataset: { default_permission_keys: ['dataset.acl.readonly'] } },
error: null,
}),
)
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).not.toBeInTheDocument()
})
it('observes a structurally shared same-version retry when response timestamps collide', async () => {
const user = userEvent.setup()
const taskVersion = '2026-07-20T10:03:00Z'
const sharedTaskData = {
pages: [{ items: [task({ id: 'shared-active-retry', updatedAt: taskVersion })] }],
}
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = sharedTaskData
tasksQuery.dataUpdatedAt = 100
tasksQuery.dataUpdateCount = 1
getTaskSnapshot
.mockRejectedValueOnce(new Error('snapshot unavailable'))
.mockResolvedValueOnce(
task({ id: 'shared-active-retry', state: 'running', updatedAt: taskVersion }),
)
streamFailedTaskThenWait('shared-active-retry')
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(await screen.findByText('PARSER_FAILED')).toBeInTheDocument()
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = sharedTaskData
tasksQuery.dataUpdatedAt = 100
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
})
it('rechecks a same-version active lifecycle after a denied terminal snapshot', async () => {
const user = userEvent.setup()
const taskVersion = '2026-07-20T10:03:00Z'
const sharedTaskData = {
pages: [{ items: [task({ id: 'denied-active-retry', updatedAt: taskVersion })] }],
}
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = sharedTaskData
tasksQuery.dataUpdatedAt = 100
tasksQuery.dataUpdateCount = 1
getTaskSnapshot
.mockRejectedValueOnce(new Response(null, { status: 403 }))
.mockResolvedValueOnce(
task({ id: 'denied-active-retry', state: 'running', updatedAt: taskVersion }),
)
streamFailedTaskThenWait('denied-active-retry')
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledOnce())
tasksQuery.data = sharedTaskData
tasksQuery.dataUpdatedAt = 100
tasksQuery.dataUpdateCount = 2
act(() => notifyTaskQuerySuccess())
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalledTimes(2))
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
})
it('reconciles a same-version terminal list after trusted local retry progress', async () => {
const user = userEvent.setup()
const taskVersion = '2026-07-20T10:01:00Z'
documentsQuery.data = { pages: [{ items: [document()] }] }
tasksQuery.data = {
pages: [
{
items: [task({ id: 'trusted-terminal', state: 'failed', updatedAt: taskVersion })],
},
],
}
retryMutation.mutateAsync.mockResolvedValue(
task({ id: 'trusted-terminal', state: 'queued', updatedAt: taskVersion }),
)
getTaskSnapshot.mockResolvedValue(
task({
errorCode: 'TERMINAL_CONFIRMED',
id: 'trusted-terminal',
state: 'failed',
updatedAt: taskVersion,
}),
)
streamProcessingTaskEvents.mockImplementation(async function* () {
yield {
data: {
progressPercent: 30,
stage: 'parsed' as const,
state: 'running' as const,
updatedAt: taskVersion,
},
event: 'progress' as const,
id: 'trusted-terminal:running',
}
await new Promise<void>(() => {})
})
const rendered = render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":1}',
}),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
expect(
await screen.findByRole('button', { name: 'dataset.newKnowledge.interruptTask' }),
).toBeInTheDocument()
tasksQuery.data = {
pages: [
{
items: [task({ id: 'trusted-terminal', state: 'failed', updatedAt: taskVersion })],
},
],
}
tasksQuery.dataUpdatedAt = 100
rendered.rerender(<DocumentsPage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(getTaskSnapshot).toHaveBeenCalled())
expect(await screen.findByText('TERMINAL_CONFIRMED')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).toBeInTheDocument()
})
})