Skip to content

Commit 9f6fa32

Browse files
Add vitest coverage for router, pages, and tool renderers
1 parent 0435b2d commit 9f6fa32

8 files changed

Lines changed: 633 additions & 0 deletions

File tree

static/js/app.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { state } from './shared/state.js';
3+
4+
const showProjects = vi.fn();
5+
const showWorkspace = vi.fn();
6+
const loadSession = vi.fn();
7+
const showSearchPage = vi.fn();
8+
9+
vi.mock('./projects.js', () => ({ showProjects }));
10+
vi.mock('./sessions.js', () => ({ showWorkspace, loadSession, selectSession: vi.fn(), copyAll: vi.fn() }));
11+
vi.mock('./search.js', () => ({ showSearchPage, doSearch: vi.fn() }));
12+
vi.mock('./export.js', () => ({ bulkExport: vi.fn(), downloadSession: vi.fn() }));
13+
vi.mock('./shared/theme.js', () => ({
14+
HLJS_THEME_SHEETS: {},
15+
applyHljsTheme: vi.fn(),
16+
applyTheme: vi.fn(),
17+
toggleTheme: vi.fn(),
18+
setWorkspaceMode: vi.fn(),
19+
}));
20+
21+
describe('router (app.js)', () => {
22+
beforeAll(async () => {
23+
window.scrollTo = vi.fn();
24+
Element.prototype.scrollIntoView = vi.fn();
25+
await import('./app.js');
26+
document.dispatchEvent(new Event('DOMContentLoaded'));
27+
});
28+
29+
beforeEach(() => {
30+
document.body.innerHTML = '<div id="content"></div><span id="footer-year"></span>';
31+
state.currentProject = null;
32+
state.cachedSessions = [];
33+
state.navInProgress = false;
34+
showProjects.mockClear();
35+
showWorkspace.mockClear();
36+
loadSession.mockClear();
37+
showSearchPage.mockClear();
38+
window.location.hash = '';
39+
localStorage.clear();
40+
});
41+
42+
function routeTo(hash) {
43+
window.location.hash = hash;
44+
window.dispatchEvent(new HashChangeEvent('hashchange'));
45+
}
46+
47+
it('dispatches default hash to showProjects', () => {
48+
routeTo('');
49+
expect(showProjects).toHaveBeenCalled();
50+
});
51+
52+
it('dispatches #search to showSearchPage on hashchange', () => {
53+
showProjects.mockClear();
54+
routeTo('#search');
55+
expect(showSearchPage).toHaveBeenCalledTimes(1);
56+
expect(showProjects).not.toHaveBeenCalled();
57+
});
58+
59+
it('dispatches #project/<name> to showWorkspace', () => {
60+
showProjects.mockClear();
61+
routeTo('#project/my-project');
62+
expect(showWorkspace).toHaveBeenCalledWith('my-project');
63+
});
64+
65+
it('dispatches #project/<name>/<sessionId> to showWorkspace when cache is cold', () => {
66+
routeTo('#project/my-project/sess-abc');
67+
expect(showWorkspace).toHaveBeenCalledWith('my-project', 'sess-abc');
68+
expect(loadSession).not.toHaveBeenCalled();
69+
});
70+
71+
it('loads session from cache when project matches and sidebar exists', () => {
72+
state.currentProject = 'my-project';
73+
state.cachedSessions = [{ id: 'sess-abc' }];
74+
document.body.innerHTML += '<div id="sidebar"><button class="sidebar-item" id="sidebar-sess-abc"></button></div>';
75+
routeTo('#project/my-project/sess-abc');
76+
expect(loadSession).toHaveBeenCalledWith('my-project', 'sess-abc');
77+
expect(showWorkspace).not.toHaveBeenCalled();
78+
expect(document.getElementById('sidebar-sess-abc').classList.contains('active')).toBe(true);
79+
});
80+
81+
it('falls back to showProjects when project name is a malformed URI', () => {
82+
showProjects.mockClear();
83+
routeTo('#project/%E0%A4%A');
84+
expect(showProjects).toHaveBeenCalled();
85+
expect(showWorkspace).not.toHaveBeenCalled();
86+
});
87+
88+
it('falls back to showProjects when session project segment is malformed', () => {
89+
showProjects.mockClear();
90+
routeTo('#project/%E0%A4%A/sess-1');
91+
expect(showProjects).toHaveBeenCalled();
92+
expect(showWorkspace).not.toHaveBeenCalled();
93+
});
94+
95+
it('re-runs routing when hashchange fires', () => {
96+
showProjects.mockClear();
97+
routeTo('#search');
98+
expect(showSearchPage).toHaveBeenCalledTimes(1);
99+
showSearchPage.mockClear();
100+
routeTo('#project/other');
101+
expect(showWorkspace).toHaveBeenCalledWith('other');
102+
});
103+
});

