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
35 changes: 35 additions & 0 deletions .github/workflows/docker-compose-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,41 @@ jobs:
- name: Test all service endpoints
run: mise run test:services

- name: Setup Node.js for E2E tests
Comment thread
jeastham1993 marked this conversation as resolved.
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: e2e/package-lock.json

- name: Install Playwright dependencies
working-directory: e2e
run: npm ci

- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium

- name: Create e2e .env file
working-directory: e2e
run: |
echo "BASE_URL=http://localhost:8080" > .env
echo "CI=true" >> .env

- name: Run E2E tests
working-directory: e2e
run: npx playwright test
env:
CI: true

- name: Upload Playwright report on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 7

- name: Cleanup
if: always()
run: mise run compose:down
64 changes: 62 additions & 2 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: E2E Tests

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This runs the suite after every service completes its AWS deploy. I think this is a bit OTT, but I can't see an easy way of saying "when all the deploys are done", without having one big mega-job that everything is tied into.

Open to suggestions here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this works, how long do the tests take to run? I assume not very long.

permissions:
contents: read
id-token: write # Required for AWS OIDC authentication

on:
# Manual trigger for development iteration
Expand All @@ -19,6 +20,10 @@ on:
branches:
- main

env:
AWS_REGION: eu-west-1
ENV: dev

jobs:
e2e-tests:
runs-on: ubuntu-latest
Expand All @@ -28,5 +33,60 @@ jobs:
github.event.workflow_run.conclusion == 'success'

steps:
- name: E2E Tests Placeholder
run: echo "TODO"
- name: Checkout code
uses: actions/checkout@v4

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
role-session-name: GitHub_E2E_Tests
aws-region: ${{ env.AWS_REGION }}

- name: Get deployment URL from CDK stack
id: get-url
run: |
BASE_URL=$(aws cloudformation describe-stacks \
--stack-name "StickerlandiaSharedResources-${{ env.ENV }}" \
--query "Stacks[0].Outputs[?OutputKey=='BaseUrl'].OutputValue" \
--output text)
echo "BASE_URL=${BASE_URL}" >> $GITHUB_OUTPUT
echo "Deployment URL: ${BASE_URL}"

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: e2e/package-lock.json

- name: Install Playwright dependencies
working-directory: e2e
run: npm ci

- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium

- name: Run E2E tests against AWS
working-directory: e2e
run: npx playwright test
env:
CI: true
BASE_URL: ${{ steps.get-url.outputs.BASE_URL }}

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 7

- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: e2e/test-results/
retention-days: 7
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@
.beads
cdk.context.json
cdk.out

# E2E test artifacts
e2e/node_modules/
e2e/.auth/
e2e/test-results/
e2e/playwright-report/
e2e/blob-report/
e2e/.env
12 changes: 12 additions & 0 deletions e2e/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# E2E Test Configuration
# Copy this file to .env to override defaults

# Target deployment URL (default: http://localhost:8080)
BASE_URL=http://localhost:8080

# Test user credentials (defaults from README - can be overridden)
# TEST_USER_EMAIL=user@stickerlandia.com
# TEST_USER_PASSWORD=Stickerlandia2025!

# CI mode (set automatically in CI environments)
CI=false
52 changes: 52 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# E2E Tests

Playwright-based end-to-end tests for Stickerlandia. Can be pointed at any deployment.

## Test Suites

| Suite | Description |
|-------|-------------|
| `public` | Landing page, public dashboard |
| `auth` | Login and registration flows |
| `authenticated` | Protected routes (dashboard, collection, sticker detail) |
| `api` | Health checks, sticker catalogue endpoints |

## Running Tests

```bash
# Against local Docker Compose
mise run compose:ui-test

# Against AWS (uses DEPLOYMENT_HOST_URL from .env)
mise run aws:ui-test

# Interactive mode
mise run compose:ui-test:ui

# Against custom URL
BASE_URL=https://example.com npx playwright test
```

## Configuration

Copy `.env.example` to `.env`:

```bash
BASE_URL=http://localhost:8080
TEST_USER_EMAIL=user@stickerlandia.com
TEST_USER_PASSWORD=Stickerlandia2025!
```

## Structure

```
e2e/
├── tests/
│ ├── auth/ # Login, registration, logout
│ ├── authenticated/ # Protected route tests
│ ├── public/ # Public page tests
│ └── api/ # API endpoint tests
├── fixtures/ # Shared test helpers
├── page-objects/ # Page object models
└── playwright.config.ts
```
152 changes: 152 additions & 0 deletions e2e/fixtures/auth.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { test as base, expect, type Page } from '@playwright/test';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A fixture to get us into a logged in state

import { LOGIN_SELECTORS, waitForTokenProcessing } from './selectors';

