mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
237 lines
9.3 KiB
TypeScript
237 lines
9.3 KiB
TypeScript
/**
|
|
* `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<typeof import('child_process')>('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<typeof import('node:fs')>('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<AnalyzeResult> 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<unknown>>;
|
|
let closeDbHandle: Mock<() => Promise<void>>;
|
|
|
|
/** 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');
|
|
});
|
|
});
|