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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,50 @@ npm test
# End-to-end tests (requires Chromium)
npx playwright install --with-deps chromium
npm run test:e2e

### E2E Test Suite (Playwright)

DevTrack ships a Playwright-based end-to-end suite that covers the full user journey — from OAuth sign-in through to dashboard rendering and API route correctness. **No real GitHub or Supabase credentials are needed**; all external calls are mocked inside the specs via `page.route()`.

#### Spec files

| File | What it covers |
|------|----------------|
| `e2e/auth.spec.ts` | Landing page loads, "Sign in with GitHub" button visible, OAuth redirect fires, unauthenticated dashboard redirects |
| `e2e/dashboard.spec.ts` | Dashboard renders all 6 widgets after mock login, no uncaught console errors |
| `e2e/goals.spec.ts` | Create goal → POST fires with correct payload → goal appears in list; delete goal → removed from list |
| `e2e/streak.spec.ts` | Streak widget shows numeric current/longest values, freeze button visible and triggers API call |
| `e2e/api.spec.ts` | `/api/metrics/contributions` returns 200 with valid session, 401 without; `/api/goals` POST returns 401 without session |

The existing smoke specs (`e2e/landing.spec.js`, `e2e/auth-bypass.spec.js`, etc.) remain untouched.

#### Running locally

```bash
# 1. Install Playwright browsers (one-time)
npx playwright install --with-deps chromium

# 2. Run the full suite (dev server auto-starts on port 3002)
npm run test:e2e

# 3. Run a single spec file
npx playwright test e2e/goals.spec.ts

# 4. Open the interactive UI runner
npx playwright test --ui

# 5. View the HTML report after a run
npx playwright show-report
\```

The test server is configured in `playwright.config.mjs`. It auto-starts `next dev` on `http://127.0.0.1:3002` with placeholder credentials so no `.env.local` is required for E2E runs.

#### CI

E2E tests run automatically on every pull request targeting `main` via `.github/workflows/e2e.yml`. The job builds the Next.js app in standalone mode, installs Chromium, runs the suite, and uploads the Playwright HTML report as an artifact retained for 7 days.
```

4. Everything else in the README stays exactly as it is. The rest of the file — Docker setup, Roadmap, Contributing, Sponsors, etc. — don't touch.
```

---
Expand Down
157 changes: 157 additions & 0 deletions e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";

/**
* api.spec.ts
* Covers: /api/metrics/contributions returns 200 with valid session;
* 401 (or redirect) without a session. Other critical API route checks.
*
* All assertions use Playwright's APIRequestContext so they hit the actual
* Next.js route handlers — no mocking of the routes under test.
*/

const AUTH_SECRET =
process.env.NEXTAUTH_SECRET ?? "test-nextauth-secret-for-playwright-tests";

/** Build a signed next-auth session cookie value. */
async function buildSessionCookie(): Promise<string> {
return encode({
secret: AUTH_SECRET,
token: {
name: "Playwright User",
email: "playwright@devtrack.test",
sub: "99001",
githubLogin: "playwright-user",
githubId: "99001",
accessToken: "mock-access-token",
},
maxAge: 60 * 60,
});
}

test("[API E2E] /api/metrics/contributions returns 401 without a session", async ({
request,
}) => {
const res = await request.get("/api/metrics/contributions");
// Without a session, the route must reject — 401 or a redirect (302→/).
expect([401, 302, 403]).toContain(res.status());
});

test("[API E2E] /api/goals returns 401 without a session", async ({
request,
}) => {
const res = await request.get("/api/goals");
expect([401, 302, 403]).toContain(res.status());
});

test("[API E2E] /api/metrics/streak returns 401 without a session", async ({
request,
}) => {
const res = await request.get("/api/metrics/streak");
expect([401, 302, 403]).toContain(res.status());
});

