From 4dfdbcd100547cce68965a491fcd51968579666d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Wed, 1 Jul 2026 13:25:16 -0400 Subject: [PATCH 1/2] COD-160 unified session list: backend service + endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of the read-only "complete + searchable session list". - New src/services/unified-session-service.ts: mergeUnifiedSessions() combines live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux stats into one list de-duped by sessionId, with precedence history < lifecycle < persisted < live, a meaningfulness floor that drops bare lifecycle/mux-only noise, and a stable newest-first sort. Plus filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/ sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable. - New GET /api/sessions/unified in session-routes.ts: gathers the five sources from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds the pure service, returns { sessions, total } (ApiResponse envelope). testMode short-circuits to empty. Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts (4, app.inject). --- src/services/unified-session-service.ts | 235 ++++++++++++++++++ src/web/routes/session-routes.ts | 118 +++++++++ test/routes/unified-sessions-routes.test.ts | 138 ++++++++++ test/services/unified-session-service.test.ts | 145 +++++++++++ 4 files changed, 636 insertions(+) create mode 100644 src/services/unified-session-service.ts create mode 100644 test/routes/unified-sessions-routes.test.ts create mode 100644 test/services/unified-session-service.test.ts diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts new file mode 100644 index 00000000..3d9c06f8 --- /dev/null +++ b/src/services/unified-session-service.ts @@ -0,0 +1,235 @@ +/** + * @fileoverview Pure merge/filter logic for the unified session list (COD-121). + * + * Combines four read-only views of a session — live (in-memory `Session`), + * persisted (`state.json`), transcript history (`~/.claude/projects`), and the + * lifecycle audit log — plus mux process stats, into one de-duplicated list + * keyed by sessionId. Higher-precedence sources overwrite scalar fields when + * present (history < lifecycle < persisted < live), while the `sources` array + * always accumulates every contributing view. A "meaningfulness floor" drops + * noise (bare lifecycle/mux-only rows with no name and no first prompt). + * + * PURE: no fs/IO and no node imports. All IO happens in the route that feeds + * this module its inputs, which keeps the merge/sort/filter behavior unit-testable. + */ + +export type UnifiedSessionItem = { + sessionId: string; + name?: string; + mode?: string; + status?: string; + isWorking?: boolean; + workingDir?: string; + createdAt?: number; + lastActivityAt?: number; + claudeSessionId?: string; + firstPrompt?: string; + sizeBytes?: number; + remote?: boolean; + sources: string[]; + stats?: { memoryMB: number; cpuPercent: number }; +}; + +/** Live in-memory session view (subset of `Session.toState()`). */ +export type LiveSessionInput = { + id: string; + name?: string; + mode?: string; + status?: string; + isWorking?: boolean; + workingDir?: string; + createdAt?: number; + lastActivityAt?: number; + claudeSessionId?: string; +}; + +/** Persisted session view (subset of `SessionState`). */ +export type PersistedSessionInput = { + id: string; + name?: string; + mode?: string; + status?: string; + workingDir?: string; + createdAt?: number; + lastActivityAt?: number; +}; + +/** Lifecycle audit-log view. */ +export type LifecycleInput = { + sessionId: string; + name?: string; + mode?: string; + ts: number; + event?: string; +}; + +/** Transcript-history view (one `.jsonl` per session). */ +export type HistoryInput = { + sessionId: string; + workingDir: string; + sizeBytes: number; + lastModified: string; + firstPrompt?: string; +}; + +/** Mux process-stat view. */ +export type MuxStatInput = { + sessionId: string; + muxName?: string; + mode?: string; + stats?: { memoryMB: number; cpuPercent: number }; + remote?: boolean; +}; + +export type UnifiedSources = { + live?: LiveSessionInput[]; + persisted?: PersistedSessionInput[]; + lifecycle?: LifecycleInput[]; + history?: HistoryInput[]; + mux?: MuxStatInput[]; +}; + +/** Push a source tag onto an item exactly once. */ +function addSource(item: UnifiedSessionItem, source: string): void { + if (!item.sources.includes(source)) item.sources.push(source); +} + +/** Get-or-create the accumulator item for a sessionId. */ +function ensureItem(map: Map, sessionId: string): UnifiedSessionItem { + let item = map.get(sessionId); + if (!item) { + item = { sessionId, sources: [] }; + map.set(sessionId, item); + } + return item; +} + +/** Overwrite a scalar field only when the incoming value is defined. */ +function overwrite( + item: UnifiedSessionItem, + key: K, + value: UnifiedSessionItem[K] | undefined +): void { + if (value !== undefined) item[key] = value; +} + +/** + * Merge all source views into one list, applying precedence + * (history → lifecycle → persisted → live) and the meaningfulness floor. + */ +export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionItem[] { + const map = new Map(); + + // 1) history (lowest precedence) + for (const h of sources.history ?? []) { + const item = ensureItem(map, h.sessionId); + addSource(item, 'history'); + overwrite(item, 'workingDir', h.workingDir); + overwrite(item, 'sizeBytes', h.sizeBytes); + overwrite(item, 'firstPrompt', h.firstPrompt); + const ms = Date.parse(h.lastModified); + if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; + } + + // 2) lifecycle + for (const l of sources.lifecycle ?? []) { + const item = ensureItem(map, l.sessionId); + addSource(item, 'lifecycle'); + overwrite(item, 'name', l.name); + overwrite(item, 'mode', l.mode); + if (item.lastActivityAt === undefined && typeof l.ts === 'number') item.lastActivityAt = l.ts; + } + + // 3) persisted + for (const p of sources.persisted ?? []) { + const item = ensureItem(map, p.id); + addSource(item, 'persisted'); + overwrite(item, 'name', p.name); + overwrite(item, 'mode', p.mode); + overwrite(item, 'status', p.status); + overwrite(item, 'workingDir', p.workingDir); + overwrite(item, 'createdAt', p.createdAt); + overwrite(item, 'lastActivityAt', p.lastActivityAt); + } + + // 4) live (highest precedence) + for (const v of sources.live ?? []) { + const item = ensureItem(map, v.id); + addSource(item, 'live'); + overwrite(item, 'name', v.name); + overwrite(item, 'mode', v.mode); + overwrite(item, 'status', v.status); + overwrite(item, 'isWorking', v.isWorking); + overwrite(item, 'workingDir', v.workingDir); + overwrite(item, 'createdAt', v.createdAt); + overwrite(item, 'lastActivityAt', v.lastActivityAt); + overwrite(item, 'claudeSessionId', v.claudeSessionId); + } + + // 5) mux stats + remote flag (create item if mux-only) + for (const m of sources.mux ?? []) { + const item = ensureItem(map, m.sessionId); + addSource(item, 'mux'); + overwrite(item, 'mode', m.mode); + if (m.stats) item.stats = m.stats; + if (m.remote !== undefined) item.remote = m.remote; + } + + // Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise. + const kept: UnifiedSessionItem[] = []; + for (const item of map.values()) { + const isReal = + item.sources.includes('live') || + item.sources.includes('persisted') || + item.sources.includes('history') || + (item.firstPrompt !== undefined && item.firstPrompt !== ''); + if (isReal) kept.push(item); + } + + // Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc. + kept.sort((a, b) => { + const la = a.lastActivityAt; + const lb = b.lastActivityAt; + if (la !== lb) { + if (la === undefined) return 1; + if (lb === undefined) return -1; + return lb - la; + } + const ca = a.createdAt; + const cb = b.createdAt; + if (ca !== cb) { + if (ca === undefined) return 1; + if (cb === undefined) return -1; + return cb - ca; + } + return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0; + }); + + return kept; +} + +/** + * Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId) + * with offset/limit paging. `total` is the filtered count BEFORE paging. + */ +export function filterAndPaginate( + items: UnifiedSessionItem[], + opts: { q?: string; offset?: number; limit?: number } +): { sessions: UnifiedSessionItem[]; total: number } { + const q = (opts.q ?? '').trim().toLowerCase(); + const filtered = q + ? items.filter((it) => { + const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId] + .filter((v): v is string => typeof v === 'string') + .join(' ') + .toLowerCase(); + return hay.includes(q); + }) + : items; + + const total = filtered.length; + const offset = Math.max(0, Math.floor(opts.offset ?? 0)); + const limit = Math.min(500, Math.max(1, Math.floor(opts.limit ?? 100))); + const sessions = filtered.slice(offset, offset + limit); + return { sessions, total }; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..259e300d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -55,6 +55,15 @@ import { import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; +import { + mergeUnifiedSessions, + filterAndPaginate, + type LiveSessionInput, + type PersistedSessionInput, + type LifecycleInput, + type HistoryInput, + type MuxStatInput, +} from '../../services/unified-session-service.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { RunSummaryTracker } from '../../run-summary.js'; @@ -1749,6 +1758,115 @@ export function registerSessionRoutes( return { sessions: results.slice(0, 50) }; }); + // Unified, read-only session list: merges live + persisted + lifecycle + + // transcript history + mux stats into one de-duplicated, searchable list + // (COD-121). Pure merge/filter logic lives in unified-session-service.ts. + app.get('/api/sessions/unified', async (req) => { + const query = req.query as { q?: string; offset?: string; limit?: string }; + + if (ctx.testMode) { + return { sessions: [], total: 0 }; + } + + // Live (in-memory) sessions. + const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => { + const st = s.toState(); + return { + id: st.id, + name: st.name, + mode: st.mode, + status: st.status, + isWorking: s.isWorking, + workingDir: st.workingDir, + createdAt: st.createdAt, + lastActivityAt: st.lastActivityAt, + claudeSessionId: s.claudeSessionId ?? undefined, + }; + }); + + // Persisted sessions (state.json). + const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({ + id: p.id, + name: p.name, + mode: p.mode, + status: p.status, + workingDir: p.workingDir, + createdAt: p.createdAt, + lastActivityAt: p.lastActivityAt, + })); + + // Lifecycle audit log (newest-first, capped). + let lifecycle: LifecycleInput[] = []; + try { + const entries = await getLifecycleLog().query({ limit: 2000 }); + lifecycle = entries.map((e) => ({ + sessionId: e.sessionId, + name: e.name, + mode: e.mode, + ts: e.ts, + event: e.event, + })); + } catch { + // Lifecycle log may be unavailable; treat as empty. + } + + // Transcript history (~/.claude/projects) — reuse the same scanner as the overview. + const history: HistoryInput[] = []; + try { + const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); + const headBuf = Buffer.alloc(16384); + const projectDirs = await fs.readdir(projectsDir); + for (const projDir of projectDirs) { + const projPath = join(projectsDir, projDir); + const list = await scanProjectDir(projPath, projDir, headBuf); + for (const h of list) { + history.push({ + sessionId: h.sessionId, + workingDir: h.workingDir, + sizeBytes: h.sizeBytes, + lastModified: h.lastModified, + firstPrompt: h.firstPrompt, + }); + } + } + } catch { + // Projects dir may not exist. + } + + // Mux process stats (best-effort; guard against mocks lacking the method). + let mux: MuxStatInput[] = []; + try { + const getStats = (ctx.mux as { getSessionsWithStats?: () => Promise }).getSessionsWithStats; + if (typeof getStats === 'function') { + const muxSessions = (await getStats.call(ctx.mux)) as Array<{ + sessionId: string; + muxName?: string; + mode?: string; + remote?: unknown; + stats?: { memoryMB: number; cpuPercent: number }; + }>; + mux = muxSessions.map((m) => ({ + sessionId: m.sessionId, + muxName: m.muxName, + mode: m.mode, + remote: m.remote !== undefined ? true : undefined, + stats: m.stats ? { memoryMB: m.stats.memoryMB, cpuPercent: m.stats.cpuPercent } : undefined, + })); + } + } catch { + // Mux stats are optional. + } + + const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }); + const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined; + const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined; + return filterAndPaginate(merged, { + q: query.q, + offset: Number.isNaN(offset as number) ? undefined : offset, + limit: Number.isNaN(limit as number) ? undefined : limit, + }); + }); + // ═══════════════════════════════════════════════════════════════ // Paste Image (clipboard / drag-drop upload) // ═══════════════════════════════════════════════════════════════ diff --git a/test/routes/unified-sessions-routes.test.ts b/test/routes/unified-sessions-routes.test.ts new file mode 100644 index 00000000..e20cbb0f --- /dev/null +++ b/test/routes/unified-sessions-routes.test.ts @@ -0,0 +1,138 @@ +/** + * @fileoverview Route tests for GET /api/sessions/unified (COD-121). + * + * Uses app.inject() with a local envelope harness (mirrors the production + * preSerialization wrap so bodies appear as { success: true, data }). The + * default mock ctx has testMode=true (handler short-circuits to empty); tests + * that exercise the real merge path flip testMode off and stub the extra + * read-only methods the handler calls (store.getState, mux.getSessionsWithStats). + */ + +import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; + +// Keep the handler's IO fast + deterministic: stub the lifecycle log query so it +// never reads the real ~/.codeman/session-lifecycle.jsonl, and point HOME at an +// empty temp dir so the ~/.claude/projects transcript scan is a no-op. +vi.mock('../../src/session-lifecycle-log.js', async (orig) => { + const actual = await orig(); + return { + ...actual, + getLifecycleLog: () => ({ query: async () => [] }) as unknown as ReturnType, + }; +}); + +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +let tmpHome: string; +let prevHome: string | undefined; + +beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'cod121-home-')); + prevHome = process.env.HOME; + process.env.HOME = tmpHome; // empty home → no ~/.claude/projects → fast empty scan +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createEnvelopeHarness(ctx: MockRouteContext): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + registerSessionRoutes(app, ctx as never); + + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +/** Flip the default mock into "real" mode and stub the extra reads the handler makes. */ +function makeLiveCtx(): MockRouteContext { + const ctx = createMockRouteContext(); + // Real merge path (handler short-circuits when testMode is true). + (ctx as { testMode: boolean }).testMode = false; + // store.getState().sessions — the handler reads persisted sessions here. + (ctx.store as { getState?: () => unknown }).getState = vi.fn(() => ({ sessions: {} })); + // mux.getSessionsWithStats — optional; provide an empty list so the merge runs. + (ctx.mux as { getSessionsWithStats?: () => Promise }).getSessionsWithStats = vi.fn(async () => []); + return ctx; +} + +describe('GET /api/sessions/unified', () => { + let harness: LocalHarness; + + afterEach(async () => { + if (harness) await harness.app.close(); + }); + + it('returns the {sessions,total} envelope with default (testMode) ctx', async () => { + harness = await createEnvelopeHarness(createMockRouteContext()); + const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(Array.isArray(body.data.sessions)).toBe(true); + expect(typeof body.data.total).toBe('number'); + }); + + it('surfaces seeded live sessions when testMode is off', async () => { + // The default mock pre-populates one live session: 'test-session-1'. + harness = await createEnvelopeHarness(makeLiveCtx()); + const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + const ids = body.data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).toContain('test-session-1'); + expect(body.data.total).toBeGreaterThanOrEqual(1); + }); + + it('filters with ?q=', async () => { + harness = await createEnvelopeHarness(makeLiveCtx()); + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/unified?q=no-such-session-xyz', + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.total).toBe(0); + expect(body.data.sessions).toHaveLength(0); + }); + + it('caps results with ?limit=', async () => { + harness = await createEnvelopeHarness(makeLiveCtx()); + const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified?limit=0' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + // limit clamps to a minimum of 1, so at most 1 row is returned. + expect(body.data.sessions.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts new file mode 100644 index 00000000..459a11d2 --- /dev/null +++ b/test/services/unified-session-service.test.ts @@ -0,0 +1,145 @@ +/** + * @fileoverview Unit tests for the pure unified-session merge/filter service (COD-121). + * + * Covers dedup across sources, source precedence, mux-stat merge + meaningfulness + * floor, sort ordering, and filterAndPaginate (search + paging + clamps). + * Node env only — no jsdom, no IO. + */ + +import { describe, it, expect } from 'vitest'; +import { + mergeUnifiedSessions, + filterAndPaginate, + type UnifiedSessionItem, +} from '../../src/services/unified-session-service.js'; + +describe('mergeUnifiedSessions', () => { + it('dedupes the same sessionId across live + persisted into one item', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 's1', status: 'working', isWorking: true }], + persisted: [{ id: 's1', status: 'idle' }], + history: [{ sessionId: 's1', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }], + }); + expect(merged).toHaveLength(1); + const item = merged[0]; + expect(item.sessionId).toBe('s1'); + // sources accumulate from every contributing source (order-insensitive) + expect([...item.sources].sort()).toEqual(['history', 'live', 'persisted']); + // live wins for status + expect(item.status).toBe('working'); + expect(item.isWorking).toBe(true); + }); + + it('lets live status win over persisted (precedence)', () => { + const merged = mergeUnifiedSessions({ + persisted: [{ id: 's1', status: 'idle', name: 'Persisted Name' }], + live: [{ id: 's1', status: 'working' }], + }); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe('working'); + // persisted name survives because live did not provide one + expect(merged[0].name).toBe('Persisted Name'); + }); + + it('merges mux stats onto a live item but drops a mux-only entry with no name', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 's1', status: 'working' }], + mux: [ + { sessionId: 's1', stats: { memoryMB: 42, cpuPercent: 3.5 }, remote: true }, + { sessionId: 'noise', stats: { memoryMB: 10, cpuPercent: 1 } }, + ], + }); + expect(merged).toHaveLength(1); + const item = merged[0]; + expect(item.sessionId).toBe('s1'); + expect(item.stats).toEqual({ memoryMB: 42, cpuPercent: 3.5 }); + expect(item.remote).toBe(true); + }); + + it('drops a lifecycle-only entry with no name/firstPrompt but keeps a history item with firstPrompt', () => { + const merged = mergeUnifiedSessions({ + lifecycle: [{ sessionId: 'bare', event: 'created', ts: 1000 }], + history: [ + { + sessionId: 'hist', + workingDir: '/w', + sizeBytes: 9000, + lastModified: '2026-01-02T00:00:00.000Z', + firstPrompt: 'do the thing', + }, + ], + }); + const ids = merged.map((m) => m.sessionId); + expect(ids).toContain('hist'); + expect(ids).not.toContain('bare'); + }); + + it('sorts by lastActivityAt desc with undefined last', () => { + const merged = mergeUnifiedSessions({ + live: [ + { id: 'a', status: 'idle', lastActivityAt: 100 }, + { id: 'b', status: 'idle', lastActivityAt: 300 }, + { id: 'c', status: 'idle' }, // no lastActivityAt → sorts last + { id: 'd', status: 'idle', lastActivityAt: 200 }, + ], + }); + expect(merged.map((m) => m.sessionId)).toEqual(['b', 'd', 'a', 'c']); + }); + + it('derives lastActivityAt from history lastModified when none better exists', () => { + const merged = mergeUnifiedSessions({ + history: [{ sessionId: 'h', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }], + }); + expect(merged).toHaveLength(1); + expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime()); + }); +}); + +describe('filterAndPaginate', () => { + const items: UnifiedSessionItem[] = [ + { sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' }, + { sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' }, + { sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' }, + ]; + + it('filters by name (case-insensitive)', () => { + const r = filterAndPaginate(items, { q: 'alpha' }); + expect(r.total).toBe(1); + expect(r.sessions[0].sessionId).toBe('s1'); + }); + + it('filters by firstPrompt and workingDir', () => { + expect(filterAndPaginate(items, { q: 'login bug' }).sessions[0].sessionId).toBe('s2'); + expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3'); + }); + + it('reports total as the pre-page filtered count', () => { + const r = filterAndPaginate(items, { q: 'repo', limit: 1 }); + // both s1 and s2 have /repo/ workingDir + expect(r.total).toBe(2); + expect(r.sessions).toHaveLength(1); + }); + + it('clamps limit to a max of 500', () => { + const r = filterAndPaginate(items, { limit: 99999 }); + expect(r.sessions).toHaveLength(items.length); + // clamp does not throw and returns all 3 (< 500) + expect(r.total).toBe(3); + }); + + it('clamps limit to a min of 1', () => { + const r = filterAndPaginate(items, { limit: 0 }); + expect(r.sessions).toHaveLength(1); + }); + + it('paginates with disjoint pages via offset', () => { + const page1 = filterAndPaginate(items, { offset: 0, limit: 2 }); + const page2 = filterAndPaginate(items, { offset: 2, limit: 2 }); + expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']); + expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']); + const overlap = page1.sessions + .map((s) => s.sessionId) + .filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id)); + expect(overlap).toEqual([]); + }); +}); From ce4c5dd584442c5bebc4bb18f71588ffcfa9746c Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 5 Jul 2026 21:49:31 -0400 Subject: [PATCH 2/2] test(types): stop asserting Date.now() timestamps are deep-equal createInitialRalphTrackerState() stamps lastActivity: Date.now(). The 'should create fresh instances each time' test deep-equaled two factory results, so two calls straddling a millisecond boundary differed by 1ms and failed intermittently (e.g. PR #139 CI: 1782927694581 vs ...580). Exclude the dynamic lastActivity from the equality check and assert it is a number separately, preserving the test's intent (distinct instances with identical initial field values) without the timing race. --- test/types.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/types.test.ts b/test/types.test.ts index 0f1b67f0..81c2ffab 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -101,7 +101,14 @@ describe('types utility functions', () => { const state2 = createInitialRalphTrackerState(); expect(state1).not.toBe(state2); - expect(state1).toEqual(state2); + // `lastActivity` is stamped with Date.now(), so two calls that straddle a + // millisecond boundary differ by 1ms. Compare the rest of the initial + // state for equality and assert the timestamp is a number separately. + const { lastActivity: lastActivity1, ...rest1 } = state1; + const { lastActivity: lastActivity2, ...rest2 } = state2; + expect(rest1).toEqual(rest2); + expect(typeof lastActivity1).toBe('number'); + expect(typeof lastActivity2).toBe('number'); }); it('should have correct types for all fields', () => {