Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
clean6378-max-it marked this conversation as resolved.

benchmarks:
name: Performance benchmarks (informational)
Expand Down
111 changes: 111 additions & 0 deletions static/js/app.test.js
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');
});
});
146 changes: 146 additions & 0 deletions static/js/export.test.js
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;
}
});
});
83 changes: 83 additions & 0 deletions static/js/projects.test.js
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');
});
});
24 changes: 24 additions & 0 deletions static/js/render/tool_result/file_read.test.js
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/);
});
});
Loading
Loading