Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dea7fe1
🧪 Add 503 error test for /api/card/[username]
google-labs-jules[bot] Mar 6, 2026
82edcea
🧪 [Add tests for loadCardSettings window check in cardSettings.ts]
google-labs-jules[bot] Mar 6, 2026
8045107
⚡ Optimize fetchStarredRepos with parallel fetching
google-labs-jules[bot] Mar 6, 2026
6b46d93
🧹 Extract theme logic from ThemeController to useThemeColor hook
google-labs-jules[bot] Mar 6, 2026
3754745
test: add invalid year error test for /api/dashboard/year
google-labs-jules[bot] Mar 6, 2026
6fc03ea
🧪 [test] Add tests for useDashboardData hook
google-labs-jules[bot] Mar 6, 2026
1057a5f
⚡ GitHub APIリクエストの並列化によるパフォーマンス改善
google-labs-jules[bot] Mar 6, 2026
4bf3438
🧹 Refactor fetchUserSummary to reduce complexity
google-labs-jules[bot] Mar 6, 2026
cd801de
🧪 Add tests for useDashboardData hooks
google-labs-jules[bot] Mar 6, 2026
8dbc7d0
🧪 Add tests for cardSettings.ts
google-labs-jules[bot] Mar 6, 2026
7acc941
🧪 Add testing for ShareButtons fallback logic
google-labs-jules[bot] Mar 6, 2026
ba66d7e
🧪 Fix typing and jsdom test runner issues in Github CI test suite
google-labs-jules[bot] Mar 6, 2026
77115c2
Fix type errors in cardSettings.test.ts
google-labs-jules[bot] Mar 6, 2026
6a49460
🧪 [Add tests for loadCardSettings window check in cardSettings.ts]
google-labs-jules[bot] Mar 6, 2026
862b2d2
Merge remote-tracking branch 'origin/test/card-settings-window-check-…
is0692vs Mar 6, 2026
f06e391
Merge remote-tracking branch 'origin/jules-13250148006614127640-dd8d0…
is0692vs Mar 6, 2026
69af545
Merge remote-tracking branch 'origin/fix/extract-theme-logic-38703521…
is0692vs Mar 6, 2026
d865f76
Merge remote-tracking branch 'origin/testing-improvements/api-dashboa…
is0692vs Mar 6, 2026
068e218
Merge remote-tracking branch 'origin/jules-528143515401111317-158ddf4…
is0692vs Mar 6, 2026
d6bd48d
Merge remote-tracking branch 'origin/perf/parallelize-github-commits-…
is0692vs Mar 6, 2026
c7bad75
Merge remote-tracking branch 'origin/fix/refactor-fetchUserSummary-co…
is0692vs Mar 6, 2026
6d91710
Merge remote-tracking branch 'origin/test-improvement-sharebuttons-fa…
is0692vs Mar 6, 2026
e170367
Merge PR #46 (with conflicts resolved)
is0692vs Mar 6, 2026
ff1e221
Merge PR #47 (with conflicts resolved)
is0692vs Mar 6, 2026
9c48474
Address review feedback across merged PRs
is0692vs Mar 6, 2026
09b67c6
Fix lint typing in year route tests
is0692vs Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
860 changes: 860 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,20 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"server-only": "^0.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
}
}
12 changes: 12 additions & 0 deletions src/app/api/card/[username]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,16 @@ describe("GET /api/card/[username] cache headers", () => {
expect(response.status).toBe(404);
expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60, stale-while-revalidate=120");
});

it("uses short cache header on error", async () => {
const { fetchCardData } = await import("@/lib/cardDataFetcher");
vi.mocked(fetchCardData).mockRejectedValueOnce(new Error("API Error"));

const { GET } = await import("./route");
const req = new Request("http://localhost/api/card/erroruser");
const response = await GET(req, { params: Promise.resolve({ username: "erroruser" }) });

expect(response.status).toBe(503);
expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60, stale-while-revalidate=120");
});
});
124 changes: 124 additions & 0 deletions src/app/api/dashboard/year/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { type Session } from "next-auth";

