返回 CodeWhale
content-watch.test.ts
根目录 / web / lib / content-watch.test.ts
1 import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
2
3 vi.mock("./community-agent", async (importOriginal) => {
4 const actual = await importOriginal<typeof import("./community-agent")>();
5 return { ...actual, agentChat: vi.fn() };
6 });
7
8 import { runLinkCheck, runSemanticDrift, watchDraftId } from "./content-watch";
9 import { agentChat, draftStorageKey, type AgentDraft } from "./community-agent";
10
11 const agentChatMock = agentChat as Mock;
12
13 function createFakeKV() {
14 const store = new Map<string, string>();
15 return {
16 store,
17 async get(key: string): Promise<string | null> {
18 return store.get(key) ?? null;
19 },
20 async put(key: string, value: string): Promise<void> {
21 store.set(key, value);
22 },
23 async list(opts?: { prefix?: string; limit?: number }): Promise<{ keys: { name: string }[] }> {
24 const prefix = opts?.prefix ?? "";
25 const limit = opts?.limit ?? 100;
26 return {
27 keys: [...store.keys()]
28 .filter((k) => k.startsWith(prefix))
29 .slice(0, limit)
30 .map((name) => ({ name })),
31 };
32 },
33 async delete(key: string): Promise<void> {
34 store.delete(key);
35 },
36 };
37 }
38
39 type FakeKV = ReturnType<typeof createFakeKV>;
40
41 function draftEntries(kv: FakeKV): [string, AgentDraft][] {
42 return [...kv.store.entries()]
43 .filter(([key]) => key.startsWith("draft:"))
44 .map(([key, value]) => [key, JSON.parse(value) as AgentDraft] as [string, AgentDraft]);
45 }
46
47 function okResponse(body = ""): Response {
48 return new Response(body, { status: 200 });
49 }
50
51 describe("runLinkCheck draft identity", () => {
52 let kv: FakeKV;
53
54 beforeEach(() => {
55 kv = createFakeKV();
56 // Break exactly one target; everything else answers 200 to HEAD.
57 vi.stubGlobal(
58 "fetch",
59 vi.fn(async (input: RequestInfo | URL) => {
60 const url = String(input);
61 return url === "https://buymeacoffee.com/hmbown"
62 ? new Response("broken", { status: 500 })
63 : okResponse();
64 })
65 );
66 });
67
68 afterEach(() => {
69 vi.unstubAllGlobals();
70 });
71
72 it("creates a linkcheck draft under the canonical key on first run", async () => {
73 const result = await runLinkCheck({ CURATED_KV: kv });
74
75 expect(result.ok).toBe(true);
76 expect(result.broken).toBe(1);
77
78 const drafts = draftEntries(kv);
79 expect(drafts).toHaveLength(1);
80 const [key, draft] = drafts[0];
81 expect(draft.type).toBe("linkcheck");
82 expect(draft.targetUrl).toBe("https://buymeacoffee.com/hmbown");
83 expect(key).toBe(draftStorageKey(draft));
84 expect(key.startsWith("draft:linkcheck:")).toBe(true);
85 expect(draft.id.length).toBeLessThanOrEqual(80);
86 });
87
88 it("creates no duplicate when the same breakage is seen again", async () => {
89 await runLinkCheck({ CURATED_KV: kv });
90 const second = await runLinkCheck({ CURATED_KV: kv });
91
92 expect(second.broken).toBe(1); // still broken…
93 expect(draftEntries(kv)).toHaveLength(1); // …but not re-drafted
94 });
95 });
96
97 describe("runSemanticDrift draft identity", () => {
98 let kv: FakeKV;
99
100 const env = () => ({ CURATED_KV: kv, DEEPSEEK_API_KEY: "test-key" });
101
102 function mockSources() {
103 vi.stubGlobal(
104 "fetch",
105 vi.fn(async (input: RequestInfo | URL) => {
106 const { hostname } = new URL(String(input));
107 if (hostname === "api.github.com") return new Response("[]", { status: 200 });
108 if (hostname === "raw.githubusercontent.com") return okResponse("# Changelog\n- things");
109 return okResponse("<html><body>site copy</body></html>");
110 })
111 );
112 }
113
114 function mockDrifts(drifts: unknown) {
115 agentChatMock.mockResolvedValue({
116 content: JSON.stringify({ drifts }),
117 usage: { input: 0, output: 0 },
118 });
119 }
120
121 const finding = {
122 page: "homepage",
123 claim: "Codewhale supports three modes",
124 evidence: "CHANGELOG: modes renamed in 0.9.0",
125 suggested_replacement: "Codewhale supports Plan / Act / Operate",
126 };
127
128 beforeEach(() => {
129 kv = createFakeKV();
130 agentChatMock.mockReset();
131 mockSources();
132 });
133
134 afterEach(() => {
135 vi.unstubAllGlobals();
136 });
137
138 it("creates a semantic-drift draft under the canonical key on first run", async () => {
139 mockDrifts([finding]);
140 const result = await runSemanticDrift(env());
141
142 expect(result).toEqual({ ok: true, drafted: 1 });
143 const drafts = draftEntries(kv);
144 expect(drafts).toHaveLength(1);
145 const [key, draft] = drafts[0];
146 expect(draft.type).toBe("semantic-drift");
147 expect(key).toBe(draftStorageKey(draft));
148 expect(key.startsWith("draft:semantic-drift:")).toBe(true);
149 });
150
151 it("creates no duplicate when the same finding is reported again", async () => {
152 mockDrifts([finding]);
153 await runSemanticDrift(env());
154 const second = await runSemanticDrift(env());
155
156 expect(second).toEqual({ ok: true, drafted: 0 });
157 expect(draftEntries(kv)).toHaveLength(1);
158 });
159
160 it("creates a new draft when the finding's evidence changes", async () => {
161 mockDrifts([finding]);
162 await runSemanticDrift(env());
163
164 mockDrifts([{ ...finding, evidence: "CHANGELOG: modes renamed in 0.9.1" }]);
165 const second = await runSemanticDrift(env());
166
167 expect(second).toEqual({ ok: true, drafted: 1 });
168 expect(draftEntries(kv)).toHaveLength(2);
169 });
170
171 it("does not collide findings whose truncated slug prefixes are identical", async () => {
172 const sharedPrefix = "a".repeat(120);
173 const first = { ...finding, claim: `${sharedPrefix}-first-variant` };
174 const second = { ...finding, claim: `${sharedPrefix}-second-variant` };
175 mockDrifts([first, second]);
176
177 const result = await runSemanticDrift(env());
178
179 expect(result).toEqual({ ok: true, drafted: 2 });
180 const drafts = draftEntries(kv);
181 expect(drafts).toHaveLength(2);
182 const keys = drafts.map(([key]) => key);
183 expect(new Set(keys).size).toBe(2);
184 for (const [, draft] of drafts) {
185 expect(draft.id.length).toBeLessThanOrEqual(80);
186 }
187 });
188
189 it("bounds excessive model output and skips malformed entries", async () => {
190 const flood = Array.from({ length: 500 }, (_, i) => ({
191 ...finding,
192 claim: `claim number ${i}`,
193 }));
194 const malformed = [
195 { page: "evil-page", claim: "x", evidence: "y", suggested_replacement: "z" },
196 { page: "homepage", claim: "", evidence: "y", suggested_replacement: "z" },
197 { page: "homepage", claim: "x", evidence: 42, suggested_replacement: "z" },
198 "not-an-object",
199 null,
200 ];
201 mockDrifts([...malformed, ...flood]);
202
203 const result = await runSemanticDrift(env());
204
205 expect(result.ok).toBe(true);
206 expect(result.drafted).toBe(10); // MAX_DRIFT_DRAFTS_PER_RUN
207 expect(draftEntries(kv)).toHaveLength(10);
208 });
209
210 it("treats a non-array drifts payload as empty", async () => {
211 mockDrifts(undefined);
212 const result = await runSemanticDrift(env());
213 expect(result).toEqual({ ok: true, drafted: 0 });
214 });
215 });
216
217 describe("watchDraftId", () => {
218 it("is deterministic for the same identity", async () => {
219 const a = await watchDraftId("some-slug", "identity");
220 const b = await watchDraftId("some-slug", "identity");
221 expect(a).toBe(b);
222 });
223
224 it("separates identities that share a long slug prefix", async () => {
225 const prefix = `https://example.com/${"a".repeat(120)}`;
226 const a = await watchDraftId(prefix, `linkcheck\n${prefix}?x=1`);
227 const b = await watchDraftId(prefix, `linkcheck\n${prefix}?x=2`);
228 expect(a).not.toBe(b);
229 expect(a.length).toBeLessThanOrEqual(80);
230 expect(b.length).toBeLessThanOrEqual(80);
231 });
232 });
233
233 lines TYPESCRIPT