diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js new file mode 100644 index 00000000..4737408f --- /dev/null +++ b/frontend/e2e/app.spec.js @@ -0,0 +1,114 @@ +import { test, expect } from '@playwright/test'; + +test.describe('TipStream Landing Page', () => { + test('loads and shows hero section with branding', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('nav').first()).toBeVisible(); + await expect(page.getByText('TipStream').first()).toBeVisible(); + }); + + test('shows Connect Wallet button when not authenticated', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('button', { name: /Connect Wallet/i })).toBeVisible(); + }); + + test('hero section has a Get Started call-to-action', async ({ page }) => { + await page.goto('/'); + const cta = page.getByRole('button', { name: /Get Started|Connect/i }).first(); + await expect(cta).toBeVisible(); + }); + + test('displays network indicator in header', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText(/Mainnet|Testnet|Devnet/i)).toBeVisible(); + }); + + test('footer contains branding and links', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('footer')).toBeVisible(); + await expect(page.getByText('TipStream').first()).toBeVisible(); + await expect(page.locator('footer a[href*="github"]').first()).toBeVisible(); + }); +}); + +test.describe('Theme Toggle', () => { + test('theme toggle button is accessible', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: /Switch to (dark|light) mode/i }); + await expect(toggle).toBeVisible(); + }); + + test('clicking theme toggle changes appearance', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: /Switch to (dark|light) mode/i }); + await toggle.click(); + // After toggle, the button label should change + await expect( + page.getByRole('button', { name: /Switch to (dark|light) mode/i }) + ).toBeVisible(); + }); +}); + +test.describe('Responsive Layout', () => { + test('renders correctly on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/'); + await expect(page.locator('nav').first()).toBeVisible(); + await expect(page.getByRole('button', { name: /Connect Wallet/i })).toBeVisible(); + }); + + test('renders correctly on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/'); + await expect(page.locator('nav').first()).toBeVisible(); + }); + + test('renders correctly on desktop viewport', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await expect(page.locator('nav').first()).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); +}); + +test.describe('Page Performance', () => { + test('page loads within acceptable time', async ({ page }) => { + const start = Date.now(); + await page.goto('/', { waitUntil: 'networkidle' }); + const loadTime = Date.now() - start; + expect(loadTime).toBeLessThan(10000); // 10s max + }); + + test('no console errors on initial load', async ({ page }) => { + const errors = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + await page.goto('/', { waitUntil: 'networkidle' }); + // Filter out expected network errors (API calls to Hiro may fail in test env) + const realErrors = errors.filter( + e => !e.includes('api.hiro.so') && !e.includes('fetch') && !e.includes('Failed to load') + ); + expect(realErrors).toHaveLength(0); + }); +}); + +test.describe('Accessibility Basics', () => { + test('page has navigation landmark', async ({ page }) => { + await page.goto('/'); + const navCount = await page.locator('nav').count(); + expect(navCount).toBeGreaterThanOrEqual(1); + }); + + test('buttons have accessible names', async ({ page }) => { + await page.goto('/'); + const buttons = page.getByRole('button'); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const name = await buttons.nth(i).getAttribute('aria-label') || + await buttons.nth(i).textContent(); + expect(name?.trim().length).toBeGreaterThan(0); + } + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e4999b7..a2ca78f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -2666,6 +2667,22 @@ ], "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -9599,6 +9616,53 @@ "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1635b945..9d7d3ed2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 00000000..c5d89046 --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30000, + retries: 1, + use: { + baseURL: 'http://localhost:4173', + headless: true, + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + webServer: { + command: 'npx vite preview --port 4173', + port: 4173, + reuseExistingServer: true, + timeout: 30000, + }, +});