Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/tests/unit/offline-sync-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('OfflineSyncManager', () => {
it('marks failed items and stops when no item progresses', async () => {
const storage = new MemoryStorage()
storage.queue = [item('1')]
const manager = new OfflineSyncManager(storage, jest.fn().mockRejectedValue(new Error('offline')))
const manager = new OfflineSyncManager(storage, jest.fn().mockRejectedValue(new Error('offline')))
await manager.drainQueue()
expect(storage.queue[0]).toMatchObject({ status: 'failed', lastError: 'offline' })

Expand Down
4 changes: 2 additions & 2 deletions ui/web/hooks/useNoteBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ type UseNoteBulkActionsParams = {
selectedNoteIds: Set<string>
isOffline: boolean
enqueueBatchAndDrainIfOnline: ReturnType<typeof useNoteSync>['enqueueBatchAndDrainIfOnline']
offlineCache: ReturnType<typeof useNoteSync>['offlineCache']
offlineCache: Pick<ReturnType<typeof useNoteSync>['offlineCache'], 'saveNote'>
setOfflineOverlay: ReturnType<typeof useNoteSync>['setOfflineOverlay']
setPendingCount: ReturnType<typeof useNoteSync>['setPendingCount']
deleteNoteMutation: ReturnType<typeof useDeleteNote>
deleteNoteMutation: Pick<ReturnType<typeof useDeleteNote>, 'mutateAsync'>
exitSelectionMode: ReturnType<typeof useNoteSelection>['exitSelectionMode']
setBulkDeleting: ReturnType<typeof useNoteSelection>['setBulkDeleting']
setSelectedNote: ReturnType<typeof useNoteSelection>['setSelectedNote']
Expand Down
14 changes: 8 additions & 6 deletions ui/web/hooks/useNoteSaveHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import type { useNoteSelection } from './useNoteSelection'
type UseNoteSaveHandlersParams = {
user: { id: string } | null
isOffline: boolean
offlineCache: ReturnType<typeof useNoteSync>['offlineCache']
offlineCache: Pick<ReturnType<typeof useNoteSync>['offlineCache'], 'saveNote'>
enqueueMutation: ReturnType<typeof useNoteSync>['enqueueMutation']
offlineQueueRef: ReturnType<typeof useNoteSync>['offlineQueueRef']
offlineQueueRef: {
current: Pick<ReturnType<typeof useNoteSync>['offlineQueueRef']['current'], 'getQueue'>
}
setOfflineOverlay: ReturnType<typeof useNoteSync>['setOfflineOverlay']
setPendingCount: ReturnType<typeof useNoteSync>['setPendingCount']
setFailedCount: ReturnType<typeof useNoteSync>['setFailedCount']
setLastSavedAt: ReturnType<typeof useNoteSync>['setLastSavedAt']
createNoteMutation: ReturnType<typeof useCreateNote>
updateNoteMutation: ReturnType<typeof useUpdateNote>
deleteNoteMutation: ReturnType<typeof useDeleteNote>
removeTagMutation: ReturnType<typeof useRemoveTag>
createNoteMutation: Pick<ReturnType<typeof useCreateNote>, 'mutateAsync'>
updateNoteMutation: Pick<ReturnType<typeof useUpdateNote>, 'mutateAsync'>
deleteNoteMutation: Pick<ReturnType<typeof useDeleteNote>, 'mutateAsync'>
removeTagMutation: Pick<ReturnType<typeof useRemoveTag>, 'mutateAsync'>
selectedNote: NoteViewModel | null
setSelectedNote: ReturnType<typeof useNoteSelection>['setSelectedNote']
setIsEditing: ReturnType<typeof useNoteSelection>['setIsEditing']
Expand Down
136 changes: 136 additions & 0 deletions ui/web/tests/unit/adapters/offlineStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { CachedNote, MutationQueueItem } from '@core/types/offline'
import { OFFLINE_CACHE_LIMIT_BYTES } from '@core/constants/offline'

jest.mock('@core/constants/offline', () => ({
OFFLINE_CACHE_LIMIT_BYTES: 200,
}))

import { webOfflineStorageAdapter } from '@ui/web/adapters/offlineStorage'

const makeNote = (id: string, updatedAt: string, extra: Partial<CachedNote> = {}): CachedNote => ({
id,
title: id,
description: `${id} description`,
tags: [id],
status: 'synced',
updatedAt,
...extra,
})

const makeQueueItem = (id: string, status: MutationQueueItem['status'] = 'pending'): MutationQueueItem => ({
id,
noteId: `note-${id}`,
operation: 'update',
payload: { title: id },
clientUpdatedAt: `2026-01-01T00:00:0${id.slice(-1)}Z`,
status,
})

describe('webOfflineStorageAdapter localStorage fallback', () => {
beforeEach(() => {
localStorage.clear()
})

afterEach(() => {
jest.restoreAllMocks()
})

it('saves, replaces, paginates, and deletes cached notes', async () => {
const first = makeNote('note-1', '2026-01-01T00:00:00Z')
const second = makeNote('note-2', '2026-01-02T00:00:00Z')

await webOfflineStorageAdapter.saveNote(first)
await webOfflineStorageAdapter.saveNote(second)
await webOfflineStorageAdapter.saveNote({ ...first, title: 'Updated' })

expect(await webOfflineStorageAdapter.loadNotes()).toEqual([
expect.objectContaining({ id: 'note-1', title: 'Updated' }),
second,
])
expect(await webOfflineStorageAdapter.loadNotes({ offset: 1, limit: 1 })).toEqual([second])

await webOfflineStorageAdapter.saveNotes([second])
expect(await webOfflineStorageAdapter.loadNotes()).toEqual([second])

await webOfflineStorageAdapter.deleteNote('note-2')
expect(await webOfflineStorageAdapter.loadNotes()).toEqual([])
})

it('maintains the mutation queue and updates item status', async () => {
const first = makeQueueItem('q-1')
const second = makeQueueItem('q-2', 'failed')

await webOfflineStorageAdapter.upsertQueueItem(first)
await webOfflineStorageAdapter.upsertQueueItem(second)
await webOfflineStorageAdapter.upsertQueueItem({ ...first, lastError: 'retry' })

expect(await webOfflineStorageAdapter.getQueue()).toEqual([
expect.objectContaining({ id: 'q-1', lastError: 'retry' }),
second,
])
expect(await webOfflineStorageAdapter.getPendingBatch(10)).toEqual([
expect.objectContaining({ id: 'q-1' }),
])

await webOfflineStorageAdapter.markQueueItemStatus('q-1', 'failed', 'network')
expect(await webOfflineStorageAdapter.getQueue()).toEqual([
expect.objectContaining({ id: 'q-1', status: 'failed', lastError: 'network' }),
second,
])

await webOfflineStorageAdapter.upsertQueue([first, second])
expect(await webOfflineStorageAdapter.popQueueBatch(1)).toEqual([first])
expect(await webOfflineStorageAdapter.getQueue()).toEqual([second])

await webOfflineStorageAdapter.removeQueueItems([])
await webOfflineStorageAdapter.removeQueueItems(['q-2'])
expect(await webOfflineStorageAdapter.getQueue()).toEqual([])
})

it('marks an existing note as synced and clears its pending operations', async () => {
await webOfflineStorageAdapter.saveNote(makeNote('note-1', '2026-01-01T00:00:00Z', {
status: 'pending',
pendingOps: ['update'],
}))

await webOfflineStorageAdapter.markSynced('note-1', '2026-02-01T00:00:00Z')

expect(await webOfflineStorageAdapter.loadNotes()).toEqual([
expect.objectContaining({
id: 'note-1',
status: 'synced',
updatedAt: '2026-02-01T00:00:00Z',
pendingOps: [],
}),
])

await webOfflineStorageAdapter.markSynced('missing', '2026-02-01T00:00:00Z')
await webOfflineStorageAdapter.markQueueItemStatus('missing', 'synced')
})

it('evicts the oldest notes when the configured cache limit is exceeded', async () => {
expect(OFFLINE_CACHE_LIMIT_BYTES).toBe(200)
const oldest = makeNote('old', '2020-01-01T00:00:00Z', { content: 'x'.repeat(200) })
const newest = makeNote('new', '2026-01-01T00:00:00Z')
await webOfflineStorageAdapter.saveNotes([oldest, newest])

await webOfflineStorageAdapter.enforceLimit()

expect(await webOfflineStorageAdapter.loadNotes()).toEqual([
expect.objectContaining({ id: 'new' }),
])
})

it('ignores malformed stored JSON and clears both stores', async () => {
localStorage.setItem('offline_notes', '{not json')
localStorage.setItem('offline_queue', '{not json')

expect(await webOfflineStorageAdapter.loadNotes()).toEqual([])
expect(await webOfflineStorageAdapter.getQueue()).toEqual([])

await webOfflineStorageAdapter.clearAll()

expect(localStorage.getItem('offline_notes')).toBeNull()
expect(localStorage.getItem('offline_queue')).toBeNull()
})
})
82 changes: 82 additions & 0 deletions ui/web/tests/unit/components/aiSearchToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AiSearchToggle } from '@ui/web/components/features/search/AiSearchToggle'

type MatchMediaMock = ReturnType<typeof createMatchMedia>

function createMatchMedia(matches: boolean) {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
const mediaQuery = {
matches,
media: '(hover: hover) and (pointer: fine)',
onchange: null,
addEventListener: jest.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener)),
removeEventListener: jest.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)),
dispatchEvent: jest.fn(() => true),
} as unknown as MediaQueryList
return { mediaQuery, listeners }
}

function installMatchMedia(matches: boolean): MatchMediaMock {
const mock = createMatchMedia(matches)
Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: jest.fn(() => mock.mediaQuery) })
return mock
}

