Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/server/src/binMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export async function runCliCommand() {
// Exit 2 identifies document rejections; OS and process failures remain retryable.
process.exitCode = 2;
}
} else if (command === "__workspace-search") {
const { runWorkspaceSearchWorker } = await import("./workspace/workspaceSearchWorker.ts");
runWorkspaceSearchWorker();
} else if (command === "__managed-chatgpt-token") {
const { runManagedChatGptTokenCli } = await import("./cli/managedChatGptToken.ts");
await runManagedChatGptTokenCli(process.argv.slice(3));
Expand Down
27 changes: 26 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as WorkspaceEntries from "./WorkspaceEntries.ts";
import * as WorkspacePaths from "./WorkspacePaths.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";

vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
Expand All @@ -33,6 +34,30 @@ const layerTest = Layer.empty.pipe(
Layer.provideMerge(NodeServices.layer),
);

// Keeps finders in this process so a test can intercept the native calls.
const layerInProcessFinders = Layer.effect(
WorkspaceEntries.WorkspaceEntries,
WorkspaceEntries.make,
).pipe(
Layer.provide(Layer.fresh(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layerNoDeps)),
Layer.provide(
Layer.succeed(WorkspaceSearchIndex.WorkspaceSearchWorker, {
create: async (options) => {
const created = FileFinder.create(options);
if (!created.ok) return created;
const finder = new Proxy(created.value, {
get: (target, method: keyof FileFinder) =>
method === ("alive" as string)
? true
: async (...args: Array<never>) =>
(target[method] as (...args: Array<never>) => unknown)(...args),
});
return { ok: true, value: finder as unknown as WorkspaceSearchIndex.RemoteFileFinder };
},
}),
),
);

const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) {
const fileSystem = yield* FileSystem.FileSystem;
const dir = yield* fileSystem.makeTempDirectoryScoped({
Expand Down Expand Up @@ -857,7 +882,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]);
// The second search ran on the rescanned live finder, not a rebuild.
expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 });
}),
}).pipe(Effect.provide(layerInProcessFinders)),
);
});

Expand Down
193 changes: 148 additions & 45 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import {
FileFinder,
type FileItem,
type GrepCursor,
type GrepOptions,
type GrepResult,
} from "@ff-labs/fff-node";
// @effect-diagnostics nodeBuiltinImport:off -- observes the real search process.
import * as NodeChildProcess from "node:child_process";
import * as NodeEvents from "node:events";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { FileItem, GrepCursor, GrepOptions, GrepResult } from "@ff-labs/fff-node";
import { afterEach, expect, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
Expand All @@ -17,10 +14,49 @@ import { vi } from "vite-plus/test";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";

const testLayer = VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer));
vi.mock("node:child_process", { spy: true });

const testLayer = Layer.mergeAll(VcsProcess.layer, WorkspaceSearchIndex.layerWorker).pipe(
Layer.provideMerge(NodeServices.layer),
);

afterEach(() => {
vi.restoreAllMocks();
vi.mocked(NodeChildProcess.spawn).mockClear();
});

const withFinder = (finder: Partial<WorkspaceSearchIndex.RemoteFileFinder>) =>
Effect.provideService(WorkspaceSearchIndex.WorkspaceSearchWorker, {
create: async () => ({
ok: true,
value: {
alive: true,
destroy: vi.fn(async () => {}),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
...finder,
} as WorkspaceSearchIndex.RemoteFileFinder,
}),
});

const spawnedWorkers = () =>
vi
.mocked(NodeChildProcess.spawn)
.mock.results.map((result) => result.value as NodeChildProcess.ChildProcess);

const searchNeedle = (searchIndex: WorkspaceSearchIndex.WorkspaceSearchIndex["Service"]) =>
searchIndex.searchContents({
query: "needle",
limit: 10,
caseSensitive: true,
wholeWord: false,
useRegex: false,
});

const makeNeedleWorkspace = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-search-worker-" });
yield* fileSystem.writeFileString(`${cwd}/notes.ts`, "const needle = 1;\n");
return cwd;
});

