返回 CodeWhale
github.test.ts
根目录 / web / lib / github.test.ts
1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2 import { fetchFeed, lastPageFromLink, relativeAge, relativeTime } from "./github";
3
4 // We test the pure helper functions directly.
5 // The async fetch functions require mocking the global fetch.
6
7 // ── relativeTime ──────────────────────────────────────────────────────
8
9 describe("relativeTime", () => {
10 beforeEach(() => {
11 vi.useFakeTimers();
12 vi.setSystemTime(new Date("2026-06-01T12:00:00Z"));
13 });
14
15 afterEach(() => {
16 vi.useRealTimers();
17 });
18
19 it("returns 'just now' for less than 30 seconds ago", () => {
20 expect(relativeTime("2026-06-01T11:59:45Z")).toBe("just now");
21 });
22
23 it("returns 'just now' for dates in the future", () => {
24 expect(relativeTime("2026-06-02T12:00:00Z")).toBe("just now");
25 });
26
27 it("returns minutes for < 1 hour", () => {
28 expect(relativeTime("2026-06-01T11:55:00Z")).toBe("5m");
29 expect(relativeTime("2026-06-01T11:30:00Z")).toBe("30m");
30 });
31
32 it("returns hours for < 1 day", () => {
33 expect(relativeTime("2026-06-01T09:00:00Z")).toBe("3h");
34 expect(relativeTime("2026-05-31T18:00:00Z")).toBe("18h");
35 });
36
37 it("returns days for < 30 days", () => {
38 expect(relativeTime("2026-05-25T12:00:00Z")).toBe("7d");
39 expect(relativeTime("2026-05-03T12:00:00Z")).toBe("29d");
40 });
41
42 it("returns months for < 12 months", () => {
43 expect(relativeTime("2026-03-01T12:00:00Z")).toBe("3mo");
44 expect(relativeTime("2025-08-01T12:00:00Z")).toBe("10mo");
45 });
46
47 it("returns years for >= 12 months", () => {
48 expect(relativeTime("2024-06-01T12:00:00Z")).toBe("2y");
49 expect(relativeTime("2025-01-01T00:00:00Z")).toBe("1y");
50 });
51 });
52
53 // ── relativeAge ───────────────────────────────────────────────────────
54
55 describe("relativeAge", () => {
56 beforeEach(() => {
57 vi.useFakeTimers();
58 vi.setSystemTime(new Date("2026-06-01T12:00:00Z"));
59 });
60
61 afterEach(() => {
62 vi.useRealTimers();
63 });
64
65 it("reports past ages as the negative counts Intl.RelativeTimeFormat wants", () => {
66 expect(relativeAge("2026-06-01T11:55:00Z")).toEqual({ value: -5, unit: "minute" });
67 expect(relativeAge("2026-06-01T09:00:00Z")).toEqual({ value: -3, unit: "hour" });
68 expect(relativeAge("2026-05-25T12:00:00Z")).toEqual({ value: -7, unit: "day" });
69 expect(relativeAge("2026-03-01T12:00:00Z")).toEqual({ value: -3, unit: "month" });
70 expect(relativeAge("2024-06-01T12:00:00Z")).toEqual({ value: -2, unit: "year" });
71 });
72
73 it("collapses sub-minute, future, and unparseable dates to the locale's 'now'", () => {
74 expect(relativeAge("2026-06-01T11:59:45Z")).toEqual({ value: 0, unit: "second" });
75 expect(relativeAge("2026-06-02T12:00:00Z")).toEqual({ value: 0, unit: "second" });
76 expect(relativeAge("not-a-date")).toEqual({ value: 0, unit: "second" });
77 });
78
79 it("agrees with the compact English form it backs", () => {
80 for (const iso of [
81 "2026-06-01T11:59:45Z",
82 "2026-06-01T11:30:00Z",
83 "2026-05-31T18:00:00Z",
84 "2026-05-03T12:00:00Z",
85 "2025-08-01T12:00:00Z",
86 "2025-01-01T00:00:00Z",
87 ]) {
88 const age = relativeAge(iso);
89 const compact = relativeTime(iso);
90 if (age.unit === "second") expect(compact).toBe("just now");
91 else expect(compact.startsWith(String(Math.abs(age.value)))).toBe(true);
92 }
93 });
94 });
95
96 // ── fetchFeed ─────────────────────────────────────────────────────────
97
98 function json(body: unknown): Response {
99 return new Response(JSON.stringify(body), {
100 status: 200,
101 headers: { "content-type": "application/json" },
102 });
103 }
104
105 describe("fetchFeed", () => {
106 const ISSUES = [
107 {
108 number: 4901,
109 title: "Ticker shows nothing on first paint",
110 html_url: "https://github.com/Hmbown/CodeWhale/issues/4901",
111 state: "open",
112 user: { login: "asto18089", avatar_url: "https://avatars/1" },
113 created_at: "2026-06-01T09:00:00Z",
114 updated_at: "2026-06-01T11:00:00Z",
115 comments: 2,
116 labels: [{ name: "bug", color: "d73a4a" }],
117 author_association: "CONTRIBUTOR",
118 },
119 {
120 number: 4880,
121 title: "Fixed upstream",
122 html_url: "https://github.com/Hmbown/CodeWhale/issues/4880",
123 state: "closed",
124 user: { login: "Inference1", avatar_url: "https://avatars/2" },
125 created_at: "2026-03-02T09:00:00Z",
126 updated_at: "2026-06-01T10:00:00Z",
127 closed_at: "2026-05-30T09:00:00Z",
128 comments: 0,
129 labels: [],
130 author_association: "NONE",
131 },
132 {
133 // The issues endpoint returns pull requests too; they must not double up.
134 number: 4899,
135 title: "PR echoed by the issues endpoint",
136 html_url: "https://github.com/Hmbown/CodeWhale/pull/4899",
137 state: "open",
138 user: { login: "bistack", avatar_url: "https://avatars/3" },
139 created_at: "2026-06-01T08:00:00Z",
140 updated_at: "2026-06-01T08:30:00Z",
141 comments: 0,
142 labels: [],
143 pull_request: { url: "https://api.github.com/…" },
144 },
145 ];
146
147 const PULLS = [
148 {
149 number: 4899,
150 title: "fix(tui): keep the composer caret on resize",
151 html_url: "https://github.com/Hmbown/CodeWhale/pull/4899",
152 state: "closed",
153 user: { login: "shenjackyuanjie", avatar_url: "https://avatars/4" },
154 created_at: "2026-05-29T09:00:00Z",
155 updated_at: "2026-06-01T11:30:00Z",
156 closed_at: "2026-05-31T18:00:00Z",
157 merged_at: "2026-05-31T18:00:00Z",
158 comments: 4,
159 labels: [],
160 author_association: "FIRST_TIME_CONTRIBUTOR",
161 },
162 {
163 number: 4902,
164 title: "wip: sandbox notes",
165 html_url: "https://github.com/Hmbown/CodeWhale/pull/4902",
166 state: "open",
167 user: { login: "h3c-hexin", avatar_url: "https://avatars/5" },
168 created_at: "2026-06-01T07:00:00Z",
169 updated_at: "2026-06-01T07:30:00Z",
170 draft: true,
171 comments: 0,
172 labels: [],
173 author_association: "CONTRIBUTOR",
174 },
175 ];
176
177 const RELEASES = [
178 {
179 tag_name: "v0.9.4",
180 name: "v0.9.4",
181 html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4",
182 created_at: "2026-05-28T09:00:00Z",
183 published_at: "2026-05-28T12:00:00Z",
184 author: { login: "Hmbown", avatar_url: "https://avatars/6" },
185 },
186 {
187 tag_name: "v0.9.5-rc1",
188 name: "v0.9.5-rc1",
189 html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5-rc1",
190 created_at: "2026-06-01T09:00:00Z",
191 published_at: null,
192 draft: true,
193 author: { login: "Hmbown", avatar_url: "https://avatars/6" },
194 },
195 ];
196
197 const requested: string[] = [];
198
199 beforeEach(() => {
200 requested.length = 0;
201 vi.stubGlobal(
202 "fetch",
203 vi.fn(async (url: string) => {
204 requested.push(url);
205 if (url.includes("/issues?")) return json(ISSUES);
206 if (url.includes("/pulls?")) return json(PULLS);
207 if (url.includes("/releases?")) return json(RELEASES);
208 return new Response("{}", { status: 404 });
209 }),
210 );
211 });
212
213 afterEach(() => {
214 vi.unstubAllGlobals();
215 });
216
217 it("covers issues, pulls, and releases in three calls — no per-item follow-ups", async () => {
218 await fetchFeed(undefined, 10);
219 expect(requested).toHaveLength(3);
220 expect(requested.some((u) => u.includes("/issues?"))).toBe(true);
221 expect(requested.some((u) => u.includes("/pulls?"))).toBe(true);
222 expect(requested.some((u) => u.includes("/releases?"))).toBe(true);
223 });
224
225 it("names the contributor and dates each verb by the event, not the last comment", async () => {
226 const feed = await fetchFeed(undefined, 10);
227
228 const merged = feed.find((f) => f.number === 4899 && f.kind === "pull");
229 expect(merged?.state).toBe("merged");
230 expect(merged?.author).toBe("shenjackyuanjie");
231 // Merged at 18:00 on the 31st; last touched at 11:30 the next day.
232 expect(merged?.eventAt).toBe("2026-05-31T18:00:00Z");
233
234 const openIssue = feed.find((f) => f.number === 4901);
235 expect(openIssue?.state).toBe("open");
236 expect(openIssue?.eventAt).toBe(openIssue?.createdAt);
237
238 const closedIssue = feed.find((f) => f.number === 4880);
239 expect(closedIssue?.state).toBe("closed");
240 expect(closedIssue?.eventAt).toBe("2026-05-30T09:00:00Z");
241
242 const draft = feed.find((f) => f.number === 4902);
243 expect(draft?.state).toBe("draft");
244 });
245
246 it("passes GitHub's first-time-contributor verdict through verbatim", async () => {
247 const feed = await fetchFeed(undefined, 10);
248 expect(feed.find((f) => f.number === 4899 && f.kind === "pull")?.firstTimeContributor).toBe(
249 true,
250 );
251 expect(feed.find((f) => f.number === 4901)?.firstTimeContributor).toBe(false);
252 // "NONE" is not a first-timer — only GitHub's own FIRST_TIME_CONTRIBUTOR is.
253 expect(feed.find((f) => f.number === 4880)?.firstTimeContributor).toBe(false);
254 });
255
256 it("carries published releases and drops unpublished drafts", async () => {
257 const feed = await fetchFeed(undefined, 10);
258 const releases = feed.filter((f) => f.kind === "release");
259 expect(releases).toHaveLength(1);
260 expect(releases[0].tag).toBe("v0.9.4");
261 expect(releases[0].state).toBe("published");
262 expect(releases[0].eventAt).toBe("2026-05-28T12:00:00Z");
263 expect(feed.some((f) => f.tag === "v0.9.5-rc1")).toBe(false);
264 });
265
266 it("keeps a recent release in view when a busy window would bury it", async () => {
267 vi.useFakeTimers();
268 vi.setSystemTime(new Date("2026-06-01T12:00:00Z"));
269 try {
270 // Only two slots: pure recency would fill both with the newest threads.
271 const feed = await fetchFeed(undefined, 2);
272 expect(feed).toHaveLength(2);
273 expect(feed.filter((f) => f.kind === "release").map((f) => f.tag)).toEqual(["v0.9.4"]);
274 } finally {
275 vi.useRealTimers();
276 }
277 });
278
279 it("does not pin a stale release beside today's threads", async () => {
280 vi.useFakeTimers();
281 // Two years on from the last tag: a quiet quarter reads as a quiet quarter.
282 vi.setSystemTime(new Date("2028-06-01T12:00:00Z"));
283 try {
284 const feed = await fetchFeed(undefined, 2);
285 expect(feed).toHaveLength(2);
286 expect(feed.some((f) => f.kind === "release")).toBe(false);
287 } finally {
288 vi.useRealTimers();
289 }
290 });
291
292 it("does not double-count pull requests echoed by the issues endpoint", async () => {
293 const feed = await fetchFeed(undefined, 10);
294 expect(feed.filter((f) => f.number === 4899)).toHaveLength(1);
295 expect(feed.filter((f) => f.kind === "issue").map((f) => f.number).sort()).toEqual([
296 4880, 4901,
297 ]);
298 });
299
300 it("returns an empty feed — never a placeholder — when GitHub is unreachable", async () => {
301 vi.stubGlobal(
302 "fetch",
303 vi.fn(async () => new Response("rate limited", { status: 403 })),
304 );
305 expect(await fetchFeed(undefined, 10)).toEqual([]);
306 });
307 });
308
309 describe("fetchFeed — bot authors", () => {
310 // The wire is contributor-forward: GitHub's own `[bot]` login marker keeps
311 // automated maintenance off the strip, while a bot-published release keeps
312 // its slot and drops its byline.
313 const ISSUES = [
314 {
315 number: 101,
316 title: "Automated close sweep",
317 html_url: "https://github.com/Hmbown/CodeWhale/issues/101",
318 state: "closed",
319 user: { login: "github-actions[bot]", avatar_url: "https://avatars/bot1" },
320 created_at: "2026-06-01T09:00:00Z",
321 updated_at: "2026-06-01T11:00:00Z",
322 closed_at: "2026-06-01T10:30:00Z",
323 comments: 0,
324 labels: [],
325 author_association: "NONE",
326 },
327 {
328 number: 102,
329 title: "Real report",
330 html_url: "https://github.com/Hmbown/CodeWhale/issues/102",
331 state: "open",
332 user: { login: "vFONGv", avatar_url: "https://avatars/h1" },
333 created_at: "2026-06-01T08:00:00Z",
334 updated_at: "2026-06-01T08:00:00Z",
335 comments: 0,
336 labels: [],
337 author_association: "FIRST_TIME_CONTRIBUTOR",
338 },
339 ];
340
341 const PULLS = [
342 {
343 number: 103,
344 title: "chore(deps): bump serde from 1.0.219 to 1.0.220",
345 html_url: "https://github.com/Hmbown/CodeWhale/pull/103",
346 state: "closed",
347 user: { login: "dependabot[bot]", avatar_url: "https://avatars/bot2" },
348 created_at: "2026-05-31T09:00:00Z",
349 updated_at: "2026-06-01T07:00:00Z",
350 merged_at: "2026-06-01T07:00:00Z",
351 comments: 0,
352 labels: [],
353 author_association: "CONTRIBUTOR",
354 },
355 {
356 number: 104,
357 title: "fix(tui): keep the whale rail steady",
358 html_url: "https://github.com/Hmbown/CodeWhale/pull/104",
359 state: "closed",
360 user: { login: "bistack", avatar_url: "https://avatars/h2" },
361 created_at: "2026-05-30T09:00:00Z",
362 updated_at: "2026-06-01T06:00:00Z",
363 merged_at: "2026-06-01T06:00:00Z",
364 comments: 1,
365 labels: [],
366 author_association: "CONTRIBUTOR",
367 },
368 ];
369
370 const RELEASES = [
371 {
372 tag_name: "v0.9.4",
373 name: "v0.9.4",
374 html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4",
375 created_at: "2026-05-28T09:00:00Z",
376 published_at: "2026-05-28T12:00:00Z",
377 author: { login: "release-train[bot]", avatar_url: "https://avatars/bot3" },
378 },
379 ];
380
381 beforeEach(() => {
382 vi.stubGlobal(
383 "fetch",
384 vi.fn(async (url: string) => {
385 if (url.includes("/issues?")) return json(ISSUES);
386 if (url.includes("/pulls?")) return json(PULLS);
387 if (url.includes("/releases?")) return json(RELEASES);
388 return new Response("{}", { status: 404 });
389 }),
390 );
391 });
392
393 afterEach(() => {
394 vi.unstubAllGlobals();
395 });
396
397 it("keeps bot-authored issues and pulls off the contributor wire", async () => {
398 const feed = await fetchFeed(undefined, 10);
399 expect(feed.some((f) => f.author === "github-actions[bot]")).toBe(false);
400 expect(feed.some((f) => f.author === "dependabot[bot]")).toBe(false);
401 expect(feed.some((f) => f.number === 102 && f.kind === "issue")).toBe(true);
402 expect(feed.some((f) => f.number === 104 && f.kind === "pull")).toBe(true);
403 });
404
405 it("keeps a bot-published release in view, minus its byline", async () => {
406 const feed = await fetchFeed(undefined, 10);
407 const release = feed.find((f) => f.kind === "release");
408 expect(release?.tag).toBe("v0.9.4");
409 expect(release?.author).toBe("");
410 expect(release?.authorAvatar).toBe("");
411 });
412 });
413
414 // ── lastPageFromLink (via re-export test) ──────────────────────────────
415
416 describe("lastPageFromLink", () => {
417 it("returns undefined for null input", () => {
418 expect(lastPageFromLink(null)).toBeUndefined();
419 });
420
421 it("returns undefined for empty string", () => {
422 expect(lastPageFromLink("")).toBeUndefined();
423 });
424
425 it("extracts page from Link header with last rel", () => {
426 const link =
427 '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=5>; rel="last"';
428 expect(lastPageFromLink(link)).toBe(5);
429 });
430
431 it("extracts page from multi-part Link header", () => {
432 const link = [
433 '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"',
434 '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=3>; rel="last"',
435 ].join(", ");
436 expect(lastPageFromLink(link)).toBe(3);
437 });
438
439 it("returns undefined when no last rel present", () => {
440 const link =
441 '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"';
442 expect(lastPageFromLink(link)).toBeUndefined();
443 });
444
445 it("returns undefined for invalid URL format", () => {
446 const link = "not-a-valid-link-header; rel=last";
447 expect(lastPageFromLink(link)).toBeUndefined();
448 });
449 });
450
450 lines TYPESCRIPT