import type { YearInReviewData } from "@/lib/types";

const mockSession: Session = {
user: { name: "Alice", email: "alice@example.com", image: "", login: "alice" },
accessToken: "token",
expires: new Date(Date.now() + 2 * 86400 * 1000).toISOString(),
};
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));

vi.mock("@/lib/auth", () => ({
authOptions: {},
}));

vi.mock("@/lib/githubViewer", () => ({
fetchViewerLogin: vi.fn(),
}));

vi.mock("@/lib/githubYearInReview", () => ({
fetchYearInReviewData: vi.fn(),
}));

function createMockRequest(url: string): NextRequest {
return new NextRequest(url);
}

describe("GET /api/dashboard/year validation", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("returns 401 when not authorized", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(null);

const { GET } = await import("./route");
const req = createMockRequest("http://localhost/api/dashboard/year");
const response = await GET(req);

expect(response.status).toBe(401);
});

it("returns 400 when year is invalid (not a number)", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);

const { GET } = await import("./route");
const req = createMockRequest("http://localhost/api/dashboard/year?year=abc");
const response = await GET(req);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Invalid year");
});

it("returns 400 when year is before 2008", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);

const { GET } = await import("./route");
const req = createMockRequest("http://localhost/api/dashboard/year?year=2007");
const response = await GET(req);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Invalid year");
});

it("returns 400 when year is in the future", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);

const { GET } = await import("./route");
const currentYear = new Date().getUTCFullYear();
const req = createMockRequest(`http://localhost/api/dashboard/year?year=${currentYear + 1}`);
const response = await GET(req);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Invalid year");
});

it("returns 200 and fetches data when year is valid", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);

const { fetchYearInReviewData } = await import("@/lib/githubYearInReview");
vi.mocked(fetchYearInReviewData).mockResolvedValueOnce({ data: "ok" } as unknown as YearInReviewData);

const { GET } = await import("./route");
const currentYear = new Date().getUTCFullYear();
const req = createMockRequest(`http://localhost/api/dashboard/year?year=${currentYear}`);
const response = await GET(req);

expect(response.status).toBe(200);
const data = await response.json();
expect(data).toEqual({ data: "ok" });
expect(fetchYearInReviewData).toHaveBeenCalledWith("alice", currentYear, "token");
});

it("returns 200 and falls back to current year when year is not provided", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);

const { fetchYearInReviewData } = await import("@/lib/githubYearInReview");
vi.mocked(fetchYearInReviewData).mockResolvedValueOnce({ data: "ok" } as unknown as YearInReviewData);

const { GET } = await import("./route");
const req = createMockRequest(`http://localhost/api/dashboard/year`);
const response = await GET(req);

expect(response.status).toBe(200);
const data = await response.json();
expect(data).toEqual({ data: "ok" });

const currentYear = new Date().getUTCFullYear();
expect(fetchYearInReviewData).toHaveBeenCalledWith("alice", currentYear, "token");
});
});
155 changes: 155 additions & 0 deletions src/components/ShareButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @vitest-environment jsdom
*/
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import ShareButtons from "./ShareButtons";

describe("ShareButtons", () => {
let originalClipboard: Navigator["clipboard"] | undefined;
let originalExecCommand: (commandId: string, showUI?: boolean, value?: string) => boolean;
let originalLocation: Location;

beforeEach(() => {
originalClipboard = navigator.clipboard;
originalExecCommand = document.execCommand;
originalLocation = window.location;

Object.defineProperty(window, "location", {
value: { origin: "http://localhost", href: "http://localhost/johndoe" },
writable: true,
});

vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
Object.assign(navigator, { clipboard: originalClipboard });
document.execCommand = originalExecCommand;

Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});

it("uses document.execCommand as fallback when navigator.clipboard.writeText fails", async () => {
// 1. Mock clipboard.writeText to reject
const writeTextMock = vi.fn().mockRejectedValue(new Error("Not allowed"));
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});

// 2. Mock execCommand
const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;

// 3. Spy on document.createElement, document.body.appendChild, and document.body.removeChild
// to verify the full fallback flow
const createElementSpy = vi.spyOn(document, "createElement");
const appendChildSpy = vi.spyOn(document.body, "appendChild");
const removeChildSpy = vi.spyOn(document.body, "removeChild");

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });

fireEvent.click(copyButton);

await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe");
});

await waitFor(() => {
expect(createElementSpy).toHaveBeenCalledWith("textarea");

// Find the appendChild call that appends the textarea (since React might also call appendChild)
const textareaAppendCall = appendChildSpy.mock.calls.find(
(call) => (call[0] as HTMLElement).tagName === "TEXTAREA"
);

expect(textareaAppendCall).toBeDefined();
if (textareaAppendCall) {
const appendedNode = textareaAppendCall[0] as HTMLTextAreaElement;
expect(appendedNode.value).toBe("http://localhost/johndoe");

expect(execCommandMock).toHaveBeenCalledWith("copy");

// Verify removeChild was called with the same element
expect(removeChildSpy).toHaveBeenCalledWith(appendedNode);
}
});

// Clear out React's state updates
await act(async () => {
vi.advanceTimersByTime(2500);
});
});

it("uses navigator.clipboard.writeText when available and successful", async () => {
// Mock clipboard.writeText to succeed
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});

const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });

fireEvent.click(copyButton);

await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe");
});

// Fallback should not be triggered
expect(execCommandMock).not.toHaveBeenCalled();

// Clear out React's state updates
await act(async () => {
vi.advanceTimersByTime(2500);
});
});

it("shows 'Copied!' feedback after copying", async () => {
// Mock clipboard.writeText to succeed
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});

render(<ShareButtons username="johndoe" />);

const copyButton = screen.getByRole("button", { name: "Copy profile URL" });

fireEvent.click(copyButton);

// Check if the button text changes
await waitFor(() => {
expect(screen.getByText("Copied!")).toBeDefined();
});

// Fast forward time
await act(async () => {
vi.advanceTimersByTime(2500);
});

// Check if the button text changes back
await waitFor(() => {
expect(screen.getByText("Copy URL")).toBeDefined();
});
});
});
56 changes: 2 additions & 54 deletions src/components/ThemeController.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,13 @@
"use client";

import { useEffect } from "react";
import { FastAverageColor } from "fast-average-color";
import { adjustAccentColor } from "@/lib/color";
import { useThemeColor } from "@/hooks/useThemeColor";

type Props = {
avatarUrl?: string;
topLanguageColor?: string;
};

export default function ThemeController({ avatarUrl, topLanguageColor }: Props) {
useEffect(() => {
// 1. Apply top language color immediately as a fallback/initial state
if (topLanguageColor) {
applyColor(topLanguageColor);
}

const fac = new FastAverageColor();
let isMounted = true;

// 2. Extract color from avatar asynchronously
if (avatarUrl) {
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = avatarUrl;

// Use getColorAsync to extract color
fac.getColorAsync(img, {
algorithm: 'dominant', // 'dominant' or 'simple' (average)
})
.then((color) => {
if (isMounted) {
// color.value is [r, g, b, a]
applyColor(color.value.slice(0, 3) as [number, number, number]);
}
})
.catch((e) => {
console.warn("Failed to extract color from avatar, keeping fallback color.", e);
});
}

// Cleanup: Reset to default theme colors on unmount
return () => {
isMounted = false;
fac.destroy();
resetColor();
};
}, [avatarUrl, topLanguageColor]);

useThemeColor({ avatarUrl, topLanguageColor });
return null;
}

function applyColor(color: string | [number, number, number]) {
const result = adjustAccentColor(color);
document.documentElement.style.setProperty("--accent", result.accent);
document.documentElement.style.setProperty("--accent-rgb", result.accentRgb);
document.documentElement.style.setProperty("--accent-hover", result.accentHover);
}

function resetColor() {
document.documentElement.style.removeProperty("--accent");
document.documentElement.style.removeProperty("--accent-rgb");
document.documentElement.style.removeProperty("--accent-hover");
}
Loading
Loading