Files
socraticode/tests/unit/watcher.test.ts
T
2026-02-28 17:06:21 +00:00

663 lines
24 KiB
TypeScript

// 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<Record<string, unknown> | 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();
});
});
});