diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9ccd769 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - run: yarn install --frozen-lockfile + + - run: npx playwright install --with-deps chromium + + - run: yarn test + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + update-snapshots: + if: failure() + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - run: yarn install --frozen-lockfile + + - run: npx playwright install --with-deps chromium + + - run: yarn test:update-snapshots + + - name: Commit updated snapshots + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add e2e/ + git diff --staged --quiet || (git commit -m "Update Playwright snapshots" && git push) diff --git a/.gitignore b/.gitignore index e189b5f..7c18094 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ yarn-error.log .env.local -.idea \ No newline at end of file +.idea +.claude + +/test-results/ +/playwright-report/ \ No newline at end of file diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..bb4fbde --- /dev/null +++ b/e2e/home.spec.ts @@ -0,0 +1,37 @@ +import {test, expect} from '@playwright/test' + +test.describe('Home page', () => { + test.beforeEach(async ({page}) => { + await page.goto('/') + }) + + test('renders the welcome heading', async ({page}) => { + await expect(page.getByRole('heading', {name: /Hi! I'm Timo/})).toBeVisible() + }) + + test('renders the tagline', async ({page}) => { + await expect(page.getByText(/I live in NYC/)).toBeVisible() + }) + + test('renders the profile image', async ({page}) => { + const img = page.getByAltText("Timo's face") + await expect(img).toBeVisible() + }) + + test('has navigation with site title', async ({page}) => { + await expect(page.getByRole('link', {name: 'Timo M. Staudinger'})).toBeVisible() + }) + + test('has link to Uses page', async ({page}) => { + await expect(page.getByRole('link', {name: 'Uses'})).toBeVisible() + }) + + test('renders social links', async ({page}) => { + await expect(page.locator('a[href*="github.com"]')).toBeVisible() + await expect(page.locator('a[href*="linkedin.com"]')).toBeVisible() + }) + + test('visual regression', async ({page}) => { + await expect(page).toHaveScreenshot('home.png', {maxDiffPixelRatio: 0.01}) + }) +}) diff --git a/e2e/home.spec.ts-snapshots/home-chromium.png b/e2e/home.spec.ts-snapshots/home-chromium.png new file mode 100644 index 0000000..1729ca8 Binary files /dev/null and b/e2e/home.spec.ts-snapshots/home-chromium.png differ diff --git a/e2e/home.spec.ts-snapshots/home-mobile.png b/e2e/home.spec.ts-snapshots/home-mobile.png new file mode 100644 index 0000000..9e6b70c Binary files /dev/null and b/e2e/home.spec.ts-snapshots/home-mobile.png differ diff --git a/e2e/ice-auth.spec.ts b/e2e/ice-auth.spec.ts new file mode 100644 index 0000000..bba6969 --- /dev/null +++ b/e2e/ice-auth.spec.ts @@ -0,0 +1,29 @@ +import {test, expect} from '@playwright/test' + +test.describe('ICE auth page', () => { + test.beforeEach(async ({page}) => { + await page.goto('/ice/auth') + }) + + test('renders the auth form', async ({page}) => { + await expect(page.getByRole('heading', {name: /In Case of Emergency/})).toBeVisible() + await expect(page.getByPlaceholder('Password')).toBeVisible() + await expect(page.getByRole('button', {name: 'Continue'})).toBeVisible() + }) + + test('password field is numeric input mode', async ({page}) => { + const input = page.getByPlaceholder('Password') + await expect(input).toHaveAttribute('type', 'password') + await expect(input).toHaveAttribute('inputmode', 'numeric') + }) + + test('rejects invalid PIN', async ({page}) => { + await page.getByPlaceholder('Password').fill('0000') + await page.getByRole('button', {name: 'Continue'}).click() + await page.waitForURL(/\/ice\/auth/) + }) + + test('visual regression', async ({page}) => { + await expect(page).toHaveScreenshot('ice-auth.png', {maxDiffPixelRatio: 0.01}) + }) +}) diff --git a/e2e/ice-auth.spec.ts-snapshots/ice-auth-chromium.png b/e2e/ice-auth.spec.ts-snapshots/ice-auth-chromium.png new file mode 100644 index 0000000..157ef6a Binary files /dev/null and b/e2e/ice-auth.spec.ts-snapshots/ice-auth-chromium.png differ diff --git a/e2e/ice-auth.spec.ts-snapshots/ice-auth-mobile.png b/e2e/ice-auth.spec.ts-snapshots/ice-auth-mobile.png new file mode 100644 index 0000000..ab30f8c Binary files /dev/null and b/e2e/ice-auth.spec.ts-snapshots/ice-auth-mobile.png differ diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..1055c14 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,22 @@ +import {test, expect} from '@playwright/test' + +test.describe('Navigation', () => { + test('navigate from home to uses', async ({page}) => { + await page.goto('/') + await page.getByRole('link', {name: 'Uses'}).click() + await expect(page).toHaveURL('/uses') + await expect(page.getByRole('heading', {name: 'Uses'})).toBeVisible() + }) + + test('navigate from uses back to home via title', async ({page}) => { + await page.goto('/uses') + await page.getByRole('link', {name: 'Timo M. Staudinger'}).click() + await expect(page).toHaveURL('/') + await expect(page.getByRole('heading', {name: /Hi! I'm Timo/})).toBeVisible() + }) + + test('404 page for unknown routes', async ({page}) => { + const response = await page.goto('/nonexistent-page') + expect(response?.status()).toBe(404) + }) +}) diff --git a/e2e/uses.spec.ts b/e2e/uses.spec.ts new file mode 100644 index 0000000..06bbf80 --- /dev/null +++ b/e2e/uses.spec.ts @@ -0,0 +1,37 @@ +import {test, expect} from '@playwright/test' + +test.describe('Uses page', () => { + test.beforeEach(async ({page}) => { + await page.goto('/uses') + }) + + test('renders the page header', async ({page}) => { + await expect(page.getByRole('heading', {name: 'Uses'})).toBeVisible() + await expect(page.getByText(/Software and Hardware/)).toBeVisible() + }) + + test('renders editor section', async ({page}) => { + await expect(page.getByRole('heading', {name: 'Editor'})).toBeVisible() + await expect(page.getByRole('link', {name: 'Visual Studio Code'})).toBeVisible() + }) + + test('renders hardware section', async ({page}) => { + await expect(page.getByRole('heading', {name: 'Hardware'})).toBeVisible() + }) + + test('renders desktop apps section', async ({page}) => { + await expect(page.getByRole('heading', {name: 'Desktop Apps'})).toBeVisible() + }) + + test('external links open correctly', async ({page}) => { + const vscodeLink = page.getByRole('link', {name: 'Visual Studio Code'}) + await expect(vscodeLink).toHaveAttribute('href', 'https://code.visualstudio.com/') + }) + + test('visual regression', async ({page}) => { + await expect(page).toHaveScreenshot('uses.png', { + maxDiffPixelRatio: 0.01, + fullPage: true, + }) + }) +}) diff --git a/e2e/uses.spec.ts-snapshots/uses-chromium.png b/e2e/uses.spec.ts-snapshots/uses-chromium.png new file mode 100644 index 0000000..3cc083b Binary files /dev/null and b/e2e/uses.spec.ts-snapshots/uses-chromium.png differ diff --git a/e2e/uses.spec.ts-snapshots/uses-mobile.png b/e2e/uses.spec.ts-snapshots/uses-mobile.png new file mode 100644 index 0000000..c3ffd73 Binary files /dev/null and b/e2e/uses.spec.ts-snapshots/uses-mobile.png differ diff --git a/package.json b/package.json index 1e455a4..ce38007 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "test": "playwright test", + "test:update-snapshots": "playwright test --update-snapshots" }, "dependencies": { "@vercel/analytics": "^2.0.1", @@ -27,6 +29,7 @@ }, "devDependencies": { "@next/eslint-plugin-next": "^16.2.1", + "@playwright/test": "^1.58.2", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/react": "^19.2.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5388d06 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import {defineConfig, devices} from '@playwright/test' + +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 ? 'github' : 'html', + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + { + name: 'mobile', + use: {...devices['Pixel 7']}, + }, + ], + webServer: { + command: 'yarn build && yarn start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/yarn.lock b/yarn.lock index 1d02b0e..17d3212 100644 --- a/yarn.lock +++ b/yarn.lock @@ -502,6 +502,13 @@ resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== +"@playwright/test@^1.58.2": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -1678,6 +1685,11 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" @@ -2790,6 +2802,20 @@ picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"