Skip to content

Commit fdd9b5d

Browse files
authored
[draft] ui testing (#154)
* feat(e2e): start implementing E2E tests * chore(user-management): relax rate limiting, log it, and do it by client IP * chore(ui-testing): Trigger in workflow
1 parent d5cbc40 commit fdd9b5d

27 files changed

Lines changed: 1513 additions & 5 deletions

.github/workflows/docker-compose-integration.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,41 @@ jobs:
4646
- name: Test all service endpoints
4747
run: mise run test:services
4848

49+
- name: Setup Node.js for E2E tests
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: '22'
53+
cache: 'npm'
54+
cache-dependency-path: e2e/package-lock.json
55+
56+
- name: Install Playwright dependencies
57+
working-directory: e2e
58+
run: npm ci
59+
60+
- name: Install Playwright browsers
61+
working-directory: e2e
62+
run: npx playwright install --with-deps chromium
63+
64+
- name: Create e2e .env file
65+
working-directory: e2e
66+
run: |
67+
echo "BASE_URL=http://localhost:8080" > .env
68+
echo "CI=true" >> .env
69+
70+
- name: Run E2E tests
71+
working-directory: e2e
72+
run: npx playwright test
73+
env:
74+
CI: true
75+
76+
- name: Upload Playwright report on failure
77+
uses: actions/upload-artifact@v4
78+
if: failure()
79+
with:
80+
name: playwright-report
81+
path: e2e/playwright-report/
82+
retention-days: 7
83+
4984
- name: Cleanup
5085
if: always()
5186
run: mise run compose:down

.github/workflows/e2e-tests.yml

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: E2E Tests
22

33
permissions:
44
contents: read
5+
id-token: write # Required for AWS OIDC authentication
56

67
on:
78
# Manual trigger for development iteration
@@ -19,6 +20,10 @@ on:
1920
branches:
2021
- main
2122

23+
env:
24+
AWS_REGION: eu-west-1
25+
ENV: dev
26+
2227
jobs:
2328
e2e-tests:
2429
runs-on: ubuntu-latest
@@ -28,5 +33,60 @@ jobs:
2833
github.event.workflow_run.conclusion == 'success'
2934
3035
steps:
31-
- name: E2E Tests Placeholder
32-
run: echo "TODO"
36+
- name: Checkout code
37+
uses: actions/checkout@v4
38+
39+
- name: Configure AWS credentials
40+
uses: aws-actions/configure-aws-credentials@v4
41+
with:
42+
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
43+
role-session-name: GitHub_E2E_Tests
44+
aws-region: ${{ env.AWS_REGION }}
45+
46+
- name: Get deployment URL from CDK stack
47+
id: get-url
48+
run: |
49+
BASE_URL=$(aws cloudformation describe-stacks \
50+
--stack-name "StickerlandiaSharedResources-${{ env.ENV }}" \
51+
--query "Stacks[0].Outputs[?OutputKey=='BaseUrl'].OutputValue" \
52+
--output text)
53+
echo "BASE_URL=${BASE_URL}" >> $GITHUB_OUTPUT
54+
echo "Deployment URL: ${BASE_URL}"
55+
56+
- name: Setup Node.js
57+
uses: actions/setup-node@v4
58+
with:
59+
node-version: '22'
60+
cache: 'npm'
61+
cache-dependency-path: e2e/package-lock.json
62+
63+
- name: Install Playwright dependencies
64+
working-directory: e2e
65+
run: npm ci
66+
67+
- name: Install Playwright browsers
68+
working-directory: e2e
69+
run: npx playwright install --with-deps chromium
70+
71+
- name: Run E2E tests against AWS
72+
working-directory: e2e
73+
run: npx playwright test
74+
env:
75+
CI: true
76+
BASE_URL: ${{ steps.get-url.outputs.BASE_URL }}
77+
78+
- name: Upload Playwright report
79+
uses: actions/upload-artifact@v4
80+
if: always()
81+
with:
82+
name: playwright-report
83+
path: e2e/playwright-report/
84+
retention-days: 7
85+
86+
- name: Upload test results
87+
uses: actions/upload-artifact@v4
88+
if: failure()
89+
with:
90+
name: test-results
91+
path: e2e/test-results/
92+
retention-days: 7

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@
1010
.beads
1111
cdk.context.json
1212
cdk.out
13+
14+
# E2E test artifacts
15+
e2e/node_modules/
16+
e2e/.auth/
17+
e2e/test-results/
18+
e2e/playwright-report/
19+
e2e/blob-report/
20+
e2e/.env

e2e/.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# E2E Test Configuration
2+
# Copy this file to .env to override defaults
3+
4+
# Target deployment URL (default: http://localhost:8080)
5+
BASE_URL=http://localhost:8080
6+
7+
# Test user credentials (defaults from README - can be overridden)
8+
# TEST_USER_EMAIL=user@stickerlandia.com
9+
# TEST_USER_PASSWORD=Stickerlandia2025!
10+
11+
# CI mode (set automatically in CI environments)
12+
CI=false

e2e/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# E2E Tests
2+
3+
Playwright-based end-to-end tests for Stickerlandia. Can be pointed at any deployment.
4+
5+
## Test Suites
6+
7+
| Suite | Description |
8+
|-------|-------------|
9+
| `public` | Landing page, public dashboard |
10+
| `auth` | Login and registration flows |
11+
| `authenticated` | Protected routes (dashboard, collection, sticker detail) |
12+
| `api` | Health checks, sticker catalogue endpoints |
13+
14+
## Running Tests
15+
16+
```bash
17+
# Against local Docker Compose
18+
mise run compose:ui-test
19+
20+
# Against AWS (uses DEPLOYMENT_HOST_URL from .env)
21+
mise run aws:ui-test
22+
23+
# Interactive mode
24+
mise run compose:ui-test:ui
25+
26+
# Against custom URL
27+
BASE_URL=https://example.com npx playwright test
28+
```
29+
30+
## Configuration
31+
32+
Copy `.env.example` to `.env`:
33+
34+
```bash
35+
BASE_URL=http://localhost:8080
36+
TEST_USER_EMAIL=user@stickerlandia.com
37+
TEST_USER_PASSWORD=Stickerlandia2025!
38+
```
39+
40+
## Structure
41+
42+
```
43+
e2e/
44+
├── tests/
45+
│ ├── auth/ # Login, registration, logout
46+
│ ├── authenticated/ # Protected route tests
47+
│ ├── public/ # Public page tests
48+
│ └── api/ # API endpoint tests
49+
├── fixtures/ # Shared test helpers
50+
├── page-objects/ # Page object models
51+
└── playwright.config.ts
52+
```

e2e/fixtures/auth.fixture.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { test as base, expect, type Page } from '@playwright/test';
2+
import { LOGIN_SELECTORS, waitForTokenProcessing } from './selectors';
3+
4+
// Extend the base test with custom fixtures
5+
export const test = base.extend<{
6+
authenticatedPage: Page;
7+
}>({
8+
authenticatedPage: async ({ browser }, use) => {
9+
// Create a new context with stored auth state
10+
const context = await browser.newContext({
11+
storageState: '.auth/user.json',
12+
});
13+
const page = await context.newPage();
14+
await use(page);
15+
await context.close();
16+
},
17+
});
18+
19+
export { expect };
20+
21+
/**
22+
* Helper function to perform login via the OIDC flow.
23+
* Used by auth setup and login tests.
24+
*
25+
* Note: The assertions in this function are sanity check assertions - they verify
26+
* that the login flow elements are present and functional. If these fail, it
27+
* indicates an infrastructure or IdP issue rather than a test-specific failure.
28+
*/
29+
export async function performLogin(page: Page): Promise<void> {
30+
const baseUrl = process.env.BASE_URL || 'http://localhost:8080';
31+
const testEmail = process.env.TEST_USER_EMAIL || 'user@stickerlandia.com';
32+
const testPassword = process.env.TEST_USER_PASSWORD || 'Stickerlandia2025!';
33+
34+
console.log(`[performLogin] Starting login flow to ${baseUrl}`);
35+
36+
// Navigate to home page
37+
await page.goto(baseUrl);
38+
await page.waitForLoadState('networkidle');
39+
console.log(`[performLogin] Home page loaded`);
40+
41+
// Click the "Sign In" button in the header to initiate login
42+
const signInButton = page.getByRole('button', { name: /sign in/i });
43+
await expect(signInButton).toBeVisible({ timeout: 10000 });
44+
await expect(signInButton).toBeEnabled({ timeout: 5000 });
45+
await signInButton.click();
46+
console.log(`[performLogin] Clicked Sign In button`);
47+
48+
// Wait for navigation away from home page
49+
await page.waitForURL((url) => url.href !== baseUrl && url.href !== `${baseUrl}/`, {
50+
timeout: 30000,
51+
});
52+
console.log(`[performLogin] Navigated to: ${page.url()}`);
53+
54+
// Wait for the page to fully load (the login form is server-rendered)
55+
await page.waitForLoadState('networkidle');
56+
await page.waitForLoadState('domcontentloaded');
57+
console.log(`[performLogin] Page loaded, looking for email input`);
58+
59+
// Define the email input locator
60+
const emailInput = page.locator(LOGIN_SELECTORS.emailInput).first();
61+
62+
// Wait for login form with longer timeout - the server-rendered page can be slow
63+
try {
64+
await expect(emailInput).toBeVisible({ timeout: 20000 });
65+
console.log(`[performLogin] Email input visible`);
66+
} catch {
67+
// Check if we were redirected back with a token (already authenticated)
68+
const currentUrl = page.url();
69+
console.log(`[performLogin] Email input not found. Current URL: ${currentUrl}`);
70+
if (currentUrl.includes('access_token=') || currentUrl.includes('/dashboard')) {
71+
console.log(`[performLogin] Already authenticated, skipping login`);
72+
// Already authenticated, skip login
73+
if (currentUrl.includes('access_token=')) {
74+
await page.waitForFunction(
75+
() => !window.location.search.includes('access_token'),
76+
{ timeout: 15000 }
77+
);
78+
}
79+
if (!page.url().includes('/dashboard')) {
80+
await page.goto(`${baseUrl}/dashboard`);
81+
}
82+
await page.waitForLoadState('networkidle');
83+
await page.waitForSelector('main#main', { timeout: 10000 });
84+
return;
85+
}
86+
// Re-throw with more context
87+
throw new Error(`Login form did not appear within timeout. Current URL: ${page.url()}`);
88+
}
89+
90+
// Fill in credentials
91+
console.log(`[performLogin] Filling credentials for ${testEmail}`);
92+
await emailInput.fill(testEmail);
93+
94+
const passwordInput = page.locator(LOGIN_SELECTORS.passwordInput).first();
95+
await expect(passwordInput).toBeVisible({ timeout: 5000 });
96+
await passwordInput.fill(testPassword);
97+
98+
// Click submit
99+
const submitButton = page.locator(LOGIN_SELECTORS.submitButton).first();
100+
await expect(submitButton).toBeVisible({ timeout: 5000 });
101+
await submitButton.click();
102+
console.log(`[performLogin] Clicked submit button`);
103+
104+
// Wait for redirect back to the app - either with token or to dashboard
105+
await page.waitForURL(
106+
(url) => {
107+
const href = url.href;
108+
return (
109+
href.includes('access_token=') ||
110+
href.includes('/dashboard') ||
111+
(href.includes('localhost:8080') && !href.includes('/api/users'))
112+
);
113+
},
114+
{ timeout: 30000 }
115+
);
116+
console.log(`[performLogin] Redirected to: ${page.url()}`);
117+
118+
// Handle token URL processing
119+
if (page.url().includes('access_token=')) {
120+
console.log(`[performLogin] Waiting for token to be processed`);
121+
await waitForTokenProcessing(page);
122+
console.log(`[performLogin] Token processed, URL now: ${page.url()}`);
123+
}
124+
125+
// Give the app time to process auth state
126+
await page.waitForTimeout(1000);
127+
128+
// Navigate to dashboard if not already there
129+
if (!page.url().includes('/dashboard')) {
130+
console.log(`[performLogin] Navigating to dashboard`);
131+
await page.goto(`${baseUrl}/dashboard`);
132+
}
133+
134+
await page.waitForLoadState('networkidle');
135+
console.log(`[performLogin] On dashboard, waiting for content`);
136+
137+
// Wait for actual dashboard content (not just the hidden main element)
138+
// The dashboard shows "Welcome Back" when user data is loaded
139+
await page.getByText(/Welcome Back/i).waitFor({ state: 'visible', timeout: 15000 });
140+
console.log(`[performLogin] Login complete - dashboard content visible`);
141+
}
142+
143+
// Helper to check if user is logged in
144+
export async function isLoggedIn(page: Page): Promise<boolean> {
145+
try {
146+
await page.goto('/dashboard');
147+
await page.waitForURL(/\/dashboard/, { timeout: 5000 });
148+
return true;
149+
} catch {
150+
return false;
151+
}
152+
}

0 commit comments

Comments
 (0)