返回 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, deleteDraft, draftStorageKey, markDraftResolved, 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 / Work / 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("does not count a finding the maintainer already discarded as drafted", async () => {
161 mockDrifts([finding]);
162 await runSemanticDrift(env());
163 const [[key, draft]] = draftEntries(kv);
164 await markDraftResolved(kv, "semantic-drift", draft.id, "discarded");
165 await deleteDraft(kv, key);
166
167 const second = await runSemanticDrift(env());
168
169 expect(second).toEqual({ ok: true, drafted: 0 });
170 expect(draftEntries(kv)).toHaveLength(0);
171 });
172
173 it("creates a new draft when the finding's evidence changes", async () => {
174 mockDrifts([finding]);
175 await runSemanticDrift(env());
176
177 mockDrifts([{ ...finding, evidence: "CHANGELOG: modes renamed in 0.9.1" }]);
178 const second = await runSemanticDrift(env());
179
180 expect(second).toEqual({ ok: true, drafted: 1 });
181 expect(draftEntries(kv)).toHaveLength(2);
182 });
183
184 it("does not collide findings whose truncated slug prefixes are identical", async () => {
185 const sharedPrefix = "a".repeat(120);
186 const first = { ...finding, claim: `${sharedPrefix}-first-variant` };
187 const second = { ...finding, claim: `${sharedPrefix}-second-variant` };
188 mockDrifts([first, second]);
189
190 const result = await runSemanticDrift(env());
191
192 expect(result).toEqual({ ok: true, drafted: 2 });
193 const drafts = draftEntries(kv);
194 expect(drafts).toHaveLength(2);
195 const keys = drafts.map(([key]) => key);
196 expect(new Set(keys).size).toBe(2);
197 for (const [, draft] of drafts) {
198 expect(draft.id.length).toBeLessThanOrEqual(80);
199 }
200 });
201
202 it("bounds excessive model output and skips malformed entries", async () => {
203 const flood = Array.from({ length: 500 }, (_, i) => ({
204 ...finding,
205 claim: `claim number ${i}`,
206 }));
207 const malformed = [
208 { page: "evil-page", claim: "x", evidence: "y", suggested_replacement: "z" },
209 { page: "homepage", claim: "", evidence: "y", suggested_replacement: "z" },
210 { page: "homepage", claim: "x", evidence: 42, suggested_replacement: "z" },
211 "not-an-object",
212 null,
213 ];
214 mockDrifts([...malformed, ...flood]);
215
216 const result = await runSemanticDrift(env());
217
218 expect(result.ok).toBe(true);
219 expect(result.drafted).toBe(10); // MAX_DRIFT_DRAFTS_PER_RUN
220 expect(draftEntries(kv)).toHaveLength(10);
221 });
222
223 it("treats a non-array drifts payload as empty", async () => {
224 mockDrifts(undefined);
225 const result = await runSemanticDrift(env());
226 expect(result).toEqual({ ok: true, drafted: 0 });
227 });
228 });
229
230 describe("watchDraftId", () => {
231 it("is deterministic for the same identity", async () => {
232 const a = await watchDraftId("some-slug", "identity");
233 const b = await watchDraftId("some-slug", "identity");
234 expect(a).toBe(b);
235 });
236
237 it("separates identities that share a long slug prefix", async () => {
238 const prefix = `https://example.com/${"a".repeat(120)}`;
239 const a = await watchDraftId(prefix, `linkcheck\n${prefix}?x=1`);
240 const b = await watchDraftId(prefix, `linkcheck\n${prefix}?x=2`);
241 expect(a).not.toBe(b);
242 expect(a.length).toBeLessThanOrEqual(80);
243 expect(b.length).toBeLessThanOrEqual(80);
244 });
245 });
246
246 lines TYPESCRIPT