Skip to content

Commit 0fe1d66

Browse files
Add vitest coverage for router, pages, and tool renderers (#86)
* Add vitest coverage for router, pages, and tool renderers * Address PR review feedback on frontend test coverage Enforce a 50% line-coverage floor in vitest.config.js so the static/js/ gain cannot silently regress. Restore mocks after export tests to prevent createElement spy leakage, and document fixture shapes plus the search.js no-highlight behavior for future maintainers. * Enforce frontend coverage threshold in CI js-tests jobEnforce frontend coverage threshold in CI js-tests job Run npm run test:coverage instead of npm test so vitest's 50% line threshold in vitest.config.js is actually validated on every PR. * Harden frontend test isolation per PR #86 review Mock showConfirm in export tests, verify in-flight export button state, key session fetch mocks by session ID, tighten file_read assertion, add global stub cleanup across test files, and await clipboard write assertion defensively. * Address PR #86 review: tighten coverage gates and test hygiene Raise vitest thresholds to lines 80 / functions 70 / branches 50 so the ~85% gain is actually protected. Stub URL blob methods without replacing the constructor, restore navigator.clipboard after copyAll tests, assert new_string HTML escaping in edit.test.js, and document that CI test:coverage runs the full vitest suite.
1 parent 0435b2d commit 0fe1d66

10 files changed

Lines changed: 706 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ jobs:
203203
*) echo "Unsupported Node platform: $(node -p "process.platform + '-' + process.arch")"; exit 1 ;;
204204
esac
205205
npm install --no-save "${PKG}@${ROLLUP_VERSION}"
206-
- run: npm test
206+
# Same vitest suite as npm test; --coverage enforces thresholds in vitest.config.js
207+
- run: npm run test:coverage
207208

208209
benchmarks:
209210
name: Performance benchmarks (informational)

static/js/app.test.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterAll, 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+
const origScrollTo = window.scrollTo;
23+
const origScrollIntoView = Element.prototype.scrollIntoView;
24+
25+
beforeAll(async () => {
26+
window.scrollTo = vi.fn();
27+
Element.prototype.scrollIntoView = vi.fn();
28+
await import('./app.js');
29+
document.dispatchEvent(new Event('DOMContentLoaded'));
30+
});
31+
32+
afterAll(() => {
33+
window.scrollTo = origScrollTo;
34+
Element.prototype.scrollIntoView = origScrollIntoView;
35+
});
36+
37+
beforeEach(() => {
38+
document.body.innerHTML = '<div id="content"></div><span id="footer-year"></span>';
39+
state.currentProject = null;
40+
state.cachedSessions = [];
41+
state.navInProgress = false;
42+
showProjects.mockClear();
43+
showWorkspace.mockClear();
44+
loadSession.mockClear();
45+
showSearchPage.mockClear();
46+
window.location.hash = '';
47+
localStorage.clear();
48+
});
49+
50+
function routeTo(hash) {
51+
window.location.hash = hash;
52+
window.dispatchEvent(new HashChangeEvent('hashchange'));
53+
}
54+
55+
it('dispatches default hash to showProjects', () => {
56+
routeTo('');
57+
expect(showProjects).toHaveBeenCalled();
58+
});
59+
60+
it('dispatches #search to showSearchPage on hashchange', () => {
61+
showProjects.mockClear();
62+
routeTo('#search');
63+
expect(showSearchPage).toHaveBeenCalledTimes(1);
64+
expect(showProjects).not.toHaveBeenCalled();
65+
});
66+
67+
it('dispatches #project/<name> to showWorkspace', () => {
68+
showProjects.mockClear();
69+
routeTo('#project/my-project');
70+
expect(showWorkspace).toHaveBeenCalledWith('my-project');
71+
});
72+
73+
it('dispatches #project/<name>/<sessionId> to showWorkspace when cache is cold', () => {
74+
routeTo('#project/my-project/sess-abc');
75+
expect(showWorkspace).toHaveBeenCalledWith('my-project', 'sess-abc');
76+
expect(loadSession).not.toHaveBeenCalled();
77+
});
78+
79+
it('loads session from cache when project matches and sidebar exists', () => {
80+
state.currentProject = 'my-project';
81+
state.cachedSessions = [{ id: 'sess-abc' }];
82+
document.body.innerHTML += '<div id="sidebar"><button class="sidebar-item" id="sidebar-sess-abc"></button></div>';
83+
routeTo('#project/my-project/sess-abc');
84+
expect(loadSession).toHaveBeenCalledWith('my-project', 'sess-abc');
85+
expect(showWorkspace).not.toHaveBeenCalled();
86+
expect(document.getElementById('sidebar-sess-abc').classList.contains('active')).toBe(true);
87+
});
88+
89+
it('falls back to showProjects when project name is a malformed URI', () => {
90+
showProjects.mockClear();
91+
routeTo('#project/%E0%A4%A');
92+
expect(showProjects).toHaveBeenCalled();
93+
expect(showWorkspace).not.toHaveBeenCalled();
94+
});
95+
96+
it('falls back to showProjects when session project segment is malformed', () => {
97+
showProjects.mockClear();
98+
routeTo('#project/%E0%A4%A/sess-1');
99+
expect(showProjects).toHaveBeenCalled();
100+
expect(showWorkspace).not.toHaveBeenCalled();
101+
});
102+
103+
it('re-runs routing when hashchange fires', () => {
104+
showProjects.mockClear();
105+
routeTo('#search');
106+
expect(showSearchPage).toHaveBeenCalledTimes(1);
107+
showSearchPage.mockClear();
108+
routeTo('#project/other');
109+
expect(showWorkspace).toHaveBeenCalledWith('other');
110+
});
111+
});