describe('AiSearchToggle', () => {
it('toggles an available AI search setting and cleans up its media listener', () => {
const matchMedia = installMatchMedia(true)
const onChange = jest.fn()
const { unmount } = render(<AiSearchToggle enabled={false} hasApiKey onChange={onChange} />)

const toggle = screen.getByRole('switch', { name: 'Toggle AI RAG Search' })
expect((toggle as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledWith(true)
expect(screen.getByTestId('ai-search-toggle-info-trigger')).toBeTruthy()

unmount()
expect(matchMedia.mediaQuery.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})

it('disables the switch and describes the missing API key reason', () => {
installMatchMedia(true)
const onChange = jest.fn()
render(<AiSearchToggle enabled onChange={onChange} hasApiKey={false} />)

const toggle = screen.getByRole('switch', { name: 'Toggle AI RAG Search' })
const trigger = screen.getByTestId('ai-search-toggle-trigger')
expect((toggle as HTMLButtonElement).disabled).toBe(true)
expect(trigger.getAttribute('aria-disabled')).toBe('true')
expect(trigger.getAttribute('aria-describedby')).toBe('ai-search-toggle-missing-key-hint')
fireEvent.click(toggle)
expect(onChange).not.toHaveBeenCalled()
})

it('opens and closes the selection-blocked hint on touch and keyboard input', async () => {
installMatchMedia(false)
const onChange = jest.fn()
render(<AiSearchToggle enabled={false} hasApiKey onChange={onChange} disabled disabledTitle="Select a note first" />)

const trigger = screen.getByTestId('ai-search-toggle-trigger')
fireEvent.pointerDown(trigger)
expect((await screen.findByTestId('ai-search-toggle-disabled-hint')).textContent).toContain('Select a note first')
fireEvent.keyDown(trigger, { key: 'Enter' })
await waitFor(() => expect(screen.queryByTestId('ai-search-toggle-disabled-hint')).toBeNull())
fireEvent.keyDown(trigger, { key: ' ' })
expect(await screen.findByTestId('ai-search-toggle-disabled-hint')).toBeTruthy()
fireEvent.pointerDown(document.body)
await waitFor(() => expect(screen.queryByTestId('ai-search-toggle-disabled-hint')).toBeNull())
expect(onChange).not.toHaveBeenCalled()
})

it('closes the selection hint when the blocking condition is removed', async () => {
installMatchMedia(false)
const onChange = jest.fn()
const { rerender } = render(<AiSearchToggle enabled={false} hasApiKey onChange={onChange} disabled disabledTitle="Select a note first" />)
fireEvent.pointerDown(screen.getByTestId('ai-search-toggle-trigger'))
expect(await screen.findByTestId('ai-search-toggle-disabled-hint')).toBeTruthy()

rerender(<AiSearchToggle enabled={false} hasApiKey onChange={onChange} />)
await waitFor(() => expect(screen.queryByTestId('ai-search-toggle-disabled-hint')).toBeNull())
})
})
70 changes: 70 additions & 0 deletions ui/web/tests/unit/components/aiSearchViewTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AiSearchViewTabs } from '@ui/web/components/features/search/AiSearchViewTabs'

function installMatchMedia(matches: boolean) {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
const mediaQuery = {
matches,
media: '(hover: hover) and (pointer: fine)',
onchange: null,
addEventListener: jest.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener)),
removeEventListener: jest.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)),
dispatchEvent: jest.fn(() => true),
} as unknown as MediaQueryList
Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: jest.fn(() => mediaQuery) })
return mediaQuery
}

