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