Skip to content
Open
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 @@ -787,7 +812,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
114 changes: 89 additions & 25 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,55 @@
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 Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import { vi } from "vite-plus/test";

import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";

vi.mock("node:child_process", { spy: true });

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 @@ -35,7 +72,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 @@ -44,14 +81,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 @@ -99,22 +131,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 @@ -139,3 +166,40 @@ it.effect("continues whole-word searches after a filtered grep page", () =>
}),
),
);

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(Layer.merge(WorkspaceSearchIndex.layerWorker, NodeServices.layer)),
),
);

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(Layer.merge(WorkspaceSearchIndex.layerWorker, NodeServices.layer)),
),
);
Loading
Loading