GitNexus/gitnexus/test/integration/staleness-and-stability.test.ts

295 lines
12 KiB
TypeScript

/**
* E2E Tests: Stale Data Detection + Sequential Enrichment Stability
*
* Validates the fixes in PR #396:
* 1. Sequential enrichment: impact() enrichment queries run without
* SIGSEGV on arm64 macOS (sequential on arm64, parallel elsewhere)
* 2. Consecutive tool stability: MCP server stays alive after 10+
* consecutive tool calls (no stdout corruption)
* 3. Watchdog guard: activeQueryCount prevents premature stdout restore
* 4. Stale data detection: ensureInitialized() detects meta.json changes
*
* All tests share one withTestLbugDB lifecycle to avoid cross-block
* DB closure issues (LadybugDB's shared global DB in a single fork).
*
* Issues: #285, #290, #292, #297
*/
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { initLbug, executeQuery, closeLbug } from '../../src/mcp/core/lbug-adapter.js';
// Passthrough spies on the pool adapter: real behavior, observable calls —
// the staleness tests assert a fresher metadata stamp actually triggers a
// pool reinit (closeLbug + initLbug), not merely "didn't crash". The mock
// targets core/lbug/pool-adapter.js (LocalBackend's direct import);
// mcp/core/lbug-adapter.js is a re-export shim over the same module, so the
// spies are visible through both specifiers.
vi.mock('../../src/core/lbug/pool-adapter.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/core/lbug/pool-adapter.js')>();
return {
...actual,
initLbug: vi.fn(actual.initLbug),
closeLbug: vi.fn(actual.closeLbug),
};
});
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
LOCAL_BACKEND_SEED_DATA,
LOCAL_BACKEND_FTS_INDEXES,
} from '../fixtures/local-backend-seed.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { vi } from 'vitest';
// Partial mock: registry access is faked, but everything else — critically
// `loadMeta`, which the staleness check in LocalBackend.ensureInitialized
// calls on every throttled window — stays REAL, so the staleness tests
// below exercise the true read path against the fixture metadata files.
// (A factory that omitted loadMeta made that call site throw a TypeError
// that the staleness check's catch silently swallowed — the whole "detects
// stale index" block passed without ever running the detection.)
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
withTestLbugDB(
'staleness-and-stability',
(handle) => {
let backend: LocalBackend;
let storagePath: string;
// ─── Setup ─────────────────────────────────────────────────────────
describe('setup', () => {
it('initialize backend', async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
storagePath = handle.tmpHandle.dbPath;
});
});
// ─── Block 1: Sequential enrichment queries (#285, #290, #292) ─────
describe('impact enrichment queries run without crashes', () => {
it('impact with enrichment completes without SIGSEGV', async () => {
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
expect(result).toHaveProperty('affected_processes');
expect(result).toHaveProperty('affected_modules');
});
it('impact with large maxDepth completes without crash', async () => {
const result = await backend.callTool('impact', {
target: 'login',
direction: 'downstream',
maxDepth: 5,
});
expect(result).toBeDefined();
expect(result).not.toHaveProperty('error');
});
});
// ─── Block 2: Consecutive tool call stability ──────────────────────
describe('MCP server stays alive after 10+ consecutive tool calls', () => {
it('10 consecutive cypher calls complete without stdout corruption', async () => {
for (let i = 0; i < 10; i++) {
const result = await backend.callTool('cypher', {
query: `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`,
});
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(1);
}
});
it('mixed tool calls: context → impact → query → cypher cycle', async () => {
for (let i = 0; i < 3; i++) {
const ctx = await backend.callTool('context', { name: 'login' });
expect(ctx.status).toBe('found');
const imp = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
});
expect(imp).not.toHaveProperty('error');
const qry = await backend.callTool('query', { query: 'login' });
expect(qry).not.toHaveProperty('error');
const cyp = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
expect(cyp).toHaveProperty('row_count');
}
});
it('stdout.write is still a function after all calls', () => {
expect(typeof process.stdout.write).toBe('function');
});
});
// ─── Block 3: Watchdog / activeQueryCount ──────────────────────────
describe('watchdog does not restore stdout during active queries', () => {
const REPO = 'watchdog-test';
let poolInited = false;
const ensurePool = async () => {
if (!poolInited) {
await initLbug(REPO, handle.dbPath);
poolInited = true;
}
};
afterAll(async () => {
try {
await closeLbug(REPO);
} catch {
/* best-effort */
}
});
it('parallel queries complete and stdout is restored', async () => {
await ensurePool();
const queries = Array.from({ length: 4 }, (_, i) =>
executeQuery(REPO, `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`),
);
const results = await Promise.all(queries);
expect(results).toHaveLength(4);
for (const r of results) {
expect(r.length).toBeGreaterThanOrEqual(1);
}
});
it('sequential queries still work', async () => {
await ensurePool();
for (let i = 0; i < 5; i++) {
const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name');
expect(rows.length).toBeGreaterThanOrEqual(1);
}
});
});
// ─── Block 4: Stale data detection (#297) ──────────────────────────
// LAST: triggers closeLbug internally which may affect shared state
describe('stale data detection via meta.json', () => {
it('initial query works', async () => {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
});
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(3);
});
it('detects stale index when meta.json indexedAt changes and reinits the pool', async () => {
const metaPath = path.join(storagePath, 'meta.json');
await fs.writeFile(
metaPath,
JSON.stringify({
indexedAt: new Date(Date.now() + 60000).toISOString(),
lastCommit: 'new-commit-hash',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
}),
);
const initCallsBefore = vi.mocked(initLbug).mock.calls.length;
// Beat the 5s staleness throttle without freezing real timers/IO.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(Date.now() + 10_000));
try {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
// The pool was re-inited AND the query on the fresh pool succeeded.
expect(result).toHaveProperty('row_count');
} finally {
vi.useRealTimers();
}
expect(vi.mocked(initLbug).mock.calls.length).toBeGreaterThan(initCallsBefore);
expect(vi.mocked(closeLbug)).toHaveBeenCalled();
});
it('prefers a fresher gitnexus.json over meta.json in the staleness check', async () => {
// The primary metadata filename is consulted first; the stale
// meta.json mirror left behind must not mask the newer stamp.
await fs.writeFile(
path.join(storagePath, 'gitnexus.json'),
JSON.stringify({
indexedAt: new Date(Date.now() + 120_000).toISOString(),
lastCommit: 'primary-newer-commit',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
}),
);
const initCallsBefore = vi.mocked(initLbug).mock.calls.length;
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(Date.now() + 20_000));
try {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
expect(result).toHaveProperty('row_count');
} finally {
vi.useRealTimers();
await fs.rm(path.join(storagePath, 'gitnexus.json'), { force: true });
}
expect(vi.mocked(initLbug).mock.calls.length).toBeGreaterThan(initCallsBefore);
});
it('throttle: no re-read within 5s window', async () => {
const metaPath = path.join(storagePath, 'meta.json');
await fs.writeFile(
metaPath,
JSON.stringify({
indexedAt: new Date(Date.now() + 120000).toISOString(),
lastCommit: 'another-commit',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
}),
);
try {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
expect(result).toBeDefined();
} catch {
// No crash = success
}
});
});
},
{
seed: LOCAL_BACKEND_SEED_DATA,
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Write initial meta.json for staleness tests
const metaPath = path.join(handle.tmpHandle.dbPath, 'meta.json');
const initialMeta = {
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
};
await fs.writeFile(metaPath, JSON.stringify(initialMeta));
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: initialMeta.indexedAt,
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);