describe('AiSearchViewTabs', () => {
it('changes between note and chunk views and ignores unsupported values', () => {
installMatchMedia(true)
const onChange = jest.fn()
const { rerender } = render(<AiSearchViewTabs value="note" onChange={onChange} />)

const note = screen.getByRole('radio', { name: 'Note view' })
const chunk = screen.getByRole('radio', { name: 'Chunk view' })
expect(note.getAttribute('data-state')).toBe('on')
fireEvent.click(chunk)
expect(onChange).toHaveBeenCalledWith('chunk')
rerender(<AiSearchViewTabs value="chunk" onChange={onChange} />)
fireEvent.click(note)
expect(onChange).toHaveBeenCalledWith('note')
})

it('blocks disabled tabs and exposes the disabled state without a tooltip when no title exists', () => {
installMatchMedia(true)
const onChange = jest.fn()
render(<AiSearchViewTabs value="chunk" onChange={onChange} disabled />)

const note = screen.getByRole('radio', { name: 'Note view' })
const chunk = screen.getByRole('radio', { name: 'Chunk view' })
expect(note.getAttribute('aria-disabled')).toBe('true')
expect(note.getAttribute('data-disabled')).toBe('true')
expect(note.getAttribute('tabindex')).toBe('-1')
expect(chunk.getAttribute('data-state')).toBe('on')
fireEvent.pointerDown(note)
fireEvent.click(note)
expect(onChange).not.toHaveBeenCalled()
})

it('opens the disabled hint on touch, closes outside, and removes it when enabled', async () => {
const mediaQuery = installMatchMedia(false)
const onChange = jest.fn()
const { rerender, unmount } = render(
<AiSearchViewTabs value="note" onChange={onChange} disabled disabledTitle="Choose a note first" />
)

const trigger = screen.getByTestId('ai-search-view-tabs-trigger')
fireEvent.pointerDown(trigger)
expect((await screen.findByTestId('ai-search-view-tabs-disabled-hint')).textContent).toContain('Choose a note first')
fireEvent.pointerDown(document.body)
await waitFor(() => expect(screen.queryByTestId('ai-search-view-tabs-disabled-hint')).toBeNull())

fireEvent.pointerDown(trigger)
expect(await screen.findByTestId('ai-search-view-tabs-disabled-hint')).toBeTruthy()
rerender(<AiSearchViewTabs value="note" onChange={onChange} />)
await waitFor(() => expect(screen.queryByTestId('ai-search-view-tabs-disabled-hint')).toBeNull())
unmount()
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
})
Loading