static/js/export.test.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { bulkExport, downloadSession } from './export.js';
3+
4+
vi.mock('./projects.js', () => ({ showProjects: vi.fn() }));
5+
6+
import { showProjects } from './projects.js';
7+
8+
const mockWritable = {
9+
write: vi.fn(() => Promise.resolve()),
10+
close: vi.fn(() => Promise.resolve()),
11+
abort: vi.fn(() => Promise.resolve()),
12+
};
13+
14+
const mockHandle = {
15+
createWritable: vi.fn(() => Promise.resolve(mockWritable)),
16+
};
17+
18+
describe('export', () => {
19+
beforeEach(() => {
20+
document.body.innerHTML = `
21+
<button id="btn-export-all">Export all</button>
22+
<button id="btn-export-since">Export since</button>
23+
`;
24+
vi.stubGlobal('fetch', vi.fn());
25+
vi.stubGlobal('showSaveFilePicker', vi.fn(() => Promise.resolve(mockHandle)));
26+
mockWritable.write.mockClear();
27+
mockWritable.close.mockClear();
28+
mockHandle.createWritable.mockClear();
29+
showProjects.mockClear();
30+
});
31+
32+
async function confirmExport() {
33+
const ok = document.querySelector('.confirm-ok');
34+
expect(ok).not.toBeNull();
35+
ok.click();
36+
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
37+
}
38+
39+
it('bulkExport shows progress then completes on success', async () => {
40+
fetch.mockResolvedValue({
41+
ok: true,
42+
headers: { get: () => 'application/zip' },
43+
blob: () => Promise.resolve(new Blob(['zip'], { type: 'application/zip' })),
44+
});
45+
46+
bulkExport('all');
47+
const btn = document.getElementById('btn-export-all');
48+
expect(btn.disabled).toBe(false);
49+
await confirmExport();
50+
51+
expect(btn.textContent.trim()).toBe('Export all');
52+
expect(btn.disabled).toBe(false);
53+
expect(mockHandle.createWritable).toHaveBeenCalled();
54+
expect(showProjects).toHaveBeenCalled();
55+
expect(fetch).toHaveBeenCalledWith('/api/export', expect.objectContaining({
56+
method: 'POST',
57+
body: JSON.stringify({ since: 'all' }),
58+
}));
59+
});
60+
61+
it('bulkExport surfaces 5xx errors via toast', async () => {
62+
fetch.mockResolvedValue({
63+
ok: false,
64+
status: 500,
65+
headers: { get: () => 'application/json' },
66+
json: () => Promise.resolve({ error: 'export failed' }),
67+
});
68+
69+
bulkExport('all');
70+
await confirmExport();
71+
await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull());
72+
73+
expect(document.querySelector('.toast-error').textContent).toContain('export failed');
74+
expect(showProjects).not.toHaveBeenCalled();
75+
});
76+
77+
it('bulkExport surfaces 4xx errors via toast', async () => {
78+
fetch.mockResolvedValue({
79+
ok: false,
80+
status: 403,
81+
headers: { get: () => 'text/plain' },
82+
});
83+
84+
bulkExport('incremental');
85+
await confirmExport();
86+
await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull());
87+
88+
expect(document.querySelector('.toast-error').textContent).toContain('Export failed: 403');
89+
});
90+
91+
it('downloadSession writes a blob via the file picker', async () => {
92+
fetch.mockResolvedValue({
93+
ok: true,
94+
blob: () => Promise.resolve(new Blob(['# markdown'], { type: 'text/markdown' })),
95+
});
96+
97+
await downloadSession('alpha', 'sess-abcdef12');
98+
99+
expect(fetch).toHaveBeenCalledWith('/api/export/session/alpha/sess-abcdef12');
100+
expect(mockWritable.write).toHaveBeenCalled();
101+
expect(mockWritable.close).toHaveBeenCalled();
102+
});
103+
104+
it('downloadSession falls back to blob URL when file picker is unavailable', async () => {
105+
vi.stubGlobal('showSaveFilePicker', undefined);
106+
const createObjectURL = vi.fn(() => 'blob:fake-url');
107+
const revokeObjectURL = vi.fn();
108+
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
109+
const click = vi.fn();
110+
const anchor = document.createElement('a');
111+
anchor.click = click;
112+
const createElement = document.createElement.bind(document);
113+
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
114+
if (tag === 'a') return anchor;
115+
return createElement(tag);
116+
});
117+
118+
fetch.mockResolvedValue({
119+
ok: true,
120+
blob: () => Promise.resolve(new Blob(['content'], { type: 'text/markdown' })),
121+
});
122+
123+
await downloadSession('alpha', 'sess-abcdef12');
124+
125+
expect(createObjectURL).toHaveBeenCalled();
126+
expect(anchor.download).toBe('session-sess-abc.md');
127+
expect(click).toHaveBeenCalled();
128+
});
129+
});

