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
62 changes: 62 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: E2E Tests

on:
pull_request:
branches: [main]

jobs:
playwright:
name: Playwright E2E (Desktop + Mobile)
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: client/package-lock.json

- name: Install dependencies
working-directory: client
run: npm ci

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

- name: Verify preview deployment URL is available
env:
E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }}
run: |
if [ -z "$E2E_BASE_URL" ]; then
echo "Missing E2E_BASE_URL secret. Set it to your Vercel preview deployment URL pattern for PR testing."
exit 1
fi

- name: Run Playwright tests
working-directory: client
env:
CI: true
E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }}
run: npx playwright test

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

- name: Upload failure screenshots and traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: client/test-results/
retention-days: 14
29 changes: 29 additions & 0 deletions client/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { loginViaApi, makeTestUser, signupViaApi } from './helpers';

test('user can sign up and log in', async ({ browser }) => {
const user = makeTestUser();

const signupContext = await browser.newContext();
await signupViaApi(signupContext.request, user);
await signupContext.close();

const loginContext = await browser.newContext();
await loginViaApi(loginContext.request, user);

const page = await loginContext.newPage();
await page.addInitScript(() => {
window.localStorage.setItem('onboarding_completed', 'true');
});

await page.goto('/');

const individualButton = page.getByRole('button', { name: /continue as individual/i });
if (await individualButton.isVisible()) {
await individualButton.click();
}

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

await loginContext.close();
});
77 changes: 77 additions & 0 deletions client/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';

const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'https://backend-ai-sub.onrender.com';

export function makeTestUser() {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return {
email: `e2e+${stamp}@example.com`,
password: 'SecurePass123!',
name: 'E2E Test User',
};
}

export async function signupViaApi(request: APIRequestContext, user: { email: string; password: string; name: string }) {
const response = await request.post(`${API_BASE}/api/auth/signup`, {
data: user,
});

expect(response.ok()).toBeTruthy();
return response;
}

export async function loginViaApi(request: APIRequestContext, user: { email: string; password: string }) {
const response = await request.post(`${API_BASE}/api/auth/login`, {
data: {
email: user.email,
password: user.password,
},
});

expect(response.ok()).toBeTruthy();
return response;
}

export async function bootstrapMockAuthenticatedUi(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('onboarding_completed', 'true');
});

await page.route('**/api/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: {
id: 'e2e-mock-user',
email: '[email protected]',
name: 'E2E User',
},
}),
});
});

await page.goto('/');

const individualButton = page.getByRole('button', { name: /continue as individual/i });
if (await individualButton.isVisible()) {
await individualButton.click();
}

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}

export async function openSubscriptions(page: Page) {
await page.getByRole('button', { name: 'Navigate to Subscriptions' }).click();
await expect(page.getByRole('heading', { name: 'Subscriptions' })).toBeVisible();
}

export async function addCustomSubscription(page: Page, name: string, price: string) {
await openSubscriptions(page);
await page.getByRole('button', { name: /add subscription/i }).click();
await page.getByRole('button', { name: /add custom subscription/i }).click();
await page.getByLabel(/subscription name/i).fill(name);
await page.getByLabel(/monthly price/i).fill(price);
await page.getByRole('button', { name: /add to dashboard/i }).click();
await expect(page.getByText(name).first()).toBeVisible();
}
60 changes: 60 additions & 0 deletions client/e2e/subscription-flows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { addCustomSubscription, bootstrapMockAuthenticatedUi, openSubscriptions } from './helpers';

test.beforeEach(async ({ page }) => {
await bootstrapMockAuthenticatedUi(page);
});

test('user can add a subscription', async ({ page }) => {
const subName = `Playwright Plus ${Date.now()}`;
await addCustomSubscription(page, subName, '15.99');
await expect(page.getByText(subName).first()).toBeVisible();
});

test('user can edit a subscription', async ({ page }) => {
const originalName = `Edit Me ${Date.now()}`;
const updatedName = `${originalName} Updated`;

await addCustomSubscription(page, originalName, '10.00');
await page.getByLabel(`Edit ${originalName}`).click();

await page.getByLabel(/subscription name/i).fill(updatedName);
await page.getByRole('button', { name: /save changes/i }).click();

await expect(page.getByText(updatedName).first()).toBeVisible();
});

test('user can delete a subscription', async ({ page }) => {
const subName = `Delete Me ${Date.now()}`;

await addCustomSubscription(page, subName, '22.00');
await page.getByLabel(`Delete ${subName}`).click();

await expect(page.getByText('Delete subscription?')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();

await expect(page.getByText(subName)).toHaveCount(0);
});

test('notifications are visible in the app', async ({ page }) => {
await page.getByRole('button', { name: /notifications \(/i }).click();
await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible();
await expect(page.getByText('Duplicate Subscription Detected')).toBeVisible();
});

test('user can update settings', async ({ page }) => {
await page.getByRole('button', { name: 'Navigate to Settings' }).click();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();

const budgetInput = page.getByLabel(/monthly budget limit/i);
await budgetInput.fill('777');
await expect(budgetInput).toHaveValue('777');

const weeklySummary = page.getByLabel(/weekly spending summary/i).locator('input[type="checkbox"]');
const wasChecked = await weeklySummary.isChecked();
await weeklySummary.click();
expect(await weeklySummary.isChecked()).toBe(!wasChecked);

await openSubscriptions(page);
await expect(page.getByRole('heading', { name: 'Subscriptions' })).toBeVisible();
});
5 changes: 4 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"start": "next start",
"generate-pwa-icons": "node scripts/generate-pwa-icons.js",
"generate-pwa-screenshot": "node scripts/generate-pwa-screenshot.js",
"generate-pwa-assets": "npm run generate-pwa-icons && npm run generate-pwa-screenshot"
"generate-pwa-assets": "npm run generate-pwa-icons && npm run generate-pwa-screenshot",
"e2e": "npx playwright test",
"e2e:headed": "npx playwright test --headed",
"e2e:report": "npx playwright show-report"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
Expand Down
17 changes: 17 additions & 0 deletions client/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
});