mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
/**
|
|
* Unit tests for the past-session search index (issue #261).
|
|
*
|
|
* The index is the seam that lets `GET /api/search` match sessions that are no
|
|
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter
|
|
* and are pinned here: the snapshot stays bounded, the refresh never happens on
|
|
* the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
|
|
* stored rows carry the owner needed to re-apply multi-user scoping on read,
|
|
* the snapshot is written unscoped, so losing that field would leak one user's
|
|
* folders into another user's search.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import {
|
|
buildHistorySessionIndexItems,
|
|
ensureHistorySessionIndexFresh,
|
|
getHistorySessionIndex,
|
|
isHistorySessionIndexStale,
|
|
resetHistorySessionIndex,
|
|
setHistoryIndexRefresher,
|
|
setHistorySessionIndex,
|
|
HISTORY_INDEX_MAX_ITEMS,
|
|
HISTORY_INDEX_TTL_MS,
|
|
type MergedSessionLike,
|
|
} from '../src/web/session-history-index.js';
|
|
|
|
beforeEach(() => {
|
|
resetHistorySessionIndex();
|
|
});
|
|
|
|
describe('buildHistorySessionIndexItems', () => {
|
|
const merged: MergedSessionLike[] = [
|
|
{ sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 },
|
|
{ sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 },
|
|
{ sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 },
|
|
];
|
|
|
|
it('projects name, dir, timestamp, owner and liveness', () => {
|
|
const items = buildHistorySessionIndexItems(
|
|
merged,
|
|
new Map([
|
|
['a', 'alice'],
|
|
['b', undefined],
|
|
]),
|
|
new Set(['a'])
|
|
);
|
|
expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']);
|
|
expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 });
|
|
// Transcript-only row: no owner (host-wide) and not live.
|
|
expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' });
|
|
});
|
|
|
|
it('drops rows with neither a name nor a working directory', () => {
|
|
const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set());
|
|
expect(items.some((i) => i.sessionId === 'empty')).toBe(false);
|
|
});
|
|
|
|
it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => {
|
|
const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({
|
|
sessionId: `s${i}`,
|
|
name: `session ${i}`,
|
|
workingDir: `/home/u/p${i}`,
|
|
lastActivityAt: i,
|
|
}));
|
|
expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
|
});
|
|
});
|
|
|
|
describe('snapshot storage', () => {
|
|
it('starts empty and stale', () => {
|
|
expect(getHistorySessionIndex().items).toEqual([]);
|
|
expect(isHistorySessionIndexStale()).toBe(true);
|
|
});
|
|
|
|
it('caps on write even when the caller did not', () => {
|
|
const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({
|
|
sessionId: `s${i}`,
|
|
name: 'x',
|
|
workingDir: '/x',
|
|
timestamp: i,
|
|
live: false,
|
|
}));
|
|
setHistorySessionIndex(items);
|
|
expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
|
});
|
|
|
|
it('goes stale again once the TTL elapses', () => {
|
|
const t0 = 1_000_000;
|
|
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0);
|
|
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false);
|
|
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ensureHistorySessionIndexFresh', () => {
|
|
it('returns synchronously, the rebuild must never be on the request path', async () => {
|
|
let resolveRefresh: () => void = () => {};
|
|
const refresher = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveRefresh = resolve;
|
|
})
|
|
);
|
|
setHistoryIndexRefresher(refresher);
|
|
|
|
ensureHistorySessionIndexFresh();
|
|
// Called, but the caller is already past it while the rebuild is pending.
|
|
expect(refresher).toHaveBeenCalledTimes(1);
|
|
expect(getHistorySessionIndex().items).toEqual([]);
|
|
resolveRefresh();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
it('is single-flight: a second call while a rebuild is pending is a no-op', async () => {
|
|
let resolveRefresh: () => void = () => {};
|
|
const refresher = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveRefresh = resolve;
|
|
})
|
|
);
|
|
setHistoryIndexRefresher(refresher);
|
|
|
|
ensureHistorySessionIndexFresh();
|
|
ensureHistorySessionIndexFresh();
|
|
ensureHistorySessionIndexFresh();
|
|
expect(refresher).toHaveBeenCalledTimes(1);
|
|
|
|
resolveRefresh();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
// Snapshot still stale (the fake refresher wrote nothing) → next call runs again.
|
|
ensureHistorySessionIndexFresh();
|
|
expect(refresher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not rebuild while the snapshot is fresh', () => {
|
|
const refresher = vi.fn(async () => {});
|
|
setHistoryIndexRefresher(refresher);
|
|
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]);
|
|
ensureHistorySessionIndexFresh();
|
|
expect(refresher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => {
|
|
setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1);
|
|
const refresher = vi.fn(async () => {
|
|
throw new Error('scan failed');
|
|
});
|
|
setHistoryIndexRefresher(refresher);
|
|
|
|
ensureHistorySessionIndexFresh();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(getHistorySessionIndex().items[0].sessionId).toBe('keep');
|
|
|
|
ensureHistorySessionIndexFresh();
|
|
expect(refresher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('is a no-op when no refresher is registered', () => {
|
|
expect(() => ensureHistorySessionIndexFresh()).not.toThrow();
|
|
});
|
|
});
|