static/js/projects.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { showProjects } from './projects.js';
3+
4+
const PROJECT_FIXTURE = [
5+
{
6+
name: 'alpha',
7+
path: '/data/alpha',
8+
display_name: 'Alpha Project',
9+
session_count: 2,
10+
last_modified: '2026-05-19T10:00:00Z',
11+
},
12+
{
13+
name: 'beta',
14+
path: '/data/beta',
15+
display_name: 'Beta Project',
16+
session_count: 0,
17+
last_modified: '2026-05-18T10:00:00Z',
18+
},
19+
];
20+
21+
describe('showProjects', () => {
22+
beforeEach(() => {
23+
document.body.innerHTML = '<div id="content"></div>';
24+
vi.stubGlobal('fetch', vi.fn());
25+
});
26+
27+
it('renders project cards from the API response', async () => {
28+
fetch.mockImplementation((url) => {
29+
if (url === '/api/projects') {
30+
return Promise.resolve({ ok: true, json: () => Promise.resolve(PROJECT_FIXTURE) });
31+
}
32+
if (url === '/api/export/state') {
33+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
34+
}
35+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
36+
});
37+
38+
await showProjects();
39+
40+
const content = document.getElementById('content');
41+
expect(content.innerHTML).toContain('Alpha Project');
42+
expect(content.innerHTML).toContain('2 sessions');
43+
expect(content.innerHTML).toContain('Projects without Sessions');
44+
expect(content.innerHTML).toContain('Beta Project');
45+
});
46+
47+
it('shows empty state when no projects are returned', async () => {
48+
fetch.mockImplementation((url) => {
49+
if (url === '/api/projects') {
50+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
51+
}
52+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
53+
});
54+
55+
await showProjects();
56+
57+
const content = document.getElementById('content');
58+
expect(content.innerHTML).toContain('empty-state');
59+
expect(content.innerHTML).toContain('No Claude Code projects found');
60+
});
61+
62+
it('surfaces API errors', async () => {
63+
fetch.mockImplementation((url) => {
64+
if (url === '/api/projects') {
65+
return Promise.resolve({
66+
ok: false,
67+
status: 500,
68+
json: () => Promise.resolve({ error: 'disk unavailable' }),
69+
});
70+
}
71+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
72+
});
73+
74+
await showProjects();
75+
76+
expect(document.getElementById('content').innerHTML).toContain('disk unavailable');
77+
});
78+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderFileReadResult } from './file_read.js';
3+
4+
describe('renderFileReadResult', () => {
5+
it('renders file path and line count in the summary', () => {
6+
const html = renderFileReadResult({
7+
result_type: 'file_read',
8+
file_path: '/src/main.cpp',
9+
num_lines: 42,
10+
});
11+
expect(html).toContain('/src/main.cpp');
12+
expect(html).toContain('42 lines');
13+
expect(html).toContain('tool-result');
14+
});
15+
16+
it('omits line count when num_lines is absent', () => {
17+
const html = renderFileReadResult({
18+
result_type: 'file_read',
19+
file_path: 'README.md',
20+
});
21+
expect(html).toContain('Read: README.md');
22+
expect(html).not.toContain('lines');
23+
});
24+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderBashUse } from './bash.js';
3+
4+
describe('renderBashUse', () => {
5+
it('renders command text in the tool body', () => {
6+
const html = renderBashUse({
7+
name: 'Bash',
8+
input: { command: 'ls -la', description: 'list files' },
9+
});
10+
expect(html).toContain('ls -la');
11+
expect(html).toContain('list files');
12+
expect(html).toContain('tool-call');
13+
});
14+
15+
it('escapes HTML in the command', () => {
16+
const html = renderBashUse({
17+
name: 'Bash',
18+
input: { command: '<rm -rf />' },
19+
});
20+
expect(html).not.toContain('<rm');
21+
expect(html).toContain('&lt;rm');
22+
});
23+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderEditUse } from './edit.js';
3+
4+
describe('renderEditUse', () => {
5+
it('renders file path and old/new strings', () => {
6+
const html = renderEditUse({
7+
name: 'Edit',
8+
input: {
9+
file_path: 'src/app.js',
10+
old_string: 'const x = 1;',
11+
new_string: 'const x = 2;',
12+
},
13+
});
14+
expect(html).toContain('src/app.js');
15+
expect(html).toContain('const x = 1;');
16+
expect(html).toContain('const x = 2;');
17+
expect(html).toContain('tool-call');
18+
});
19+
20+
it('escapes HTML in edit strings', () => {
21+
const html = renderEditUse({
22+
name: 'Edit',
23+
input: {
24+
file_path: 'x.txt',
25+
old_string: '<bad>',
26+
new_string: '<worse>',
27+
},
28+
});
29+
expect(html).not.toContain('<bad>');
30+
expect(html).toContain('&lt;bad&gt;');
31+
});
32+
});

0 commit comments

Comments
 (0)