diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f157984..60c75bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,8 @@ jobs: *) echo "Unsupported Node platform: $(node -p "process.platform + '-' + process.arch")"; exit 1 ;; esac npm install --no-save "${PKG}@${ROLLUP_VERSION}" - - run: npm test + # Same vitest suite as npm test; --coverage enforces thresholds in vitest.config.js + - run: npm run test:coverage benchmarks: name: Performance benchmarks (informational) diff --git a/static/js/app.test.js b/static/js/app.test.js new file mode 100644 index 0000000..03a399c --- /dev/null +++ b/static/js/app.test.js @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { state } from './shared/state.js'; + +const showProjects = vi.fn(); +const showWorkspace = vi.fn(); +const loadSession = vi.fn(); +const showSearchPage = vi.fn(); + +vi.mock('./projects.js', () => ({ showProjects })); +vi.mock('./sessions.js', () => ({ showWorkspace, loadSession, selectSession: vi.fn(), copyAll: vi.fn() })); +vi.mock('./search.js', () => ({ showSearchPage, doSearch: vi.fn() })); +vi.mock('./export.js', () => ({ bulkExport: vi.fn(), downloadSession: vi.fn() })); +vi.mock('./shared/theme.js', () => ({ + HLJS_THEME_SHEETS: {}, + applyHljsTheme: vi.fn(), + applyTheme: vi.fn(), + toggleTheme: vi.fn(), + setWorkspaceMode: vi.fn(), +})); + +describe('router (app.js)', () => { + const origScrollTo = window.scrollTo; + const origScrollIntoView = Element.prototype.scrollIntoView; + + beforeAll(async () => { + window.scrollTo = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + await import('./app.js'); + document.dispatchEvent(new Event('DOMContentLoaded')); + }); + + afterAll(() => { + window.scrollTo = origScrollTo; + Element.prototype.scrollIntoView = origScrollIntoView; + }); + + beforeEach(() => { + document.body.innerHTML = '
'; + state.currentProject = null; + state.cachedSessions = []; + state.navInProgress = false; + showProjects.mockClear(); + showWorkspace.mockClear(); + loadSession.mockClear(); + showSearchPage.mockClear(); + window.location.hash = ''; + localStorage.clear(); + }); + + function routeTo(hash) { + window.location.hash = hash; + window.dispatchEvent(new HashChangeEvent('hashchange')); + } + + it('dispatches default hash to showProjects', () => { + routeTo(''); + expect(showProjects).toHaveBeenCalled(); + }); + + it('dispatches #search to showSearchPage on hashchange', () => { + showProjects.mockClear(); + routeTo('#search'); + expect(showSearchPage).toHaveBeenCalledTimes(1); + expect(showProjects).not.toHaveBeenCalled(); + }); + + it('dispatches #project/ to showWorkspace', () => { + showProjects.mockClear(); + routeTo('#project/my-project'); + expect(showWorkspace).toHaveBeenCalledWith('my-project'); + }); + + it('dispatches #project// to showWorkspace when cache is cold', () => { + routeTo('#project/my-project/sess-abc'); + expect(showWorkspace).toHaveBeenCalledWith('my-project', 'sess-abc'); + expect(loadSession).not.toHaveBeenCalled(); + }); + + it('loads session from cache when project matches and sidebar exists', () => { + state.currentProject = 'my-project'; + state.cachedSessions = [{ id: 'sess-abc' }]; + document.body.innerHTML += ''; + routeTo('#project/my-project/sess-abc'); + expect(loadSession).toHaveBeenCalledWith('my-project', 'sess-abc'); + expect(showWorkspace).not.toHaveBeenCalled(); + expect(document.getElementById('sidebar-sess-abc').classList.contains('active')).toBe(true); + }); + + it('falls back to showProjects when project name is a malformed URI', () => { + showProjects.mockClear(); + routeTo('#project/%E0%A4%A'); + expect(showProjects).toHaveBeenCalled(); + expect(showWorkspace).not.toHaveBeenCalled(); + }); + + it('falls back to showProjects when session project segment is malformed', () => { + showProjects.mockClear(); + routeTo('#project/%E0%A4%A/sess-1'); + expect(showProjects).toHaveBeenCalled(); + expect(showWorkspace).not.toHaveBeenCalled(); + }); + + it('re-runs routing when hashchange fires', () => { + showProjects.mockClear(); + routeTo('#search'); + expect(showSearchPage).toHaveBeenCalledTimes(1); + showSearchPage.mockClear(); + routeTo('#project/other'); + expect(showWorkspace).toHaveBeenCalledWith('other'); + }); +}); diff --git a/static/js/export.test.js b/static/js/export.test.js new file mode 100644 index 0000000..cc5ab48 --- /dev/null +++ b/static/js/export.test.js @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./projects.js', () => ({ showProjects: vi.fn() })); +vi.mock('./shared/utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + showConfirm: vi.fn((_, cb) => { cb(); }), + }; +}); + +import { bulkExport, downloadSession } from './export.js'; +import { showProjects } from './projects.js'; + +const mockWritable = { + write: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()), + abort: vi.fn(() => Promise.resolve()), +}; + +const mockHandle = { + createWritable: vi.fn(() => Promise.resolve(mockWritable)), +}; + +describe('export', () => { + beforeEach(() => { + document.body.innerHTML = ` + + + `; + vi.stubGlobal('fetch', vi.fn()); + vi.stubGlobal('showSaveFilePicker', vi.fn(() => Promise.resolve(mockHandle))); + mockWritable.write.mockClear(); + mockWritable.close.mockClear(); + mockHandle.createWritable.mockClear(); + showProjects.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('bulkExport shows progress then completes on success', async () => { + let resolveFetch; + const pending = new Promise((resolve) => { resolveFetch = resolve; }); + fetch.mockImplementation(() => pending.then(() => ({ + ok: true, + headers: { get: () => 'application/zip' }, + blob: () => Promise.resolve(new Blob(['zip'], { type: 'application/zip' })), + }))); + + bulkExport('all'); + const btn = document.getElementById('btn-export-all'); + await vi.waitFor(() => expect(btn.disabled).toBe(true)); + expect(btn.textContent).toContain('Exporting'); + + resolveFetch(); + await vi.waitFor(() => expect(btn.disabled).toBe(false)); + + expect(btn.textContent.trim()).toBe('Export all'); + expect(mockHandle.createWritable).toHaveBeenCalled(); + expect(showProjects).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith('/api/export', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ since: 'all' }), + })); + }); + + it('bulkExport surfaces 5xx errors via toast', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 500, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ error: 'export failed' }), + }); + + bulkExport('all'); + await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull()); + + expect(document.querySelector('.toast-error').textContent).toContain('export failed'); + expect(showProjects).not.toHaveBeenCalled(); + }); + + it('bulkExport surfaces 4xx errors via toast', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 403, + headers: { get: () => 'text/plain' }, + }); + + bulkExport('incremental'); + await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull()); + + expect(document.querySelector('.toast-error').textContent).toContain('Export failed: 403'); + }); + + it('downloadSession writes a blob via the file picker', async () => { + fetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['# markdown'], { type: 'text/markdown' })), + }); + + await downloadSession('alpha', 'sess-abcdef12'); + + expect(fetch).toHaveBeenCalledWith('/api/export/session/alpha/sess-abcdef12'); + expect(mockWritable.write).toHaveBeenCalled(); + expect(mockWritable.close).toHaveBeenCalled(); + }); + + it('downloadSession falls back to blob URL when file picker is unavailable', async () => { + vi.stubGlobal('showSaveFilePicker', undefined); + const createObjectURL = vi.fn(() => 'blob:fake-url'); + const revokeObjectURL = vi.fn(); + const origCreate = URL.createObjectURL; + const origRevoke = URL.revokeObjectURL; + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + const click = vi.fn(); + const anchor = document.createElement('a'); + anchor.click = click; + const createElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return anchor; + return createElement(tag); + }); + + fetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['content'], { type: 'text/markdown' })), + }); + + try { + await downloadSession('alpha', 'sess-abcdef12'); + + expect(createObjectURL).toHaveBeenCalled(); + expect(anchor.download).toBe('session-sess-abc.md'); + expect(click).toHaveBeenCalled(); + } finally { + if (origCreate) URL.createObjectURL = origCreate; + else delete URL.createObjectURL; + if (origRevoke) URL.revokeObjectURL = origRevoke; + else delete URL.revokeObjectURL; + } + }); +}); diff --git a/static/js/projects.test.js b/static/js/projects.test.js new file mode 100644 index 0000000..c0cf78e --- /dev/null +++ b/static/js/projects.test.js @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showProjects } from './projects.js'; + +// ProjectDict[] — mirrors models/project.py. +const PROJECT_FIXTURE = [ + { + name: 'alpha', + path: '/data/alpha', + display_name: 'Alpha Project', + session_count: 2, + last_modified: '2026-05-19T10:00:00Z', + }, + { + name: 'beta', + path: '/data/beta', + display_name: 'Beta Project', + session_count: 0, + last_modified: '2026-05-18T10:00:00Z', + }, +]; + +describe('showProjects', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders project cards from the API response', async () => { + fetch.mockImplementation((url) => { + if (url === '/api/projects') { + return Promise.resolve({ ok: true, json: () => Promise.resolve(PROJECT_FIXTURE) }); + } + if (url === '/api/export/state') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + + await showProjects(); + + const content = document.getElementById('content'); + expect(content.innerHTML).toContain('Alpha Project'); + expect(content.innerHTML).toContain('2 sessions'); + expect(content.innerHTML).toContain('Projects without Sessions'); + expect(content.innerHTML).toContain('Beta Project'); + }); + + it('shows empty state when no projects are returned', async () => { + fetch.mockImplementation((url) => { + if (url === '/api/projects') { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + + await showProjects(); + + const content = document.getElementById('content'); + expect(content.innerHTML).toContain('empty-state'); + expect(content.innerHTML).toContain('No Claude Code projects found'); + }); + + it('surfaces API errors', async () => { + fetch.mockImplementation((url) => { + if (url === '/api/projects') { + return Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: 'disk unavailable' }), + }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + + await showProjects(); + + expect(document.getElementById('content').innerHTML).toContain('disk unavailable'); + }); +}); diff --git a/static/js/render/tool_result/file_read.test.js b/static/js/render/tool_result/file_read.test.js new file mode 100644 index 0000000..1a4a3c0 --- /dev/null +++ b/static/js/render/tool_result/file_read.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { renderFileReadResult } from './file_read.js'; + +describe('renderFileReadResult', () => { + it('renders file path and line count in the summary', () => { + const html = renderFileReadResult({ + result_type: 'file_read', + file_path: '/src/main.cpp', + num_lines: 42, + }); + expect(html).toContain('/src/main.cpp'); + expect(html).toContain('42 lines'); + expect(html).toContain('tool-result'); + }); + + it('omits line count when num_lines is absent', () => { + const html = renderFileReadResult({ + result_type: 'file_read', + file_path: 'README.md', + }); + expect(html).toContain('Read: README.md'); + expect(html).not.toMatch(/\d+ lines/); + }); +}); diff --git a/static/js/render/tool_use/bash.test.js b/static/js/render/tool_use/bash.test.js new file mode 100644 index 0000000..9405693 --- /dev/null +++ b/static/js/render/tool_use/bash.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { renderBashUse } from './bash.js'; + +describe('renderBashUse', () => { + it('renders command text in the tool body', () => { + const html = renderBashUse({ + name: 'Bash', + input: { command: 'ls -la', description: 'list files' }, + }); + expect(html).toContain('ls -la'); + expect(html).toContain('list files'); + expect(html).toContain('tool-call'); + }); + + it('escapes HTML in the command', () => { + const html = renderBashUse({ + name: 'Bash', + input: { command: '' }, + }); + expect(html).not.toContain(' { + it('renders file path and old/new strings', () => { + const html = renderEditUse({ + name: 'Edit', + input: { + file_path: 'src/app.js', + old_string: 'const x = 1;', + new_string: 'const x = 2;', + }, + }); + expect(html).toContain('src/app.js'); + expect(html).toContain('const x = 1;'); + expect(html).toContain('const x = 2;'); + expect(html).toContain('tool-call'); + }); + + it('escapes HTML in edit strings', () => { + const html = renderEditUse({ + name: 'Edit', + input: { + file_path: 'x.txt', + old_string: '', + new_string: '', + }, + }); + expect(html).not.toContain(''); + expect(html).toContain('<bad>'); + expect(html).not.toContain(''); + expect(html).toContain('<worse>'); + }); +}); diff --git a/static/js/search.test.js b/static/js/search.test.js new file mode 100644 index 0000000..e5db512 --- /dev/null +++ b/static/js/search.test.js @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showSearchPage, doSearch } from './search.js'; + +// SearchHitDict[] — mirrors models/search.py (no highlight wrapper in search.js; snippets are escaped text only). +const SEARCH_HITS = [ + { + project: 'alpha', + session_id: 'sess-1', + title: 'First hit', + role: 'user', + timestamp: '2026-05-19T10:00:00Z', + snippet: 'matched keyword here', + }, + { + project: 'beta', + session_id: 'sess-2', + title: 'Second hit', + role: 'assistant', + timestamp: '2026-05-19T11:00:00Z', + snippet: 'another line', + }, +]; + +describe('search page', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.stubGlobal('fetch', vi.fn()); + window.location.hash = ''; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('showSearchPage renders the search UI and sets hash', () => { + showSearchPage(); + expect(window.location.hash).toBe('#search'); + expect(document.getElementById('search-input')).not.toBeNull(); + expect(document.getElementById('search-results')).not.toBeNull(); + expect(document.getElementById('content').innerHTML).toContain('Search conversations'); + }); + + it('doSearch renders results with snippet text', async () => { + showSearchPage(); + fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(SEARCH_HITS), + }); + document.getElementById('search-input').value = 'keyword'; + + await doSearch(); + + const results = document.getElementById('search-results'); + expect(results.innerHTML).toContain('2 results'); + expect(results.innerHTML).toContain('First hit'); + expect(results.innerHTML).toContain('matched keyword here'); + expect(results.querySelectorAll('.search-result').length).toBe(2); + }); + + it('doSearch shows empty state when no hits', async () => { + showSearchPage(); + fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + document.getElementById('search-input').value = 'nothing'; + + await doSearch(); + + const results = document.getElementById('search-results'); + expect(results.innerHTML).toContain('0 results'); + expect(results.innerHTML).toContain('No results found'); + }); + + it('doSearch surfaces HTTP errors', async () => { + showSearchPage(); + fetch.mockResolvedValue({ ok: false, status: 503, text: () => Promise.resolve('unavailable') }); + document.getElementById('search-input').value = 'fail'; + + await doSearch(); + + expect(document.getElementById('search-results').innerHTML).toContain('Error:'); + expect(document.getElementById('search-results').innerHTML).toContain('unavailable'); + }); + + it('doSearch ignores stale responses when a newer request was started', async () => { + showSearchPage(); + let resolveFirst; + const first = new Promise((resolve) => { resolveFirst = resolve; }); + fetch + .mockImplementationOnce(() => first.then(() => ({ + ok: true, + json: () => Promise.resolve(SEARCH_HITS), + }))) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + document.getElementById('search-input').value = 'slow'; + const slow = doSearch(); + document.getElementById('search-input').value = 'fast'; + await doSearch(); + resolveFirst(); + await slow; + + expect(document.getElementById('search-results').innerHTML).toContain('0 results'); + }); +}); diff --git a/static/js/sessions.test.js b/static/js/sessions.test.js new file mode 100644 index 0000000..74b2044 --- /dev/null +++ b/static/js/sessions.test.js @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { state } from './shared/state.js'; +import { showWorkspace, loadSession, selectSession, copyAll } from './sessions.js'; + +// Session list + detail shapes mirror models/session.py (ProjectSessionRowDict / SessionDict). +const SESSION_LIST = [ + { + id: 'sess-1', + path: '/data/sess-1.jsonl', + size_bytes: 1024, + modified: 1716112800, + title: 'First chat', + models: ['claude-sonnet'], + first_timestamp: '2026-05-19T10:00:00Z', + last_timestamp: '2026-05-19T10:30:00Z', + }, + { + id: 'sess-2', + path: '/data/sess-2.jsonl', + size_bytes: 2048, + modified: 1716116400, + title: 'Second chat', + models: ['claude-opus'], + first_timestamp: '2026-05-19T11:00:00Z', + last_timestamp: '2026-05-19T11:15:00Z', + }, +]; + +const SESSION_DETAIL = { + session_id: 'sess-1', + title: 'First chat', + messages: [ + { role: 'user', text: 'Hello world', timestamp: '2026-05-19T10:00:00Z' }, + { + role: 'assistant', + text: 'Hi there', + timestamp: '2026-05-19T10:01:00Z', + model: 'claude-sonnet', + usage: { output_tokens: 12 }, + }, + ], + metadata: { + models_used: ['claude-sonnet'], + total_input_tokens: 10, + total_output_tokens: 20, + total_tool_calls: 0, + compactions: 0, + first_timestamp: '2026-05-19T10:00:00Z', + last_timestamp: '2026-05-19T10:30:00Z', + }, +}; + +function mockWorkspaceFetch() { + fetch.mockImplementation((url) => { + if (url === '/api/projects') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ name: 'alpha', display_name: 'Alpha' }]), + }); + } + if (url === '/api/projects/alpha/sessions') { + return Promise.resolve({ ok: true, json: () => Promise.resolve(SESSION_LIST) }); + } + if (url.startsWith('/api/sessions/alpha/')) { + const sessionId = decodeURIComponent(url.split('/').pop()); + const row = SESSION_LIST.find((s) => s.id === sessionId) ?? SESSION_LIST[0]; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + ...SESSION_DETAIL, + session_id: sessionId, + title: row.title, + }), + }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); +} + +describe('sessions workspace', () => { + let clipboardRestore; + + beforeEach(() => { + document.body.innerHTML = '
'; + state.currentProject = null; + state.cachedSessions = []; + state.projectDisplayNames = {}; + vi.stubGlobal('fetch', vi.fn()); + window.location.hash = ''; + }); + + afterEach(() => { + if (clipboardRestore) { + Object.defineProperty(navigator, 'clipboard', { + value: clipboardRestore, + configurable: true, + writable: true, + }); + clipboardRestore = null; + } + vi.unstubAllGlobals(); + }); + + it('showWorkspace populates the sidebar with session entries', async () => { + mockWorkspaceFetch(); + await showWorkspace('alpha'); + + const sidebar = document.getElementById('sidebar'); + expect(sidebar).not.toBeNull(); + expect(sidebar.innerHTML).toContain('First chat'); + expect(sidebar.innerHTML).toContain('Second chat'); + expect(sidebar.querySelectorAll('.sidebar-item').length).toBe(2); + expect(state.cachedSessions).toHaveLength(2); + }); + + it('showWorkspace marks the selected session active in the sidebar', async () => { + mockWorkspaceFetch(); + await showWorkspace('alpha', 'sess-2'); + + const active = document.querySelector('.sidebar-item.active'); + expect(active).not.toBeNull(); + expect(active.id).toBe('sidebar-sess-2'); + }); + + it('loadSession renders messages in the main panel', async () => { + mockWorkspaceFetch(); + await showWorkspace('alpha'); + await loadSession('alpha', 'sess-1'); + + const panel = document.getElementById('session-content'); + expect(panel.innerHTML).toContain('First chat'); + expect(panel.innerHTML).toContain('Hello world'); + expect(panel.innerHTML).toContain('Hi there'); + expect(panel.querySelector('.bubble-user')).not.toBeNull(); + expect(panel.querySelector('.bubble-ai')).not.toBeNull(); + }); + + it('loadSession surfaces HTTP errors', async () => { + document.body.innerHTML = '
'; + fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ error: 'missing session' }), + }); + + await loadSession('alpha', 'missing'); + + expect(document.getElementById('session-content').innerHTML).toContain('missing session'); + }); + + it('selectSession updates the location hash', () => { + selectSession('alpha', 'sess-2'); + expect(window.location.hash).toBe('#project/alpha/sess-2'); + }); + + it('copyAll writes session text to the clipboard', async () => { + const writeText = vi.fn(() => Promise.resolve()); + clipboardRestore = navigator.clipboard; + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, + }); + const el = document.createElement('div'); + el.className = 'session-content-inner'; + el.textContent = 'Line one\nLine two'; + Object.defineProperty(el, 'innerText', { get: () => el.textContent }); + document.body.appendChild(el); + + copyAll(); + + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('Line one\nLine two')); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index db7b414..73cb255 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -9,6 +9,11 @@ export default defineConfig({ reporter: ['text', 'lcov'], include: ['static/js/**/*.js'], exclude: ['static/js/**/*.test.js'], + thresholds: { + lines: 80, + functions: 70, + branches: 50, + }, }, }, });