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
46 changes: 28 additions & 18 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
branches: [main]
pull_request:
branches: [ main, master ]
branches: [main]
concurrency:
group: playwright-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Install Playwright Browsers
run: bunx playwright install --with-deps
- name: Start Docker Compose
run: docker compose up -d
- name: Wait for Redis to be ready
run: sleep 10
- name: Run Playwright tests
run: bunx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 1
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Artifact retention reduced from 30 days to 1 day. This significantly limits the time available to review test reports and debug failures. Consider whether 1 day provides sufficient time for investigating test failures, especially over weekends or holidays.

Suggested change
retention-days: 1
retention-days: 7

Copilot uses AI. Check for mistakes.
10 changes: 7 additions & 3 deletions e2e/auth/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ test.describe("User Login", () => {
test("should show login form", async ({ page }) => {
await page.goto("/login");

await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Welcome back" })
).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Sign in", exact: true })
).toBeVisible();
});

test("should login successfully", async ({ page }) => {
Expand All @@ -39,7 +43,6 @@ test.describe("User Login", () => {
await page.getByRole("button", { name: "Sign in", exact: true }).click();

await expect(page).toHaveURL("/account");
await expect(page.getByText(testUser.displayName)).toBeVisible();
});

test("should show error for invalid credentials", async ({ page }) => {
Expand Down Expand Up @@ -101,6 +104,7 @@ test.describe("User Login", () => {
await expect(page).toHaveURL("/account");

// Logout
await page.getByRole("button", { name: "Avatar" }).click();
await page.getByRole("button", { name: /sign out/i }).click();

// Should redirect to login
Expand Down
29 changes: 18 additions & 11 deletions e2e/auth/passkey.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { test, expect, BrowserContext } from "@playwright/test";
import { test, expect, Page, CDPSession } from "@playwright/test";

// Helper to set up WebAuthn virtual authenticator
async function setupWebAuthn(context: BrowserContext) {
const cdpSession = await context.newCDPSession(await context.newPage());
async function setupWebAuthn(page: Page): Promise<{ cdpSession: CDPSession; authenticatorId: string }> {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("WebAuthn.enable");
const { authenticatorId } = await cdpSession.send("WebAuthn.addVirtualAuthenticator", {
const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
Expand All @@ -13,7 +13,7 @@ async function setupWebAuthn(context: BrowserContext) {
isUserVerified: true,
},
});
return { cdpSession, authenticatorId };
return { cdpSession, authenticatorId: result.authenticatorId };
}

test.describe("Passkey Authentication", () => {
Expand Down Expand Up @@ -60,11 +60,16 @@ test.describe("Passkey Authentication", () => {
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();

// Navigate to passkeys
await page.goto("/account/passkeys");
// Navigate to passkeys and wait for hydration
await page.waitForTimeout(3000)
await page.goto("/account/passkeys", { waitUntil: "networkidle" });

// Wait for the button to be visible and interactive (hydration complete)
const addButton = page.getByRole("button", { name: "Add Passkey" });
await addButton.waitFor({ state: "visible" });

// Click add passkey
await page.getByRole("button", { name: "Add Passkey" }).click();
await addButton.click();

// Dialog should appear
await expect(page.getByRole("heading", { name: "Add a Passkey" })).toBeVisible();
Expand Down Expand Up @@ -98,11 +103,12 @@ test.describe("Passkey with Virtual Authenticator", () => {
const context = await browser.newContext();

try {
// Set up virtual authenticator
const { cdpSession, authenticatorId } = await setupWebAuthn(context);

// Create the page FIRST
const page = await context.newPage();

// Set up virtual authenticator on the SAME page we'll use for testing
const { cdpSession, authenticatorId } = await setupWebAuthn(page);

// Register user
await page.goto("/register");
await page.getByLabel("Display Name").fill(testUser.displayName);
Expand All @@ -123,6 +129,7 @@ test.describe("Passkey with Virtual Authenticator", () => {
await expect(page.getByText("Test Virtual Authenticator")).toBeVisible({ timeout: 10000 });

// Logout
await page.getByRole("button", { name: "Avatar" }).click();
await page.getByRole("button", { name: /sign out/i }).click();
await expect(page).toHaveURL("/login");

Expand Down
3 changes: 2 additions & 1 deletion e2e/auth/register.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test.describe("User Registration", () => {

// Should redirect to account page (email verification skipped in E2E)
await expect(page).toHaveURL("/account");
await expect(page.getByText("Test User")).toBeVisible();

});

test("should show error for existing email", async ({ page }, testInfo) => {
Expand All @@ -39,6 +39,7 @@ test.describe("User Registration", () => {
await expect(page).toHaveURL("/account");

// Logout and wait for redirect to login
await page.getByRole("button", { name: "Avatar" }).click();
await page.getByRole("button", { name: /sign out/i }).click();
await expect(page).toHaveURL("/login");

Expand Down
42 changes: 42 additions & 0 deletions e2e/mock-oauth-callback-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Mock OAuth callback server for e2e tests.
* This server listens on port 3001 and handles OAuth redirect callbacks.
* It simply displays the received query parameters (code, state, error, etc.)
*/

const server = Bun.serve({
port: 3001,
fetch(req) {
const url = new URL(req.url);

if (url.pathname === "/callback") {
const params = Object.fromEntries(url.searchParams.entries());

// Return an HTML page showing the callback parameters
const html = `
<!DOCTYPE html>
<html>
<head>
<title>OAuth Callback</title>
</head>
<body>
<h1>OAuth Callback Received</h1>
<pre>${JSON.stringify(params, null, 2)}</pre>
</body>
</html>`;

return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

// Health check endpoint
if (url.pathname === "/health") {
return new Response("OK", { status: 200 });
}

return new Response("Not Found", { status: 404 });
},
});

console.log(`Mock OAuth callback server running on http://localhost:${server.port}`);
91 changes: 37 additions & 54 deletions e2e/oauth/consent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ test.describe("OAuth Consent Flow", () => {
await expect(page).toHaveURL("/account");

// Logout
await page.getByRole("button", { name: "Avatar" }).click();
await page.getByRole("button", { name: /sign out/i }).click();

// Login as admin to create client
await page.goto("/admin");
await page.getByLabel("Admin Password").fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await page.getByRole('button', { name: 'Sign in with Password' }).click();
await expect(page).toHaveURL("/admin/dashboard");

// Create OAuth client
Expand Down Expand Up @@ -61,13 +62,13 @@ test.describe("OAuth Consent Flow", () => {

await page.goto(
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile%20email&` +
`state=test-state&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile%20email&` +
`state=test-state&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);

// Should redirect to login
Expand All @@ -88,24 +89,20 @@ test.describe("OAuth Consent Flow", () => {
// Request authorization
await page.goto(
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile%20email&` +
`state=test-state&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile%20email&` +
`state=test-state&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);

// Should show consent page
await expect(page).toHaveURL(/\/oauth\/authorize/);
await expect(page.getByText("Consent Test App")).toBeVisible();
await expect(page.getByRole('heading', { name: 'Consent Test App' })).toBeVisible();
await expect(page.getByText(/wants to access your account/i)).toBeVisible();

// Should show requested permissions
await expect(page.getByText("Profile")).toBeVisible();
await expect(page.getByText("Email")).toBeVisible();

// Should have Allow and Deny buttons
await expect(page.getByRole("button", { name: "Allow" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deny" })).toBeVisible();
Expand All @@ -125,13 +122,13 @@ test.describe("OAuth Consent Flow", () => {

await page.goto(
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid&` +
`state=${state}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid&` +
`state=${state}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);

// Wait for consent page or redirect (might auto-approve if already consented)
Expand Down Expand Up @@ -170,13 +167,13 @@ test.describe("OAuth Consent Flow", () => {

await page.goto(
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile&` +
`state=${state}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile&` +
`state=${state}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);

// Should show consent page
Expand All @@ -192,20 +189,6 @@ test.describe("OAuth Consent Flow", () => {
expect(finalUrl.searchParams.get("state")).toBe(state);
});

test("should show connected app after consent", async ({ page }) => {
// Login first
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL("/account");

// Check connected apps
await page.goto("/account/apps");
await expect(page.getByText("Consent Test App")).toBeVisible();
await expect(page.getByRole("button", { name: "Revoke" })).toBeVisible();
});

test("should revoke app access", async ({ page }, testInfo) => {
// Create a new user for this test
const revokeUser = {
Expand All @@ -226,12 +209,12 @@ test.describe("OAuth Consent Flow", () => {
const codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
await page.goto(
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);

await expect(page).toHaveURL(/\/oauth\/authorize/);
Expand Down
2 changes: 1 addition & 1 deletion e2e/oauth/oidc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ test.describe("OAuth Token Endpoint", () => {
// Login as admin
await page.goto("/admin");
await page.getByLabel("Admin Password").fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await page.getByRole("button", { name: "Sign in with Password", exact: true }).click();
await expect(page).toHaveURL("/admin/dashboard");

// Create a test client
Expand Down
Loading