// Extend the base test with custom fixtures
export const test = base.extend<{
authenticatedPage: Page;
}>({
authenticatedPage: async ({ browser }, use) => {
// Create a new context with stored auth state
const context = await browser.newContext({
storageState: '.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});

export { expect };

/**
* Helper function to perform login via the OIDC flow.
* Used by auth setup and login tests.
*
* Note: The assertions in this function are sanity check assertions - they verify
* that the login flow elements are present and functional. If these fail, it
* indicates an infrastructure or IdP issue rather than a test-specific failure.
*/
export async function performLogin(page: Page): Promise<void> {
const baseUrl = process.env.BASE_URL || 'http://localhost:8080';
const testEmail = process.env.TEST_USER_EMAIL || 'user@stickerlandia.com';
const testPassword = process.env.TEST_USER_PASSWORD || 'Stickerlandia2025!';

console.log(`[performLogin] Starting login flow to ${baseUrl}`);

// Navigate to home page
await page.goto(baseUrl);
await page.waitForLoadState('networkidle');
console.log(`[performLogin] Home page loaded`);

// Click the "Sign In" button in the header to initiate login
const signInButton = page.getByRole('button', { name: /sign in/i });
await expect(signInButton).toBeVisible({ timeout: 10000 });
await expect(signInButton).toBeEnabled({ timeout: 5000 });
await signInButton.click();
console.log(`[performLogin] Clicked Sign In button`);

// Wait for navigation away from home page
await page.waitForURL((url) => url.href !== baseUrl && url.href !== `${baseUrl}/`, {
timeout: 30000,
});
console.log(`[performLogin] Navigated to: ${page.url()}`);

// Wait for the page to fully load (the login form is server-rendered)
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');
console.log(`[performLogin] Page loaded, looking for email input`);

// Define the email input locator
const emailInput = page.locator(LOGIN_SELECTORS.emailInput).first();

// Wait for login form with longer timeout - the server-rendered page can be slow
try {
await expect(emailInput).toBeVisible({ timeout: 20000 });
console.log(`[performLogin] Email input visible`);
} catch {
// Check if we were redirected back with a token (already authenticated)
const currentUrl = page.url();
console.log(`[performLogin] Email input not found. Current URL: ${currentUrl}`);
if (currentUrl.includes('access_token=') || currentUrl.includes('/dashboard')) {
console.log(`[performLogin] Already authenticated, skipping login`);
// Already authenticated, skip login
if (currentUrl.includes('access_token=')) {
await page.waitForFunction(
() => !window.location.search.includes('access_token'),
{ timeout: 15000 }
);
}
if (!page.url().includes('/dashboard')) {
await page.goto(`${baseUrl}/dashboard`);
}
await page.waitForLoadState('networkidle');
await page.waitForSelector('main#main', { timeout: 10000 });
return;
}
// Re-throw with more context
throw new Error(`Login form did not appear within timeout. Current URL: ${page.url()}`);
}

// Fill in credentials
console.log(`[performLogin] Filling credentials for ${testEmail}`);
await emailInput.fill(testEmail);

const passwordInput = page.locator(LOGIN_SELECTORS.passwordInput).first();
await expect(passwordInput).toBeVisible({ timeout: 5000 });
await passwordInput.fill(testPassword);

// Click submit
const submitButton = page.locator(LOGIN_SELECTORS.submitButton).first();
await expect(submitButton).toBeVisible({ timeout: 5000 });
await submitButton.click();
console.log(`[performLogin] Clicked submit button`);

// Wait for redirect back to the app - either with token or to dashboard
await page.waitForURL(
(url) => {
const href = url.href;
return (
href.includes('access_token=') ||
href.includes('/dashboard') ||
(href.includes('localhost:8080') && !href.includes('/api/users'))
);
},
{ timeout: 30000 }
);
console.log(`[performLogin] Redirected to: ${page.url()}`);

// Handle token URL processing
if (page.url().includes('access_token=')) {
console.log(`[performLogin] Waiting for token to be processed`);
await waitForTokenProcessing(page);
console.log(`[performLogin] Token processed, URL now: ${page.url()}`);
}

// Give the app time to process auth state
await page.waitForTimeout(1000);

// Navigate to dashboard if not already there
if (!page.url().includes('/dashboard')) {
console.log(`[performLogin] Navigating to dashboard`);
await page.goto(`${baseUrl}/dashboard`);
}

await page.waitForLoadState('networkidle');
console.log(`[performLogin] On dashboard, waiting for content`);

// Wait for actual dashboard content (not just the hidden main element)
// The dashboard shows "Welcome Back" when user data is loaded
await page.getByText(/Welcome Back/i).waitFor({ state: 'visible', timeout: 15000 });
console.log(`[performLogin] Login complete - dashboard content visible`);
}

// Helper to check if user is logged in
export async function isLoggedIn(page: Page): Promise<boolean> {
try {
await page.goto('/dashboard');
await page.waitForURL(/\/dashboard/, { timeout: 5000 });
return true;
} catch {
return false;
}
}
Loading
Loading