Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/services/code-index/__tests__/code-index-scan-executor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { CodeIndexScanExecutor } from "../code-index-scan-executor"
import type { IDirectoryScanner } from "../interfaces"

vi.mock("../../../i18n", () => ({ t: (key: string) => key }))

function setup() {
const scanner = { scanDirectory: vi.fn<IDirectoryScanner["scanDirectory"]>() }
const vectorStore = { markIndexingIncomplete: vi.fn().mockResolvedValue(undefined) }
const stateManager = { setSystemState: vi.fn(), reportBlockIndexingProgress: vi.fn() }
return {
scanner,
vectorStore,
stateManager,
executor: new CodeIndexScanExecutor("/workspace", scanner, vectorStore, stateManager),
}
}

describe("CodeIndexScanExecutor", () => {
it.each(["runFullScan", "runIncrementalScan"] as const)(
"%s skips failure validation when cancellation occurs during the scan",
async (method) => {
const { executor, scanner } = setup()
const controller = new AbortController()
scanner.scanDirectory.mockImplementation(async (_path, onError, _onIndexed, onParsed) => {
onParsed?.(3)
onError?.(new Error("batch failure"))
controller.abort()
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 3 }
})
await expect(executor[method](controller.signal)).resolves.toBe(false)
},
)

it("completes an incremental scan with no changed files", async () => {
const { executor, scanner } = setup()
scanner.scanDirectory.mockResolvedValue({ stats: { processed: 0, skipped: 2 }, totalBlockCount: 0 })
await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true)
})

it.each(["runFullScan", "runIncrementalScan"] as const)(
"%s preserves progress and signal wiring",
async (method) => {
const { executor, scanner, vectorStore, stateManager } = setup()
const signal = new AbortController().signal
scanner.scanDirectory.mockImplementation(async (path, _onError, onIndexed, onParsed, receivedSignal) => {
expect(path).toBe("/workspace")
expect(receivedSignal).toBe(signal)
expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledOnce()
onParsed?.(2)
onIndexed?.(1)
onParsed?.(1)
onIndexed?.(2)
return { stats: { processed: 2, skipped: 0 }, totalBlockCount: 3 }
})
await expect(executor[method](signal)).resolves.toBe(true)
expect(stateManager.reportBlockIndexingProgress.mock.calls).toEqual([
[0, 2],
[1, 2],
[1, 3],
[3, 3],
])
expect(stateManager.setSystemState).toHaveBeenCalledExactlyOnceWith(
"Indexing",
method === "runFullScan"
? "Services ready. Starting workspace scan..."
: "Checking for new or modified files...",
)
},
)

it.each(["runFullScan", "runIncrementalScan"] as const)("%s rejects missing results", async (method) => {
const { executor } = setup()
// An unconfigured mock simulates a scanner violating its return contract.
await expect(executor[method](new AbortController().signal)).rejects.toThrow(
method === "runFullScan"
? "Scan failed, is scanner initialized?"
: "Incremental scan failed, is scanner initialized?",
)
})

it.each(["runFullScan", "runIncrementalScan"] as const)(
"%s returns cancellation before validating results",
async (method) => {
const { executor, scanner } = setup()
const controller = new AbortController()
// Abort before the unconfigured scanner returns a missing result.
controller.abort()
await expect(executor[method](controller.signal)).resolves.toBe(false)
expect(scanner.scanDirectory).toHaveBeenCalledOnce()
},
)

it.each(["runFullScan", "runIncrementalScan"] as const)(
"%s propagates scanner and marker failures unchanged",
async (method) => {
const { executor, scanner, vectorStore } = setup()
const signal = new AbortController().signal
const failure = new Error("scan failed")
scanner.scanDirectory.mockRejectedValueOnce(failure)
await expect(executor[method](signal)).rejects.toBe(failure)
const abort = new DOMException("stopped", "AbortError")
scanner.scanDirectory.mockRejectedValueOnce(abort)
await expect(executor[method](signal)).rejects.toBe(abort)
vectorStore.markIndexingIncomplete.mockRejectedValueOnce(failure)
await expect(executor[method](signal)).rejects.toBe(failure)
expect(scanner.scanDirectory).toHaveBeenCalledTimes(2)
},
)

