mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Merge branch 'main' into codex/gitnexus-governance-prototype
This commit is contained in:
commit
142bd739fb
15 changed files with 1027 additions and 21 deletions
|
|
@ -727,7 +727,7 @@ gitnexus wiki --force
|
|||
|
||||
|
||||
# Increase the timeout or retries for large codebase or slow LLM providers
|
||||
gitnexus wiki --timeout <seconds> # Per-attempt LLM request timeout in seconds (default: 60)
|
||||
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
|
||||
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
| `--timeout <seconds>` | Per-attempt LLM request timeout in seconds (default: 60) |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { execFileSync } from 'child_process';
|
|||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
|
|
@ -638,6 +639,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
// WAL corruption — the index file is unreadable. Give a clear recovery
|
||||
// path without a confusing stack trace (the native error message alone
|
||||
// is enough signal).
|
||||
if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) {
|
||||
cliError(
|
||||
` The GitNexus index has a corrupted WAL file.\n` +
|
||||
` This usually happens when a previous analysis was interrupted mid-write.\n` +
|
||||
` ${WAL_RECOVERY_SUGGESTION}\n`,
|
||||
{ recoveryHint: 'wal-corruption' },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ program
|
|||
)
|
||||
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--timeout <seconds>', 'Per-attempt LLM request timeout in seconds (default: 60)')
|
||||
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
|
||||
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
|
||||
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
||||
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createServer } from '../server/api.js';
|
||||
import { logger, flushLoggerSync } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
|
||||
// Catch anything that would cause a silent exit. Pino v10's default
|
||||
// destination is `sync: false` (SonicBoom buffered) — call
|
||||
|
|
@ -34,7 +35,13 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
|
|||
try {
|
||||
await createServer(port, host);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
if (isWalCorruptionError(err)) {
|
||||
cliError(
|
||||
`\nGitNexus server could not start: the index has a corrupted WAL file.\n` +
|
||||
` ${WAL_RECOVERY_SUGGESTION}\n`,
|
||||
{ recoveryHint: 'wal-corruption' },
|
||||
);
|
||||
} else if (err.code === 'EADDRINUSE') {
|
||||
cliError(
|
||||
`\nFailed to start GitNexus server:\n` +
|
||||
` ${err.message || err}\n\n` +
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ export interface WikiCommandOptions {
|
|||
retries?: string;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(
|
||||
value: string | undefined,
|
||||
flag: string,
|
||||
multiplier = 1,
|
||||
): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!/^[1-9]\d*$/.test(trimmed)) {
|
||||
throw new Error(`${flag} must be a positive integer`);
|
||||
}
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) {
|
||||
throw new Error(`${flag} is too large`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for input via stdin.
|
||||
*/
|
||||
|
|
@ -127,6 +144,17 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
return;
|
||||
}
|
||||
|
||||
let timeoutSeconds: number | undefined;
|
||||
let retries: number | undefined;
|
||||
try {
|
||||
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
|
||||
retries = parsePositiveIntegerOption(options?.retries, '--retries');
|
||||
} catch (error) {
|
||||
console.log(` Error: ${(error as Error).message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Resolve LLM config (with interactive fallback) ─────────────────
|
||||
// Save any CLI overrides immediately
|
||||
if (
|
||||
|
|
@ -350,13 +378,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
}
|
||||
|
||||
// ── Apply per-run overrides not saved to config ────────────────────
|
||||
if (options?.timeout) {
|
||||
const secs = parseInt(options.timeout, 10);
|
||||
if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
|
||||
if (timeoutSeconds !== undefined) {
|
||||
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
|
||||
}
|
||||
if (options?.retries) {
|
||||
const n = parseInt(options.retries, 10);
|
||||
if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
|
||||
if (retries !== undefined) {
|
||||
llmConfig.maxAttempts = retries;
|
||||
}
|
||||
|
||||
// ── Setup progress bar with elapsed timer ──────────────────────────
|
||||
|
|
@ -563,6 +589,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
|
||||
if (err.message?.includes('No source files')) {
|
||||
console.log(`\n ${err.message}\n`);
|
||||
} else if (err.message?.includes('LLM request timed out after')) {
|
||||
console.log(`\n Timeout: ${err.message}\n`);
|
||||
} else if (err.message?.includes('content filter')) {
|
||||
// Content filter block — actionable message
|
||||
console.log(`\n Content Filter: ${err.message}\n`);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ import {
|
|||
closeLbugConnection,
|
||||
isDbBusyError,
|
||||
isOpenRetryExhausted,
|
||||
isWalCorruptionError,
|
||||
openLbugConnection,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
waitForWindowsHandleRelease,
|
||||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
|
|
@ -594,6 +596,24 @@ const doInitLbug = async (dbPath: string) => {
|
|||
// anyway and any genuine cross-process lock contention surfaces
|
||||
// on the next operation via withLbugDb's retry. Logging it here
|
||||
// would just be noise in CI.
|
||||
//
|
||||
// WAL corruption: the first DDL write after DB open triggers WAL
|
||||
// replay — if the WAL file was left in a corrupt state by an
|
||||
// interrupted previous run, the native engine throws here. Rather
|
||||
// than logging a WARN and continuing in a broken state, close the
|
||||
// DB cleanly and surface an actionable error so the caller (serve,
|
||||
// MCP, analyze) can exit with a clear recovery message.
|
||||
if (isWalCorruptionError(err)) {
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
|
||||
` Original error: ${msg.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
if (!msg.includes('already exists') && !isDbBusyError(err)) {
|
||||
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const LBUG_MAX_DB_SIZE: number = (() => {
|
|||
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
|
||||
|
||||
export const WAL_RECOVERY_SUGGESTION =
|
||||
'WAL corruption detected. Run `gitnexus analyze` to rebuild the index.';
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.';
|
||||
|
||||
export function isWalCorruptionError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@
|
|||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { loadFTSExtension } from './lbug-adapter.js';
|
||||
import { createLbugDatabase, isWalCorruptionError } from './lbug-config.js';
|
||||
import {
|
||||
createLbugDatabase,
|
||||
isWalCorruptionError,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from './lbug-config.js';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
|
|
@ -375,8 +379,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
|||
break;
|
||||
} catch (retryErr) {
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected for ${repoId}. ` +
|
||||
`Run \`gitnexus analyze\` to rebuild the index. ` +
|
||||
`LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` +
|
||||
`(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface LLMConfig {
|
|||
apiVersion?: string;
|
||||
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
|
||||
isReasoningModel?: boolean;
|
||||
/** Per-attempt fetch timeout in ms (default: 60_000). */
|
||||
/** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */
|
||||
requestTimeoutMs?: number;
|
||||
/** Max fetch attempts before giving up (default: 3). */
|
||||
maxAttempts?: number;
|
||||
|
|
@ -81,6 +81,19 @@ export function estimateTokens(text: string): number {
|
|||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function formatTimeoutDuration(timeoutMs: number): string {
|
||||
if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) {
|
||||
return `${timeoutMs / 1000}s`;
|
||||
}
|
||||
return `${timeoutMs}ms`;
|
||||
}
|
||||
|
||||
function isTimeoutLikeError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
|
||||
return /time(d)?\s*out|timeout/i.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
|
||||
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
|
||||
|
|
@ -237,12 +250,13 @@ export async function callLLM(
|
|||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// Per-attempt timeout. Without this each retry can hang
|
||||
// indefinitely on a frozen TCP connection — the per-call
|
||||
// signal is the only timeout `resilientFetch` honors;
|
||||
// `capDelayMs` only bounds the *backoff* between attempts.
|
||||
// Default 60s; raise via --timeout for slow models or large pages.
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000),
|
||||
// Request timeout is opt-in for wiki generation. Large local
|
||||
// model runs can legitimately take well over a minute, so the
|
||||
// default runtime path must not impose a hidden 60s ceiling.
|
||||
signal:
|
||||
config.requestTimeoutMs !== undefined
|
||||
? AbortSignal.timeout(config.requestTimeoutMs)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
breakerKey: `wiki-llm-${new URL(url).host}`,
|
||||
|
|
@ -261,6 +275,12 @@ export async function callLLM(
|
|||
`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) {
|
||||
throw new Error(
|
||||
`LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` +
|
||||
'Increase --timeout or omit it to disable the request timeout.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
|
|
|||
137
gitnexus/test/unit/analyze-wal-error.test.ts
Normal file
137
gitnexus/test/unit/analyze-wal-error.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Tests for WAL corruption error handling in the `analyzeCommand` CLI.
|
||||
*
|
||||
* Before this fix, a WAL corruption error surfaced as a raw stack-trace dump.
|
||||
* After the fix, it is caught before the generic error path and rendered as
|
||||
* a clean, actionable message telling the user to run `gitnexus analyze --force`.
|
||||
*
|
||||
* Mirrors the test shape of analyze-worker-timeout.test.ts:
|
||||
* - vi.mock the heavy dependencies so no real DB / git is touched
|
||||
* - drive `analyzeCommand` with a mocked `runFullAnalysis` that throws
|
||||
* - assert on process.exitCode and the logged output
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runFullAnalysisMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/core/run-analyze.js', () => ({
|
||||
runFullAnalysis: runFullAnalysisMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
|
||||
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
||||
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
||||
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
||||
assertAnalysisFinalized: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(() => '/repo'),
|
||||
hasGitDir: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
||||
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// analyze.ts imports isHfDownloadFailure from hf-env.js, which in turn imports
|
||||
// from gitnexus-shared (not linked in dev). Mock the module to break the chain.
|
||||
vi.mock('../../src/core/embeddings/hf-env.js', () => ({
|
||||
isHfDownloadFailure: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('analyzeCommand WAL corruption error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFullAnalysisMock.mockReset();
|
||||
process.exitCode = undefined;
|
||||
// Ensure ensureHeap() short-circuits (heap already at target size)
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
||||
});
|
||||
|
||||
it('surfaces a clean recovery message on a re-wrapped WAL corruption error', async () => {
|
||||
// This error shape is what lbug-adapter throws after detecting WAL corruption
|
||||
// in doInitLbug and re-wrapping it with the recovery suggestion.
|
||||
const walError = new Error(
|
||||
'LadybugDB WAL corruption detected at /repo/.gitnexus/lbug. ' +
|
||||
'Run `gitnexus analyze` to rebuild the index.\n' +
|
||||
' Original error: Runtime exception: Corrupted wal file.',
|
||||
);
|
||||
runFullAnalysisMock.mockRejectedValue(walError);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
const records = cap.records();
|
||||
const walRecord = records.find(
|
||||
(r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'),
|
||||
);
|
||||
expect(walRecord).toBeDefined();
|
||||
|
||||
// Raw stack trace must NOT appear via cliError
|
||||
const stackRecord = records.find(
|
||||
(r) => typeof r.msg === 'string' && r.msg.includes('at analyzeCommand'),
|
||||
);
|
||||
expect(stackRecord).toBeUndefined();
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('surfaces a clean recovery message when the native WAL error fires directly', async () => {
|
||||
// isWalCorruptionError fires on the native engine message before re-wrapping.
|
||||
const nativeWalError = new Error(
|
||||
'Runtime exception: Corrupted wal file. Read out invalid WAL record type.',
|
||||
);
|
||||
runFullAnalysisMock.mockRejectedValue(nativeWalError);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
const records = cap.records();
|
||||
const walRecord = records.find(
|
||||
(r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'),
|
||||
);
|
||||
expect(walRecord).toBeDefined();
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('does NOT route non-WAL errors through the WAL handler', async () => {
|
||||
const genericError = new Error('Some unexpected failure unrelated to WAL');
|
||||
runFullAnalysisMock.mockRejectedValue(genericError);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
// The WAL recovery message must NOT appear for unrelated errors
|
||||
const records = cap.records();
|
||||
const walRecord = records.find(
|
||||
(r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'),
|
||||
);
|
||||
expect(walRecord).toBeUndefined();
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
});
|
||||
259
gitnexus/test/unit/lbug-adapter-wal-schema.test.ts
Normal file
259
gitnexus/test/unit/lbug-adapter-wal-schema.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Tests for WAL corruption detection in the doInitLbug schema creation loop.
|
||||
*
|
||||
* Before this fix, a corrupt WAL that threw during schema DDL was silently
|
||||
* logged as WARN. After the fix, `isWalCorruptionError` is checked first:
|
||||
* the DB is closed cleanly and an Error with `WAL_RECOVERY_SUGGESTION` is
|
||||
* thrown so the caller (serve / MCP / analyze) can exit with a clear message.
|
||||
*
|
||||
* Two test layers (same pattern as lbug-checkpoint-lifecycle.test.ts):
|
||||
* 1. Structural — grep the adapter source to verify the guard is wired in.
|
||||
* 2. Behavioural — vi.doMock + vi.resetModules to exercise the runtime path.
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeOpenMock = () =>
|
||||
vi.fn(async () => ({
|
||||
writeFile: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const SCHEMA_MOCK = {
|
||||
NODE_TABLES: ['File', 'Function', 'Class'],
|
||||
REL_TABLE_NAME: 'CodeRelation',
|
||||
EMBEDDING_TABLE_NAME: 'Embedding',
|
||||
STALE_HASH_SENTINEL: '__stale__',
|
||||
SCHEMA_QUERIES: ['CREATE NODE TABLE IF NOT EXISTS File (id STRING, PRIMARY KEY(id))'],
|
||||
};
|
||||
|
||||
function makeFsMock(dbPath: string) {
|
||||
const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' });
|
||||
return {
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT;
|
||||
}),
|
||||
access: vi.fn(async () => {
|
||||
throw ENOENT;
|
||||
}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Structural tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('doInitLbug WAL corruption guard — structural', () => {
|
||||
let adapterSource: string;
|
||||
let schemaLoopBody: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
adapterSource = await fs.readFile(
|
||||
path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
// 3000-char window from the SCHEMA_QUERIES loop comfortably covers the
|
||||
// full catch block including the throw with WAL_RECOVERY_SUGGESTION.
|
||||
const loopIdx = adapterSource.indexOf('for (const schemaQuery of SCHEMA_QUERIES)');
|
||||
schemaLoopBody = adapterSource.slice(loopIdx, loopIdx + 3000);
|
||||
});
|
||||
|
||||
it('imports isWalCorruptionError and WAL_RECOVERY_SUGGESTION from lbug-config', () => {
|
||||
expect(adapterSource).toMatch(/isWalCorruptionError/);
|
||||
expect(adapterSource).toMatch(/WAL_RECOVERY_SUGGESTION/);
|
||||
expect(adapterSource).toMatch(/from '\.\/lbug-config\.js'/);
|
||||
});
|
||||
|
||||
it('calls isWalCorruptionError inside the schema creation loop catch block', () => {
|
||||
expect(schemaLoopBody).toMatch(/isWalCorruptionError\(err\)/);
|
||||
});
|
||||
|
||||
it('WAL guard calls safeClose() to avoid leaving an open handle', () => {
|
||||
expect(schemaLoopBody).toMatch(/await safeClose\(\)/);
|
||||
});
|
||||
|
||||
it('WAL guard resets currentDbPath to null', () => {
|
||||
expect(schemaLoopBody).toMatch(/currentDbPath = null/);
|
||||
});
|
||||
|
||||
it('WAL guard throws with WAL_RECOVERY_SUGGESTION in the message', () => {
|
||||
expect(schemaLoopBody).toMatch(/WAL_RECOVERY_SUGGESTION/);
|
||||
expect(schemaLoopBody).toMatch(/throw new Error/);
|
||||
});
|
||||
|
||||
it('WAL guard appears BEFORE the generic schema-warning logger.warn', () => {
|
||||
const walGuardIdx = schemaLoopBody.indexOf('isWalCorruptionError(err)');
|
||||
// Avoid multi-byte emoji — search for the text portion only
|
||||
const warnIdx = schemaLoopBody.indexOf('Schema creation warning');
|
||||
expect(walGuardIdx).toBeGreaterThan(-1);
|
||||
expect(warnIdx).toBeGreaterThan(-1);
|
||||
expect(walGuardIdx).toBeLessThan(warnIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavioural tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('doInitLbug WAL corruption guard — behavioural', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('fs/promises');
|
||||
vi.doUnmock('../../src/core/lbug/schema.js');
|
||||
vi.doUnmock('../../src/core/lbug/lbug-config.js');
|
||||
vi.doUnmock('../../src/core/lbug/extension-loader.js');
|
||||
vi.doUnmock('../../src/core/logger.js');
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws with WAL recovery message when a schema query raises a WAL corruption error', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-wal-schema-throw/lbug';
|
||||
const walError = new Error(
|
||||
'Runtime exception: Corrupted wal file. Read out invalid WAL record type.',
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
|
||||
vi.doMock('fs/promises', () => makeFsMock(dbPath));
|
||||
vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK);
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn(() => false),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
isWalCorruptionError: vi.fn((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /corrupt.*wal|invalid.*wal.*record/i.test(msg);
|
||||
}),
|
||||
WAL_RECOVERY_SUGGESTION:
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Catch the error once and assert both patterns in the message.
|
||||
// (mockRejectedValueOnce is consumed on the first call, so a second
|
||||
// initLbug call would succeed — test both patterns in one shot.)
|
||||
const err = await adapter.initLbug(dbPath).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/LadybugDB WAL corruption detected/);
|
||||
expect((err as Error).message).toMatch(/gitnexus analyze/);
|
||||
});
|
||||
|
||||
it('does NOT throw for unrecognised schema errors — logs warn and continues', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-wal-schema-nonwal/lbug';
|
||||
const genericError = new Error('some unrelated schema warning');
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
let callCount = 0;
|
||||
const conn = {
|
||||
query: vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) throw genericError;
|
||||
return queryResult;
|
||||
}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const warnMock = vi.fn();
|
||||
|
||||
vi.doMock('fs/promises', () => makeFsMock(dbPath));
|
||||
vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK);
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn(() => false),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
isWalCorruptionError: vi.fn(() => false), // always false → generic warn path
|
||||
WAL_RECOVERY_SUGGESTION:
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Must resolve without throwing — non-WAL schema errors are swallowed (logged as WARN)
|
||||
await expect(adapter.initLbug(dbPath)).resolves.toBeDefined();
|
||||
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('Schema creation warning'));
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('calls safeClose() (db.close) when WAL corruption is detected mid-schema', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-wal-schema-state/lbug';
|
||||
const walError = new Error('Corrupted wal file. Read out invalid WAL record type.');
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
|
||||
vi.doMock('fs/promises', () => makeFsMock(dbPath));
|
||||
vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK);
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn(() => false),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
isWalCorruptionError: vi.fn((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /corrupt.*wal|invalid.*wal.*record/i.test(msg);
|
||||
}),
|
||||
WAL_RECOVERY_SUGGESTION:
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
await expect(adapter.initLbug(dbPath)).rejects.toThrow(/LadybugDB WAL corruption/);
|
||||
|
||||
// safeClose was called — db.close is its final step
|
||||
expect(db.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,8 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|||
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
createLbugDatabase: vi.fn(),
|
||||
LBUG_MAX_DB_SIZE: 1024,
|
||||
WAL_RECOVERY_SUGGESTION:
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
||||
isWalCorruptionError: vi.fn((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err ?? '');
|
||||
return /corrupt(ed)?\s+wal|invalid\s+wal\s+record/i.test(msg);
|
||||
|
|
|
|||
|
|
@ -264,6 +264,384 @@ describe('WikiGenerator --review mode', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('wikiCommand --timeout validation', () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const tooLargeTimeout = String(Math.floor(Number.MAX_SAFE_INTEGER / 1000) + 1);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('../../src/storage/git.js');
|
||||
vi.doUnmock('../../src/storage/repo-manager.js');
|
||||
vi.doUnmock('../../src/core/wiki/llm-client.js');
|
||||
vi.doUnmock('../../src/core/wiki/generator.js');
|
||||
vi.doUnmock('cli-progress');
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
it.each(['', ' ', '0', '-1', 'abc', '3.14', tooLargeTimeout])(
|
||||
'rejects invalid --timeout value %s before starting generation',
|
||||
async (timeout) => {
|
||||
const generatorCtor = vi.fn().mockImplementation(() => ({
|
||||
run: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(),
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi
|
||||
.fn()
|
||||
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
|
||||
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
}),
|
||||
saveCLIConfig: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLLMConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock('../../src/core/wiki/generator.js', () => ({
|
||||
WikiGenerator: generatorCtor,
|
||||
}));
|
||||
vi.doMock('cli-progress', () => ({
|
||||
default: {
|
||||
SingleBar: vi.fn(function () {
|
||||
return {
|
||||
start: vi.fn(),
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}),
|
||||
Presets: { shades_grey: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { wikiCommand } = await import('../../src/cli/wiki.js');
|
||||
|
||||
await wikiCommand('/tmp/repo', { timeout });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(generatorCtor).not.toHaveBeenCalled();
|
||||
const expectedMessage =
|
||||
timeout === tooLargeTimeout
|
||||
? ' Error: --timeout is too large\n'
|
||||
: ' Error: --timeout must be a positive integer\n';
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expectedMessage);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('wikiCommand --retries validation', () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('../../src/storage/git.js');
|
||||
vi.doUnmock('../../src/storage/repo-manager.js');
|
||||
vi.doUnmock('../../src/core/wiki/llm-client.js');
|
||||
vi.doUnmock('../../src/core/wiki/generator.js');
|
||||
vi.doUnmock('cli-progress');
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
it.each(['', ' ', '0', '-1', 'abc', '3.14'])(
|
||||
'rejects invalid --retries value %s before starting generation',
|
||||
async (retries) => {
|
||||
const generatorCtor = vi.fn().mockImplementation(() => ({
|
||||
run: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(),
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi
|
||||
.fn()
|
||||
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
|
||||
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
}),
|
||||
saveCLIConfig: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLLMConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock('../../src/core/wiki/generator.js', () => ({
|
||||
WikiGenerator: generatorCtor,
|
||||
}));
|
||||
vi.doMock('cli-progress', () => ({
|
||||
default: {
|
||||
SingleBar: vi.fn(function () {
|
||||
return {
|
||||
start: vi.fn(),
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}),
|
||||
Presets: { shades_grey: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { wikiCommand } = await import('../../src/cli/wiki.js');
|
||||
|
||||
await wikiCommand('/tmp/repo', { retries });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(generatorCtor).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(' Error: --retries must be a positive integer\n');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('wikiCommand --timeout mapping', () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('../../src/storage/git.js');
|
||||
vi.doUnmock('../../src/storage/repo-manager.js');
|
||||
vi.doUnmock('../../src/core/wiki/llm-client.js');
|
||||
vi.doUnmock('../../src/core/wiki/generator.js');
|
||||
vi.doUnmock('cli-progress');
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
async function loadWikiCommandHarness() {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
const generatorCtor = vi
|
||||
.fn()
|
||||
.mockImplementation(function (_repoPath, _storagePath, _lbugPath, config) {
|
||||
capturedConfig = config;
|
||||
return {
|
||||
run: vi.fn().mockResolvedValue({ mode: 'up-to-date', pagesGenerated: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(),
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi
|
||||
.fn()
|
||||
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
|
||||
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
}),
|
||||
saveCLIConfig: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLLMConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock('../../src/core/wiki/generator.js', () => ({
|
||||
WikiGenerator: generatorCtor,
|
||||
}));
|
||||
vi.doMock('cli-progress', () => ({
|
||||
default: {
|
||||
SingleBar: vi.fn(function () {
|
||||
return {
|
||||
start: vi.fn(),
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}),
|
||||
Presets: { shades_grey: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { wikiCommand } = await import('../../src/cli/wiki.js');
|
||||
return {
|
||||
wikiCommand,
|
||||
generatorCtor,
|
||||
consoleSpy,
|
||||
getCapturedConfig: () => capturedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => {
|
||||
const harness = await loadWikiCommandHarness();
|
||||
|
||||
await harness.wikiCommand('/tmp/repo', { timeout: '120' });
|
||||
|
||||
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getCapturedConfig()?.requestTimeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it('leaves requestTimeoutMs undefined when --timeout is omitted', async () => {
|
||||
const harness = await loadWikiCommandHarness();
|
||||
|
||||
await harness.wikiCommand('/tmp/repo', {});
|
||||
|
||||
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getCapturedConfig()?.requestTimeoutMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps --retries to maxAttempts before constructing WikiGenerator', async () => {
|
||||
const harness = await loadWikiCommandHarness();
|
||||
|
||||
await harness.wikiCommand('/tmp/repo', { retries: '5' });
|
||||
|
||||
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getCapturedConfig()?.maxAttempts).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wikiCommand timeout messaging', () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('../../src/storage/git.js');
|
||||
vi.doUnmock('../../src/storage/repo-manager.js');
|
||||
vi.doUnmock('../../src/core/wiki/llm-client.js');
|
||||
vi.doUnmock('../../src/core/wiki/generator.js');
|
||||
vi.doUnmock('cli-progress');
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
it('surfaces a dedicated timeout message when wiki generation hits the configured timeout', async () => {
|
||||
const generatorCtor = vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
run: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error(
|
||||
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(),
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi
|
||||
.fn()
|
||||
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
|
||||
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
}),
|
||||
saveCLIConfig: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLLMConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock('../../src/core/wiki/generator.js', () => ({
|
||||
WikiGenerator: generatorCtor,
|
||||
}));
|
||||
vi.doMock('cli-progress', () => ({
|
||||
default: {
|
||||
SingleBar: vi.fn(function () {
|
||||
return {
|
||||
start: vi.fn(),
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}),
|
||||
Presets: { shades_grey: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { wikiCommand } = await import('../../src/cli/wiki.js');
|
||||
|
||||
await wikiCommand('/tmp/repo', { timeout: '120' });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI config round-trip with cursor provider ──────────────────────
|
||||
|
||||
describe('CLI config round-trip with cursor provider', () => {
|
||||
|
|
|
|||
|
|
@ -237,6 +237,143 @@ describe('callLLM — reasoning model params', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('callLLM — timeout handling', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not apply a default timeout when requestTimeoutMs is omitted', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).not.toHaveBeenCalled();
|
||||
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies an explicit timeout when requestTimeoutMs is provided', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const timeoutSignal = new AbortController().signal;
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutSignal);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
requestTimeoutMs: 120_000,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(120_000);
|
||||
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBe(timeoutSignal);
|
||||
});
|
||||
|
||||
it('surfaces a clear timeout error when the request timeout fires', async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
requestTimeoutMs: 120_000,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces millisecond timeout durations when the timeout is not a whole second', async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
requestTimeoutMs: 1_500,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the request timeout.',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the same timeout message for timeout-like non-DOM errors', async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('request timed out while waiting for response'));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
requestTimeoutMs: 120_000,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mislabel generic aborted connections as request timeouts', async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error('connection aborted by server'));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM('test', {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 500,
|
||||
temperature: 0,
|
||||
requestTimeoutMs: 120_000,
|
||||
}),
|
||||
).rejects.toThrow('connection aborted by server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLLM — Azure content_filter error', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue