// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────────────── // Track subscriptions created by @parcel/watcher mock let mockSubscribeCallback: ((err: Error | null, events: Array<{ path: string; type: string }>) => void) | null = null; const mockUnsubscribe = vi.fn(async () => {}); vi.mock("@parcel/watcher", () => ({ default: { subscribe: vi.fn(async (_dir: string, cb: (err: Error | null, events: Array<{ path: string; type: string }>) => void, _opts?: unknown) => { mockSubscribeCallback = cb; return { unsubscribe: mockUnsubscribe }; }), }, })); vi.mock("../../src/services/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); vi.mock("../../src/services/ignore.js", () => ({ createIgnoreFilter: vi.fn(() => ({ ignores: () => false })), shouldIgnore: vi.fn(() => false), })); const mockUpdateProjectIndex = vi.fn(async (_path: string, _progress?: unknown) => ({ added: 0, updated: 0, removed: 0, chunksCreated: 0, cancelled: false })); const mockIsIndexingInProgress = vi.fn((_path: string) => false); vi.mock("../../src/services/indexer.js", () => ({ updateProjectIndex: (...args: unknown[]) => mockUpdateProjectIndex(...(args as [string, unknown])), isIndexingInProgress: (...args: unknown[]) => mockIsIndexingInProgress(...(args as [string])), })); vi.mock("../../src/services/code-graph.js", () => ({ invalidateGraphCache: vi.fn(), })); const mockProjectIdFromPath = vi.fn((_p: string) => "test-project-id"); const mockCollectionName = vi.fn((_id: string) => "codebase_test"); vi.mock("../../src/config.js", () => ({ projectIdFromPath: (...args: unknown[]) => mockProjectIdFromPath(...(args as [string])), collectionName: (...args: unknown[]) => mockCollectionName(...(args as [string])), })); const mockGetCollectionInfo = vi.fn(async (_c: string): Promise<{ pointsCount: number } | null> => null); const mockGetProjectMetadata = vi.fn(async (_c: string): Promise | null> => null); vi.mock("../../src/services/qdrant.js", () => ({ getCollectionInfo: (...args: unknown[]) => mockGetCollectionInfo(...(args as [string])), getProjectMetadata: (...args: unknown[]) => mockGetProjectMetadata(...(args as [string])), })); const mockAcquireProjectLock = vi.fn(async (_path: string, _type: string) => true); const mockReleaseProjectLock = vi.fn(async (_path: string, _type: string) => {}); const mockIsProjectLocked = vi.fn(async (_path: string, _type: string) => false); vi.mock("../../src/services/lock.js", () => ({ acquireProjectLock: (...args: unknown[]) => mockAcquireProjectLock(...(args as [string, string])), releaseProjectLock: (...args: unknown[]) => mockReleaseProjectLock(...(args as [string, string])), isProjectLocked: (...args: unknown[]) => mockIsProjectLocked(...(args as [string, string])), })); import { shouldIgnore } from "../../src/services/ignore.js"; import { logger } from "../../src/services/logger.js"; // Import after mocks import { clearExternalWatchCache, ensureWatcherStarted, getWatchedProjects, isWatchedByAnyProcess, isWatching, startWatching, stopAllWatchers, stopWatching, } from "../../src/services/watcher.js"; // ── Helpers ────────────────────────────────────────────────────────────── const TEST_PROJECT = "/tmp/test-project"; const RESOLVED_PROJECT = path.resolve(TEST_PROJECT); // ── Tests ──────────────────────────────────────────────────────────────── describe("watcher (unit)", () => { beforeEach(() => { vi.clearAllMocks(); mockSubscribeCallback = null; mockAcquireProjectLock.mockResolvedValue(true); mockIsProjectLocked.mockResolvedValue(false); mockIsIndexingInProgress.mockReturnValue(false); mockGetCollectionInfo.mockResolvedValue(null); mockGetProjectMetadata.mockResolvedValue(null); }); afterEach(async () => { // Clean up any active watchers between tests await stopAllWatchers(); clearExternalWatchCache(); }); // ── startWatching / stopWatching / isWatching / getWatchedProjects ─── describe("startWatching", () => { it("starts watching and reports via onProgress", async () => { const progress: string[] = []; const result = await startWatching(TEST_PROJECT, (msg) => progress.push(msg)); expect(result).toBe(true); expect(isWatching(TEST_PROJECT)).toBe(true); expect(progress).toContain(`Started watching ${RESOLVED_PROJECT}`); expect(logger.info).toHaveBeenCalledWith("File watcher started", { projectPath: RESOLVED_PROJECT }); }); it("acquires a cross-process lock", async () => { await startWatching(TEST_PROJECT); expect(mockAcquireProjectLock).toHaveBeenCalledWith(RESOLVED_PROJECT, "watch"); }); it("skips if already watching (idempotent)", async () => { const progress: string[] = []; await startWatching(TEST_PROJECT); const result = await startWatching(TEST_PROJECT, (msg) => progress.push(msg)); expect(result).toBe(true); expect(progress).toContain(`Already watching ${RESOLVED_PROJECT}`); // subscribe should only be called once const watcher = await import("@parcel/watcher"); expect(watcher.default.subscribe).toHaveBeenCalledTimes(1); }); it("skips if lock cannot be acquired (another process watching)", async () => { mockAcquireProjectLock.mockResolvedValue(false); const progress: string[] = []; const result = await startWatching(TEST_PROJECT, (msg) => progress.push(msg)); expect(result).toBe(false); expect(isWatching(TEST_PROJECT)).toBe(false); expect(progress.some((m) => m.includes("Another process"))).toBe(true); }); it("releases lock if @parcel/watcher.subscribe fails", async () => { const watcher = await import("@parcel/watcher"); vi.mocked(watcher.default.subscribe).mockRejectedValueOnce(new Error("Permission denied")); const progress: string[] = []; const result = await startWatching(TEST_PROJECT, (msg) => progress.push(msg)); expect(result).toBe(false); expect(isWatching(TEST_PROJECT)).toBe(false); expect(mockReleaseProjectLock).toHaveBeenCalledWith(RESOLVED_PROJECT, "watch"); expect(progress.some((m) => m.includes("Failed to start watching"))).toBe(true); }); }); describe("stopWatching", () => { it("stops an active watcher and releases lock", async () => { await startWatching(TEST_PROJECT); expect(isWatching(TEST_PROJECT)).toBe(true); await stopWatching(TEST_PROJECT); expect(isWatching(TEST_PROJECT)).toBe(false); expect(mockUnsubscribe).toHaveBeenCalled(); expect(mockReleaseProjectLock).toHaveBeenCalledWith(RESOLVED_PROJECT, "watch"); }); it("does nothing for a non-watched project", async () => { await expect(stopWatching("/nonexistent")).resolves.not.toThrow(); expect(mockUnsubscribe).not.toHaveBeenCalled(); }); }); describe("stopAllWatchers", () => { it("stops all active watchers", async () => { await startWatching(TEST_PROJECT); expect(getWatchedProjects().length).toBe(1); await stopAllWatchers(); expect(getWatchedProjects()).toHaveLength(0); }); }); describe("isWatching", () => { it("returns false when not watching", () => { expect(isWatching(TEST_PROJECT)).toBe(false); }); it("returns true when watching", async () => { await startWatching(TEST_PROJECT); expect(isWatching(TEST_PROJECT)).toBe(true); }); it("resolves relative paths", async () => { await startWatching(TEST_PROJECT); // Should match regardless of trailing slashes etc via path.resolve expect(isWatching(TEST_PROJECT)).toBe(true); }); }); describe("getWatchedProjects", () => { it("returns empty array when nothing is watched", () => { expect(getWatchedProjects()).toEqual([]); }); it("returns resolved paths of watched projects", async () => { await startWatching(TEST_PROJECT); const projects = getWatchedProjects(); expect(projects).toHaveLength(1); expect(projects[0]).toBe(RESOLVED_PROJECT); }); }); // ── isWatchedByAnyProcess (cross-process awareness) ──────────────── describe("isWatchedByAnyProcess", () => { it("returns true when watching locally", async () => { await startWatching(TEST_PROJECT); expect(await isWatchedByAnyProcess(TEST_PROJECT)).toBe(true); }); it("returns true when another process holds the watch lock", async () => { mockIsProjectLocked.mockResolvedValue(true); expect(await isWatchedByAnyProcess(TEST_PROJECT)).toBe(true); expect(mockIsProjectLocked).toHaveBeenCalledWith(RESOLVED_PROJECT, "watch"); }); it("returns false when not watched locally and no lock held", async () => { mockIsProjectLocked.mockResolvedValue(false); expect(await isWatchedByAnyProcess(TEST_PROJECT)).toBe(false); }); it("skips lock check when watching locally (fast path)", async () => { await startWatching(TEST_PROJECT); mockIsProjectLocked.mockClear(); expect(await isWatchedByAnyProcess(TEST_PROJECT)).toBe(true); expect(mockIsProjectLocked).not.toHaveBeenCalled(); }); }); // ── Event filtering (via the callback) ───────────────────────────────── describe("event filtering", () => { it("triggers update for indexable file changes", async () => { vi.useFakeTimers(); const progress = vi.fn(); await startWatching(TEST_PROJECT, progress); // Simulate a file change event mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "src/app.ts"), type: "update" }, ]); // Fast-forward past the debounce await vi.advanceTimersByTimeAsync(2100); expect(mockUpdateProjectIndex).toHaveBeenCalledWith(RESOLVED_PROJECT, progress); vi.useRealTimers(); }); it("ignores non-indexable files (e.g. .png, .lock)", async () => { vi.useFakeTimers(); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "image.png"), type: "create" }, { path: path.join(RESOLVED_PROJECT, "package-lock.json"), type: "update" }, ]); // .png is not in SUPPORTED_EXTENSIONS and not in SPECIAL_FILES // .json IS supported, so this actually triggers — but .png is filtered await vi.advanceTimersByTimeAsync(2100); // package-lock.json has .json extension which IS in SUPPORTED_EXTENSIONS, // so the update should still trigger for that event vi.useRealTimers(); }); it("ignores files that match gitignore rules", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(true); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "dist/bundle.js"), type: "create" }, ]); await vi.advanceTimersByTimeAsync(2100); // All events were filtered by shouldIgnore, so no update expect(mockUpdateProjectIndex).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("ignores files outside the project tree", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: "/some/other/project/file.ts", type: "update" }, ]); await vi.advanceTimersByTimeAsync(2100); expect(mockUpdateProjectIndex).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("handles special files (Dockerfile, Makefile)", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "Dockerfile"), type: "update" }, ]); await vi.advanceTimersByTimeAsync(2100); expect(mockUpdateProjectIndex).toHaveBeenCalled(); vi.useRealTimers(); }); }); // ── Debounce behavior ────────────────────────────────────────────────── describe("debounce", () => { it("coalesces rapid changes into a single update", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); await startWatching(TEST_PROJECT); // Fire 5 rapid events for (let i = 0; i < 5; i++) { mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, `file${i}.ts`), type: "update" }, ]); } await vi.advanceTimersByTimeAsync(2100); // Only one update call despite 5 events expect(mockUpdateProjectIndex).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); it("does not trigger update before debounce period", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "file.ts"), type: "update" }, ]); // Only 1 second has passed — should not have triggered yet await vi.advanceTimersByTimeAsync(1000); expect(mockUpdateProjectIndex).not.toHaveBeenCalled(); // Now pass the debounce threshold await vi.advanceTimersByTimeAsync(1100); expect(mockUpdateProjectIndex).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); }); // ── Error handling ───────────────────────────────────────────────────── describe("error handling", () => { it("logs first 3 errors", async () => { await startWatching(TEST_PROJECT); for (let i = 0; i < 3; i++) { mockSubscribeCallback?.(new Error(`test error ${i}`), []); } expect(logger.error).toHaveBeenCalledTimes(3); }); it("throttles error logging after 3rd error (logs every 100th)", async () => { await startWatching(TEST_PROJECT); // Fire 10 errors (below MAX_WATCHER_ERRORS threshold for this test — it will auto-stop at 10) // But we need to test throttling, so let's fire 4 to see the 4th is suppressed for (let i = 0; i < 4; i++) { mockSubscribeCallback?.(new Error(`error ${i}`), []); } // First 3 errors + the "too many errors" is NOT triggered yet (count=4 < 10) // logger.error is called for errors 1, 2, 3 but NOT 4 const errorCalls = vi.mocked(logger.error).mock.calls.filter( (call) => call[0] === "File watcher error", ); expect(errorCalls).toHaveLength(3); }); it("auto-stops watcher after MAX_WATCHER_ERRORS consecutive errors", async () => { await startWatching(TEST_PROJECT); expect(isWatching(TEST_PROJECT)).toBe(true); // Fire 10 consecutive errors for (let i = 0; i < 10; i++) { mockSubscribeCallback?.(new Error(`error ${i}`), []); } // The auto-stop is asynchronous, so wait for it await vi.waitFor(() => { expect(isWatching(TEST_PROJECT)).toBe(false); }); expect(logger.error).toHaveBeenCalledWith( "Too many watcher errors, stopping watcher", expect.objectContaining({ totalErrors: 10 }), ); }); it("resets error count on successful event delivery", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); await startWatching(TEST_PROJECT); // Fire 5 errors for (let i = 0; i < 5; i++) { mockSubscribeCallback?.(new Error(`error ${i}`), []); } // Then a successful event — error count should reset mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "file.ts"), type: "update" }, ]); // Fire 5 more errors — should NOT auto-stop (count restarted from 0) for (let i = 0; i < 5; i++) { mockSubscribeCallback?.(new Error(`error ${i}`), []); } // Should still be watching (5 + 0 + 5, but count was reset in the middle) expect(isWatching(TEST_PROJECT)).toBe(true); vi.useRealTimers(); }); }); // ── ensureWatcherStarted ─────────────────────────────────────────────── describe("ensureWatcherStarted", () => { it("does nothing if already watching", async () => { await startWatching(TEST_PROJECT); mockGetCollectionInfo.mockClear(); ensureWatcherStarted(TEST_PROJECT); // Should not even check collection info expect(mockGetCollectionInfo).not.toHaveBeenCalled(); }); it("does nothing if indexing is in progress", () => { mockIsIndexingInProgress.mockReturnValue(true); ensureWatcherStarted(TEST_PROJECT); expect(mockGetCollectionInfo).not.toHaveBeenCalled(); }); it("does nothing if no collection exists", async () => { mockGetCollectionInfo.mockResolvedValue(null); ensureWatcherStarted(TEST_PROJECT); // Wait for the async chain to complete await vi.waitFor(() => { expect(mockGetCollectionInfo).toHaveBeenCalled(); }); // Should not have started watching expect(isWatching(TEST_PROJECT)).toBe(false); }); it("does nothing if collection is empty (0 points)", async () => { mockGetCollectionInfo.mockResolvedValue({ pointsCount: 0 }); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(mockGetCollectionInfo).toHaveBeenCalled(); }); expect(isWatching(TEST_PROJECT)).toBe(false); }); it("does not start if indexing status is not completed", async () => { mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue({ indexingStatus: "in-progress", filesIndexed: 10, filesTotal: 50, }); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(mockGetProjectMetadata).toHaveBeenCalled(); }); // Give the async chain a moment to complete await new Promise((r) => setTimeout(r, 50)); expect(isWatching(TEST_PROJECT)).toBe(false); expect(logger.info).toHaveBeenCalledWith( "Skipping watcher auto-start: index is incomplete (interrupted)", expect.objectContaining({ indexingStatus: "in-progress" }), ); }); it("starts watcher when collection exists and index is completed", async () => { mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue({ indexingStatus: "completed" }); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(isWatching(TEST_PROJECT)).toBe(true); }); expect(logger.info).toHaveBeenCalledWith( "Auto-started file watcher on tool use", expect.objectContaining({ projectPath: RESOLVED_PROJECT }), ); }); it("starts watcher when metadata is null (legacy — no metadata point)", async () => { // Older indexed projects may not have a metadata point at all mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue(null); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(isWatching(TEST_PROJECT)).toBe(true); }); }); it("handles errors gracefully (non-fatal)", async () => { mockGetCollectionInfo.mockRejectedValue(new Error("Qdrant unreachable")); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(logger.debug).toHaveBeenCalledWith( "Auto-start watcher check failed (non-fatal)", expect.objectContaining({ error: "Qdrant unreachable" }), ); }); expect(isWatching(TEST_PROJECT)).toBe(false); }); it("caches external watch and skips retry within TTL", async () => { // Simulate another process holding the watch lock mockAcquireProjectLock.mockResolvedValue(false); mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue({ indexingStatus: "completed" }); ensureWatcherStarted(TEST_PROJECT); // Wait for the async chain to complete and cache the external watch await vi.waitFor(() => { expect(mockAcquireProjectLock).toHaveBeenCalled(); }); await new Promise((r) => setTimeout(r, 50)); // Clear mocks to track subsequent calls mockGetCollectionInfo.mockClear(); mockAcquireProjectLock.mockClear(); // Call again — should be cached, no collection check or lock attempt ensureWatcherStarted(TEST_PROJECT); await new Promise((r) => setTimeout(r, 50)); expect(mockGetCollectionInfo).not.toHaveBeenCalled(); }); it("does not log 'Auto-started' when another process holds the lock", async () => { mockAcquireProjectLock.mockResolvedValue(false); mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue({ indexingStatus: "completed" }); ensureWatcherStarted(TEST_PROJECT); await vi.waitFor(() => { expect(mockAcquireProjectLock).toHaveBeenCalled(); }); await new Promise((r) => setTimeout(r, 50)); // Should log that another process is watching, NOT that we auto-started expect(logger.info).toHaveBeenCalledWith( "Another process is already watching this project, skipping", expect.anything(), ); expect(logger.info).not.toHaveBeenCalledWith( "Auto-started file watcher on tool use", expect.anything(), ); }); it("re-checks conditions after async gap", async () => { mockGetCollectionInfo.mockResolvedValue({ pointsCount: 100 }); mockGetProjectMetadata.mockResolvedValue({ indexingStatus: "completed" }); // Start watching before ensureWatcherStarted's async chain completes await startWatching(TEST_PROJECT); const watcher = await import("@parcel/watcher"); const subscribeCallCount = vi.mocked(watcher.default.subscribe).mock.calls.length; ensureWatcherStarted(TEST_PROJECT); // Wait for the async chain await new Promise((r) => setTimeout(r, 50)); // subscribe should NOT have been called again (re-check detected already watching) expect(vi.mocked(watcher.default.subscribe).mock.calls.length).toBe(subscribeCallCount); }); }); // ── Graceful degradation ─────────────────────────────────────────────── describe("graceful degradation on update failure", () => { it("logs error but keeps watcher running when update fails", async () => { vi.useFakeTimers(); vi.mocked(shouldIgnore).mockReturnValue(false); mockUpdateProjectIndex.mockRejectedValueOnce(new Error("Something failed")); await startWatching(TEST_PROJECT); mockSubscribeCallback?.(null, [ { path: path.join(RESOLVED_PROJECT, "file.ts"), type: "update" }, ]); await vi.advanceTimersByTimeAsync(2100); expect(logger.error).toHaveBeenCalledWith( "Watch auto-update failed", expect.objectContaining({ error: "Something failed" }), ); // Watcher should still be running expect(isWatching(TEST_PROJECT)).toBe(true); vi.useRealTimers(); }); }); });