Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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');
});
});
136 changes: 136 additions & 0 deletions static/js/export.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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();
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
Comment thread
clean6378-max-it marked this conversation as resolved.
Outdated
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();
});
});
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/);
});
});
23 changes: 23 additions & 0 deletions static/js/render/tool_use/bash.test.js
Original file line number Diff line number Diff line change
@@ -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: '<rm -rf />' },
});
expect(html).not.toContain('<rm');
expect(html).toContain('&lt;rm');
});
});
Loading
Loading