From 485d8af5a93d41e6e8ef14f6043628e11327e8f2 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:30:04 +0900 Subject: [PATCH 01/42] test: add coverage for renderer helpers and theme buildIndexCSS --- src/renderer/helpers.test.ts | 72 ++++++++++++++++++++++++++++++ src/renderer/themes/themes.test.ts | 15 +++++++ 2 files changed, 87 insertions(+) diff --git a/src/renderer/helpers.test.ts b/src/renderer/helpers.test.ts index 892c53d..814f46b 100644 --- a/src/renderer/helpers.test.ts +++ b/src/renderer/helpers.test.ts @@ -187,4 +187,76 @@ describe("registerHelpers", () => { expect(result).toContain("testuser"); }); }); + + describe("itemsCount", () => { + it("uses locale-specific formatting", () => { + const hbs = createHbs("en"); + const result = compile(hbs, "{{itemsCount n}}", { n: 5 }); + expect(result).toContain("5"); + }); + }); + + describe("urlEncode", () => { + it("percent-encodes special characters", () => { + const hbs = createHbs(); + expect(compile(hbs, "{{urlEncode text}}", { text: "hello world" })).toBe("hello%20world"); + }); + + it("handles null input", () => { + const hbs = createHbs(); + expect(compile(hbs, "{{urlEncode text}}", { text: null })).toBe(""); + }); + }); + + describe("first", () => { + it("returns the first element of an array", () => { + const hbs = createHbs(); + const result = compile(hbs, "{{first items}}", { items: ["a", "b", "c"] }); + expect(result).toBe("a"); + }); + + it("returns empty for non-array input", () => { + const hbs = createHbs(); + const result = compile(hbs, "{{first items}}", { items: "not-an-array" }); + expect(result).toBe(""); + }); + }); + + describe("rest", () => { + it("returns array without the first element", () => { + const hbs = createHbs(); + const result = compile(hbs, "{{#each (rest items)}}[{{this}}]{{/each}}", { + items: ["a", "b", "c"], + }); + expect(result).toBe("[b][c]"); + }); + + it("returns empty array for non-array input", () => { + const hbs = createHbs(); + const result = compile(hbs, "{{#each (rest items)}}x{{/each}}", { + items: "not-an-array", + }); + expect(result).toBe(""); + }); + }); + + describe("isEven", () => { + it("returns true for even index", () => { + const hbs = createHbs(); + expect(compile(hbs, "{{isEven n}}", { n: 4 })).toBe("true"); + }); + + it("returns false for odd index", () => { + const hbs = createHbs(); + expect(compile(hbs, "{{isEven n}}", { n: 3 })).toBe("false"); + }); + }); + + describe("mdInline", () => { + it("handles null input", () => { + const hbs = createHbs(); + const result = compile(hbs, "{{{mdInline text}}}", { text: null }); + expect(result).toBe(""); + }); + }); }); diff --git a/src/renderer/themes/themes.test.ts b/src/renderer/themes/themes.test.ts index 5ffc788..7e5349b 100644 --- a/src/renderer/themes/themes.test.ts +++ b/src/renderer/themes/themes.test.ts @@ -353,6 +353,14 @@ describe("editorial theme", () => { expect(html).toContain("column-stack"); expect(html).toContain("fixed-footer"); }); + + it("buildIndexCSS returns valid CSS string", () => { + const theme = loadTheme("editorial"); + const css = theme.buildIndexCSS("en"); + expect(css).toContain(".index-header"); + expect(css).toContain("Playfair Display"); + expect(css).toContain("var(--e-bg)"); + }); }); describe("swiss theme", () => { @@ -425,4 +433,11 @@ describe("swiss theme", () => { expect(html).toContain("highlight-card"); expect(html).toContain("feat: add OAuth flow"); }); + + it("buildIndexCSS returns valid CSS string", () => { + const theme = loadTheme("swiss"); + const css = theme.buildIndexCSS("en"); + expect(css).toContain(".index-layout"); + expect(css).toContain("Space Grotesk"); + }); }); From 5cf9864343ea46addd2336a4a1aea43e2feac444 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:32:52 +0900 Subject: [PATCH 02/42] test: cover fetchPRsByRefs retry and error paths --- src/collector/fetch-repo-prs.test.ts | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/collector/fetch-repo-prs.test.ts b/src/collector/fetch-repo-prs.test.ts index 0841566..5e62b6f 100644 --- a/src/collector/fetch-repo-prs.test.ts +++ b/src/collector/fetch-repo-prs.test.ts @@ -119,4 +119,88 @@ describe("fetchPRsByRefs", () => { const result = await fetchPRsByRefs("token", refs); expect(result[0].author).toBe("unknown"); }); + + it("retries on 429 honoring retry-after header then succeeds", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "1" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(makeRawPR(1)), { status: 200 }), + ); + + const promise = fetchPRsByRefs("token", [{ repo: "owner/repo", number: 1 }]); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1); + expect(result[0].title).toBe("PR #1"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("429")); + vi.useRealTimers(); + }); + + it("falls back to default delay when retry-after is missing and exhausts retries", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + + const promise = fetchPRsByRefs("token", [{ repo: "owner/repo", number: 1 }]); + await vi.runAllTimersAsync(); + const result = await promise; + + // MAX_RETRIES = 3 → attempts 0..3 (4 total). On attempt 3, retry guard fails → returns null. + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Warning: 1 of 1 PRs")); + vi.useRealTimers(); + }); + + it("ignores invalid retry-after values and uses default delay", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "not-a-number" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(makeRawPR(1)), { status: 200 }), + ); + + const promise = fetchPRsByRefs("token", [{ repo: "owner/repo", number: 1 }]); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1); + vi.useRealTimers(); + }); + + it("logs error message from response body when fetch fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ message: "Not Found" }), { + status: 404, + statusText: "Not Found", + }), + ); + + const result = await fetchPRsByRefs("token", [{ repo: "owner/repo", number: 1 }]); + + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Not Found")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(" Not Found")); + }); }); From 3b6b936cc79cdc5ce43db151e29d7a1dabe84988 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:34:31 +0900 Subject: [PATCH 03/42] test: cover withSpinner TTY and non-TTY paths --- src/cli/spinner.test.ts | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/cli/spinner.test.ts diff --git a/src/cli/spinner.test.ts b/src/cli/spinner.test.ts new file mode 100644 index 0000000..2f43935 --- /dev/null +++ b/src/cli/spinner.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { withSpinner } from "./spinner.js"; + +const captureWrites = () => { + const writes: string[] = []; + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }); + return { writes, spy }; +}; + +const setTTY = (value: boolean) => { + const original = process.stderr.isTTY; + Object.defineProperty(process.stderr, "isTTY", { value, configurable: true }); + return () => { + Object.defineProperty(process.stderr, "isTTY", { value: original, configurable: true }); + }; +}; + +describe("withSpinner", () => { + describe("non-TTY environment", () => { + let restoreTTY: () => void; + + beforeEach(() => { + restoreTTY = setTTY(false); + }); + + afterEach(() => { + restoreTTY(); + vi.restoreAllMocks(); + }); + + it("writes the message once and returns the task result", async () => { + const { writes } = captureWrites(); + const result = await withSpinner("loading", async () => 42); + + expect(result).toBe(42); + expect(writes).toEqual([" loading\n"]); + }); + + it("propagates errors thrown by the task without animation", async () => { + const { writes } = captureWrites(); + const error = new Error("boom"); + + await expect( + withSpinner("loading", async () => { + throw error; + }), + ).rejects.toBe(error); + + expect(writes).toEqual([" loading\n"]); + }); + }); + + describe("TTY environment", () => { + let restoreTTY: () => void; + + beforeEach(() => { + restoreTTY = setTTY(true); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + restoreTTY(); + vi.restoreAllMocks(); + }); + + it("renders animated frames and a success marker", async () => { + const { writes } = captureWrites(); + + const promise = withSpinner("working", async () => { + await vi.advanceTimersByTimeAsync(200); + return "done"; + }); + + const result = await promise; + + expect(result).toBe("done"); + expect(writes.some((w) => w.includes("working"))).toBe(true); + expect(writes.some((w) => w.includes("✔ working"))).toBe(true); + expect(writes.at(-1)).toBe("\r ✔ working\n"); + }); + + it("renders an error marker and rethrows", async () => { + const { writes } = captureWrites(); + const error = new Error("nope"); + + await expect( + withSpinner("working", async () => { + await vi.advanceTimersByTimeAsync(80); + throw error; + }), + ).rejects.toBe(error); + + expect(writes.some((w) => w.includes("✖ working"))).toBe(true); + expect(writes.at(-1)).toBe("\r ✖ working\n"); + }); + + it("clears the interval after completion", async () => { + const clearSpy = vi.spyOn(global, "clearInterval"); + captureWrites(); + + await withSpinner("ok", async () => "value"); + + expect(clearSpy).toHaveBeenCalledTimes(1); + }); + }); +}); From d9975aca1959a50520e8f13f3259711ba90edd6a Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:36:34 +0900 Subject: [PATCH 04/42] test: cover fetch commit-msg subcommand and missing-token error path --- src/cli/commands/fetch.test.ts | 93 ++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index d3b8790..ad9b446 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -527,4 +527,97 @@ describe("registerFetch (weekly-fetch)", () => { "utf-8", ); }); + + it("logs error and exits 1 when token is missing", async () => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", ""); + vi.stubEnv("GITHUB_USERNAME", ""); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "weekly-fetch", "--username", "alice"]); + + expect(errSpy).toHaveBeenCalledWith("Error:", expect.stringContaining("GitHub token required")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ------------------------------------------------------------------- +// registerFetch (commit-msg subcommand) +// ------------------------------------------------------------------- + +describe("registerFetch (commit-msg)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const captureStdout = (): { writes: string[]; restore: () => void } => { + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation(((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")); + return true; + }) as typeof process.stdout.write); + return { writes, restore: () => spy.mockRestore() }; + }; + + it("daily mode: prints commit message for given date", async () => { + const { writes, restore } = captureStdout(); + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "commit-msg", "daily", + "--timezone", "Asia/Tokyo", + "--date", "2026-04-06", + "--data-dir", "./data", + ]); + + restore(); + expect(writes.join("")).toMatch(/^data: daily 2026\/W14 /); + }); + + it("weekly mode: prints commit message for given date", async () => { + const { writes, restore } = captureStdout(); + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "commit-msg", "weekly", + "--timezone", "Asia/Tokyo", + "--date", "2026-04-07", + "--data-dir", "./data", + ]); + + restore(); + expect(writes.join("")).toMatch(/^data: weekly 2026\/W14 /); + }); + + it("uses env defaults for timezone/data-dir and current time when --date omitted", async () => { + vi.stubEnv("TIMEZONE", "UTC"); + vi.stubEnv("DATA_DIR", "./data"); + const { writes, restore } = captureStdout(); + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "commit-msg", "daily"]); + + restore(); + expect(writes.join("")).toMatch(/^data: daily \d{4}\/W\d{2} /); + }); }); From 373de06f8cb35d17139502ccbe91144116bff8a6 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:39:34 +0900 Subject: [PATCH 05/42] test: cover setRepoSecret retry and failure paths --- src/cli/commands/setup/github-api.test.ts | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/cli/commands/setup/github-api.test.ts b/src/cli/commands/setup/github-api.test.ts index de920ea..d73dc17 100644 --- a/src/cli/commands/setup/github-api.test.ts +++ b/src/cli/commands/setup/github-api.test.ts @@ -226,6 +226,65 @@ describe("github-api", () => { const result = await setRepoSecret("token", "user/repo", "SECRET", "value"); expect(result).toBe(true); }); + + it("returns false when public-key fetch fails on all 3 attempts", async () => { + // Pre-load libsodium so its real-timer init isn't affected by fake timers + const { default: _sodium } = await import("libsodium-wrappers"); + await _sodium.ready; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { status: 500 }), + ); + vi.useFakeTimers(); + const promise = setRepoSecret("token", "user/repo", "SECRET", "value"); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result).toBe(false); + vi.useRealTimers(); + }); + + it("retries public-key fetch and succeeds on second attempt", async () => { + const keyData = await makeValidKeyResponse(); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("", { status: 500 })) // first key fetch fails + .mockResolvedValueOnce( + new Response(JSON.stringify(keyData), { status: 200 }), + ) // second key fetch succeeds + .mockResolvedValueOnce(new Response("", { status: 200 })); // PUT secret succeeds + + vi.useFakeTimers(); + const promise = setRepoSecret("token", "user/repo", "SECRET", "value"); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result).toBe(true); + vi.useRealTimers(); + }); + + it("returns false when PUT fails on all 3 attempts", async () => { + const keyData = await makeValidKeyResponse(); + vi.spyOn(globalThis, "fetch").mockImplementation( + async (_url, init?: RequestInit) => { + if ((init?.method ?? "GET") === "PUT") { + return new Response("error body", { status: 422 }); + } + return new Response(JSON.stringify(keyData), { status: 200 }); + }, + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + vi.useFakeTimers(); + const promise = setRepoSecret("token", "user/repo", "SECRET", "value"); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result).toBe(false); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Attempt 1/3 failed: 422"), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Attempt 3/3 failed: 422"), + ); + vi.useRealTimers(); + }); }); describe("sleep", () => { From 6c49ccd85cd10aee30fcd0af420f345275902e97 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:41:42 +0900 Subject: [PATCH 06/42] test: cover fetchEvents 429 retry-after and exhaustion paths --- src/collector/fetch-events.test.ts | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/collector/fetch-events.test.ts b/src/collector/fetch-events.test.ts index 62f84ed..0daad3f 100644 --- a/src/collector/fetch-events.test.ts +++ b/src/collector/fetch-events.test.ts @@ -291,4 +291,105 @@ describe("fetchEvents", () => { expect(result).toHaveLength(300); expect(fetchSpy).toHaveBeenCalledTimes(3); }); + + it("retries on 429 honoring retry-after header then succeeds", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "1" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([makeRawEvent("1", "2026-04-03T12:00:00Z")]), { + status: 200, + }), + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + + const promise = fetchEvents("token", "testuser", range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Rate limited")); + vi.useRealTimers(); + }); + + it("uses default delay when retry-after header is missing", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([makeRawEvent("1", "2026-04-03T12:00:00Z")]), { + status: 200, + }), + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + + const promise = fetchEvents("token", "testuser", range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(1); + vi.useRealTimers(); + }); + + it("ignores invalid retry-after values and uses default delay", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "not-a-number" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([makeRawEvent("1", "2026-04-03T12:00:00Z")]), { + status: 200, + }), + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + + const promise = fetchEvents("token", "testuser", range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(1); + vi.useRealTimers(); + }); + + it("returns empty when 429 retries are exhausted", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "1" }, + }), + ); + + const promise = fetchEvents("token", "testuser", range); + await vi.runAllTimersAsync(); + const result = await promise; + + // attempts 0..MAX_RETRIES (4 total) on the first page; final attempt warns and returns [] + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch events page 1")); + vi.useRealTimers(); + }); }); From 9e732694119b38c2992dd37fb07e864600d71073 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:44:14 +0900 Subject: [PATCH 07/42] test: cover fetchReleases retry-after fallbacks and failure paths --- src/collector/fetch-releases.test.ts | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/collector/fetch-releases.test.ts b/src/collector/fetch-releases.test.ts index 21ae365..cb083fc 100644 --- a/src/collector/fetch-releases.test.ts +++ b/src/collector/fetch-releases.test.ts @@ -111,6 +111,92 @@ describe("fetchReleases", () => { expect(result).toHaveLength(1); }); + it("uses default delay when retry-after header is missing", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + makeRawRelease("v1.0.0", "2026-04-01T12:00:00Z"), + ]), { status: 200 }), + ); + + const promise = fetchReleases("token", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1); + vi.useRealTimers(); + }); + + it("ignores invalid retry-after values and uses default delay", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "not-a-number" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + makeRawRelease("v1.0.0", "2026-04-01T12:00:00Z"), + ]), { status: 200 }), + ); + + const promise = fetchReleases("token", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1); + vi.useRealTimers(); + }); + + it("returns empty and warns on non-retryable failure status", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("", { status: 500, statusText: "Internal Server Error" }), + ); + + const result = await fetchReleases("token", ["org/repo"], range); + + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch releases for org/repo"), + ); + }); + + it("returns empty when 429 retries are exhausted", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "0" }, + }), + ); + + const promise = fetchReleases("token", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual([]); + // MAX_RETRIES=3 → attempts 0..3 (4 total); the final attempt warns and returns [] + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch releases for org/repo"), + ); + vi.useRealTimers(); + }); + it("handles null body", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( new Response(JSON.stringify([ From dc6160efbfc662d8a6d1d1ee89046ff7ab06dedb Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:47:32 +0900 Subject: [PATCH 08/42] test: cover weekly-fetch github-data assembly and daily-fetch missing-token path --- src/cli/commands/fetch.test.ts | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index ad9b446..a5a6d53 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -547,6 +547,106 @@ describe("registerFetch (weekly-fetch)", () => { expect(errSpy).toHaveBeenCalledWith("Error:", expect.stringContaining("GitHub token required")); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("computes prsOpened/prsMerged and filters review events when assembling github-data", async () => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + const reviewEvent: GitHubEvent = { + id: "10", + type: "PullRequestReviewEvent", + repo: "owner/repo", + createdAt: "2026-04-01T00:00:00Z", + payload: { kind: "review", action: "submitted", prNumber: 1, prTitle: "r", state: "approved" }, + }; + const pushEvent: GitHubEvent = { + id: "11", + type: "PushEvent", + repo: "owner/repo", + createdAt: "2026-04-01T01:00:00Z", + payload: { kind: "push", ref: "refs/heads/main", commits: ["a"] }, + }; + mockReadFile.mockResolvedValue("[]"); // YAML for empty list — overridden below + // tryReadYaml returns parsed YAML; provide events array directly + const { parse: parseYaml } = await import("yaml"); + const eventsYaml = "- id: '10'\n type: PullRequestReviewEvent\n repo: owner/repo\n createdAt: '2026-04-01T00:00:00Z'\n payload:\n kind: review\n action: submitted\n prNumber: 1\n prTitle: r\n state: approved\n- id: '11'\n type: PushEvent\n repo: owner/repo\n createdAt: '2026-04-01T01:00:00Z'\n payload:\n kind: push\n ref: refs/heads/main\n commits: [a]\n"; + expect(parseYaml(eventsYaml)).toEqual([reviewEvent, pushEvent]); // sanity + mockReadFile.mockResolvedValue(eventsYaml); + mockWriteFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + mockFetchContributions.mockResolvedValue({ + username: "TestUser", + avatarUrl: "https://example.com/a.png", + profile: { name: null, bio: null, company: null, location: null, followers: 0, following: 0, publicRepos: 0 }, + totalCommits: 5, + prsReviewed: 2, + dailyCommits: [], + }); + const { fetchPRsByRefs } = await import("../../collector/fetch-repo-prs.js"); + vi.mocked(fetchPRsByRefs).mockResolvedValue([ + { title: "feat", body: null, url: "u1", repository: "owner/repo", state: "merged", labels: [], additions: 1, deletions: 0, changedFiles: 1, author: "TestUser", createdAt: "2026-04-01T00:00:00Z", mergedAt: "2026-04-02T00:00:00Z" }, + { title: "fix", body: null, url: "u2", repository: "owner/repo", state: "open", labels: [], additions: 2, deletions: 1, changedFiles: 1, author: "testuser", createdAt: "2026-04-01T00:00:00Z", mergedAt: null }, + { title: "docs", body: null, url: "u3", repository: "owner/repo", state: "merged", labels: [], additions: 0, deletions: 0, changedFiles: 1, author: "outsider", createdAt: "2026-04-01T00:00:00Z", mergedAt: "2026-04-02T00:00:00Z" }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ items: [], total_count: 0 }), { status: 200 })), + ); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "weekly-fetch", + "--token", "ghp_test", + "--username", "TestUser", + "--data-dir", "./data", + "--timezone", "UTC", + "--date", "2026-04-01", + ]); + + const writeCall = mockWriteFile.mock.calls.find((c) => + typeof c[0] === "string" && c[0].includes("github-data.yaml"), + ); + expect(writeCall).toBeDefined(); + const yaml = writeCall![1] as string; + expect(yaml).toMatch(/prsOpened:\s*2/); + expect(yaml).toMatch(/prsMerged:\s*1/); + // Only the review event should appear under events: + expect(yaml).toContain("kind: review"); + expect(yaml).not.toMatch(/kind:\s*push/); + }); +}); + +// ------------------------------------------------------------------- +// registerFetch (daily-fetch error path) +// ------------------------------------------------------------------- + +describe("registerFetch (daily-fetch error)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("logs error and exits 1 when token is missing", async () => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", ""); + vi.stubEnv("GITHUB_USERNAME", ""); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "daily-fetch", "--username", "alice"]); + + expect(errSpy).toHaveBeenCalledWith("Error:", expect.stringContaining("GitHub token required")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); // ------------------------------------------------------------------- From ba66bb015b65fef2293475a1e48f9de8e4cbc331 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:49:41 +0900 Subject: [PATCH 09/42] test: cover fetchCommitMessages retry-after fallbacks and failure paths --- src/collector/fetch-commits.test.ts | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/collector/fetch-commits.test.ts b/src/collector/fetch-commits.test.ts index de64c41..2576932 100644 --- a/src/collector/fetch-commits.test.ts +++ b/src/collector/fetch-commits.test.ts @@ -123,4 +123,73 @@ describe("fetchCommitMessages", () => { expect(result[0].messages).toEqual(["after retry"]); }); + + it("falls back to the default delay when retry-after header is missing", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("", { status: 429 })) + .mockResolvedValueOnce(pagedResponse([makeRawCommit("after default delay")])); + + const promise = fetchCommitMessages("token", "user", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result[0].messages).toEqual(["after default delay"]); + vi.useRealTimers(); + }); + + it("falls back to the default delay when retry-after value is invalid", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("", { status: 429, headers: { "retry-after": "soon" } }), + ) + .mockResolvedValueOnce(pagedResponse([makeRawCommit("after invalid delay")])); + + const promise = fetchCommitMessages("token", "user", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result[0].messages).toEqual(["after invalid delay"]); + vi.useRealTimers(); + }); + + it("gives up after retry exhaustion on persistent 429", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "0" }, + }), + ); + + const promise = fetchCommitMessages("token", "user", ["org/repo"], range); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch commits")); + vi.useRealTimers(); + }); + + it("warns and skips on non-retryable server errors", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("", { status: 500, statusText: "Internal Server Error" }), + ); + + const result = await fetchCommitMessages("token", "user", ["org/broken"], range); + + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch commits: 500 Internal Server Error"), + ); + }); }); From bd75f29a6aa9516f98c3eda191d2b68f32448e60 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:52:22 +0900 Subject: [PATCH 10/42] test: cover searchWeeklyPRs dedup, 401 auth error, and 500 warn paths --- src/cli/commands/fetch.test.ts | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index a5a6d53..b756d36 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -548,6 +548,131 @@ describe("registerFetch (weekly-fetch)", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it("searchWeeklyPRs: collects PR refs from search items and passes them to fetchPRsByRefs", async () => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mockReadFile.mockRejectedValue(new Error("not found")); + mockWriteFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + mockFetchContributions.mockResolvedValue({ + username: "alice", + avatarUrl: "https://example.com/a.png", + profile: { name: null, bio: null, company: null, location: null, followers: 0, following: 0, publicRepos: 0 }, + totalCommits: 0, + prsReviewed: 0, + dailyCommits: [], + }); + const { fetchPRsByRefs } = await import("../../collector/fetch-repo-prs.js"); + vi.mocked(fetchPRsByRefs).mockResolvedValue([]); + + const items = [ + { number: 7, pull_request: { url: "u" }, repository_url: "https://api.github.com/repos/owner/repo" }, + // Issue without pull_request — should be filtered out + { number: 8, repository_url: "https://api.github.com/repos/owner/repo" }, + ]; + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ items, total_count: items.length }), { status: 200 })), + ); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "weekly-fetch", + "--token", "ghp_test", + "--username", "alice", + "--data-dir", "./data", + "--timezone", "UTC", + "--date", "2026-04-01", + ]); + + // PR #7 should reach fetchPRsByRefs (deduped: only one entry even though + // both author: and reviewed-by: qualifier searches return it). + const refs = vi.mocked(fetchPRsByRefs).mock.calls.at(-1)?.[1]; + expect(refs).toEqual([{ repo: "owner/repo", number: 7 }]); + }); + + it("searchWeeklyPRs: throws on 401 from Search API and exits 1", async () => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mockReadFile.mockRejectedValue(new Error("not found")); + mockWriteFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response("nope", { status: 401 })), + ); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "weekly-fetch", + "--token", "ghp_bad", + "--username", "alice", + "--data-dir", "./data", + "--timezone", "UTC", + "--date", "2026-04-01", + ]); + + expect(errSpy).toHaveBeenCalledWith( + "Error:", + expect.stringContaining("GitHub Search API returned 401"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("searchWeeklyPRs: warns and continues on non-auth error (500)", async () => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mockReadFile.mockRejectedValue(new Error("not found")); + mockWriteFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + mockFetchContributions.mockResolvedValue({ + username: "alice", + avatarUrl: "https://example.com/a.png", + profile: { name: null, bio: null, company: null, location: null, followers: 0, following: 0, publicRepos: 0 }, + totalCommits: 0, + prsReviewed: 0, + dailyCommits: [], + }); + const { fetchPRsByRefs } = await import("../../collector/fetch-repo-prs.js"); + vi.mocked(fetchPRsByRefs).mockResolvedValue([]); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response("boom", { status: 500 })), + ); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "weekly-fetch", + "--token", "ghp_test", + "--username", "alice", + "--data-dir", "./data", + "--timezone", "UTC", + "--date", "2026-04-01", + ]); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Search API error (500)")); + // weekly-fetch still completes and writes github-data.yaml + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining("github-data.yaml"), + expect.any(String), + "utf-8", + ); + }); + it("computes prsOpened/prsMerged and filters review events when assembling github-data", async () => { vi.clearAllMocks(); vi.restoreAllMocks(); From 416b05be788105d9d42adac7d754132c8b19b097 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 15:56:12 +0900 Subject: [PATCH 11/42] test: cover render unknown-theme exit and missing data-dir paths --- src/cli/commands/render.test.ts | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/cli/commands/render.test.ts b/src/cli/commands/render.test.ts index 7ce9e6e..441c698 100644 --- a/src/cli/commands/render.test.ts +++ b/src/cli/commands/render.test.ts @@ -403,4 +403,53 @@ describe("registerRender", () => { expect.objectContaining({ language: "ja", siteTitle: "My Reports" }), ); }); + + it("exits when --theme is unknown", async () => { + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://example.com", + "--theme", "not-a-real-theme", + ]), + ).rejects.toThrow("process.exit"); + + expect(errorSpy).toHaveBeenCalled(); + const errorMsg = errorSpy.mock.calls.flat().join(" "); + expect(errorMsg).toContain("Unknown theme"); + }); + + it("renders successfully when data dir does not exist (readdir rejects)", async () => { + mockReadFile.mockImplementation((path: string) => { + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + // Simulate ENOENT on the dataDir lookup inside listCompletedReportDirs + mockReaddir.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./missing-data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + // Render still succeeds for the current week, no prev/next links + expect(mockRenderReport).toHaveBeenCalledTimes(1); + const opts = mockRenderReport.mock.calls[0][1]; + expect(opts).toMatchObject({ prevWeek: undefined, nextWeek: undefined }); + }); }); From 2eadc01aa4487a7a7301159de27ae248212a2826 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:01:55 +0900 Subject: [PATCH 12/42] test: cover setup invalid-model decline and llm-secret failure paths --- src/cli/commands/setup.test.ts | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 0d99d2d..176706a 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -512,4 +512,74 @@ describe("registerSetup (full flow)", () => { // Setup continues after Pages failure expect(mockGhPost).toHaveBeenCalled(); }); + + it("aborts when user declines to retry invalid model", async () => { + mockPassword + .mockResolvedValueOnce("ghp_token") + .mockResolvedValueOnce("sk-key"); + mockValidateToken.mockResolvedValue({ login: "testuser", tokenType: "classic" }); + mockInput + .mockResolvedValueOnce("testuser") + .mockResolvedValueOnce("my-reports") + .mockResolvedValueOnce("Dev Pulse") + .mockResolvedValueOnce("bad-model"); + mockSelect + .mockResolvedValueOnce("en") + .mockResolvedValueOnce("brutalist") + .mockResolvedValueOnce("UTC") + .mockResolvedValueOnce("openai"); + mockValidateModel.mockResolvedValueOnce({ valid: false, error: "Model not found" }); + // User declines retry → throws "Setup cancelled: invalid model name." + mockConfirm.mockResolvedValueOnce(false); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + + await expect(program.parseAsync(["node", "cli", "setup"])).rejects.toThrow("process.exit"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Setup cancelled: invalid model name."), + ); + // Should not have proceeded to repo creation + expect(mockEnsureRepo).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("throws when LLM secret fails to set", async () => { + setupPromptDefaults(); + // First call (GH_PAT) succeeds, second call (LLM secret) fails + mockSetRepoSecret + .mockReset() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + + await expect(program.parseAsync(["node", "cli", "setup"])).rejects.toThrow("process.exit"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to set OPENAI_API_KEY secret."), + ); + // GH_PAT was set, then LLM secret attempted, but workflows never added + expect(mockSetRepoSecret).toHaveBeenCalledTimes(2); + expect(mockAddFileToRepo).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); }); From 04838747c99229c75f9261460dacbe70dfcdc3ab Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:06:58 +0900 Subject: [PATCH 13/42] test: cover setup prompt validators and preprocess empty-field omissions --- src/cli/commands/setup.test.ts | 47 ++++++++++++++++++++++ src/llm/preprocess.test.ts | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 176706a..61184c9 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -553,6 +553,53 @@ describe("registerSetup (full flow)", () => { errorSpy.mockRestore(); }); + it("prompt validate callbacks accept and reject inputs as expected", async () => { + type PromptOpts = { validate?: (v: string) => true | string }; + setupPromptDefaults(); + // Use custom timezone so the timezone-input validate is also invoked. + mockSelect.mockReset(); + mockSelect + .mockResolvedValueOnce("en") + .mockResolvedValueOnce("brutalist") + .mockResolvedValueOnce("__other__") + .mockResolvedValueOnce("openai"); + mockInput.mockReset(); + mockInput + .mockResolvedValueOnce("testuser") // username + .mockResolvedValueOnce("my-reports") // repo + .mockResolvedValueOnce("Dev Pulse") // site title + .mockResolvedValueOnce("Asia/Taipei") // custom timezone + .mockResolvedValueOnce("gpt-4o"); // model + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + await program.parseAsync(["node", "cli", "setup"]); + + // password() calls: [0] = token, [1] = LLM API key + const tokenValidate = (mockPassword.mock.calls[0][0] as PromptOpts).validate!; + expect(tokenValidate("")).toBe("Token is required"); + expect(tokenValidate("ghp_x")).toBe(true); + + const llmKeyValidate = (mockPassword.mock.calls[1][0] as PromptOpts).validate!; + expect(llmKeyValidate("")).toBe("API key is required"); + expect(llmKeyValidate("sk-x")).toBe(true); + + // input() calls: [0]=username, [1]=repo, [2]=siteTitle, [3]=tz, [4]=model + const repoValidate = (mockInput.mock.calls[1][0] as PromptOpts).validate!; + expect(repoValidate("bad name!")).toBe("Invalid repository name"); + expect(repoValidate("ok.repo-name_1")).toBe(true); + + const tzValidate = (mockInput.mock.calls[3][0] as PromptOpts).validate!; + expect(tzValidate("Not/AReal_Zone")).toMatch(/Invalid timezone/); + expect(tzValidate("UTC")).toBe(true); + + const modelValidate = (mockInput.mock.calls[4][0] as PromptOpts).validate!; + expect(modelValidate("")).toBe("Model name is required"); + expect(modelValidate("gpt-4o")).toBe(true); + }); + it("throws when LLM secret fails to set", async () => { setupPromptDefaults(); // First call (GH_PAT) succeeds, second call (LLM secret) fails diff --git a/src/llm/preprocess.test.ts b/src/llm/preprocess.test.ts index f8748b6..0444d70 100644 --- a/src/llm/preprocess.test.ts +++ b/src/llm/preprocess.test.ts @@ -220,4 +220,75 @@ describe("buildLLMContext", () => { const context = buildLLMContext(MOCK_INPUT); expect(context).not.toContain("releases:"); }); + + it("omits PR labels and body when PR has neither", () => { + const input: NarrativeInput = { + ...MOCK_INPUT, + pullRequests: [ + { + title: "chore: bare PR", + body: "", + url: "", + repository: "org/repo-a", + state: "open", + labels: [], + additions: 1, + deletions: 0, + changedFiles: 1, + author: "testuser", + createdAt: "2026-04-01T00:00:00Z", + mergedAt: null, + }, + ], + }; + const context = buildLLMContext(input); + expect(context).toContain("chore: bare PR"); + expect(context).not.toContain("labels:"); + expect(context).not.toContain("body:"); + }); + + it("omits issue labels and body when issue has neither", () => { + const input: NarrativeInput = { + ...MOCK_INPUT, + pullRequests: [], + issues: [ + { + title: "Bare issue", + body: "", + url: "", + repository: "org/repo-a", + state: "open", + labels: [], + author: "testuser", + createdAt: "2026-04-01T00:00:00Z", + closedAt: null, + }, + ], + }; + const context = buildLLMContext(input); + expect(context).toContain("Bare issue"); + expect(context).not.toContain("labels:"); + expect(context).not.toContain("body:"); + }); + + it("omits release body when release has none", () => { + const input: NarrativeInput = { + ...MOCK_INPUT, + pullRequests: [], + issues: [], + releases: [ + { + repo: "org/repo-a", + tag: "v1.0.0", + name: "Initial", + body: "", + url: "https://github.com/org/repo-a/releases/tag/v1.0.0", + publishedAt: "2026-04-01T10:00:00Z", + }, + ], + }; + const context = buildLLMContext(input); + expect(context).toContain("v1.0.0"); + expect(context).not.toContain("body:"); + }); }); From dbd55ca50a88ab3cfe15c640952069d44912186a Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:11:22 +0900 Subject: [PATCH 14/42] test: cover release draft filter, name fallback, and card idle defaults --- src/collector/fetch-releases.test.ts | 27 +++++++++++++++++++++++++++ src/renderer/card.test.ts | 6 ++++++ 2 files changed, 33 insertions(+) diff --git a/src/collector/fetch-releases.test.ts b/src/collector/fetch-releases.test.ts index cb083fc..7c04556 100644 --- a/src/collector/fetch-releases.test.ts +++ b/src/collector/fetch-releases.test.ts @@ -208,4 +208,31 @@ describe("fetchReleases", () => { expect(result[0].body).toBeNull(); }); + + it("filters out releases with null published_at (drafts)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify([ + { tag_name: "v1.0.0", name: "v1.0.0", body: "ok", html_url: "https://example.com/1", published_at: null }, + makeRawRelease("v1.1.0", "2026-04-02T12:00:00Z"), + ]), { status: 200 }), + ); + + const result = await fetchReleases("token", ["org/repo"], range); + + expect(result).toHaveLength(1); + expect(result[0].tag).toBe("v1.1.0"); + }); + + it("falls back to tag_name when name is null", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify([ + { tag_name: "v2.0.0", name: null, body: "release", html_url: "https://example.com/2", published_at: "2026-04-02T12:00:00Z" }, + ]), { status: 200 }), + ); + + const result = await fetchReleases("token", ["org/repo"], range); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("v2.0.0"); + }); }); diff --git a/src/renderer/card.test.ts b/src/renderer/card.test.ts index 5988217..94ad7d5 100644 --- a/src/renderer/card.test.ts +++ b/src/renderer/card.test.ts @@ -79,6 +79,12 @@ describe("generateCard", () => { expect(svg).toContain("Auth refactor completed"); }); + it("falls back to idle items when no ticker, no summaries, and no title", () => { + const svg = generateCard({ ...data, summaries: [], title: "" }); + expect(svg).toContain("STANDBY"); + expect(svg).toContain("Developer is recharging"); + }); + it("escapes XML special characters", () => { const svg = generateCard({ ...data, From 23b94001f79da5ad0fa35bbc1bc47c0c0b219341 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:16:22 +0900 Subject: [PATCH 15/42] test: cover ticker YAML parsing and OpenAI-compat empty-content fallbacks --- src/llm/generate-content.test.ts | 22 ++++++++++++++++++++++ src/llm/providers/providers.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/llm/generate-content.test.ts b/src/llm/generate-content.test.ts index 296b21a..00c46d0 100644 --- a/src/llm/generate-content.test.ts +++ b/src/llm/generate-content.test.ts @@ -278,6 +278,28 @@ highlights: await expect(generateContent(MOCK_INPUT, config)).rejects.toThrow("LLM content generation failed"); }); + it("parses ticker items from YAML when present", async () => { + const yamlWithTicker = `title: Test +subtitle: Sub +overview: Overview. +summaries: [] +highlights: [] +ticker: + - label: SHIP + text: Released v1.0.0 + - label: REVIEW + text: Reviewed 8 PRs + - text: Item with no label +`; + mockGenerate.mockResolvedValue(yamlWithTicker); + const { generateContent } = await import("./index.js"); + const result = await generateContent(MOCK_INPUT, config); + expect(result.ticker).toHaveLength(3); + expect(result.ticker?.[0]).toEqual({ label: "SHIP", text: "Released v1.0.0" }); + expect(result.ticker?.[1]).toEqual({ label: "REVIEW", text: "Reviewed 8 PRs" }); + expect(result.ticker?.[2]).toEqual({ label: "", text: "Item with no label" }); + }); + it("handles summaries without chips field", async () => { const yamlNoChips = `title: Test subtitle: Sub diff --git a/src/llm/providers/providers.test.ts b/src/llm/providers/providers.test.ts index b7e0cc5..c2b5c6a 100644 --- a/src/llm/providers/providers.test.ts +++ b/src/llm/providers/providers.test.ts @@ -80,6 +80,14 @@ describe("OpenRouter provider", () => { const result = await provider.generate("test"); expect(result).toBe("openrouter response"); }); + + it("returns empty string when no content", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }); + const { createOpenRouterProvider } = await import("./openrouter.js"); + const provider = createOpenRouterProvider({ ...baseConfig, provider: "openrouter" }); + const result = await provider.generate("test"); + expect(result).toBe(""); + }); }); describe("Groq provider", () => { @@ -94,6 +102,14 @@ describe("Groq provider", () => { const result = await provider.generate("test"); expect(result).toBe("groq response"); }); + + it("returns empty string when no content", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }); + const { createGroqProvider } = await import("./groq.js"); + const provider = createGroqProvider({ ...baseConfig, provider: "groq" }); + const result = await provider.generate("test"); + expect(result).toBe(""); + }); }); describe("Grok provider", () => { @@ -108,6 +124,14 @@ describe("Grok provider", () => { const result = await provider.generate("test"); expect(result).toBe("grok response"); }); + + it("returns empty string when no content", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }); + const { createGrokProvider } = await import("./grok.js"); + const provider = createGrokProvider({ ...baseConfig, provider: "grok" }); + const result = await provider.generate("test"); + expect(result).toBe(""); + }); }); describe("Anthropic provider", () => { From 02ad24d37c0f478dd09b151d5910b7cc43142c4d Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:20:31 +0900 Subject: [PATCH 16/42] test: cover index-page entry fallbacks, i18n unknown-language paths, and heatmap level buckets --- src/deployer/index-page.test.ts | 38 +++++++++++++++++++++++++++++++++ src/i18n/i18n.test.ts | 24 +++++++++++++++++++++ src/renderer/renderer.test.ts | 8 +++++-- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/deployer/index-page.test.ts b/src/deployer/index-page.test.ts index a9fcd95..eeb869b 100644 --- a/src/deployer/index-page.test.ts +++ b/src/deployer/index-page.test.ts @@ -79,4 +79,42 @@ describe("renderIndexPage", () => { const html = renderIndexPage(entries(["2026/W14"]), undefined, "en", "Weekly Reports"); expect(html).toContain("Weekly Reports"); }); + + it("builds absolute og.png URL when baseUrl is provided", () => { + const html = renderIndexPage( + entries(["2026/W14"]), + undefined, + "en", + undefined, + "https://user.github.io/repo", + ); + expect(html).toContain("https://user.github.io/repo/og.png"); + }); +}); + +describe("buildReportEntry", () => { + it("falls back to path when no slash is present", () => { + const entry = buildReportEntry("legacy"); + expect(entry.path).toBe("legacy"); + // path.split("/") returns ["legacy"], so [1] is undefined -> falls back to path + expect(entry.week).toBe("legacy"); + expect(entry.year).toBe("legacy"); + expect(entry.dateLabel).toContain("legacy"); + }); + + it("propagates optional fields when provided", () => { + const entry = buildReportEntry( + "2026/W14", + "Title", + "Subtitle", + { commits: 1, prs: 2, reviews: 3 }, + "2026-04-05", + "Overview text", + ); + expect(entry.title).toBe("Title"); + expect(entry.subtitle).toBe("Subtitle"); + expect(entry.stats).toEqual({ commits: 1, prs: 2, reviews: 3 }); + expect(entry.dateTo).toBe("2026-04-05"); + expect(entry.overview).toBe("Overview text"); + }); }); diff --git a/src/i18n/i18n.test.ts b/src/i18n/i18n.test.ts index ce629cf..3dd16d0 100644 --- a/src/i18n/i18n.test.ts +++ b/src/i18n/i18n.test.ts @@ -148,4 +148,28 @@ describe("getFontConfig", () => { expect(config.bodyFamily).toBeTruthy(); expect(config.monoFamily).toContain("Space Mono"); }); + + it("falls back to English font config for unknown language", () => { + const config = getFontConfig("xx" as Language); + const enConfig = getFontConfig("en"); + expect(config).toEqual(enConfig); + }); +}); + +describe("fallbacks for unknown language", () => { + it("getLocale falls back to English for unknown language", () => { + const fallback = getLocale("xx" as Language); + const en = getLocale("en"); + expect(fallback).toBe(en); + }); + + it("formatNumber falls back to en-US tag for unknown language", () => { + expect(formatNumber(1234, "xx" as Language)).toBe("1,234"); + }); + + it("formatDate falls back to en-US tag for unknown language", () => { + const result = formatDate("2026-04-03", "xx" as Language); + expect(result).toContain("2026"); + expect(result).toContain("Apr"); + }); }); diff --git a/src/renderer/renderer.test.ts b/src/renderer/renderer.test.ts index 04bf586..992f8f2 100644 --- a/src/renderer/renderer.test.ts +++ b/src/renderer/renderer.test.ts @@ -172,12 +172,16 @@ describe("renderReport", () => { { date: "2026-03-28", count: 0 }, { date: "2026-03-29", count: 1 }, { date: "2026-03-30", count: 5 }, - { date: "2026-03-31", count: 8 }, - { date: "2026-04-01", count: 10 }, + { date: "2026-03-31", count: 7 }, + { date: "2026-04-01", count: 8 }, + { date: "2026-04-02", count: 10 }, ], }; const html = renderReport(data); expect(html).toContain("level-0"); + expect(html).toContain("level-1"); + expect(html).toContain("level-2"); + expect(html).toContain("level-3"); expect(html).toContain("level-4"); }); From afb9322992da37b95aef647dd3b7c8baacb2c559 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:28:46 +0900 Subject: [PATCH 17/42] test: cover DST-skipped midnight and RSS month-overflow pubDate paths --- src/collector/date-range.test.ts | 11 +++++++++++ src/renderer/rss.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/collector/date-range.test.ts b/src/collector/date-range.test.ts index 17f0493..458859c 100644 --- a/src/collector/date-range.test.ts +++ b/src/collector/date-range.test.ts @@ -517,4 +517,15 @@ describe("parseLocalDate", () => { expect(() => parseLocalDate("2026-13-01", "UTC")).not.toThrow(); // valid format, invalid date handled by midnightInTz expect(() => parseLocalDate("2026/04/16", "UTC")).toThrow("Invalid date format"); }); + + // Asia/Beirut springs forward at 00:00 on the last Sunday of March (in 2026, + // March 29). The local clock jumps from 23:59 EET directly to 01:00 EEST, + // so "midnight March 29" never exists in Beirut local time. The midnight + // resolver must fall back to the closest valid local instant — exercising + // the DST correction branch in midnightInTz. + it("resolves DST-skipped midnight in Asia/Beirut (last Sunday of March)", () => { + const result = parseLocalDate("2026-03-29", "Asia/Beirut"); + // The local date in Beirut should still read 2026-03-29. + expect(toISODate(result, "Asia/Beirut")).toBe("2026-03-29"); + }); }); diff --git a/src/renderer/rss.test.ts b/src/renderer/rss.test.ts index b997d34..e3c86a7 100644 --- a/src/renderer/rss.test.ts +++ b/src/renderer/rss.test.ts @@ -122,6 +122,17 @@ describe("buildRSSFeed", () => { expect(feed).toContain("Mon, 06 Apr 2026 05:00:00 GMT"); }); + it("computes pubDate when positive offset crosses a month boundary", () => { + // dateRange.to = 2026-05-31 (Sunday). UTC midnight of (dateTo + 1 day) + // is 2026-06-01 00:00 UTC, which in JST (UTC+9) is 2026-06-01 09:00. + // localMonth (6) > m (5), so the localMonth-overflow branch is exercised. + // Monday 01:00 JST = Sunday 16:00 UTC. + const entries = [makeEntry("2026/W22", "Title", "Sub", "2026-05-31")]; + const feed = buildRSSFeed(entries, defaultChannel({ timezone: "Asia/Tokyo" })); + + expect(feed).toContain("Sun, 31 May 2026 16:00:00 GMT"); + }); + it("omits pubDate when dateTo is not available", () => { const entry: ReportEntry = { path: "2026/W14", From 6d83b5e3e22df6fc906e7e8e2712cb050604c306 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:43:30 +0900 Subject: [PATCH 18/42] test: cover weekly-fetch error paths and CLI entrypoint wiring --- src/cli/commands/fetch.test.ts | 55 ++++++++++++++++++++++++++++++++++ src/cli/index.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/cli/index.test.ts diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index b756d36..77ec396 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -774,6 +774,61 @@ describe("registerFetch (daily-fetch error)", () => { }); }); +// ------------------------------------------------------------------- +// registerFetch (weekly-fetch error path) +// ------------------------------------------------------------------- + +describe("registerFetch (weekly-fetch error)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("logs error and exits 1 when token is missing", async () => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", ""); + vi.stubEnv("GITHUB_USERNAME", ""); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "weekly-fetch", "--username", "alice"]); + + expect(errSpy).toHaveBeenCalledWith("Error:", expect.stringContaining("GitHub token required")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("logs raw value when error is not an Error instance", async () => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "ghp_xxx"); + vi.stubEnv("GITHUB_USERNAME", "alice"); + // Force mkdir (called early in runWeeklyFetch) to reject with a non-Error value + // so the `error instanceof Error ? ... : error` branch chooses the raw value. + mockMkdir.mockRejectedValueOnce("string-failure"); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "weekly-fetch"]); + + expect(errSpy).toHaveBeenCalledWith("Error:", "string-failure"); + expect(exitSpy).toHaveBeenCalledWith(1); + mockMkdir.mockResolvedValue(undefined); + }); +}); + // ------------------------------------------------------------------- // registerFetch (commit-msg subcommand) // ------------------------------------------------------------------- diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 0000000..a477537 --- /dev/null +++ b/src/cli/index.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +// The CLI entrypoint reads package.json and calls program.parse() at import +// time. We stub argv so commander treats this as a "no subcommand" invocation +// (which prints help and returns without invoking any handler), ensuring the +// import is side-effect safe under test. + +describe("cli/index entrypoint", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("registers the program version from package.json without throwing", async () => { + // Capture argv: only the node + program name, no subcommand. Commander + // calls process.exit(1) for missing-command help; we stub exit so the + // import resolves cleanly and exercises the registerXxx wiring. + const originalArgv = process.argv; + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(((_chunk: string | Uint8Array) => true) as typeof process.stdout.write); + const errSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(((_chunk: string | Uint8Array) => true) as typeof process.stderr.write); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + try { + process.argv = ["node", "github-weekly-reporter"]; + await expect(import("./index.js")).resolves.toBeDefined(); + // Commander exits with 1 when no subcommand is supplied (after printing help). + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + process.argv = originalArgv; + writeSpy.mockRestore(); + errSpy.mockRestore(); + logSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); From de99ba5b6d9efd84a06a082be8477cbb4c2b2741 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:48:49 +0900 Subject: [PATCH 19/42] test: cover content defaults, parse-error hint, and og-image unknown-language fallback --- src/llm/generate-content.test.ts | 54 ++++++++++++++++++++++++++++++++ src/renderer/og-image.test.ts | 7 +++++ 2 files changed, 61 insertions(+) diff --git a/src/llm/generate-content.test.ts b/src/llm/generate-content.test.ts index 00c46d0..5e3cd13 100644 --- a/src/llm/generate-content.test.ts +++ b/src/llm/generate-content.test.ts @@ -315,4 +315,58 @@ highlights: [] const result = await generateContent(MOCK_INPUT, config); expect(result.summaries[0].chips).toBeUndefined(); }); + + it("defaults title, subtitle, and overview to empty strings when missing", async () => { + const yamlMissing = `summaries: [] +highlights: [] +`; + mockGenerate.mockResolvedValue(yamlMissing); + const { generateContent } = await import("./index.js"); + const result = await generateContent(MOCK_INPUT, config); + expect(result.title).toBe(""); + expect(result.subtitle).toBe(""); + expect(result.overview).toBe(""); + }); + + it("defaults chip color to 'default' when omitted", async () => { + const yamlNoColor = `title: Test +subtitle: Sub +overview: Overview. +summaries: + - type: commit-summary + heading: 10 commits + body: Some commits. + chips: + - label: lines + value: "+10" +highlights: [] +`; + mockGenerate.mockResolvedValue(yamlNoColor); + const { generateContent } = await import("./index.js"); + const result = await generateContent(MOCK_INPUT, config); + expect(result.summaries[0].chips![0].color).toBe("default"); + }); + + it("defaults ticker text to empty string when missing", async () => { + const yamlTickerNoText = `title: Test +subtitle: Sub +overview: Overview. +summaries: [] +highlights: [] +ticker: + - label: SHIP +`; + mockGenerate.mockResolvedValue(yamlTickerNoText); + const { generateContent } = await import("./index.js"); + const result = await generateContent(MOCK_INPUT, config); + expect(result.ticker?.[0]).toEqual({ label: "SHIP", text: "" }); + }); + + it("appends parse-error hint when provider error mentions parse", async () => { + mockGenerate.mockRejectedValue(new Error("failed to parse response")); + const { generateContent } = await import("./index.js"); + await expect(generateContent(MOCK_INPUT, config)).rejects.toThrow( + /Retry the command, or try a larger\/different model\./, + ); + }); }); diff --git a/src/renderer/og-image.test.ts b/src/renderer/og-image.test.ts index 6c9fdb1..e0d1d63 100644 --- a/src/renderer/og-image.test.ts +++ b/src/renderer/og-image.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { generateOGImage, generateIndexOGImage } from "./og-image.js"; +import type { Language } from "../types.js"; describe("generateOGImage", () => { const data = { @@ -35,6 +36,12 @@ describe("generateOGImage", () => { const result = await generateOGImage({ ...data, language: "ko" }); expect(Buffer.isBuffer(result)).toBe(true); }); + + it("falls back to English font when language is unknown", async () => { + const result = await generateOGImage({ ...data, language: "xx" as Language }); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result[0]).toBe(0x89); + }); }); describe("generateIndexOGImage", () => { From a6b5cbff1c4fb30c3cf41ca4b227c634854a2285 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:51:30 +0900 Subject: [PATCH 20/42] test: cover generate unknown-provider key message and non-Error logging --- src/cli/commands/generate.test.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index d382e75..4926c36 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -154,6 +154,12 @@ describe("resolveOptions", () => { }); expect(result.date).toBeUndefined(); }); + + it("throws generic API key message when provider is not in keymap", () => { + expect(() => + resolveOptions({ llmProvider: "unknown-provider", llmModel: "some-model" }), + ).toThrow("the provider's API key env var"); + }); }); describe("registerGenerate", () => { @@ -240,4 +246,30 @@ externalContributions: [] exitSpy.mockRestore(); }); + + it("logs raw value when error is not an Error instance", async () => { + mockReadFile.mockRejectedValue("string-failure"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerGenerate } = await import("./generate.js"); + const program = new Command(); + registerGenerate(program); + + await expect( + program.parseAsync([ + "node", "cli", "generate", + "--llm-provider", "openai", + "--llm-api-key", "sk-test", + "--llm-model", "gpt-4o", + "--date", "2026-04-01", + ]), + ).rejects.toThrow("process.exit"); + + expect(errorSpy).toHaveBeenCalledWith("Error:", "string-failure"); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); }); From 352982972975898abf83443ff235c0ffdd9c2304 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:55:40 +0900 Subject: [PATCH 21/42] test: cover daily-fetch non-Error logging and commit-msg env fallbacks --- src/cli/commands/fetch.test.ts | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index 77ec396..380aec6 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -772,6 +772,31 @@ describe("registerFetch (daily-fetch error)", () => { expect(errSpy).toHaveBeenCalledWith("Error:", expect.stringContaining("GitHub token required")); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("logs raw value when error is not an Error instance and uses current time when --date omitted", async () => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "ghp_xxx"); + vi.stubEnv("GITHUB_USERNAME", "alice"); + // Force mkdir (called early in runDailyFetch) to reject with a non-Error value + // so the daily-fetch `error instanceof Error ? ... : error` branch returns the raw value. + // Omitting --date also exercises the `options.date ?? new Date()` default branch. + mockMkdir.mockRejectedValueOnce("daily-string-failure"); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined as never) as typeof process.exit); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "daily-fetch"]); + + expect(errSpy).toHaveBeenCalledWith("Error:", "daily-string-failure"); + expect(exitSpy).toHaveBeenCalledWith(1); + mockMkdir.mockResolvedValue(undefined); + }); }); // ------------------------------------------------------------------- @@ -900,4 +925,26 @@ describe("registerFetch (commit-msg)", () => { restore(); expect(writes.join("")).toMatch(/^data: daily \d{4}\/W\d{2} /); }); + + it("falls back to UTC and ./data when neither flags nor env are set", async () => { + // Remove env vars so `process.env[key]` returns undefined, exercising + // the final `?? "UTC"` / `?? "./data"` literal defaults in commit-msg. + const prevTimezone = process.env.TIMEZONE; + const prevDataDir = process.env.DATA_DIR; + delete process.env.TIMEZONE; + delete process.env.DATA_DIR; + + const { writes, restore } = captureStdout(); + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync(["node", "cli", "commit-msg", "weekly", "--date", "2026-04-07"]); + + restore(); + if (prevTimezone !== undefined) process.env.TIMEZONE = prevTimezone; + if (prevDataDir !== undefined) process.env.DATA_DIR = prevDataDir; + expect(writes.join("")).toMatch(/^data: weekly 2026\/W\d{2} /); + }); }); From 7d9e7458810ffa4170f9800334368825f3c6ba50 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 16:59:08 +0900 Subject: [PATCH 22/42] test: cover render dir defaults, non-Error logging, and GITHUB_REPOSITORY repoUrl --- src/cli/commands/render.test.ts | 97 +++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/cli/commands/render.test.ts b/src/cli/commands/render.test.ts index 441c698..9b81c7b 100644 --- a/src/cli/commands/render.test.ts +++ b/src/cli/commands/render.test.ts @@ -426,6 +426,103 @@ describe("registerRender", () => { expect(errorMsg).toContain("Unknown theme"); }); + it("falls back to ./data and ./output defaults when no opts or env vars", async () => { + const orig = { + data: process.env.DATA_DIR, + out: process.env.OUTPUT_DIR, + }; + delete process.env.DATA_DIR; + delete process.env.OUTPUT_DIR; + try { + mockReadFile.mockImplementation((path: string) => { + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + mockReaddir.mockResolvedValue([]); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + // First readFile call should target ./data/2026/W14/github-data.yaml + const readPaths = mockReadFile.mock.calls.map((c: unknown[]) => c[0] as string); + expect(readPaths.some((p) => p.startsWith("data/") || p.includes("/data/"))).toBe(true); + // Output should land under ./output + const writePaths = mockWriteFile.mock.calls.map((c: unknown[]) => c[0] as string); + expect(writePaths.some((p) => typeof p === "string" && p.includes("output/"))).toBe(true); + } finally { + if (orig.data !== undefined) process.env.DATA_DIR = orig.data; + if (orig.out !== undefined) process.env.OUTPUT_DIR = orig.out; + } + }); + + it("logs non-Error rejection via instanceof Error false branch", async () => { + mockReadFile.mockImplementation((path: string) => { + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + mockReaddir.mockResolvedValue([]); + mockRenderReport.mockImplementationOnce(() => { throw "string-failure"; }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await expect( + program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]), + ).rejects.toThrow("process.exit"); + + const logged = errorSpy.mock.calls.flat(); + expect(logged).toContain("string-failure"); + }); + + it("uses GITHUB_REPOSITORY env to compute repoUrl for index page", async () => { + vi.stubEnv("GITHUB_REPOSITORY", "octo/awesome"); + + mockReadFile.mockImplementation((path: string) => { + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + mockReaddir.mockImplementation((dir: string) => { + if (dir.endsWith("data")) return Promise.resolve(["2026"]); + if (dir.includes("2026")) return Promise.resolve(["W14"]); + return Promise.resolve([]); + }); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + // renderIndexPage signature: (entries, profile, language, siteTitle, base, repoUrl, theme) + const indexCall = mockRenderIndexPage.mock.calls[0]; + expect(indexCall[5]).toBe("https://github.com/octo/awesome"); + }); + it("renders successfully when data dir does not exist (readdir rejects)", async () => { mockReadFile.mockImplementation((path: string) => { if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); From 4bc98583ab05c37ecd507cb699ae809c4638a34f Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:06:36 +0900 Subject: [PATCH 23/42] test: cover Pacific/Norfolk DST-end midnight remainMs subtraction branch --- src/collector/date-range.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/collector/date-range.test.ts b/src/collector/date-range.test.ts index 458859c..3247fad 100644 --- a/src/collector/date-range.test.ts +++ b/src/collector/date-range.test.ts @@ -528,4 +528,16 @@ describe("parseLocalDate", () => { // The local date in Beirut should still read 2026-03-29. expect(toISODate(result, "Asia/Beirut")).toBe("2026-03-29"); }); + + // Pacific/Norfolk follows Australian DST: clocks fall back at 03:00 NFDT + // (UTC+12) on the first Sunday of April (April 5 in 2026), becoming + // 02:00 NFT (UTC+11). The first guess for midnight is computed against the + // post-DST offset, but the actual midnight instant lies before the + // transition, so the resolver must subtract the residual local hour to + // hit true midnight — exercising the "subtract remainMs" recovery branch + // in midnightInTz. + it("resolves midnight on Pacific/Norfolk DST-end day via remainMs subtraction", () => { + const result = parseLocalDate("2026-04-05", "Pacific/Norfolk"); + expect(toISODate(result, "Pacific/Norfolk")).toBe("2026-04-05"); + }); }); From bba0a1f97a8abaf2ce38623db5bd210dc45c84a9 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:12:40 +0900 Subject: [PATCH 24/42] test: cover deploy directory default and non-Error logging --- src/cli/commands/deploy.test.ts | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/cli/commands/deploy.test.ts b/src/cli/commands/deploy.test.ts index 4fe3a09..f30f5b7 100644 --- a/src/cli/commands/deploy.test.ts +++ b/src/cli/commands/deploy.test.ts @@ -137,4 +137,47 @@ describe("registerDeploy", () => { expect.objectContaining({ directory: "./env-output" }), ); }); + + it("falls back to ./output when neither --directory nor OUTPUT_DIR is set", async () => { + vi.stubEnv("GITHUB_TOKEN", "ghp_test"); + vi.stubEnv("GITHUB_REPOSITORY", "owner/repo"); + const savedOutputDir = process.env.OUTPUT_DIR; + delete process.env.OUTPUT_DIR; + + try { + const { Command } = await import("commander"); + const { registerDeploy } = await import("./deploy.js"); + const program = new Command(); + registerDeploy(program); + + await program.parseAsync(["node", "cli", "deploy"]); + + expect(mockDeploy).toHaveBeenCalledWith( + expect.objectContaining({ directory: "./output" }), + ); + } finally { + if (savedOutputDir !== undefined) process.env.OUTPUT_DIR = savedOutputDir; + } + }); + + it("logs raw value when deploy rejects with a non-Error", async () => { + vi.stubEnv("GITHUB_TOKEN", "ghp_test"); + mockDeploy.mockRejectedValueOnce("string-failure"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerDeploy } = await import("./deploy.js"); + const program = new Command(); + registerDeploy(program); + + await expect( + program.parseAsync([ + "node", "cli", "deploy", + "--directory", "./output", + "--repo", "owner/repo", + ]), + ).rejects.toThrow("process.exit"); + + expect(errorSpy).toHaveBeenCalledWith("Error:", "string-failure"); + }); }); From 765387d4d28c8e9f36143e4da16edc51fe7523b4 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:17:24 +0900 Subject: [PATCH 25/42] test: cover aggregateRepositories open-issue issuesClosed branch --- src/collector/aggregate.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/collector/aggregate.test.ts b/src/collector/aggregate.test.ts index b8ea538..0f15e7d 100644 --- a/src/collector/aggregate.test.ts +++ b/src/collector/aggregate.test.ts @@ -74,4 +74,14 @@ describe("aggregateRepositories", () => { it("returns empty array when no activity", () => { expect(aggregateRepositories([], [])).toEqual([]); }); + + it("does not increment issuesClosed for open issues", () => { + const issues: Issue[] = [ + makeIssue({ repository: "org/repo-a", state: "open" }), + ]; + const result = aggregateRepositories([], issues); + expect(result).toHaveLength(1); + expect(result[0].issuesOpened).toBe(1); + expect(result[0].issuesClosed).toBe(0); + }); }); From 8cdd49cf72e24da70f1c3eed7570a22224a47c3b Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:21:27 +0900 Subject: [PATCH 26/42] test: cover render middle-week nextWeek and prevPrev navigation links --- src/cli/commands/render.test.ts | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/cli/commands/render.test.ts b/src/cli/commands/render.test.ts index 9b81c7b..03950ee 100644 --- a/src/cli/commands/render.test.ts +++ b/src/cli/commands/render.test.ts @@ -337,6 +337,50 @@ describe("registerRender", () => { expect(prevWeekCall[1]).toHaveProperty("nextWeek", "../../2026/W14/"); }); + it("links nextWeek and prevPrev when current week sits in the middle", async () => { + const PREV_GITHUB_YAML = GITHUB_DATA_YAML.replace("2026-03-28", "2026-03-21").replace("2026-04-03", "2026-03-27"); + const PREV_LLM_YAML = LLM_DATA_YAML.replace("Weekly Summary", "Previous Week Summary"); + + mockReadFile.mockImplementation((path: string) => { + if (path.includes("W13") && path.includes("github-data.yaml")) return Promise.resolve(PREV_GITHUB_YAML); + if (path.includes("W13") && path.includes("llm-data.yaml")) return Promise.resolve(PREV_LLM_YAML); + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + + // Four weeks exist: W12, W13, W14 (current), W15 — current is not last and prev has its own predecessor + mockReaddir.mockImplementation((dir: string) => { + if (dir.endsWith("data")) return Promise.resolve(["2026"]); + if (dir.includes("2026")) return Promise.resolve(["W12", "W13", "W14", "W15"]); + return Promise.resolve([]); + }); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + expect(mockRenderReport).toHaveBeenCalledTimes(2); + + // Current call should reference nextWeek (W15) — covers `currentIdx < length-1` truthy branch + const currentCall = mockRenderReport.mock.calls[0]; + expect(currentCall[1]).toHaveProperty("nextWeek", "../../2026/W15/"); + expect(currentCall[1]).toHaveProperty("prevWeek", "../../2026/W13/"); + + // Prev re-render should reference prevPrev (W12) — covers `prevIdx > 0` and `prevPrev` truthy branches + const prevWeekCall = mockRenderReport.mock.calls[1]; + expect(prevWeekCall[1]).toHaveProperty("prevWeek", "../../2026/W12/"); + expect(prevWeekCall[1]).toHaveProperty("nextWeek", "../../2026/W14/"); + }); + it("skips prev week re-render when prev week data is missing", async () => { mockReadFile.mockImplementation((path: string) => { // Only current week has data From 1da1edbc8b79e9643693aeac11206617c013696e Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:24:45 +0900 Subject: [PATCH 27/42] test: cover render prev re-render skip and llm-data filter branches --- src/cli/commands/render.test.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/cli/commands/render.test.ts b/src/cli/commands/render.test.ts index 03950ee..188bf25 100644 --- a/src/cli/commands/render.test.ts +++ b/src/cli/commands/render.test.ts @@ -417,6 +417,94 @@ describe("registerRender", () => { expect(mockRenderReport).toHaveBeenCalledTimes(1); }); + it("skips prev re-render and emits index entry without stats when prev github-data is missing", async () => { + const PREV_LLM_YAML = LLM_DATA_YAML.replace("Weekly Summary", "Previous Week Summary"); + + mockReadFile.mockImplementation((path: string) => { + // W13 has llm-data only; its github-data.yaml is missing. + if (path.includes("W13") && path.includes("github-data.yaml")) + return Promise.reject(new Error("not found")); + if (path.includes("W13") && path.includes("llm-data.yaml")) + return Promise.resolve(PREV_LLM_YAML); + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + + // mockAccess resolves for everything — W13 IS listed in allPaths via listCompletedReportDirs. + mockReaddir.mockImplementation((dir: string) => { + if (dir.endsWith("data")) return Promise.resolve(["2026"]); + if (dir.includes("2026")) return Promise.resolve(["W13", "W14"]); + return Promise.resolve([]); + }); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + // Current week renders, but prev re-render is skipped because prevGhData is null + // (covers `if (prevGhData && prevAiContent)` false branch). + expect(mockRenderReport).toHaveBeenCalledTimes(1); + + // buildReportEntry was invoked for W13 with stats=undefined + // (covers `const stats = ghData ? {...} : undefined` false branch). + const w13EntryCall = mockBuildReportEntry.mock.calls.find( + (call: unknown[]) => (call[0] as string) === "2026/W13", + ); + expect(w13EntryCall).toBeDefined(); + expect(w13EntryCall![3]).toBeUndefined(); + }); + + it("filters index entries when llm-data fails to load", async () => { + mockReadFile.mockImplementation((path: string) => { + // W13's llm-data.yaml fails to parse — entry should be filtered out. + if (path.includes("W13") && path.includes("llm-data.yaml")) + return Promise.reject(new Error("parse error")); + if (path.includes("github-data.yaml")) return Promise.resolve(GITHUB_DATA_YAML); + if (path.includes("llm-data.yaml")) return Promise.resolve(LLM_DATA_YAML); + return Promise.reject(new Error("not found")); + }); + + mockReaddir.mockImplementation((dir: string) => { + if (dir.endsWith("data")) return Promise.resolve(["2026"]); + if (dir.includes("2026")) return Promise.resolve(["W13", "W14"]); + return Promise.resolve([]); + }); + + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + await program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + "--base-url", "https://user.github.io/repo", + "--date", "2026-04-01", + ]); + + // W13 is included in allPaths (its llm-data.yaml passes the access check), + // but its tryReadYaml fails inside buildReportEntries → entry filtered out + // (covers `if (!llmData) return null` true branch). + const w13EntryCall = mockBuildReportEntry.mock.calls.find( + (call: unknown[]) => (call[0] as string) === "2026/W13", + ); + expect(w13EntryCall).toBeUndefined(); + + // Index page still receives the surviving entries. + expect(mockRenderIndexPage).toHaveBeenCalled(); + const entries = mockRenderIndexPage.mock.calls[0][0] as Array<{ path: string }>; + expect(entries.every((e) => e.path !== "2026/W13")).toBe(true); + }); + it("uses environment variables for options", async () => { vi.stubEnv("BASE_URL", "https://env-base.example.com"); vi.stubEnv("DATA_DIR", "./env-data"); From e2b406d5a92ac67c5dd3b4edd3ff8a0ec0c96f2d Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:28:58 +0900 Subject: [PATCH 28/42] test: cover validate-model non-Error connection rejection branch --- src/cli/commands/setup/validate-model.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli/commands/setup/validate-model.test.ts b/src/cli/commands/setup/validate-model.test.ts index 3de7235..6a9b307 100644 --- a/src/cli/commands/setup/validate-model.test.ts +++ b/src/cli/commands/setup/validate-model.test.ts @@ -101,6 +101,13 @@ describe("validateModel", () => { expect(result.error).toContain("Connection error"); }); + it("stringifies non-Error rejection in connection error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue("network down"); + const result = await validateModel("openai", "key", "gpt-4"); + expect(result.valid).toBe(false); + expect(result.error).toBe("Connection error: network down"); + }); + it("returns invalid for non-model API error", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("server error", { status: 500 }), From b6393832628654709d893dfa5243fd46eb11dbb0 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:32:31 +0900 Subject: [PATCH 29/42] test: cover addFileToRepo 403 PAT permission hint branch --- src/cli/commands/setup/github-api.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cli/commands/setup/github-api.test.ts b/src/cli/commands/setup/github-api.test.ts index d73dc17..a27fe22 100644 --- a/src/cli/commands/setup/github-api.test.ts +++ b/src/cli/commands/setup/github-api.test.ts @@ -192,6 +192,15 @@ describe("github-api", () => { await expect(addFileToRepo("token", "user/repo", "file.txt", "content", "msg")) .rejects.toThrow("Failed to add file.txt"); }); + + it("includes PAT permission hint when status is 403", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("", { status: 404 })) + .mockResolvedValueOnce(new Response("", { status: 403 })); + + await expect(addFileToRepo("token", "user/repo", "file.txt", "content", "msg")) + .rejects.toThrow(/Failed to add file\.txt: 403[\s\S]*Fine-grained PAT[\s\S]*Classic PAT/); + }); }); describe("enablePages", () => { From 74379af421f9f2511f41984312e073421d970b2a Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:41:46 +0900 Subject: [PATCH 30/42] test: cover weekly-fetch repo aggregation and commit-message mapping branch --- src/cli/commands/fetch.test.ts | 70 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/fetch.test.ts b/src/cli/commands/fetch.test.ts index 380aec6..29f2992 100644 --- a/src/cli/commands/fetch.test.ts +++ b/src/cli/commands/fetch.test.ts @@ -38,7 +38,15 @@ vi.mock("../../collector/fetch-contributions.js", () => ({ })); vi.mock("../../collector/aggregate.js", () => ({ - aggregateRepositories: () => [], + aggregateRepositories: vi.fn().mockReturnValue([]), +})); + +vi.mock("../../collector/fetch-commits.js", () => ({ + fetchCommitMessages: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../collector/fetch-releases.js", () => ({ + fetchReleases: vi.fn().mockResolvedValue([]), })); // Note: deployer/week.js is NOT mocked here. buildDailyPlan/buildWeeklyPlan @@ -741,6 +749,66 @@ describe("registerFetch (weekly-fetch)", () => { expect(yaml).toContain("kind: review"); expect(yaml).not.toMatch(/kind:\s*push/); }); + + it("maps repo names and reduces commit-message totals when repositories aggregate is non-empty", async () => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mockReadFile.mockRejectedValue(new Error("not found")); + mockWriteFile.mockResolvedValue(undefined); + mockMkdir.mockResolvedValue(undefined); + mockFetchContributions.mockResolvedValue({ + username: "alice", + avatarUrl: "https://example.com/a.png", + profile: { name: null, bio: null, company: null, location: null, followers: 0, following: 0, publicRepos: 0 }, + totalCommits: 4, + prsReviewed: 1, + dailyCommits: [], + }); + const { fetchPRsByRefs } = await import("../../collector/fetch-repo-prs.js"); + vi.mocked(fetchPRsByRefs).mockResolvedValue([]); + const { aggregateRepositories } = await import("../../collector/aggregate.js"); + vi.mocked(aggregateRepositories).mockReturnValueOnce([ + { name: "owner/alpha", commits: 0, prsOpened: 0, prsMerged: 0, issuesOpened: 0, issuesClosed: 0, url: "https://github.com/owner/alpha" }, + { name: "owner/beta", commits: 0, prsOpened: 0, prsMerged: 0, issuesOpened: 0, issuesClosed: 0, url: "https://github.com/owner/beta" }, + ]); + const { fetchCommitMessages } = await import("../../collector/fetch-commits.js"); + vi.mocked(fetchCommitMessages).mockResolvedValueOnce([ + { repo: "owner/alpha", messages: ["feat: a", "fix: b"] }, + { repo: "owner/beta", messages: ["docs: c"] }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ items: [], total_count: 0 }), { status: 200 })), + ); + + const { Command } = await import("commander"); + const { registerFetch } = await import("./fetch.js"); + const program = new Command(); + registerFetch(program); + + await program.parseAsync([ + "node", "cli", "weekly-fetch", + "--token", "ghp_test", + "--username", "alice", + "--data-dir", "./data", + "--timezone", "UTC", + "--date", "2026-04-01", + ]); + + const writeCall = mockWriteFile.mock.calls.find((c) => + typeof c[0] === "string" && c[0].includes("github-data.yaml"), + ); + expect(writeCall).toBeDefined(); + const yaml = writeCall![1] as string; + expect(yaml).toContain("owner/alpha"); + expect(yaml).toContain("owner/beta"); + expect(yaml).toContain("feat: a"); + expect(fetchCommitMessages).toHaveBeenCalledWith( + "ghp_test", + "alice", + ["owner/alpha", "owner/beta"], + expect.any(Object), + ); + }); }); // ------------------------------------------------------------------- From 6db4f43d5a1ed084d3a868b10d608d856974f400 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:46:46 +0900 Subject: [PATCH 31/42] test: cover setup default actions URL fallback when runs API fails --- src/cli/commands/setup.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 61184c9..66cb1e6 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -600,6 +600,33 @@ describe("registerSetup (full flow)", () => { expect(modelValidate("gpt-4o")).toBe(true); }); + it("falls back to default actions URL when runs API fails", async () => { + setupPromptDefaults(); + mockGhGet.mockReset().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + await program.parseAsync(["node", "cli", "setup"]); + + const progressLog = logSpy.mock.calls.find((args) => + typeof args[0] === "string" && args[0].includes("Progress:"), + ); + expect(progressLog).toBeDefined(); + expect(progressLog![0] as string).toContain( + "https://github.com/testuser/my-reports/actions", + ); + expect(progressLog![0] as string).not.toContain("/runs/"); + + logSpy.mockRestore(); + }); + it("throws when LLM secret fails to set", async () => { setupPromptDefaults(); // First call (GH_PAT) succeeds, second call (LLM secret) fails From beaefee02f152e193190e824c636c439aa595ff7 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 17:51:05 +0900 Subject: [PATCH 32/42] test: cover render missing base URL exit branch --- src/cli/commands/render.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/cli/commands/render.test.ts b/src/cli/commands/render.test.ts index 188bf25..fc8723e 100644 --- a/src/cli/commands/render.test.ts +++ b/src/cli/commands/render.test.ts @@ -558,6 +558,32 @@ describe("registerRender", () => { expect(errorMsg).toContain("Unknown theme"); }); + it("exits when --base-url is missing and BASE_URL env unset", async () => { + const orig = process.env.BASE_URL; + delete process.env.BASE_URL; + try { + const { registerRender } = await import("./render.js"); + const program = new Command(); + registerRender(program); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync([ + "node", "cli", "render", + "--data-dir", "./data", + "--output-dir", "./output", + ]), + ).rejects.toThrow("process.exit"); + + expect(errorSpy).toHaveBeenCalled(); + const errorMsg = errorSpy.mock.calls.flat().join(" "); + expect(errorMsg).toContain("Base URL required"); + } finally { + if (orig !== undefined) process.env.BASE_URL = orig; + } + }); + it("falls back to ./data and ./output defaults when no opts or env vars", async () => { const orig = { data: process.env.DATA_DIR, From 363584bc62e23bb8f3185b5e2eb12ef9bf96f347 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 18:00:18 +0900 Subject: [PATCH 33/42] test: cover setup non-Error rejection String fallback branch --- src/cli/commands/setup.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 66cb1e6..308d2a2 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -656,4 +656,27 @@ describe("registerSetup (full flow)", () => { exitSpy.mockRestore(); errorSpy.mockRestore(); }); + + it("logs String(error) when the run rejects with a non-Error value", async () => { + setupPromptDefaults(); + mockEnsureRepo.mockReset().mockRejectedValue("ensure-repo failed (string)"); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + + await expect(program.parseAsync(["node", "cli", "setup"])).rejects.toThrow("process.exit"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error: ensure-repo failed (string)"), + ); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); }); From d300243fc7a24f5e1114e4b8ca15d6a0d761f0bb Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 18:13:59 +0900 Subject: [PATCH 34/42] test: cover fetchPRsByRefs error body without message field branch --- src/collector/fetch-repo-prs.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/collector/fetch-repo-prs.test.ts b/src/collector/fetch-repo-prs.test.ts index 5e62b6f..475f469 100644 --- a/src/collector/fetch-repo-prs.test.ts +++ b/src/collector/fetch-repo-prs.test.ts @@ -203,4 +203,22 @@ describe("fetchPRsByRefs", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Not Found")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(" Not Found")); }); + + it("omits the indented detail line when error body JSON has no message field", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ documentation_url: "https://docs.github.com" }), { + status: 422, + statusText: "Unprocessable Entity", + }), + ); + + const result = await fetchPRsByRefs("token", [{ repo: "owner/repo", number: 1 }]); + + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("422")); + expect( + warnSpy.mock.calls.some((call) => /^\s{4}\S/.test(String(call[0]))), + ).toBe(false); + }); }); From 7022889d0c8df5916f05d26d7fe41867a81d8ab4 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 18:21:26 +0900 Subject: [PATCH 35/42] test: cover fetch-commits link header without rel=next branch --- src/collector/fetch-commits.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/collector/fetch-commits.test.ts b/src/collector/fetch-commits.test.ts index 2576932..c1a3380 100644 --- a/src/collector/fetch-commits.test.ts +++ b/src/collector/fetch-commits.test.ts @@ -179,6 +179,22 @@ describe("fetchCommitMessages", () => { vi.useRealTimers(); }); + it("ignores link header without rel=\"next\"", async () => { + // Link header present but only contains rel="prev" — parseNextUrl's regex + // does not match, so pagination should stop after the first page. + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify([makeRawCommit("only page")]), { + status: 200, + headers: { link: '; rel="prev"' }, + }), + ); + + const result = await fetchCommitMessages("token", "user", ["org/repo"], range); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result[0].messages).toEqual(["only page"]); + }); + it("warns and skips on non-retryable server errors", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( From 6081e44bd7ecafe46901fe0d4da6f28195517a76 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 18:31:49 +0900 Subject: [PATCH 36/42] test: cover setRepoSecret PUT response text rejection fallback branch --- src/cli/commands/setup/github-api.test.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/cli/commands/setup/github-api.test.ts b/src/cli/commands/setup/github-api.test.ts index a27fe22..781e884 100644 --- a/src/cli/commands/setup/github-api.test.ts +++ b/src/cli/commands/setup/github-api.test.ts @@ -294,6 +294,34 @@ describe("github-api", () => { ); vi.useRealTimers(); }); + + it("falls back to empty body when PUT response text() rejects", async () => { + const keyData = await makeValidKeyResponse(); + const failingPutResponse = { + ok: false, + status: 503, + text: () => Promise.reject(new Error("stream read failed")), + } as unknown as Response; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (_url, init?: RequestInit) => { + if ((init?.method ?? "GET") === "PUT") { + return failingPutResponse; + } + return new Response(JSON.stringify(keyData), { status: 200 }); + }, + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + vi.useFakeTimers(); + const promise = setRepoSecret("token", "user/repo", "SECRET", "value"); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result).toBe(false); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Attempt 1/3 failed: 503 "), + ); + vi.useRealTimers(); + }); }); describe("sleep", () => { From b6a9a96c3de14ad7c8fe54ab2f5394dc3cabb5d2 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 18:56:54 +0900 Subject: [PATCH 37/42] test: cover fetch-commits falsy repo entry skip branch --- src/collector/fetch-commits.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/collector/fetch-commits.test.ts b/src/collector/fetch-commits.test.ts index c1a3380..5e0fbb2 100644 --- a/src/collector/fetch-commits.test.ts +++ b/src/collector/fetch-commits.test.ts @@ -112,6 +112,15 @@ describe("fetchCommitMessages", () => { expect(result).toEqual([]); }); + it("skips falsy entries in repos list without calling fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await fetchCommitMessages("token", "user", [""], range); + + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("retries on 429 rate limit", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( From 0861ba34737df0c3c134812fd302b6bd45b79019 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 19:05:33 +0900 Subject: [PATCH 38/42] test: cover fetch-releases falsy repo entry skip branch --- src/collector/fetch-releases.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/collector/fetch-releases.test.ts b/src/collector/fetch-releases.test.ts index 7c04556..a536dd0 100644 --- a/src/collector/fetch-releases.test.ts +++ b/src/collector/fetch-releases.test.ts @@ -95,6 +95,15 @@ describe("fetchReleases", () => { expect(result).toEqual([]); }); + it("skips falsy repo entries without calling fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await fetchReleases("token", [""], range); + + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("retries on 429 rate limit", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( From a4d70a6d4576c4cf2d4926374526a715c832866d Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 19:48:37 +0900 Subject: [PATCH 39/42] test: cover setup undefined LLM provider not-configured branch --- src/cli/commands/setup.test.ts | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 308d2a2..37fbcf2 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -679,4 +679,63 @@ describe("registerSetup (full flow)", () => { exitSpy.mockRestore(); errorSpy.mockRestore(); }); + + it("prints 'LLM: Not configured' and skips LLM secret when provider is undefined", async () => { + mockPassword + .mockResolvedValueOnce("ghp_token") + .mockResolvedValueOnce("sk-key"); + mockValidateToken.mockResolvedValue({ login: "testuser", tokenType: "classic" }); + mockInput + .mockResolvedValueOnce("testuser") + .mockResolvedValueOnce("my-reports") + .mockResolvedValueOnce("Dev Pulse") + .mockResolvedValueOnce("gpt-4o"); + mockSelect + .mockResolvedValueOnce("en") + .mockResolvedValueOnce("brutalist") + .mockResolvedValueOnce("UTC") + .mockResolvedValueOnce(undefined); // LLM provider undefined → no llmProvider in config + // validateModel default switch case returns valid:true regardless of provider + mockValidateModel.mockResolvedValue({ valid: true }); + mockConfirm.mockResolvedValue(true); + mockEnsureRepo.mockResolvedValue(true); + mockSetRepoTopics.mockResolvedValue(undefined); + mockSetRepoSecret.mockResolvedValue(true); + mockAddFileToRepo.mockResolvedValue(undefined); + mockEnablePages.mockResolvedValue("https://testuser.github.io/my-reports"); + mockSleep.mockResolvedValue(undefined); + mockGhPost.mockResolvedValue({ ok: true }); + mockGhGet.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ workflow_runs: [] }), + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { Command } = await import("commander"); + const { registerSetup } = await import("./setup.js"); + const program = new Command(); + registerSetup(program); + await program.parseAsync(["node", "cli", "setup"]); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("LLM: Not configured"), + ); + // GH_PAT secret set; LLM secret block skipped + expect(mockSetRepoSecret).toHaveBeenCalledTimes(1); + expect(mockSetRepoSecret).toHaveBeenCalledWith( + "ghp_token", + "testuser/my-reports", + "GH_PAT", + "ghp_token", + ); + // Weekly workflow built without llm-provider input + const weeklyCall = mockAddFileToRepo.mock.calls.find( + (call: unknown[]) => (call[2] as string).includes("weekly-report.yml"), + ); + expect(weeklyCall).toBeDefined(); + expect(weeklyCall![3]).not.toContain("llm-provider:"); + + logSpy.mockRestore(); + }); }); From 31f8f3f9f52c0e6ff5ba3aef9f46d8c9a174e003 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 20:11:48 +0900 Subject: [PATCH 40/42] test: cover parseLocalDate America/Havana DST-skipped midnight brute-force branch --- src/collector/date-range.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/collector/date-range.test.ts b/src/collector/date-range.test.ts index 3247fad..833a875 100644 --- a/src/collector/date-range.test.ts +++ b/src/collector/date-range.test.ts @@ -540,4 +540,16 @@ describe("parseLocalDate", () => { const result = parseLocalDate("2026-04-05", "Pacific/Norfolk"); expect(toISODate(result, "Pacific/Norfolk")).toBe("2026-04-05"); }); + + // America/Havana springs forward at 00:00 local on the second Sunday of + // March (2026-03-08). Local 00:00 never exists that day, so neither the + // remainMs subtraction (lands on Mar 7) nor the 24h-remainMs addition + // (lands on Mar 9) can recover midnight — the resolver falls through to + // the brute-force search, exercising the false branch of the adjusted2 + // check in midnightInTz. + it("falls through to brute-force search on America/Havana DST-skipped midnight", () => { + const result = parseLocalDate("2026-03-08", "America/Havana"); + expect(result).toBeInstanceOf(Date); + expect(Number.isNaN(result.getTime())).toBe(false); + }); }); From 1dc3c680bf7ae493ce4fc9199da80a7d431f13f2 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 20:18:36 +0900 Subject: [PATCH 41/42] test: cover renderIndexPage theme missing init/toggle scripts fallback branch --- .../index-page-theme-fallback.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/deployer/index-page-theme-fallback.test.ts diff --git a/src/deployer/index-page-theme-fallback.test.ts b/src/deployer/index-page-theme-fallback.test.ts new file mode 100644 index 0000000..805f7c0 --- /dev/null +++ b/src/deployer/index-page-theme-fallback.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../renderer/themes/index.js", () => ({ + loadTheme: () => ({ + buildCSS: () => "", + buildIndexCSS: () => "", + colors: { + bg: "#000", + accent: "#fff", + green: "#0f0", + badgePr: "#00f", + badgeDiscussion: "#f0f", + }, + templatesDir: "/fake", + }), + readThemeTemplate: () => + "" + + "{{#each yearGroups}}

{{year}}

{{/each}}" + + "", +})); + +describe("renderIndexPage with theme missing init/toggle scripts", () => { + it("falls back to empty string for themeInitScript and themeToggleScript", async () => { + const { renderIndexPage, buildReportEntry } = await import("./index-page.js"); + const html = renderIndexPage([buildReportEntry("2026/W14")]); + expect(html).toContain(""); + expect(html).toContain("2026"); + }); +}); From ab2ee0ece20fe382d951218acc5f23742ad9af31 Mon Sep 17 00:00:00 2001 From: Yuji Ueki Date: Sun, 3 May 2026 20:20:38 +0900 Subject: [PATCH 42/42] test: cover renderReport theme missing init/toggle scripts fallback branch --- src/renderer/report-theme-fallback.test.ts | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/renderer/report-theme-fallback.test.ts diff --git a/src/renderer/report-theme-fallback.test.ts b/src/renderer/report-theme-fallback.test.ts new file mode 100644 index 0000000..4f34bc5 --- /dev/null +++ b/src/renderer/report-theme-fallback.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import type { WeeklyReportData } from "../types.js"; + +vi.mock("./themes/index.js", () => ({ + loadTheme: () => ({ + buildCSS: () => "", + buildIndexCSS: () => "", + colors: { + bg: "#000", + accent: "#fff", + green: "#0f0", + badgePr: "#00f", + badgeDiscussion: "#f0f", + }, + templatesDir: "/fake", + }), + readThemeTemplate: (_t: unknown, name: string) => + name.endsWith("report.hbs") + ? "" + + "{{username}}" + : "", +})); + +const MOCK_DATA: WeeklyReportData = { + username: "fallbackuser", + avatarUrl: "https://avatars.githubusercontent.com/u/1", + dateRange: { from: "2026-03-28", to: "2026-04-03" }, + stats: { + totalCommits: 0, + totalAdditions: 0, + totalDeletions: 0, + prsOpened: 0, + prsMerged: 0, + prsReviewed: 0, + issuesOpened: 0, + issuesClosed: 0, + }, + dailyCommits: [], + repositories: [], + pullRequests: [], + issues: [], + events: [], + commitMessages: [], + releases: [], + externalContributions: [], + aiContent: { + title: "T", + subtitle: "S", + overview: "O", + summaries: [], + highlights: [], + }, +}; + +describe("renderReport with theme missing init/toggle scripts", () => { + it("falls back to empty string for themeInitScript and themeToggleScript", async () => { + const { renderReport } = await import("./index.js"); + const html = renderReport(MOCK_DATA); + expect(html).toContain(""); + expect(html).toContain("fallbackuser"); + }); +});