static/js/export.test.js

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

static/js/projects.test.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { showProjects } from './projects.js';
3+
4+
// ProjectDict[] — mirrors models/project.py.
5+
const PROJECT_FIXTURE = [
6+
{
7+
name: 'alpha',
8+
path: '/data/alpha',
9+
display_name: 'Alpha Project',
10+
session_count: 2,
11+
last_modified: '2026-05-19T10:00:00Z',
12+
},
13+
{
14+
name: 'beta',
15+
path: '/data/beta',
16+
display_name: 'Beta Project',
17+
session_count: 0,
18+
last_modified: '2026-05-18T10:00:00Z',
19+
},
20+
];
21+
22+
describe('showProjects', () => {
23+
beforeEach(() => {
24+
document.body.innerHTML = '<div id="content"></div>';
25+
vi.stubGlobal('fetch', vi.fn());
26+
});
27+
28+
afterEach(() => {
29+
vi.unstubAllGlobals();
30+
});
31+
32+
it('renders project cards from the API response', async () => {
33+
fetch.mockImplementation((url) => {
34+
if (url === '/api/projects') {
35+
return Promise.resolve({ ok: true, json: () => Promise.resolve(PROJECT_FIXTURE) });
36+
}
37+
if (url === '/api/export/state') {
38+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
39+
}
40+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
41+
});
42+
43+
await showProjects();
44+
45+
const content = document.getElementById('content');
46+
expect(content.innerHTML).toContain('Alpha Project');
47+
expect(content.innerHTML).toContain('2 sessions');
48+
expect(content.innerHTML).toContain('Projects without Sessions');
49+
expect(content.innerHTML).toContain('Beta Project');
50+
});
51+
52+
it('shows empty state when no projects are returned', async () => {
53+
fetch.mockImplementation((url) => {
54+
if (url === '/api/projects') {
55+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
56+
}
57+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
58+
});
59+
60+
await showProjects();
61+
62+
const content = document.getElementById('content');
63+
expect(content.innerHTML).toContain('empty-state');
64+
expect(content.innerHTML).toContain('No Claude Code projects found');
65+
});
66+
67+
it('surfaces API errors', async () => {
68+
fetch.mockImplementation((url) => {
69+
if (url === '/api/projects') {
70+
return Promise.resolve({
71+
ok: false,
72+
status: 500,
73+
json: () => Promise.resolve({ error: 'disk unavailable' }),
74+
});
75+
}
76+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
77+
});
78+
79+
await showProjects();
80+
81+
expect(document.getElementById('content').innerHTML).toContain('disk unavailable');
82+
});
83+
});
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.toMatch(/\d+ lines/);
23+
});
24+
});

0 commit comments

Comments
 (0)