diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da8950f..5023eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,10 @@ jobs: - name: Build run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e + diff --git a/.gitignore b/.gitignore index 7b8da95..9ea0a34 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/test-results/ +/playwright-report/ # next.js /.next/ diff --git a/docs/TESTING.md b/docs/TESTING.md index 56a2bbb..6be8bc5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,3 +15,19 @@ snapshot tests. For UI changes, run `npm run dev` and manually exercise the affected page — see the root README for the golden-path flows to check. + +## End-to-End Tests + +Browser-level smoke tests for public pages use [Playwright](https://playwright.dev/): + +```bash +npm run build +npm run test:e2e +``` + +The test suite exercises: +- Home page hero and FAQ sections +- Navbar responsive collapsing into the mobile menu at 390px viewport width +- Form submission error handling on `/login` and `/register` with mocked API responses +- Unauthenticated access redirect from `/dashboard` to `/login` + diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..f8794c7 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Public pages smoke tests', () => { + test('/ renders the hero and FAQ', async ({ page }) => { + await page.goto('/'); + + // Hero section checks + const hero = page.locator('section').first(); + const heroHeading = hero.getByRole('heading', { level: 1 }); + await expect(heroHeading).toBeVisible(); + await expect(heroHeading).toContainText("Tickets that can't be"); + + await expect( + hero.getByRole('link', { name: 'Start issuing tickets' }), + ).toBeVisible(); + await expect( + hero.getByRole('link', { name: 'Browse the marketplace' }), + ).toBeVisible(); + + // FAQ section checks + const faqHeading = page.getByRole('heading', { name: 'Frequently asked' }); + await expect(faqHeading).toBeVisible(); + + const faqQuestion = page.getByText( + 'Do I need to know anything about crypto to use this?', + ); + await expect(faqQuestion).toBeVisible(); + + // Opening an FAQ item reveals the answer + await faqQuestion.click(); + await expect( + page.getByText('To browse events and read ticket data, no.'), + ).toBeVisible(); + }); + + test('the navbar collapses into the mobile menu at 390px width', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + // Desktop nav container is hidden on small screens + const desktopNavLinks = page.locator('header nav .md\\:flex'); + for (const locator of await desktopNavLinks.all()) { + await expect(locator).toBeHidden(); + } + + // Mobile menu toggle button is visible + const menuToggle = page.getByRole('button', { name: 'Open menu' }); + await expect(menuToggle).toBeVisible(); + + // Click to open mobile menu + await menuToggle.click(); + + // Menu toggle changes label to Close menu + const closeToggle = page.getByRole('button', { name: 'Close menu' }); + await expect(closeToggle).toBeVisible(); + + // Navigation links in the mobile menu are visible + const header = page.locator('header'); + await expect(header.getByRole('link', { name: 'About' })).toBeVisible(); + await expect(header.getByRole('link', { name: 'Security' })).toBeVisible(); + await expect(header.getByRole('link', { name: 'Marketplace' })).toBeVisible(); + await expect(header.getByRole('link', { name: 'Log in' })).toBeVisible(); + await expect(header.getByRole('link', { name: 'Get started' })).toBeVisible(); + }); + + test('/login submits a mocked API failure and shows the error', async ({ + page, + }) => { + await page.route('**/auth/login', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Invalid email or password' }), + }); + }); + + await page.goto('/login'); + + await page.getByLabel('Email').fill('user@example.com'); + await page.getByLabel('Password').fill('wrongpassword'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect( + page.getByText('Invalid email or password'), + ).toBeVisible(); + }); + + test('/register submits a mocked API failure and shows the error', async ({ + page, + }) => { + await page.route('**/auth/register', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ message: 'Email already registered' }), + }); + }); + + await page.goto('/register'); + + await page.getByLabel('Name').fill('Test User'); + await page.getByLabel('Email').fill('existing@example.com'); + await page.getByLabel('Password').fill('validPassword123'); + await page.getByRole('button', { name: 'Create account' }).click(); + + await expect( + page.getByText('Email already registered'), + ).toBeVisible(); + }); + + test('unauthenticated visit to /dashboard lands on /login', async ({ + page, + }) => { + await page.goto('/dashboard'); + + await expect(page).toHaveURL(/\/login/); + await expect( + page.getByRole('heading', { name: 'Welcome back' }), + ).toBeVisible(); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..5cddeb1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "playwright-report/**", + "test-results/**", ]), ]); diff --git a/package-lock.json b/package-lock.json index 4791f30..033eefc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/qrcode": "^1.5.6", @@ -1494,6 +1495,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -6578,6 +6595,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index 5799df7..70b2a7b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@stellar/freighter-api": "^6.0.1", @@ -22,6 +23,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/qrcode": "^1.5.6", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..15c7879 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.PORT || 3000; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'list' : 'html', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run start', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000', + }, + }, +}); diff --git a/src/app/my-tickets/page.tsx b/src/app/my-tickets/page.tsx index 401c1b8..c969ca0 100644 --- a/src/app/my-tickets/page.tsx +++ b/src/app/my-tickets/page.tsx @@ -1,13 +1,12 @@ 'use client'; -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useState } from 'react'; import { Eye, EyeOff, ExternalLink } from 'lucide-react'; import { useAuth } from '@/lib/auth-context'; import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch, ApiError } from '@/lib/api'; import { signAndSubmit } from '@/lib/onchain'; import { ticketingContractUrl } from '@/lib/event-details'; -import type { Ticket } from '@/lib/types'; import { INDUSTRY_LABELS, type Ticket } from '@/lib/types'; import { formatEventDate, maxResalePrice } from '@/lib/event-details'; import { FormError } from '@/components/form-error'; @@ -166,8 +165,6 @@ export default function MyTicketsPage() { } async function handleTransfer(ticketId: string, recipient: TransferRecipient) { - async function handleTransfer(e: FormEvent, ticketId: string) { - e.preventDefault(); const wallet = requireWallet(); if (!wallet) return; setError(null); @@ -198,11 +195,8 @@ export default function MyTicketsPage() { } async function handleListForResale(ticket: Ticket) { - const ticketId = ticket.id; - async function handleListForResale(ticketId: string) { if (!/^[1-9]\d*$/.test(resalePrice)) return; - async function handleListForResale(e: FormEvent, ticketId: string) { - e.preventDefault(); + const ticketId = ticket.id; const wallet = requireWallet(); if (!wallet) return; const cap = maxResalePrice(ticket.ticketType?.price, ticket.event?.maxResaleMultiplierBps); @@ -323,6 +317,7 @@ export default function MyTicketsPage() { View on-chain (ticket #{ticket.chainTicketId}) + )} {ticket.status === 'VALID' ? (