test("[API E2E] /api/metrics/contributions returns 200 with valid session cookie", async ({
page,
request,
}) => {
const sessionToken = await buildSessionCookie();

// Add the signed cookie to the browser context.
await page.context().addCookies([
{
name: "next-auth.session-token",
value: sessionToken,
domain: "127.0.0.1",
path: "/",
httpOnly: true,
sameSite: "Lax",
secure: false,
expires: Math.floor(Date.now() / 1000) + 60 * 60,
},
]);

// Mock the NextAuth session verify call so the API handler resolves the user.
await page.route("**/api/auth/session**", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
user: { name: "Playwright User", email: "playwright@devtrack.test" },
githubLogin: "playwright-user",
githubId: "99001",
accessToken: "mock-access-token",
expires: "2099-01-01T00:00:00.000Z",
}),
})
);

// Use the same browser context's fetch so the cookie is sent.
const res = await page.evaluate(async () => {
const r = await fetch("/api/metrics/contributions?days=7");
return { status: r.status, ok: r.ok };
});

// With a valid session the route must respond 200.
expect(res.status).toBe(200);
expect(res.ok).toBe(true);
});

test("[API E2E] /api/auth/session returns a JSON object", async ({
request,
}) => {
const res = await request.get("/api/auth/session");
expect(res.status()).toBe(200);
const body = await res.json();
// An unauthenticated session is an empty object {}, never null/undefined.
expect(typeof body).toBe("object");
});

test("[API E2E] /api/goals POST without session returns 401 or 403", async ({
request,
}) => {
const res = await request.post("/api/goals", {
data: { title: "Hack the planet", target: 1, unit: "commits", recurrence: "none" },
});
expect([401, 403]).toContain(res.status());
});

test("[API E2E] /api/metrics/contributions with days param returns valid JSON when authenticated", async ({
page,
}) => {
const sessionToken = await buildSessionCookie();

await page.context().addCookies([
{
name: "next-auth.session-token",
value: sessionToken,
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**", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
user: { name: "Playwright User", email: "playwright@devtrack.test" },
githubLogin: "playwright-user",
githubId: "99001",
accessToken: "mock-access-token",
expires: "2099-01-01T00:00:00.000Z",
}),
})
);

const result = await page.evaluate(async () => {
const r = await fetch("/api/metrics/contributions?days=30");
const body = await r.json();
return { status: r.status, bodyType: typeof body };
});

expect(result.status).toBe(200);
expect(result.bodyType).toBe("object");
});
77 changes: 77 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";

/**
* auth.spec.ts
* Covers: landing page loads, "Sign in with GitHub" button present,
* OAuth redirect fires, and unauthenticated dashboard protection.
*/

test("[Auth E2E] landing page loads with H1 heading", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page).toHaveTitle(/DevTrack/i);
});

test("[Auth E2E] Sign in with GitHub button is visible on landing", async ({
page,
}) => {
await page.goto("/");
const signInBtn = page
.getByRole("link", { name: /sign in with github/i })
.first();
await expect(signInBtn).toBeVisible();
});

test("[Auth E2E] Sign in with GitHub button points to NextAuth GitHub provider", async ({
page,
}) => {
await page.goto("/");
const signInBtn = page
.getByRole("link", { name: /sign in with github/i })
.first();
await expect(signInBtn).toHaveAttribute(
"href",
/\/api\/auth\/signin\/github/
);
});

test("[Auth E2E] OAuth redirect fires when Sign in link is clicked", async ({
page,
}) => {
// Mock the GitHub OAuth endpoint so we don't need real credentials.
await page.route("**/api/auth/signin/github**", async (route) => {
await route.fulfill({
status: 302,
headers: { Location: "https://github.com/login/oauth/authorize?mock=1" },
});
});

await page.goto("/");
const signInBtn = page
.getByRole("link", { name: /sign in with github/i })
.first();

const [response] = await Promise.all([
page.waitForResponse("**/api/auth/signin/github**"),
signInBtn.click(),
]);

// The mock returns 302 — confirm the redirect was triggered.
expect([200, 302]).toContain(response.status());
});

test("[Auth E2E] /dashboard redirects unauthenticated users to landing page", async ({
page,
}) => {
await page.goto("/dashboard", { waitUntil: "load" });
await expect(page).toHaveURL(/\/$/, { timeout: 10_000 });
});

test("[Auth E2E] landing page shows DevTrack feature section", async ({
page,
}) => {
await page.goto("/");
// Features section or a recognised feature keyword must be present.
const features = page.locator("#features");
await expect(features).toBeVisible();
});
Loading
Loading