-
Notifications
You must be signed in to change notification settings - Fork 1
Add vitest coverage for router, pages, and tool renderers #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9f6fa32
Add vitest coverage for router, pages, and tool renderers
clean6378-max-it d04fea5
Address PR review feedback on frontend test coverage
clean6378-max-it 1694bcc
Enforce frontend coverage threshold in CI js-tests jobEnforce fronten…
clean6378-max-it 8b02a9d
Harden frontend test isolation per PR #86 review
clean6378-max-it d87612b
Address PR #86 review: tighten coverage gates and test hygiene
clean6378-max-it File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<div id="content"></div><span id="footer-year"></span>'; | ||
| 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/<name> to showWorkspace', () => { | ||
| showProjects.mockClear(); | ||
| routeTo('#project/my-project'); | ||
| expect(showWorkspace).toHaveBeenCalledWith('my-project'); | ||
| }); | ||
|
|
||
| it('dispatches #project/<name>/<sessionId> 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 += '<div id="sidebar"><button class="sidebar-item" id="sidebar-sess-abc"></button></div>'; | ||
| 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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ` | ||
| <button id="btn-export-all">Export all</button> | ||
| <button id="btn-export-since">Export since</button> | ||
| `; | ||
| 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; | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<div id="content"></div>'; | ||
| 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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.