Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
103 changes: 103 additions & 0 deletions static/js/app.test.js
Original file line number Diff line number Diff line change
@@ -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 = '<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');
});
});
129 changes: 129 additions & 0 deletions static/js/export.test.js
Original file line number Diff line number Diff line change
@@ -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 = `
<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();
});

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 });
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();
});
});
78 changes: 78 additions & 0 deletions static/js/projects.test.js
Original file line number Diff line number Diff line change
@@ -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 = '<div id="content"></div>';
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');
});
});
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.toContain('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');
});
});
32 changes: 32 additions & 0 deletions static/js/render/tool_use/edit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { renderEditUse } from './edit.js';

describe('renderEditUse', () => {
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: '<bad>',
new_string: '<worse>',
},
});
expect(html).not.toContain('<bad>');
expect(html).toContain('&lt;bad&gt;');
Comment thread
clean6378-max-it marked this conversation as resolved.
});
});
Loading
Loading