function fileItem(relativePath: string): FileItem {
Expand All @@ -43,7 +79,7 @@ it.effect("filters image searches before applying the result limit", () =>
...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)),
fileItem("public/icon.svg"),
];
const fileSearch = vi.fn(() => ({
const fileSearch = vi.fn(async () => ({
ok: true as const,
value: {
items,
Expand All @@ -52,14 +88,9 @@ it.effect("filters image searches before applying the result limit", () =>
totalFiles: items.length,
},
}));
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
fileSearch,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project").pipe(
withFinder({ fileSearch }),
);
const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true);
const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true);

Expand Down Expand Up @@ -89,7 +120,7 @@ it.effect("fetches past a first page of Git-ignored results", () =>
fileItem("hidden/c.ts"),
fileItem("src/a.ts"),
];
const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({
const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({
ok: true as const,
value: {
items: items.slice(0, options?.pageSize),
Expand All @@ -98,14 +129,8 @@ it.effect("fetches past a first page of Git-ignored results", () =>
totalFiles: items.length,
},
}));
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
fileSearch,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

const searchIndex = yield* WorkspaceSearchIndex.make(cwd);
const searchIndex = yield* WorkspaceSearchIndex.make(cwd).pipe(withFinder({ fileSearch }));
const result = yield* searchIndex.search("a", 1, "file", false);

expect(result).toEqual({ entries: [{ kind: "file", path: "src/a.ts" }], truncated: false });
Expand All @@ -120,17 +145,14 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
Effect.scoped(
Effect.gen(function* () {
const items = [fileItem("dir\\one/report.txt")];
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
fileSearch: vi.fn(() => ({
ok: true as const,
value: { items, scores: [], totalMatched: 1, totalFiles: 1 },
})),
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
const fileSearch = vi.fn(async () => ({
ok: true as const,
value: { items, scores: [], totalMatched: 1, totalFiles: 1 },
}));

const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project").pipe(
withFinder({ fileSearch }),
);
const result = yield* searchIndex.search("report", 50, "file");

expect(result.entries).toEqual([{ kind: "file", path: "dir\\one/report.txt" }]);
Expand Down Expand Up @@ -174,22 +196,17 @@ it.effect("continues whole-word searches after a filtered grep page", () =>
filteredFileCount: 1,
nextCursor: cursor,
});
const grep = vi.fn((_query: string, options?: GrepOptions) =>
const grep = vi.fn(async (_query: string, options?: GrepOptions) =>
options?.cursor
? { ok: true as const, value: grepResult("needle", [[0, 6]], null) }
: {
ok: true as const,
value: grepResult("needleSuffix", [[0, 6]], nextCursor),
},
);
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
grep,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content");
const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content").pipe(
withFinder({ grep }),
);
const result = yield* searchIndex.searchContents({
query: "needle",
limit: 1,
Expand All @@ -215,6 +232,92 @@ it.effect("continues whole-word searches after a filtered grep page", () =>
).pipe(Effect.provide(testLayer)),
);

it.effect("reads an incomplete scan from the finder that answers the search", () =>
Effect.scoped(
Effect.gen(function* () {
let firstExited = false;
const timedOutScan = vi.fn(async () => ({ ok: true as const, value: false }));
const fileSearch = vi.fn(async () => ({
ok: true as const,
value: { items: [fileItem("src/a.ts")], scores: [], totalMatched: 1, totalFiles: 1 },
}));
const first = {
get alive() {
return !firstExited;
},
destroy: vi.fn(async () => {}),
waitForIndexReady: timedOutScan,
// The scan finished, then the process exited right after replying.
isScanning: vi.fn(async () => {
firstExited = true;
return false;
}),
fileSearch: vi.fn(async () => {
throw new Error("The workspace search worker exited.");
}),
} as unknown as WorkspaceSearchIndex.RemoteFileFinder;
const rebuilt = {
alive: true,
destroy: vi.fn(async () => {}),
waitForIndexReady: timedOutScan,
isScanning: vi.fn(async () => true),
fileSearch,
} as unknown as WorkspaceSearchIndex.RemoteFileFinder;
const finders = [first, rebuilt];
const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project").pipe(
Effect.provideService(WorkspaceSearchIndex.WorkspaceSearchWorker, {
create: async () => ({ ok: true, value: finders.shift()! }),
}),
);

const duringExit = yield* searchIndex.search("", 10, "file").pipe(
Effect.map(({ truncated }) => truncated),
Effect.catchTags({
WorkspaceSearchIndexSearchFailed: (error) => Effect.succeed(error._tag),
}),
);
const afterRebuild = yield* searchIndex.search("", 10, "file");

expect(duringExit).toBe("WorkspaceSearchIndexSearchFailed");
expect(afterRebuild).toEqual({
entries: [{ kind: "file", path: "src/a.ts" }],
truncated: true,
});
}),
).pipe(Effect.provide(testLayer)),
);

it.live("rebuilds the index in a new search process after the worker exits", () =>
Effect.gen(function* () {
const cwd = yield* makeNeedleWorkspace;
const searchIndex = yield* WorkspaceSearchIndex.make(cwd, "content");
const before = yield* searchNeedle(searchIndex);
const [crashed] = spawnedWorkers();
yield* Effect.promise(() => {
const exited = NodeEvents.EventEmitter.once(crashed!, "exit");
crashed!.kill();
return exited;
});

expect(yield* searchNeedle(searchIndex)).toEqual(before);
expect(before.matches.map((match) => match.path)).toEqual(["notes.ts"]);
expect(spawnedWorkers()).toHaveLength(2);
}).pipe(Effect.scoped, Effect.provide(testLayer)),
);

it.live("ends the search process once its last index closes", () =>
Effect.gen(function* () {
const cwd = yield* makeNeedleWorkspace;
yield* Effect.scoped(Effect.flatMap(WorkspaceSearchIndex.make(cwd, "content"), searchNeedle));
const [worker] = spawnedWorkers();

const code =
worker!.exitCode ??
(yield* Effect.promise(() => NodeEvents.EventEmitter.once(worker!, "exit")))[0];
expect(code).toBe(0);
}).pipe(Effect.scoped, Effect.provide(testLayer)),
);

// fff reads HOME on Linux and macOS but the user profile on Windows, so a temp HOME cannot stand in there.
it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"refuses to index a workspace rooted at the home directory",
Expand Down
Loading
Loading