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,
+ },
},
},
});