/** * `createLaunchAnalysisWorker`'s collapsed-index guard — ORDERING, not just status. * * `backend.init()` is the PUBLISH step (it is `LocalBackend.refreshRepos()`, * which swaps the freshly-registered repo into the in-memory map every MCP tool * and HTTP route resolves through). The guard added in #2899 read * `graphWriteCollapsed` only AFTER that call had already resolved, so a * known-incomplete database was live and queryable before the job was ever * marked `failed` — the job status was a label on a published index rather than * a gate. These tests pin the order, because the order is the defect. * * `analyze-launch.ts` had ZERO test coverage before this file, which is why a * field-name drift against `analyze-worker-ipc.ts`'s wire shape would have made * the branch permanently dead and silently restored the pre-guard behaviour. * The worker messages below are therefore built by calling the PRODUCTION * projection `projectAnalyzeResultForIpc` rather than hand-rolling a literal, so * a rename of `graphWriteCollapsed` breaks these tests instead of disabling the * branch they cover. */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { EventEmitter } from 'node:events'; // `vi.mock` factories are hoisted above every top-level `const`, and this file // imports the module under test statically — so anything a factory closes over // must be hoisted with it. const H = vi.hoisted(() => ({ forkMock: vi.fn(), STORAGE_PATH: '/tmp/gitnexus-test-storage', REPO_PATH: '/tmp/gitnexus-test-repo', METADATA_FILE: 'gitnexus.json', })); const { forkMock, REPO_PATH } = H; vi.mock('child_process', async () => { const actual = await vi.importActual('child_process'); return { ...actual, fork: H.forkMock }; }); // The launcher's finalization gate (`waitForSettledIndex`) probes the registry // and the filesystem. Pin both so the gate settles on its FIRST poll — the gate // itself is not under test here and its 200ms poll would otherwise put a real // timer between the worker message and the assertions. vi.mock('../../src/storage/repo-manager.js', () => ({ canonicalizePath: (p: string) => p, getStoragePath: () => H.STORAGE_PATH, INDEX_METADATA_FILE: H.METADATA_FILE, listRegisteredRepos: async () => [{ path: H.REPO_PATH, storagePath: H.STORAGE_PATH }], registryPathEquals: (a: string, b: string) => a === b, })); vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, // Both index files were (re)written far in the future relative to jobStartMs… statSync: () => ({ mtimeMs: Number.MAX_SAFE_INTEGER }), // …and no WAL/shadow/checkpoint sidecar remains. existsSync: () => false, }; }); import { createLaunchAnalysisWorker } from '../../src/server/analyze-launch.js'; import { JobManager } from '../../src/server/analyze-job.js'; import { projectAnalyzeResultForIpc } from '../../src/server/analyze-worker-ipc.js'; import type { AnalyzeResult } from '../../src/core/run-analyze.js'; import type { CompleteMessage } from '../../src/server/analyze-worker.js'; const REPO_NAME = 'collapse-fixture'; /** * Build the exact `complete` message the worker puts on the wire, by running the * production projection. The `graphWriteCollapsed` key is therefore whatever * `analyze-worker-ipc.ts` actually sends — not a literal this test invented. */ const completeMessage = (graphWriteCollapsed?: { expected: number; persisted: number }) => { const result = { repoName: REPO_NAME, repoPath: REPO_PATH, stats: { files: 10, nodes: 100, edges: 500 }, ...(graphWriteCollapsed ? { graphWriteCollapsed } : {}), } satisfies Partial as AnalyzeResult; return { type: 'complete', result: projectAnalyzeResultForIpc(result) } satisfies CompleteMessage; }; interface FakeChild extends EventEmitter { stderr: EventEmitter; send: Mock<(msg: unknown) => boolean>; kill: Mock<(signal?: NodeJS.Signals) => boolean>; } const makeChild = (): FakeChild => { const child = new EventEmitter() as FakeChild; child.stderr = new EventEmitter(); child.send = vi.fn(); child.kill = vi.fn(); return child; }; describe('createLaunchAnalysisWorker — collapsed index is never published', () => { let jobManager: JobManager; let child: FakeChild; let calls: string[]; let backendInit: Mock<() => Promise>; let closeDbHandle: Mock<() => Promise>; /** Drive one analyze to its terminal state and return the observed call order. */ const runWorker = async (msg: CompleteMessage) => { const launch = createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, acquireRepoLock: () => null, releaseRepoLock: () => {}, closeDbHandle, }); const job = jobManager.createJob({ repoPath: REPO_PATH }); launch(job, REPO_PATH, {}); child.emit('message', msg); await vi.waitFor(() => expect(calls).toContain('updateJob:terminal')); return jobManager.getJob(job.id); }; beforeEach(() => { calls = []; jobManager = new JobManager(); child = makeChild(); forkMock.mockImplementation(() => child); backendInit = vi.fn(async () => { calls.push('backend.init'); return true; }); closeDbHandle = vi.fn(async () => { calls.push('closeDbHandle'); }); const realUpdate = jobManager.updateJob.bind(jobManager); vi.spyOn(jobManager, 'updateJob').mockImplementation((id, update) => { calls.push(`updateJob:${update.status ?? 'progress'}`); realUpdate(id, update); // Recorded after the real call so the marker only lands once the status is // committed — `updateJob` drops any update to an already-terminal job. calls.push( ...['complete', 'failed'] .filter((s) => s === update.status) .map(() => 'updateJob:terminal'), ); }); }); it('forwards the Spring Actuator snapshot path to the worker', () => { const launch = createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, acquireRepoLock: () => null, releaseRepoLock: () => {}, closeDbHandle, }); const job = jobManager.createJob({ repoPath: REPO_PATH }); launch(job, REPO_PATH, { springActuatorPath: 'runtime/actuator' }); expect(child.send).toHaveBeenCalledWith( expect.objectContaining({ options: expect.objectContaining({ springActuatorPath: 'runtime/actuator', }), }), ); }); afterEach(() => { jobManager.dispose(); vi.restoreAllMocks(); forkMock.mockReset(); }); it('does not publish the index — backend.init() is never called for a collapsed run', async () => { await runWorker(completeMessage({ expected: 500, persisted: 3 })); // The defect: init() resolved FIRST, so the incomplete graph was live and // queryable by every MCP/API consumer before the job was marked failed. expect(backendInit).not.toHaveBeenCalled(); expect(calls).not.toContain('backend.init'); // The cached handle is still evicted — the worker rewrote the DB files on // disk, so a pre-rewrite handle is stale whatever the outcome was. Eviction // is not publication. expect(closeDbHandle).toHaveBeenCalledTimes(1); expect(calls.indexOf('closeDbHandle')).toBeLessThan(calls.indexOf('updateJob:failed')); }); it('marks the collapsed run failed and still reports repoName', async () => { const job = await runWorker(completeMessage({ expected: 500, persisted: 3 })); expect(job?.status).toBe('failed'); // The success path sets repoName; api.ts's repo-resolution wait matches jobs // on it first. Dropping it here cost one of three match keys for no reason. expect(job?.repoName).toBe(REPO_NAME); expect(job?.error).toContain('INCOMPLETELY'); expect(job?.error).toContain('3 of 500'); // The failure is explicit about the index being unreachable, not merely stale. expect(job?.error).toContain('NOT published'); }); it('publishes and completes a healthy run, in that order', async () => { const job = await runWorker(completeMessage()); expect(job?.status).toBe('complete'); expect(job?.repoName).toBe(REPO_NAME); expect(backendInit).toHaveBeenCalledTimes(1); // Publish strictly BEFORE the terminal complete, so the repo really is // queryable when the client receives the SSE complete event. expect(calls).toEqual([ 'updateJob:analyzing', 'closeDbHandle', 'backend.init', 'updateJob:complete', 'updateJob:terminal', ]); }); it('reads the collapse flag under the name analyze-worker-ipc.ts actually sends', async () => { const wire = completeMessage({ expected: 500, persisted: 3 }); // Guards against a silent rename: the branch under test keys off this exact // field, and the message was produced by the production projection. expect(Object.keys(wire.result)).toContain('graphWriteCollapsed'); expect(wire.result.graphWriteCollapsed).toEqual({ expected: 500, persisted: 3 }); // A projection that stopped carrying the field must not read as healthy. const healthy = completeMessage(); expect(healthy.result.graphWriteCollapsed).toBeUndefined(); const job = await runWorker(healthy); expect(job?.status).toBe('complete'); }); });