Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: E2E

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
workflow_dispatch:

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
playwright:
name: Playwright smoke tests
runs-on: ubuntu-latest
env:
NEXTAUTH_SECRET: playwright-placeholder-secret-that-is-long-enough
NEXTAUTH_URL: http://127.0.0.1:3000
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000
GITHUB_ID: playwright-github-id
GITHUB_SECRET: playwright-github-secret
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key
SUPABASE_SERVICE_ROLE_KEY: placeholder-service-role-key
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install app dependencies
run: npm ci

- name: Install Playwright browsers
run: npx -y @playwright/test@1.49.1 install --with-deps chromium

- name: Run Playwright tests
run: npx -y @playwright/test@1.49.1 test

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
200 changes: 200 additions & 0 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";

const authSecret = "playwright-placeholder-secret-that-is-long-enough";

test.beforeEach(async ({ page }) => {
await page.context().addCookies([
{
name: "next-auth.session-token",
value: await encode({
secret: authSecret,
token: {
name: "Playwright User",
email: "playwright@example.com",
sub: "12345",
githubLogin: "playwright-user",
githubId: "12345",
accessToken: "test-token",
},
maxAge: 60 * 60,
}),
domain: "127.0.0.1",
path: "/",
httpOnly: true,
sameSite: "Lax",
secure: false,
expires: Math.floor(Date.now() / 1000) + 60 * 60,
},
]);

await page.route("**/api/auth/session", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
user: { name: "Playwright User", email: "playwright@example.com" },
githubLogin: "playwright-user",
githubId: "12345",
accessToken: "test-token",
expires: "2099-01-01T00:00:00.000Z",
}),
});
});

await page.route("**/api/user/settings", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ is_public: true }),
});
});

await page.route("**/api/metrics/contributions**", async (route) => {
const url = new URL(route.request().url());
const days = Number(url.searchParams.get("days") ?? 30);
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
"2026-05-16": days >= 7 ? 3 : 1,
"2026-05-17": 5,
"2026-05-18": 2,
},
}),
});
});

await page.route("**/api/goals", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
contentType: "application/json",
status: 201,
body: JSON.stringify({ ok: true }),
});
return;
}

await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
goals: [
{
id: "goal-1",
title: "Make 10 commits",
target: 10,
current: 4,
unit: "commits",
recurrence: "weekly",
period_start: "2026-05-18",
},
],
}),
});
});

const metricRoutes = [
"**/api/metrics/prs**",
"**/api/metrics/pr-breakdown**",
"**/api/metrics/issues**",
"**/api/metrics/repos**",
"**/api/metrics/languages**",
"**/api/metrics/streak**",
"**/api/metrics/pinned-repos**",
"**/api/metrics/weekly-summary**",
"**/api/metrics/compare**",
"**/api/metrics/repo-health**",
"**/api/streak/freeze**",
"**/api/user/github-accounts**",
];

for (const pattern of metricRoutes) {
await page.route(pattern, async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(mockMetricResponse(route.request().url())),
});
});
}
});

test("dashboard widgets render with mocked metrics", async ({ page }) => {
await page.goto("/dashboard");

await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Commit Activity" })).toBeVisible();
await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Weekly Goals" })).toBeVisible();
await expect(page.getByText("Make 10 commits")).toBeVisible();
});

test("contribution graph range buttons request a new range", async ({ page }) => {
const contributionRequests = [];
page.on("request", (request) => {
if (request.url().includes("/api/metrics/contributions")) {
contributionRequests.push(request.url());
}
});

await page.goto("/dashboard");
await page.getByRole("button", { name: "Show 90-day range" }).click();

await expect.poll(() => contributionRequests.some((url) => url.includes("days=90"))).toBe(true);
});

test("goal form posts a new goal", async ({ page }) => {
const goalPosts = [];
page.on("request", (request) => {
if (request.url().endsWith("/api/goals") && request.method() === "POST") {
goalPosts.push(request.postDataJSON());
}
});

await page.goto("/dashboard");
await page.getByLabel("Goal title").fill("Ship one PR");
await page.getByLabel("Target").fill("1");
await page.getByLabel("Unit").fill("PR");
await page.getByRole("button", { name: "Add goal" }).click();

await expect.poll(() => goalPosts).toHaveLength(1);
expect(goalPosts[0]).toMatchObject({
title: "Ship one PR",
target: 1,
unit: "PR",
});
});

function mockMetricResponse(url) {
if (url.includes("/api/metrics/prs")) {
return { open: 2, merged: 8, avgReviewHours: 6, mergeRate: "80%" };
}
if (url.includes("/api/metrics/pr-breakdown")) {
return { merged: 8, open: 2, closed: 1 };
}
if (url.includes("/api/metrics/issues")) {
return { opened: 4, closed: 3, open: 1 };
}
if (url.includes("/api/metrics/repos") || url.includes("/api/metrics/pinned-repos")) {
return { repos: [{ name: "demo/repo", commits: 12, url: "https://github.com/demo/repo" }] };
}
if (url.includes("/api/metrics/languages")) {
return { languages: [{ language: "TypeScript", count: 12 }] };
}
if (url.includes("/api/metrics/streak")) {
return { current: 3, longest: 9, lastCommitDate: "2026-05-18", totalActiveDays: 12 };
}
if (url.includes("/api/metrics/weekly-summary")) {
return { commits: 10, pullRequests: 3, mergedPullRequests: 2 };
}
if (url.includes("/api/metrics/compare")) {
return { user: { commits: 10 }, friend: { commits: 8 } };
}
if (url.includes("/api/metrics/repo-health")) {
return { repositories: [] };
}
if (url.includes("/api/streak/freeze")) {
return { freezes: [] };
}
if (url.includes("/api/user/github-accounts")) {
return { accounts: [] };
}
return {};
}
21 changes: 21 additions & 0 deletions e2e/landing.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";

test("landing page renders GitHub sign-in entrypoint", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("heading", { name: "DevTrack" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Sign in with GitHub" }),
).toHaveAttribute("href", /\/api\/auth\/signin\/github\?callbackUrl=\/dashboard/);
await expect(page.getByRole("link", { name: "View on GitHub" })).toHaveAttribute(
"href",
"https://github.com/Priyanshu-byte-coder/devtrack",
);
});

test("dashboard stays protected for unauthenticated users", async ({ page }) => {
await page.goto("/dashboard");

await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toBeVisible();
});
13 changes: 13 additions & 0 deletions e2e/public-profile.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";

test("public profile route renders without requiring authentication", async ({ page }) => {
await page.goto("/u/playwright-user");

await expect(page).toHaveURL(/\/u\/playwright-user$/);
await expect(
page.getByRole("heading", {
name: /(@playwright-user's Profile|Profile Not Found)/,
}),
).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toHaveCount(0);
});
44 changes: 44 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineConfig, devices } from "@playwright/test";

const PORT = Number(process.env.PORT ?? 3000);
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`;

export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: {
timeout: 8_000,
},
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: {
command: `node node_modules/next/dist/bin/next dev -H 127.0.0.1 -p ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NEXTAUTH_SECRET: "playwright-placeholder-secret-that-is-long-enough",
NEXTAUTH_URL: baseURL,
NEXT_PUBLIC_APP_URL: baseURL,
GITHUB_ID: "playwright-github-id",
GITHUB_SECRET: "playwright-github-secret",
NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder-anon-key",
SUPABASE_SERVICE_ROLE_KEY: "placeholder-service-role-key",
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});