it.each([
{ found: 0, indexed: 0, error: false, message: undefined },
{ found: 3, indexed: 0, error: false, message: "embeddings:orchestrator.indexingFailedNoBlocks" },
{ found: 3, indexed: 0, error: true, message: "Indexing failed: batch failure" },
{ found: 0, indexed: 0, error: true, message: "Indexing failed completely: batch failure" },
{ found: 10, indexed: 9, error: true, message: undefined },
{
found: 10,
indexed: 8,
error: true,
message: "Indexing partially failed: Only 8 of 10 blocks were indexed. batch failure",
},
{ found: 10, indexed: 8, error: false, message: undefined },
])(
"preserves full-scan policy for $indexed/$found blocks, error=$error",
async ({ found, indexed, error, message }) => {
const { executor, scanner } = setup()
scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => {
onParsed?.(found)
onIndexed?.(indexed)
if (error) onError?.(new Error("batch failure"))
return { stats: { processed: 1, skipped: 0 }, totalBlockCount: found }
})
const result = executor.runFullScan(new AbortController().signal)
if (message) await expect(result).rejects.toThrow(message)
else await expect(result).resolves.toBe(true)
},
)

it.each([0, 2])("preserves incremental batch-error tolerance with %s indexed blocks", async (indexed) => {
const { executor, scanner } = setup()
scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => {
onParsed?.(3)
onIndexed?.(indexed)
onError?.(new Error("batch failure"))
return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 }
})
await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true)
})
})
66 changes: 66 additions & 0 deletions src/services/code-index/__tests__/orchestrator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,72 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => {
}
})

it.each([false, true])(
"keeps watcher startup and completion after scanning (incremental: %s)",
async (incremental) => {
const events: string[] = []
vectorStore.initialize.mockResolvedValue(false)
vectorStore.hasIndexedData.mockResolvedValue(incremental)
vectorStore.markIndexingIncomplete.mockImplementation(async () => {
events.push("incomplete")
})
scanner.scanDirectory.mockImplementation(async () => {
events.push("scan")
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 }
})
fileWatcher.initialize.mockImplementation(async () => {
events.push("watcher")
})
vectorStore.markIndexingComplete.mockImplementation(async () => {
events.push("complete")
})
const orchestrator = new CodeIndexOrchestrator(
configManager,
stateManager,
workspacePath,
cacheManager,
vectorStore,
scanner,
fileWatcher,
)
await orchestrator.startIndexing()
expect(events).toEqual(["incomplete", "scan", "watcher", "complete"])
expect(orchestrator.state).toBe("Indexed")
expect(cacheManager.clearCacheFile).not.toHaveBeenCalled()
expect(vectorStore.clearCollection).not.toHaveBeenCalled()
},
)

it.each([false, true])(
"keeps returned cancellation cleanup in the owner (incremental: %s)",
async (incremental) => {
vectorStore.initialize.mockResolvedValue(false)
vectorStore.hasIndexedData.mockResolvedValue(incremental)
const orchestrator = new CodeIndexOrchestrator(
configManager,
stateManager,
workspacePath,
cacheManager,
vectorStore,
scanner,
fileWatcher,
)
scanner.scanDirectory.mockImplementation(async () => {
orchestrator.stopIndexing()
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 }
})
// Preserve the original normal-return path: a failed flush is retried by the catch handler.
cacheManager.flush.mockRejectedValueOnce(new Error("flush failed"))
await orchestrator.startIndexing()
expect(cacheManager.flush).toHaveBeenCalledTimes(2)
expect(orchestrator.state).toBe("Standby")
expect(fileWatcher.initialize).not.toHaveBeenCalled()
expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled()
expect(vectorStore.clearCollection).not.toHaveBeenCalled()
expect(cacheManager.clearCacheFile).not.toHaveBeenCalled()
},
)

