fix(test): unify staleness-and-stability into single withTestLbugDB block

All 4 test blocks now share one DB lifecycle to avoid cross-block
"Database is closed" errors caused by LadybugDB's shared global DB
in a single vitest fork. Staleness detection (which triggers closeLbug)
runs last to avoid invalidating connections for other blocks.

11/11 tests pass on macOS, Ubuntu, and Windows.
This commit is contained in:
hiromima 2026-03-20 21:06:30 +09:00
parent 5b012c3351
commit 2ff4d93314

View file

@ -2,14 +2,15 @@
* E2E Tests: Stale Data Detection + Sequential Enrichment Stability
*
* Validates the fixes in PR #396:
* 1. Stale data detection: ensureInitialized() detects when the index
* was rebuilt (meta.json changed) and re-opens the connection pool
* 2. Sequential enrichment: impact() enrichment queries run without
* 1. Sequential enrichment: impact() enrichment queries run without
* SIGSEGV on arm64 macOS (sequential on arm64, parallel elsewhere)
* 3. Consecutive tool stability: MCP server stays alive after 10+
* 2. Consecutive tool stability: MCP server stays alive after 10+
* consecutive tool calls (no stdout corruption)
* 4. All core tools (context, query, cypher, impact) return valid
* results after the concurrency changes
* 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
*/
@ -32,21 +33,123 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
}));
// ─── Block 1: Stale data detection (#297) ────────────────────────────
withTestLbugDB('staleness-and-stability', (handle) => {
let backend: LocalBackend;
let storagePath: string;
withTestLbugDB('staleness-detection', (handle) => {
describe('stale data detection via meta.json', () => {
let backend: LocalBackend;
let storagePath: string;
it('setup backend and verify initial state', async () => {
// ─── 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;
});
});
// Verify initial query works
// ─── 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',
});
@ -55,50 +158,39 @@ withTestLbugDB('staleness-detection', (handle) => {
});
it('detects stale index when meta.json indexedAt changes', async () => {
// Write a meta.json with a different indexedAt to simulate re-index
const metaPath = path.join(storagePath, 'meta.json');
const freshMeta = {
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 },
};
await fs.writeFile(metaPath, JSON.stringify(freshMeta));
}));
// The next tool call should trigger re-init internally.
// It may fail (the DB hasn't actually changed) but should NOT crash
// with SIGSEGV or corrupt the WAL.
// Next call triggers re-init. May fail but must NOT crash.
try {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
// If it succeeds, verify it returns valid data
expect(result).toBeDefined();
} catch (err: any) {
// Re-init failure is acceptable (DB path didn't actually change)
// but SIGSEGV or WAL corruption would crash the process entirely
expect(err.message).not.toMatch(/SIGSEGV/i);
}
});
it('does not re-read meta.json within 5s throttle window', async () => {
// Write meta.json with yet another timestamp
it('throttle: no re-read within 5s window', async () => {
const metaPath = path.join(storagePath, 'meta.json');
const newerMeta = {
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 },
};
await fs.writeFile(metaPath, JSON.stringify(newerMeta));
}));
// Immediate second call should be throttled (no fs.readFile)
// This test verifies the throttle doesn't cause errors
try {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt',
});
expect(result).toBeDefined();
} catch {
// Acceptable — the point is no crash
// No crash = success
}
});
});
@ -108,7 +200,7 @@ withTestLbugDB('staleness-detection', (handle) => {
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Write initial meta.json
// Write initial meta.json for staleness tests
const metaPath = path.join(handle.tmpHandle.dbPath, 'meta.json');
const initialMeta = {
indexedAt: new Date().toISOString(),
@ -132,193 +224,3 @@ withTestLbugDB('staleness-detection', (handle) => {
(handle as any)._backend = backend;
},
});
// ─── Block 2: Sequential enrichment queries (#285, #290, #292) ───────
withTestLbugDB('sequential-enrichment', (handle) => {
describe('impact enrichment queries run without crashes', () => {
let backend: LocalBackend;
it('setup', async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
});
it('impact with enrichment completes without SIGSEGV', async () => {
// This is the core regression test: impact() runs 3 enrichment queries
// that previously used Promise.all and caused SIGSEGV on arm64 macOS.
// Now they run sequentially on arm64 macOS and in parallel elsewhere.
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
});
// May return an error if the DB was affected by prior test blocks.
// The key assertion: no SIGSEGV crash (process would exit if so).
if (!result.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();
// No crash = success. Error response is acceptable (DB state may vary).
});
});
}, {
seed: LOCAL_BACKEND_SEED_DATA,
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
});
// ─── Block 3: Consecutive tool call stability ────────────────────────
withTestLbugDB('consecutive-stability', (handle) => {
describe('MCP server stays alive after 10+ consecutive tool calls', () => {
let backend: LocalBackend;
it('setup', async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
});
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 () => {
// Cycle through all 4 core tools 3 times
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 properly restored after all calls', () => {
// Verify stdout wasn't permanently silenced by the silenceStdout
// mechanism used to prevent native stdout corruption
const isOriginal = process.stdout.write !== ((() => true) as any);
expect(isOriginal).toBe(true);
});
});
}, {
seed: LOCAL_BACKEND_SEED_DATA,
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
});
// ─── Block 4: activeQueryCount watchdog interaction ──────────────────
withTestLbugDB('watchdog-query-guard', (handle) => {
describe('watchdog does not restore stdout during active queries', () => {
const REPO = 'watchdog-test';
let inited = false;
const ensurePool = async () => {
if (!inited) {
await initLbug(REPO, handle.dbPath);
inited = true;
}
};
afterAll(async () => {
try { await closeLbug(REPO); } catch { /* best-effort */ }
});
it('parallel queries complete and stdout is restored', async () => {
await ensurePool();
// Run 4 parallel queries — if the watchdog incorrectly restores
// stdout during execution, native output could corrupt the MCP
// stdio stream. The test verifies all queries complete cleanly.
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);
}
// After all queries complete, stdout should be restored
const isOriginal = process.stdout.write !== ((() => true) as any);
expect(isOriginal).toBe(true);
});
it('sequential queries with intentional delay still work', async () => {
await ensurePool();
// Run queries one by one — each silences/restores stdout
for (let i = 0; i < 5; i++) {
const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name');
expect(rows.length).toBeGreaterThanOrEqual(1);
}
});
});
}, {
seed: LOCAL_BACKEND_SEED_DATA,
});