/** * Regression coverage for SearchManager.timeline() anchor dispatch. * * Bug history: HTTP query params arrive as strings, so the * `typeof anchor === 'number'` dispatch missed the observation-ID branch * and silently fell through to ISO-timestamp parsing — returning a * wrong-epoch window with the correct anchor still echoed in the header. * * The fix coerces stringified numerics in `SearchManager.timeline()` via * `anchorAsNumber`. These tests guard that fix by exercising: * (a) numeric anchor as JS number * (b) numeric anchor as string (THE bug case) * (c) session-ID string anchor "S" * (d) ISO-timestamp anchor * (e) garbage anchor (must return isError: true) * * Pattern source: tests/session_store.test.ts uses real SessionStore * against ':memory:' SQLite. We follow the same approach (no SessionStore * mocks) and additionally instantiate real SessionSearch over the same DB * handle, plus real FormattingService and TimelineService. ChromaSync is * passed as null (the timeline anchor branch does not require Chroma). */ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; // ModeManager is a global singleton that requires `loadMode()` to be // called before use. The formatter path inside `SearchManager.timeline()` // calls `ModeManager.getInstance().getTypeIcon(...)`, which throws if no // mode is loaded. Existing worker tests (e.g. tests/worker/search/ // result-formatter.test.ts) follow the same pattern: stub ModeManager // so the unrelated config singleton does not blow up the unit under // test. We deliberately do NOT mock SessionStore — that's the data // layer the bug travelled through, and faking it would defeat the // regression coverage. mock.module('../../src/services/domain/ModeManager.js', () => ({ ModeManager: { getInstance: () => ({ getActiveMode: () => ({ name: 'code', prompts: {}, observation_types: [ { id: 'discovery', icon: 'I' }, ], observation_concepts: [], }), getObservationTypes: () => [{ id: 'discovery', icon: 'I' }], getTypeIcon: (_type: string) => 'I', getWorkEmoji: () => 'W', }), }, })); import { Database } from 'bun:sqlite'; import { SessionStore } from '../../src/services/sqlite/SessionStore.js'; import { SessionSearch } from '../../src/services/sqlite/SessionSearch.js'; import { FormattingService } from '../../src/services/worker/FormattingService.js'; import { TimelineService } from '../../src/services/worker/TimelineService.js'; import { SearchManager } from '../../src/services/worker/SearchManager.js'; const PROJECT = 'timeline-anchor-test'; const MEMORY_SESSION_ID = 'mem-session-timeline-anchor'; const CONTENT_SESSION_ID = 'content-timeline-anchor'; interface SeededObservation { id: number; epoch: number; } function seedObservations(store: SessionStore, count: number): SeededObservation[] { const sdkId = store.createSDKSession(CONTENT_SESSION_ID, PROJECT, 'initial prompt'); store.updateMemorySessionId(sdkId, MEMORY_SESSION_ID); // Anchor the synthetic timeline well in the past so it cannot collide with // any "recent rows" the buggy code path would otherwise return. const baseEpoch = Date.UTC(2024, 0, 1, 0, 0, 0); // 2024-01-01T00:00:00Z const stepMs = 60_000; // 1 minute apart, deterministic ordering const seeded: SeededObservation[] = []; for (let i = 0; i < count; i++) { const epoch = baseEpoch + i * stepMs; const result = store.storeObservation( MEMORY_SESSION_ID, PROJECT, { type: 'discovery', title: `Synthetic observation #${i + 1}`, subtitle: null, facts: [], narrative: `Narrative for synthetic observation ${i + 1}`, concepts: [], files_read: [], files_modified: [], }, i + 1, 0, epoch ); seeded.push({ id: result.id, epoch: result.createdAtEpoch }); } return seeded; } /** * Pull the observation IDs out of the timeline's formatted markdown. * Each observation row renders as `| # |