From 9f6fa32903defa5276efc2de386a8fe803d73ab8 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 16 Jun 2026 16:58:43 +0800 Subject: [PATCH 1/5] Add vitest coverage for router, pages, and tool renderers --- static/js/app.test.js | 103 ++++++++++++ static/js/export.test.js | 129 ++++++++++++++++ static/js/projects.test.js | 78 ++++++++++ .../js/render/tool_result/file_read.test.js | 24 +++ static/js/render/tool_use/bash.test.js | 23 +++ static/js/render/tool_use/edit.test.js | 32 ++++ static/js/search.test.js | 98 ++++++++++++ static/js/sessions.test.js | 146 ++++++++++++++++++ 8 files changed, 633 insertions(+) create mode 100644 static/js/app.test.js create mode 100644 static/js/export.test.js create mode 100644 static/js/projects.test.js create mode 100644 static/js/render/tool_result/file_read.test.js create mode 100644 static/js/render/tool_use/bash.test.js create mode 100644 static/js/render/tool_use/edit.test.js create mode 100644 static/js/search.test.js create mode 100644 static/js/sessions.test.js diff --git a/static/js/app.test.js b/static/js/app.test.js new file mode 100644 index 0000000..9e8ed2e --- /dev/null +++ b/static/js/app.test.js @@ -0,0 +1,103 @@ +import { 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)', () => { + beforeAll(async () => { + window.scrollTo = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + await import('./app.js'); + document.dispatchEvent(new Event('DOMContentLoaded')); + }); + + 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..8826f8a --- /dev/null +++ b/static/js/export.test.js @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { bulkExport, downloadSession } from './export.js'; + +vi.mock('./projects.js', () => ({ showProjects: vi.fn() })); + +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(); + }); + + async function confirmExport() { + const ok = document.querySelector('.confirm-ok'); + expect(ok).not.toBeNull(); + ok.click(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + } + + it('bulkExport shows progress then completes on success', async () => { + fetch.mockResolvedValue({ + ok: true, + headers: { get: () => 'application/zip' }, + blob: () => Promise.resolve(new Blob(['zip'], { type: 'application/zip' })), + }); + + bulkExport('all'); + const btn = document.getElementById('btn-export-all'); + expect(btn.disabled).toBe(false); + await confirmExport(); + + expect(btn.textContent.trim()).toBe('Export all'); + expect(btn.disabled).toBe(false); + 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 confirmExport(); + 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 confirmExport(); + 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(); + vi.stubGlobal('URL', { createObjectURL, 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' })), + }); + + await downloadSession('alpha', 'sess-abcdef12'); + + expect(createObjectURL).toHaveBeenCalled(); + expect(anchor.download).toBe('session-sess-abc.md'); + expect(click).toHaveBeenCalled(); + }); +}); diff --git a/static/js/projects.test.js b/static/js/projects.test.js new file mode 100644 index 0000000..1264700 --- /dev/null +++ b/static/js/projects.test.js @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { showProjects } from './projects.js'; + +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()); + }); + + 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..5ac87ee --- /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.toContain('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>'); + }); +}); diff --git a/static/js/search.test.js b/static/js/search.test.js new file mode 100644 index 0000000..33f4c34 --- /dev/null +++ b/static/js/search.test.js @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { showSearchPage, doSearch } from './search.js'; + +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 = ''; + }); + + 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..294694a --- /dev/null +++ b/static/js/sessions.test.js @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { state } from './shared/state.js'; +import { showWorkspace, loadSession, selectSession, copyAll } from './sessions.js'; + +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/')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(SESSION_DETAIL) }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); +} + +describe('sessions workspace', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + state.currentProject = null; + state.cachedSessions = []; + state.projectDisplayNames = {}; + vi.stubGlobal('fetch', vi.fn()); + window.location.hash = ''; + }); + + 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()); + Object.assign(navigator, { clipboard: { writeText } }); + 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(); + + expect(writeText).toHaveBeenCalledWith('Line one\nLine two'); + }); +}); From d04fea5c84e2272ce8d734d4d154d3e1a2c38464 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 16 Jun 2026 17:26:09 +0800 Subject: [PATCH 2/5] 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. --- static/js/export.test.js | 6 +++++- static/js/projects.test.js | 1 + static/js/search.test.js | 1 + static/js/sessions.test.js | 1 + vitest.config.js | 3 +++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/static/js/export.test.js b/static/js/export.test.js index 8826f8a..d7e56a4 100644 --- a/static/js/export.test.js +++ b/static/js/export.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bulkExport, downloadSession } from './export.js'; vi.mock('./projects.js', () => ({ showProjects: vi.fn() })); @@ -29,6 +29,10 @@ describe('export', () => { showProjects.mockClear(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + async function confirmExport() { const ok = document.querySelector('.confirm-ok'); expect(ok).not.toBeNull(); diff --git a/static/js/projects.test.js b/static/js/projects.test.js index 1264700..d70b11f 100644 --- a/static/js/projects.test.js +++ b/static/js/projects.test.js @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { showProjects } from './projects.js'; +// ProjectDict[] — mirrors models/project.py. const PROJECT_FIXTURE = [ { name: 'alpha', diff --git a/static/js/search.test.js b/static/js/search.test.js index 33f4c34..0c5d141 100644 --- a/static/js/search.test.js +++ b/static/js/search.test.js @@ -1,6 +1,7 @@ import { 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', diff --git a/static/js/sessions.test.js b/static/js/sessions.test.js index 294694a..aef6ae6 100644 --- a/static/js/sessions.test.js +++ b/static/js/sessions.test.js @@ -2,6 +2,7 @@ import { 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', diff --git a/vitest.config.js b/vitest.config.js index db7b414..cc496f0 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -9,6 +9,9 @@ export default defineConfig({ reporter: ['text', 'lcov'], include: ['static/js/**/*.js'], exclude: ['static/js/**/*.test.js'], + thresholds: { + lines: 50, + }, }, }, }); From 1694bcccf4cd4f22f630789c0e9356ec70375c5b Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 16 Jun 2026 17:36:05 +0800 Subject: [PATCH 3/5] 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. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f157984..bbfd3f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: *) echo "Unsupported Node platform: $(node -p "process.platform + '-' + process.arch")"; exit 1 ;; esac npm install --no-save "${PKG}@${ROLLUP_VERSION}" - - run: npm test + - run: npm run test:coverage benchmarks: name: Performance benchmarks (informational) From 8b02a9db00dae7b7caa08cefcce7074fb497fdde Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 16 Jun 2026 17:52:12 +0800 Subject: [PATCH 4/5] 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. --- static/js/app.test.js | 10 +++++- static/js/export.test.js | 33 ++++++++++--------- static/js/projects.test.js | 6 +++- .../js/render/tool_result/file_read.test.js | 2 +- static/js/search.test.js | 6 +++- static/js/sessions.test.js | 19 +++++++++-- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/static/js/app.test.js b/static/js/app.test.js index 9e8ed2e..03a399c 100644 --- a/static/js/app.test.js +++ b/static/js/app.test.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { state } from './shared/state.js'; const showProjects = vi.fn(); @@ -19,6 +19,9 @@ vi.mock('./shared/theme.js', () => ({ })); describe('router (app.js)', () => { + const origScrollTo = window.scrollTo; + const origScrollIntoView = Element.prototype.scrollIntoView; + beforeAll(async () => { window.scrollTo = vi.fn(); Element.prototype.scrollIntoView = vi.fn(); @@ -26,6 +29,11 @@ describe('router (app.js)', () => { document.dispatchEvent(new Event('DOMContentLoaded')); }); + afterAll(() => { + window.scrollTo = origScrollTo; + Element.prototype.scrollIntoView = origScrollIntoView; + }); + beforeEach(() => { document.body.innerHTML = '
'; state.currentProject = null; diff --git a/static/js/export.test.js b/static/js/export.test.js index d7e56a4..0265605 100644 --- a/static/js/export.test.js +++ b/static/js/export.test.js @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { bulkExport, downloadSession } from './export.js'; 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 = { @@ -30,30 +37,28 @@ describe('export', () => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); - async function confirmExport() { - const ok = document.querySelector('.confirm-ok'); - expect(ok).not.toBeNull(); - ok.click(); - await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); - } - it('bulkExport shows progress then completes on success', async () => { - fetch.mockResolvedValue({ + 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'); - expect(btn.disabled).toBe(false); - await confirmExport(); + 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(btn.disabled).toBe(false); expect(mockHandle.createWritable).toHaveBeenCalled(); expect(showProjects).toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith('/api/export', expect.objectContaining({ @@ -71,7 +76,6 @@ describe('export', () => { }); bulkExport('all'); - await confirmExport(); await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull()); expect(document.querySelector('.toast-error').textContent).toContain('export failed'); @@ -86,7 +90,6 @@ describe('export', () => { }); bulkExport('incremental'); - await confirmExport(); await vi.waitFor(() => expect(document.querySelector('.toast-error')).not.toBeNull()); expect(document.querySelector('.toast-error').textContent).toContain('Export failed: 403'); diff --git a/static/js/projects.test.js b/static/js/projects.test.js index d70b11f..c0cf78e 100644 --- a/static/js/projects.test.js +++ b/static/js/projects.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { showProjects } from './projects.js'; // ProjectDict[] — mirrors models/project.py. @@ -25,6 +25,10 @@ describe('showProjects', () => { vi.stubGlobal('fetch', vi.fn()); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('renders project cards from the API response', async () => { fetch.mockImplementation((url) => { if (url === '/api/projects') { diff --git a/static/js/render/tool_result/file_read.test.js b/static/js/render/tool_result/file_read.test.js index 5ac87ee..1a4a3c0 100644 --- a/static/js/render/tool_result/file_read.test.js +++ b/static/js/render/tool_result/file_read.test.js @@ -19,6 +19,6 @@ describe('renderFileReadResult', () => { file_path: 'README.md', }); expect(html).toContain('Read: README.md'); - expect(html).not.toContain('lines'); + expect(html).not.toMatch(/\d+ lines/); }); }); diff --git a/static/js/search.test.js b/static/js/search.test.js index 0c5d141..e5db512 100644 --- a/static/js/search.test.js +++ b/static/js/search.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +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). @@ -28,6 +28,10 @@ describe('search page', () => { window.location.hash = ''; }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('showSearchPage renders the search UI and sets hash', () => { showSearchPage(); expect(window.location.hash).toBe('#search'); diff --git a/static/js/sessions.test.js b/static/js/sessions.test.js index aef6ae6..cba834d 100644 --- a/static/js/sessions.test.js +++ b/static/js/sessions.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { state } from './shared/state.js'; import { showWorkspace, loadSession, selectSession, copyAll } from './sessions.js'; @@ -62,7 +62,16 @@ function mockWorkspaceFetch() { return Promise.resolve({ ok: true, json: () => Promise.resolve(SESSION_LIST) }); } if (url.startsWith('/api/sessions/alpha/')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(SESSION_DETAIL) }); + 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}`)); }); @@ -78,6 +87,10 @@ describe('sessions workspace', () => { window.location.hash = ''; }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('showWorkspace populates the sidebar with session entries', async () => { mockWorkspaceFetch(); await showWorkspace('alpha'); @@ -142,6 +155,6 @@ describe('sessions workspace', () => { copyAll(); - expect(writeText).toHaveBeenCalledWith('Line one\nLine two'); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('Line one\nLine two')); }); }); From d87612bce82c0cc9e4ce9bbf4ac31996606cb4b8 Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 17 Jun 2026 00:29:15 +0800 Subject: [PATCH 5/5] 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. --- .github/workflows/ci.yml | 1 + static/js/export.test.js | 22 ++++++++++++++++------ static/js/render/tool_use/edit.test.js | 2 ++ static/js/sessions.test.js | 17 ++++++++++++++++- vitest.config.js | 4 +++- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfd3f2..60c75bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,7 @@ jobs: *) echo "Unsupported Node platform: $(node -p "process.platform + '-' + process.arch")"; exit 1 ;; esac npm install --no-save "${PKG}@${ROLLUP_VERSION}" + # Same vitest suite as npm test; --coverage enforces thresholds in vitest.config.js - run: npm run test:coverage benchmarks: diff --git a/static/js/export.test.js b/static/js/export.test.js index 0265605..cc5ab48 100644 --- a/static/js/export.test.js +++ b/static/js/export.test.js @@ -112,7 +112,10 @@ describe('export', () => { vi.stubGlobal('showSaveFilePicker', undefined); const createObjectURL = vi.fn(() => 'blob:fake-url'); const revokeObjectURL = vi.fn(); - vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); + 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; @@ -127,10 +130,17 @@ describe('export', () => { blob: () => Promise.resolve(new Blob(['content'], { type: 'text/markdown' })), }); - await downloadSession('alpha', 'sess-abcdef12'); - - expect(createObjectURL).toHaveBeenCalled(); - expect(anchor.download).toBe('session-sess-abc.md'); - expect(click).toHaveBeenCalled(); + 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/render/tool_use/edit.test.js b/static/js/render/tool_use/edit.test.js index a8af306..4ce2497 100644 --- a/static/js/render/tool_use/edit.test.js +++ b/static/js/render/tool_use/edit.test.js @@ -28,5 +28,7 @@ describe('renderEditUse', () => { }); expect(html).not.toContain(''); expect(html).toContain('<bad>'); + expect(html).not.toContain(''); + expect(html).toContain('<worse>'); }); }); diff --git a/static/js/sessions.test.js b/static/js/sessions.test.js index cba834d..74b2044 100644 --- a/static/js/sessions.test.js +++ b/static/js/sessions.test.js @@ -78,6 +78,8 @@ function mockWorkspaceFetch() { } describe('sessions workspace', () => { + let clipboardRestore; + beforeEach(() => { document.body.innerHTML = '
'; state.currentProject = null; @@ -88,6 +90,14 @@ describe('sessions workspace', () => { }); afterEach(() => { + if (clipboardRestore) { + Object.defineProperty(navigator, 'clipboard', { + value: clipboardRestore, + configurable: true, + writable: true, + }); + clipboardRestore = null; + } vi.unstubAllGlobals(); }); @@ -146,7 +156,12 @@ describe('sessions workspace', () => { it('copyAll writes session text to the clipboard', async () => { const writeText = vi.fn(() => Promise.resolve()); - Object.assign(navigator, { clipboard: { writeText } }); + 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'; diff --git a/vitest.config.js b/vitest.config.js index cc496f0..73cb255 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -10,7 +10,9 @@ export default defineConfig({ include: ['static/js/**/*.js'], exclude: ['static/js/**/*.test.js'], thresholds: { - lines: 50, + lines: 80, + functions: 70, + branches: 50, }, }, },