it("should not call clearCollection() or clear cache when initialize() fails (indexing not started)", async () => {
// Arrange: fail at initialize()
vectorStore.initialize.mockRejectedValue(new Error("Qdrant unreachable"))
Expand Down
101 changes: 101 additions & 0 deletions src/services/code-index/code-index-scan-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { IDirectoryScanner, IVectorStore } from "./interfaces"
import type { CodeIndexStateManager } from "./state-manager"
import { t } from "../../i18n"

/** Executes scans; cancellation cleanup, watcher startup and completion remain with the orchestrator. */
export class CodeIndexScanExecutor {
constructor(
private readonly workspacePath: string,
private readonly scanner: IDirectoryScanner,
private readonly vectorStore: Pick<IVectorStore, "markIndexingIncomplete">,
private readonly stateManager: Pick<CodeIndexStateManager, "setSystemState" | "reportBlockIndexingProgress">,
) {}

/** Returns false when the scanner returns after cancellation, preserving the owner's normal-return path. */
async runIncrementalScan(signal: AbortSignal): Promise<boolean> {
console.log(
"[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...",

Check warning on line 17 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:17: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
)
this.stateManager.setSystemState("Indexing", "Checking for new or modified files...")
const summary = await this.scanWorkspace(signal, "incremental")

Check warning on line 20 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:20: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
if (!summary) return false

// Preserve the existing incremental policy: reported batch errors do not prevent completion.
if (summary.found > 0) {

Check warning on line 24 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:24: 4 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
console.log(
`[CodeIndexOrchestrator] Incremental scan completed: ${summary.indexed} blocks indexed from new/changed files`,

Check warning on line 26 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:26: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
} else {
console.log("[CodeIndexOrchestrator] No new or changed files found")

Check warning on line 29 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:29: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
}
return true
}

/** Returns false on returned cancellation; scanner rejections propagate unchanged. */
async runFullScan(signal: AbortSignal): Promise<boolean> {
this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...")
const summary = await this.scanWorkspace(signal, "full")
if (!summary) return false
this.validateFullScan(summary.indexed, summary.found, summary.batchErrors)
return true
}

private async scanWorkspace(
signal: AbortSignal,
kind: "full" | "incremental",
): Promise<{ indexed: number; found: number; batchErrors: Error[] } | undefined> {
await this.vectorStore.markIndexingIncomplete()
let indexed = 0
let found = 0
const batchErrors: Error[] = []
const result = await this.scanner.scanDirectory(
this.workspacePath,
(batchError: Error) => {
console.error(
`[CodeIndexOrchestrator] Error during ${kind === "full" ? "initial" : "incremental"} scan batch: ${batchError.message}`,

Check warning on line 55 in src/services/code-index/code-index-scan-executor.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/code-index/code-index-scan-executor.ts:55: 7 mutation test gaps; example: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
batchError,
)
batchErrors.push(batchError)
},
(count: number) => {
indexed += count
this.stateManager.reportBlockIndexingProgress(indexed, found)
},
(count: number) => {
found += count
this.stateManager.reportBlockIndexingProgress(indexed, found)
},
signal,
)

// Cancellation precedes missing-result checks and validation, just as in the orchestrator.
if (signal.aborted) return undefined
if (!result) {
throw new Error(
kind === "full"
? "Scan failed, is scanner initialized?"
: "Incremental scan failed, is scanner initialized?",
)
}
return { indexed, found, batchErrors }
}

private validateFullScan(indexed: number, found: number, batchErrors: Error[]): void {
const firstError = batchErrors[0]
if (indexed === 0 && found > 0) {
throw new Error(
firstError
? `Indexing failed: ${firstError.message}`
: t("embeddings:orchestrator.indexingFailedNoBlocks"),
)
}
if (firstError && (found - indexed) / found > 0.1) {
throw new Error(
`Indexing partially failed: Only ${indexed} of ${found} blocks were indexed. ${firstError.message}`,
)
}
if (firstError && indexed === 0) {
throw new Error(`Indexing failed completely: ${firstError.message}`)
}
}
}
Loading
Loading