Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

# testing
/coverage
/test-results/
/playwright-report/

# next.js
/.next/
Expand Down
16 changes: 16 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

123 changes: 123 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"playwright-report/**",
"test-results/**",
]),
]);

Expand Down
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
});
26 changes: 5 additions & 21 deletions src/app/my-tickets/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -323,6 +317,7 @@ export default function MyTicketsPage() {
View on-chain (ticket #{ticket.chainTicketId})
<ExternalLink size={12} aria-hidden="true" />
</a>
)}
{ticket.status === 'VALID' ? (
<div className="mt-3">
<TicketQr value={ticket.qrSecret} />
Expand Down Expand Up @@ -367,10 +362,7 @@ export default function MyTicketsPage() {
)}

{activeAction?.ticketId === ticket.id && activeAction.type === 'transfer' && (
<form
onSubmit={(e) => handleTransfer(e, ticket.id)}
className="mt-3 flex gap-2"
>
<div className="mt-3 flex gap-2">
<label htmlFor={`transfer-email-${ticket.id}`} className="sr-only">
Recipient email
</label>
Expand Down Expand Up @@ -410,17 +402,11 @@ export default function MyTicketsPage() {
size="sm"
>
{busyTicketId === ticket.id ? 'Sending…' : 'Confirm'}
<Button type="submit" loading={busyTicketId === ticket.id} size="sm">
{busyTicketId === ticket.id ? 'Sending…' : 'Send'}
</Button>
</form>
</div>
)}
{activeAction?.ticketId === ticket.id && activeAction.type === 'resell' && (
<div className="mt-3 flex flex-wrap gap-2">
<form
onSubmit={(e) => handleListForResale(e, ticket.id)}
className="mt-3 flex gap-2"
>
<label htmlFor={`resale-price-${ticket.id}`} className="sr-only">
Asking price
</label>
Expand All @@ -440,7 +426,6 @@ export default function MyTicketsPage() {
disabled={!isValidResalePrice}
size="sm"
>
<Button type="submit" loading={busyTicketId === ticket.id} size="sm">
{busyTicketId === ticket.id ? 'Listing…' : 'List'}
</Button>
{maxResalePrice(ticket.ticketType?.price, ticket.event?.maxResaleMultiplierBps) !==
Expand All @@ -451,7 +436,6 @@ export default function MyTicketsPage() {
</p>
)}
</div>
</form>
)}
</li>
))}
Expand Down
Loading