260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import * as os from "node:os";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { RULES_INJECTOR_STORAGE } from "./constants";
|
|
|
|
type StatSnapshot = { mtimeMs: number; size: number };
|
|
|
|
let trackedRulePath = "";
|
|
let statSnapshots: Array<StatSnapshot | Error> = [];
|
|
let trackedReadFileCount = 0;
|
|
let mockedHomeDir = "";
|
|
|
|
const originalReadFileSync = fs.readFileSync.bind(fs);
|
|
const originalStatSync = fs.statSync.bind(fs);
|
|
const originalHomedir = os.homedir.bind(os);
|
|
|
|
mock.module("node:fs", () => ({
|
|
...fs,
|
|
readFileSync: (filePath: string, encoding?: string) => {
|
|
if (filePath === trackedRulePath) {
|
|
trackedReadFileCount += 1;
|
|
}
|
|
return originalReadFileSync(filePath, encoding as never);
|
|
},
|
|
statSync: (filePath: string) => {
|
|
if (filePath === trackedRulePath) {
|
|
const next = statSnapshots.shift();
|
|
if (next instanceof Error) {
|
|
throw next;
|
|
}
|
|
if (next) {
|
|
return {
|
|
mtimeMs: next.mtimeMs,
|
|
size: next.size,
|
|
isFile: () => true,
|
|
} as ReturnType<typeof originalStatSync>;
|
|
}
|
|
}
|
|
return originalStatSync(filePath);
|
|
},
|
|
}));
|
|
|
|
mock.module("node:os", () => ({
|
|
...os,
|
|
homedir: () => mockedHomeDir || originalHomedir(),
|
|
}));
|
|
|
|
mock.module("./matcher", () => ({
|
|
shouldApplyRule: () => ({ applies: true, reason: "matched" }),
|
|
isDuplicateByRealPath: (realPath: string, cache: Set<string>) =>
|
|
cache.has(realPath),
|
|
createContentHash: (content: string) => `hash:${content}`,
|
|
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash),
|
|
}));
|
|
|
|
function createOutput(): { title: string; output: string; metadata: unknown } {
|
|
return { title: "tool", output: "", metadata: {} };
|
|
}
|
|
|
|
async function createProcessor(projectRoot: string): Promise<{
|
|
processFilePathForInjection: (
|
|
filePath: string,
|
|
sessionID: string,
|
|
output: { title: string; output: string; metadata: unknown }
|
|
) => Promise<void>;
|
|
}> {
|
|
const { createRuleInjectionProcessor } = await import("./injector");
|
|
const sessionCaches = new Map<
|
|
string,
|
|
{ contentHashes: Set<string>; realPaths: Set<string> }
|
|
>();
|
|
|
|
return createRuleInjectionProcessor({
|
|
workspaceDirectory: projectRoot,
|
|
truncator: {
|
|
truncate: async (_sessionID: string, content: string) => ({
|
|
result: content,
|
|
truncated: false,
|
|
}),
|
|
},
|
|
getSessionCache: (sessionID: string) => {
|
|
if (!sessionCaches.has(sessionID)) {
|
|
sessionCaches.set(sessionID, {
|
|
contentHashes: new Set<string>(),
|
|
realPaths: new Set<string>(),
|
|
});
|
|
}
|
|
const cache = sessionCaches.get(sessionID);
|
|
if (!cache) {
|
|
throw new Error("Session cache should exist");
|
|
}
|
|
return cache;
|
|
},
|
|
});
|
|
}
|
|
|
|
function getInjectedRulesPath(sessionID: string): string {
|
|
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
|
|
}
|
|
|
|
describe("createRuleInjectionProcessor", () => {
|
|
afterAll(() => {
|
|
mock.restore();
|
|
});
|
|
|
|
let testRoot: string;
|
|
let projectRoot: string;
|
|
let homeRoot: string;
|
|
let targetFile: string;
|
|
let ruleFile: string;
|
|
let ruleRealPath: string;
|
|
|
|
beforeEach(() => {
|
|
testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`);
|
|
projectRoot = join(testRoot, "project");
|
|
homeRoot = join(testRoot, "home");
|
|
targetFile = join(projectRoot, "src", "index.ts");
|
|
ruleFile = join(
|
|
projectRoot,
|
|
".github",
|
|
"instructions",
|
|
"typescript.instructions.md"
|
|
);
|
|
|
|
mkdirSync(join(projectRoot, ".git"), { recursive: true });
|
|
mkdirSync(join(projectRoot, "src"), { recursive: true });
|
|
mkdirSync(join(projectRoot, ".github", "instructions"), { recursive: true });
|
|
mkdirSync(homeRoot, { recursive: true });
|
|
|
|
writeFileSync(targetFile, "export const value = 1;\n");
|
|
writeFileSync(ruleFile, "rule-content\n");
|
|
|
|
ruleRealPath = fs.realpathSync(ruleFile);
|
|
trackedRulePath = ruleFile;
|
|
statSnapshots = [];
|
|
trackedReadFileCount = 0;
|
|
mockedHomeDir = homeRoot;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(testRoot)) {
|
|
rmSync(testRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reads and parses same file once when stat is unchanged", async () => {
|
|
// given
|
|
statSnapshots = [
|
|
{ mtimeMs: 1000, size: 13 },
|
|
{ mtimeMs: 1000, size: 13 },
|
|
];
|
|
const processor = await createProcessor(projectRoot);
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
|
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
|
|
|
// then
|
|
expect(trackedReadFileCount).toBe(1);
|
|
});
|
|
|
|
it("re-reads file when mtime changes", async () => {
|
|
// given
|
|
statSnapshots = [
|
|
{ mtimeMs: 1000, size: 13 },
|
|
{ mtimeMs: 2000, size: 13 },
|
|
];
|
|
const processor = await createProcessor(projectRoot);
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
|
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
|
|
|
// then
|
|
expect(trackedReadFileCount).toBe(2);
|
|
});
|
|
|
|
it("re-reads file when size changes", async () => {
|
|
// given
|
|
statSnapshots = [
|
|
{ mtimeMs: 1000, size: 13 },
|
|
{ mtimeMs: 1000, size: 21 },
|
|
];
|
|
const processor = await createProcessor(projectRoot);
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
|
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
|
|
|
// then
|
|
expect(trackedReadFileCount).toBe(2);
|
|
});
|
|
|
|
it("does not save injected rules when all candidates are already cached", async () => {
|
|
// given
|
|
const sessionID = `dirty-no-new-${Date.now()}`;
|
|
const injectedPath = getInjectedRulesPath(sessionID);
|
|
if (fs.existsSync(injectedPath)) {
|
|
fs.unlinkSync(injectedPath);
|
|
}
|
|
|
|
const { createRuleInjectionProcessor } = await import("./injector");
|
|
const processor = createRuleInjectionProcessor({
|
|
workspaceDirectory: projectRoot,
|
|
truncator: {
|
|
truncate: async (_sessionID: string, content: string) => ({
|
|
result: content,
|
|
truncated: false,
|
|
}),
|
|
},
|
|
getSessionCache: () => ({
|
|
contentHashes: new Set<string>(),
|
|
realPaths: new Set<string>([ruleRealPath]),
|
|
}),
|
|
});
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, sessionID, createOutput());
|
|
|
|
// then
|
|
expect(fs.existsSync(injectedPath)).toBe(false);
|
|
});
|
|
|
|
it("saves injected rules when a new rule is added", async () => {
|
|
// given
|
|
const sessionID = `dirty-new-${Date.now()}`;
|
|
const injectedPath = getInjectedRulesPath(sessionID);
|
|
if (fs.existsSync(injectedPath)) {
|
|
fs.unlinkSync(injectedPath);
|
|
}
|
|
const processor = await createProcessor(projectRoot);
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, sessionID, createOutput());
|
|
|
|
// then
|
|
expect(fs.existsSync(injectedPath)).toBe(true);
|
|
|
|
if (fs.existsSync(injectedPath)) {
|
|
fs.unlinkSync(injectedPath);
|
|
}
|
|
});
|
|
|
|
it("falls back to direct read and parse when statSync throws", async () => {
|
|
// given
|
|
statSnapshots = [new Error("stat failed"), new Error("stat failed")];
|
|
const processor = await createProcessor(projectRoot);
|
|
|
|
// when
|
|
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
|
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
|
|
|
// then
|
|
expect(trackedReadFileCount).toBe(2);
|
|
});
|
|
});
|