diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 0000000..85bc277 --- /dev/null +++ b/.env.e2e @@ -0,0 +1,44 @@ +# Environment for the full-stack E2E suite (`bun run test:e2e`). +# +# Unlike .env.test (which fakes everything), these tests run against REAL local services: +# - Supabase Auth + Postgres from `supabase start` (API :54321, DB :54322, Inbucket :54324) +# - Minio as a real S3, brought up by tools/e2e.sh (docker compose) +# +# The Supabase publishable/secret keys are NOT set here: tools/e2e.sh injects them from +# `supabase status` at runtime so this file never goes stale against the local CLI's keys. + +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +# Postgres = the database that `supabase start` runs. +PGHOST=127.0.0.1 +PGPORT=54322 +PGUSER=postgres +PGDATABASE=postgres +PGPASSWORD=postgres + +# Real Supabase Auth. Keys (NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY) are injected +# by tools/e2e.sh from `supabase status`. +SUPABASE_IMPLEMENTATION=real +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 + +# Real S3 via Minio (docker/docker-compose.dev.yml). PUBLIC_S3_BASE_URL is path-style: endpoint + +# bucket, since the download route redirects the browser straight to the object. +S3_IMPLEMENTATION=real +S3_ENDPOINT=http://localhost:9000 +PUBLIC_S3_BASE_URL=http://localhost:9000/paradb-maps-e2e +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=minioadmin +S3_ACCESS_KEY_SECRET=minioadmin +S3_MAPS_BUCKET=paradb-maps-e2e + +# Everything else stays faked/local - not under test here. +SENTRY_DSN=sentryDsn +SENTRY_ENV=e2e +AXIOM_IMPLEMENTATION=fake +FLAGS_IMPLEMENTATION=local +FLAGS_EDGE_CONFIG=abc +FLAGS_EDGE_CONFIG_KEY=123 +AXIOM_API_TOKEN=local +AXIOM_DATASET=local +NEXT_PUBLIC_AXIOM_API_TOKEN=local +NEXT_PUBLIC_AXIOM_DATASET=local diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..7de90ea --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,60 @@ +name: E2E + +on: + pull_request: + workflow_dispatch: + +# Cancel superseded runs on the same PR/branch so updates don't queue up. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Cache bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck E2E sources + run: bun run typecheck:e2e + + - name: Install Playwright (Chromium + OS deps) + run: bunx playwright install --with-deps chromium + + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + # Brings up the real Supabase stack (Auth, Postgres, Inbucket) that the E2E suite requires. + # Minio (real S3) and the Next dev server are started by the test script itself. + - name: Start Supabase + run: supabase start + + - name: Run E2E tests + run: bun run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index ffa37b9..6a37106 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ data.ms .env .env.* +# ...but the non-secret, committed local/test/e2e env files are tracked. +!.env.localdev +!.env.test +!.env.e2e # dependencies /node_modules @@ -40,3 +44,9 @@ next-env.d.ts # Local dev fake-backend state (file-backed PGlite + on-disk fake S3) .fake_dev/ + +# Playwright E2E artifacts +/playwright-report/ +/test-results/ +/blob-report/ +/playwright/.cache/ diff --git a/bun.lock b/bun.lock index ba75c57..e5afdb9 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@babel/plugin-syntax-typescript": "^7.28.6", "@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite-socket": "^0.1.6", + "@playwright/test": "^1.60.0", "@types/jest": "^29.5.5", "@types/pg": "^8.10.2", "@types/postcss-modules-values": "^4", @@ -651,6 +652,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@prisma/instrumentation": ["@prisma/instrumentation@6.19.0", "", { "dependencies": { "@opentelemetry/instrumentation": ">=0.52.0 <1" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg=="], "@react-aria/autocomplete": ["@react-aria/autocomplete@3.0.0-rc.4", "", { "dependencies": { "@react-aria/combobox": "^3.14.1", "@react-aria/focus": "^3.21.3", "@react-aria/i18n": "^3.12.14", "@react-aria/interactions": "^3.26.0", "@react-aria/listbox": "^3.15.1", "@react-aria/searchfield": "^3.8.10", "@react-aria/textfield": "^3.18.3", "@react-aria/utils": "^3.32.0", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/combobox": "^3.12.1", "@react-types/autocomplete": "3.0.0-alpha.36", "@react-types/button": "^3.14.1", "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-4bMMVNaCuYDZX9HM4ZNSAImZMcL/orwhLLe818+lyzmSrvGmW9h433PZxTolb0d+FnJVfn1MDY0zEWLiyI86GA=="], @@ -2219,6 +2222,10 @@ "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -2825,6 +2832,8 @@ "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/docker/docker-compose.e2e.yml b/docker/docker-compose.e2e.yml new file mode 100644 index 0000000..2490a1a --- /dev/null +++ b/docker/docker-compose.e2e.yml @@ -0,0 +1,21 @@ +# Minio (real S3) for the full-stack E2E suite. Self-contained and separate from the dev compose so +# the E2E run never touches dev state. Brought up by tools/e2e.sh, which polls readiness host-side. +services: + minio: + image: minio/minio:latest + container_name: paradb_minio_e2e + command: server /data --console-address ":9001" + ports: + - 9000:9000 + environment: + # Root credentials the app signs presigned URLs with (see .env.e2e S3_ACCESS_KEY_*). + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + # The browser uploads to presigned URLs cross-origin (localhost:3000 -> :9000); allow CORS. + - MINIO_API_CORS_ALLOW_ORIGIN=* + volumes: + - paradb_minio_e2e_data:/data + restart: unless-stopped + +volumes: + paradb_minio_e2e_data: diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..6f3c635 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; + +/** + * Canonical metadata for the committed map fixtures under `e2e/fixtures/`. The values here MUST match + * the `recordingMetadata` baked into the zips (see `e2e/fixtures/README.md`); the tests assert on + * them and drive search/filter from them, so distinct artist/mapper/description across the two maps + * is what makes the filter assertions meaningful. + */ +export type MapFixture = { + /** Absolute path to the zip, ready for `setInputFiles`. */ + zipPath: string; + title: string; + artist: string; + mapper: string; + description: string; + /** The file entries the downloaded zip must contain (directories excluded). */ + expectedEntries: string[]; +}; + +const fixturesDir = path.join(__dirname, 'fixtures'); + +export const MAP_ONE: MapFixture = { + zipPath: path.join(fixturesDir, 'E2E_Map_One.zip'), + title: 'E2E Map One Alpha', + artist: 'Aurora Borealis', + mapper: 'MapperAlpha', + description: 'First e2e map uniquealphadesc', + expectedEntries: [ + 'MapOne/MapOne_Easy.rlrr', + 'MapOne/album.jpg', + 'MapOne/song.ogg', + 'MapOne/drums.ogg', + ], +}; + +export const MAP_TWO: MapFixture = { + zipPath: path.join(fixturesDir, 'E2E_Map_Two.zip'), + title: 'E2E Map Two Beta', + artist: 'Crimson Tide', + mapper: 'MapperBeta', + description: 'Second e2e map uniquebetadesc', + expectedEntries: [ + 'MapTwo/MapTwo_Hard.rlrr', + 'MapTwo/album.jpg', + 'MapTwo/song.ogg', + 'MapTwo/drums.ogg', + ], +}; diff --git a/e2e/fixtures/E2E_Map_One.zip b/e2e/fixtures/E2E_Map_One.zip new file mode 100644 index 0000000..bc3357c Binary files /dev/null and b/e2e/fixtures/E2E_Map_One.zip differ diff --git a/e2e/fixtures/E2E_Map_Two.zip b/e2e/fixtures/E2E_Map_Two.zip new file mode 100644 index 0000000..5cebb53 Binary files /dev/null and b/e2e/fixtures/E2E_Map_Two.zip differ diff --git a/e2e/fixtures/README.md b/e2e/fixtures/README.md new file mode 100644 index 0000000..ec3b2ac --- /dev/null +++ b/e2e/fixtures/README.md @@ -0,0 +1,53 @@ +# E2E map fixtures + +Two valid Paradiddle map zips with **distinct, known metadata** so the E2E +search/filter assertions are meaningful. The map title/artist/mapper/description +come from the `recordingMetadata` inside the `.rlrr`, not from any form field, so +controlled metadata has to live in the fixture itself. + +| Fixture | Title | Artist | Mapper (creator) | Description | +| ----------------- | ----------------- | --------------- | ---------------- | ----------------------------- | +| `E2E_Map_One.zip` | E2E Map One Alpha | Aurora Borealis | MapperAlpha | First e2e map uniquealphadesc | +| `E2E_Map_Two.zip` | E2E Map Two Beta | Crimson Tide | MapperBeta | Second e2e map uniquebetadesc | + +The audio tracks and album art are reused from the unit-test fixtures under +`src/services/maps/tests/files/` (`silence.ogg`, `album.jpg`). + +The canonical metadata used by the tests lives in `e2e/fixtures.ts`, keep the two +in sync if you regenerate the zips. + +## Regenerating + +These are committed binaries (same as the `Test_valid.zip` unit fixtures). To +rebuild them, run from the repo root: + +```python +# python3 - <<'PY' +import json, os, zipfile +SRC = "src/services/maps/tests/files" +silence = open(f"{SRC}/silence.ogg", "rb").read() +album = open(f"{SRC}/album.jpg", "rb").read() + +def build(name, folder, difficulty, title, artist, creator, description, complexity): + rlrr = { + "version": 0.6, + "recordingMetadata": {"title": title, "description": description, + "coverImagePath": "album.jpg", "artist": artist, "creator": creator, + "length": 11.1814, "complexity": complexity}, + "audioFileData": {"songTracks": ["song.ogg"], "drumTracks": ["drums.ogg"], + "calibrationOffset": 0.0}, + "instruments": [], "events": [], "bpmEvents": [{"bpm": 120.0, "time": 0.0}], + } + with zipfile.ZipFile(f"e2e/fixtures/{name}", "w", zipfile.ZIP_DEFLATED) as z: + z.writestr(f"{folder}/", "") + z.writestr(f"{folder}/{folder}_{difficulty}.rlrr", json.dumps(rlrr, indent=2)) + z.writestr(f"{folder}/album.jpg", album) + z.writestr(f"{folder}/song.ogg", silence) + z.writestr(f"{folder}/drums.ogg", silence) + +build("E2E_Map_One.zip", "MapOne", "Easy", "E2E Map One Alpha", "Aurora Borealis", + "MapperAlpha", "First e2e map uniquealphadesc", 3) +build("E2E_Map_Two.zip", "MapTwo", "Hard", "E2E Map Two Beta", "Crimson Tide", + "MapperBeta", "Second e2e map uniquebetadesc", 4) +# PY +``` diff --git a/e2e/full_journey.e2e.ts b/e2e/full_journey.e2e.ts new file mode 100644 index 0000000..f26bad9 --- /dev/null +++ b/e2e/full_journey.e2e.ts @@ -0,0 +1,140 @@ +import { expect, Page, test } from '@playwright/test'; +import { MAP_ONE, MAP_TWO, MapFixture } from './fixtures'; +import { + expectLoggedOut, + login, + logout, + makeUser, + signUpAndConfirm, + TestUser, +} from './helpers/auth'; +import { + applySimpleFilter, + favoritesCell, + goHome, + mapRow, + searchMaps, + uploadMap, +} from './helpers/maps'; +import { assertZipMatchesFixture } from './helpers/zip'; + +async function favoriteMap(page: Page) { + // The favorite button only renders once the map's userProjection has loaded; wait for it so a + // missing projection fails loudly here rather than as an opaque click timeout. + const favorite = page.getByRole('button', { name: '❤' }); + await expect(favorite).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes('/api/favorites') && r.request().method() === 'POST' + ), + favorite.click(), + ]); +} + +async function downloadAndVerify(page: Page, id: string, fixture: MapFixture) { + const resp = await page.request.get(`/api/maps/${id}/download`); + expect(resp.ok()).toBeTruthy(); + await assertZipMatchesFixture(await resp.body(), fixture); +} + +// One long, stateful journey across two users; runs as a single ordered test so the shared state +// (the uploaded maps, the favorite counts) flows naturally between steps. +test('full happy-path journey across two users', async ({ page }) => { + test.setTimeout(300_000); + + const user1: TestUser = makeUser('one'); + const user2: TestUser = makeUser('two'); + let map1Id = ''; + let map2Id = ''; + + await test.step('load homepage', async () => { + await page.goto('/'); + await expect(page.getByPlaceholder('Search for a song or artist...')).toBeVisible(); + await expectLoggedOut(page); + }); + + await test.step('sign up (with email confirmation), log out, log back in', async () => { + await signUpAndConfirm(page, user1); + await logout(page); + await login(page, user1); + }); + + await test.step('upload a map and land on its page', async () => { + map1Id = await uploadMap(page, MAP_ONE); + await expect(page.getByText(MAP_ONE.artist, { exact: true })).toBeVisible(); + }); + + await test.step('map page shows owner actions for the uploader', async () => { + await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reupload' })).toBeVisible(); + }); + + await test.step('favorite and download the map as the owner', async () => { + await favoriteMap(page); + await downloadAndVerify(page, map1Id, MAP_ONE); + }); + + await test.step('homepage lists the new map with 1 favorite', async () => { + await goHome(page); + await searchMaps(page, MAP_ONE.title); + await expect(mapRow(page, map1Id)).toBeVisible(); + await expect(favoritesCell(page, map1Id)).toHaveText('1'); + }); + + await test.step('log out and sign up as a second user', async () => { + await logout(page); + await signUpAndConfirm(page, user2); + }); + + await test.step('second user sees the map and opens it (no owner actions)', async () => { + await goHome(page); + await searchMaps(page, MAP_ONE.title); + await expect(mapRow(page, map1Id)).toBeVisible(); + await mapRow(page, map1Id).locator('a').first().click(); + await page.waitForURL(`**/map/${map1Id}`); + + await expect(page.getByText(MAP_ONE.title, { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Reupload' })).toHaveCount(0); + }); + + await test.step('second user favorites and downloads the map', async () => { + await favoriteMap(page); + await downloadAndVerify(page, map1Id, MAP_ONE); + }); + + await test.step('homepage now shows 2 favorites', async () => { + await goHome(page); + await searchMaps(page, MAP_ONE.title); + await expect(favoritesCell(page, map1Id)).toHaveText('2'); + }); + + await test.step('second user uploads a second, distinct map', async () => { + map2Id = await uploadMap(page, MAP_TWO); + }); + + await test.step('search finds each map by its distinct title', async () => { + await goHome(page); + await searchMaps(page, MAP_ONE.title); + await expect(mapRow(page, map1Id)).toBeVisible(); + await expect(mapRow(page, map2Id)).toHaveCount(0); + + await goHome(page); + await searchMaps(page, MAP_TWO.title); + await expect(mapRow(page, map2Id)).toBeVisible(); + await expect(mapRow(page, map1Id)).toHaveCount(0); + }); + + await test.step('artist / mapper / description filters select the right map', async () => { + for (const [label, value] of [ + ['Artist', MAP_ONE.artist], + ['Mapper', MAP_ONE.mapper], + ['Description', MAP_ONE.description], + ] as const) { + await goHome(page); + await applySimpleFilter(page, label, value); + await expect(mapRow(page, map1Id), `${label} filter should include map one`).toBeVisible(); + await expect(mapRow(page, map2Id), `${label} filter should exclude map two`).toHaveCount(0); + } + }); +}); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..47a16f0 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,70 @@ +import { expect, Page } from '@playwright/test'; +import { getConfirmationLink } from './inbucket'; + +export type TestUser = { + username: string; + email: string; + password: string; +}; + +// Strong, fixed password (zxcvbn-friendly) with no overlap with the generated usernames/emails. +const PASSWORD = 'quartz-meadow-7Vx-galleon'; + +/** A fresh user with a process-unique username/email so re-runs never collide in the persisted DB. */ +export function makeUser(label: string): TestUser { + const unique = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + return { + username: `e2e_${label}_${unique}`, + email: `e2e_${label}_${unique}@example.com`, + password: PASSWORD, + }; +} + +export async function expectLoggedIn(page: Page, username: string) { + await expect(page.getByText(`Logged in as ${username}`)).toBeVisible(); +} + +export async function expectLoggedOut(page: Page) { + await expect(page.getByRole('link', { name: 'Login' })).toBeVisible(); +} + +/** + * Signs a brand-new user up through the real form, then completes email confirmation by following + * the link from the local mail catcher. Confirmation establishes a session, so the user lands logged + * in on the home page. + */ +export async function signUpAndConfirm(page: Page, user: TestUser) { + await page.goto('/signup'); + await page.getByLabel('Username').fill(user.username); + await page.getByLabel('Email').fill(user.email); + await page.getByLabel('Password').fill(user.password); + await page.getByRole('button', { name: 'Signup' }).click(); + + // Confirmations are on, so the form routes to the verification notice rather than logging in. + await page.waitForURL('**/signup/email-verification'); + await expect(page.getByText('Email verification needed', { exact: true })).toBeVisible(); + + const confirmLink = await getConfirmationLink(user.email); + // Visiting the link verifies the OTP server-side, sets the session cookies, and redirects to the + // site root - so the user ends up logged in on the home page. + await page.goto(confirmLink); + await page.waitForURL((url) => url.pathname === '/'); + + await expectLoggedIn(page, user.username); +} + +export async function login(page: Page, user: TestUser) { + await page.goto('/login'); + await page.getByLabel('Username/email').fill(user.username); + await page.getByLabel('Password').fill(user.password); + await page.getByRole('button', { name: 'Login' }).click(); + + // The presenter does a full navigation to the home page once the session is set. + await page.waitForURL((url) => url.pathname === '/'); + await expectLoggedIn(page, user.username); +} + +export async function logout(page: Page) { + await page.getByRole('link', { name: 'Logout' }).click(); + await expectLoggedOut(page); +} diff --git a/e2e/helpers/inbucket.ts b/e2e/helpers/inbucket.ts new file mode 100644 index 0000000..53fb920 --- /dev/null +++ b/e2e/helpers/inbucket.ts @@ -0,0 +1,87 @@ +/** + * Reads the signup confirmation email out of Supabase's local mail catcher and returns the + * `/signup/confirm` link, so the E2E flow can complete real email confirmation without SMTP. + * + * Supabase has shipped two different catchers under the `[inbucket]` config over time (Inbucket and, + * more recently, Mailpit) on the same port, with different REST APIs. We try both so the suite works + * regardless of which one the installed CLI bundles. + */ +const MAIL_BASE = process.env.SUPABASE_INBUCKET_URL || 'http://127.0.0.1:54324'; + +const CONFIRM_LINK = /(https?:\/\/[^\s"'<>]*\/signup\/confirm\?[^\s"'<>]*)/; + +function extractConfirmLink(body: string): string | undefined { + const match = body.match(CONFIRM_LINK); + if (!match) { + return undefined; + } + // Email bodies HTML-encode the ampersands between query params; undo that so the link is usable. + return match[1].replace(/&/g, '&'); +} + +async function fetchJson(url: string): Promise { + try { + const resp = await fetch(url); + if (!resp.ok) { + return undefined; + } + return await resp.json(); + } catch { + return undefined; + } +} + +// Mailpit: search by recipient, then fetch the message's rendered bodies. +async function tryMailpit(email: string): Promise { + const search = await fetchJson( + `${MAIL_BASE}/api/v1/search?query=${encodeURIComponent(`to:${email}`)}` + ); + const id = search?.messages?.[0]?.ID; + if (!id) { + return undefined; + } + const message = await fetchJson(`${MAIL_BASE}/api/v1/message/${id}`); + if (!message) { + return undefined; + } + return extractConfirmLink(`${message.HTML ?? ''}\n${message.Text ?? ''}`); +} + +// Inbucket: list the mailbox (named by the local part of the address), then fetch the newest message. +async function tryInbucket(email: string): Promise { + const mailbox = email.split('@')[0]; + const messages = await fetchJson(`${MAIL_BASE}/api/v1/mailbox/${mailbox}`); + if (!Array.isArray(messages) || messages.length === 0) { + return undefined; + } + // Pick the most recent message by date so we're agnostic to the API's list ordering (and pick the + // latest if a confirmation was ever resent). Tolerate lower/upper-case field names across versions. + const at = (m: any) => new Date(m.date ?? m.Date ?? 0).getTime(); + const newest = messages.reduce((a, b) => (at(b) >= at(a) ? b : a)); + const id = newest.id ?? newest.Id; + const message = await fetchJson(`${MAIL_BASE}/api/v1/mailbox/${mailbox}/${id}`); + const body = message?.body; + if (!body) { + return undefined; + } + return extractConfirmLink(`${body.html ?? ''}\n${body.text ?? ''}`); +} + +/** + * Polls the mail catcher until the confirmation email for `email` arrives, returning its + * `/signup/confirm` link. Throws if it never shows up within the timeout. + */ +export async function getConfirmationLink( + email: string, + { timeoutMs = 30_000, intervalMs = 1_000 } = {} +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const link = (await tryMailpit(email)) ?? (await tryInbucket(email)); + if (link) { + return link; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`No confirmation email arrived for ${email} within ${timeoutMs}ms`); +} diff --git a/e2e/helpers/maps.ts b/e2e/helpers/maps.ts new file mode 100644 index 0000000..459a231 --- /dev/null +++ b/e2e/helpers/maps.ts @@ -0,0 +1,68 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { MapFixture } from '../fixtures'; + +/** + * Uploads a map through the real submit flow (presigned PUT to S3 + server-side validation) and + * returns the new map's id, parsed from the post-upload redirect to /map/{id}. + */ +export async function uploadMap(page: Page, fixture: MapFixture): Promise { + await page.goto('/map/submit'); + await page.locator('input[type="file"]').setInputFiles(fixture.zipPath); + await page.getByRole('button', { name: 'Submit' }).click(); + + // On success the presenter routes to the new map page; /map/submit is the only other /map/* path. + await page.waitForURL( + (url) => /^\/map\/[^/]+$/.test(url.pathname) && !url.pathname.endsWith('/submit'), + { + timeout: 60_000, + } + ); + const id = new URL(page.url()).pathname.split('/').pop()!; + // exact: true so we match the title heading, not Next's route announcer (" - ParaDB"). + await expect(page.getByText(fixture.title, { exact: true })).toBeVisible(); + return id; +} + +/** The home-page table row for a given map, located by its /map/{id} link. */ +export function mapRow(page: Page, id: string): Locator { + return page.locator('tr', { has: page.locator(`a[href="/map/${id}"]`) }); +} + +/** The favorites cell (5th column) of a map's row. */ +export function favoritesCell(page: Page, id: string): Locator { + return mapRow(page, id).locator('td').nth(4); +} + +/** Runs a free-text search from the home page and waits for the results to load. */ +export async function searchMaps(page: Page, query: string) { + await page.getByPlaceholder('Search for a song or artist...').fill(query); + await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/maps') && r.request().method() === 'GET'), + page.getByRole('button', { name: 'Search' }).click(), + ]); +} + +/** Opens the advanced-filter panel, sets one simple field, and applies the search. */ +export async function applySimpleFilter(page: Page, label: string, value: string) { + if ( + !(await page + .getByLabel(label) + .isVisible() + .catch(() => false)) + ) { + await page.getByRole('button', { name: 'Toggle filters' }).click(); + } + await page.getByLabel(label).fill(value); + await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/maps') && r.request().method() === 'GET'), + page.getByRole('button', { name: 'Search' }).click(), + ]); +} + +/** Resets the home page to a clean, unfiltered state. */ +export async function goHome(page: Page) { + await page.goto('/'); + await page.waitForResponse( + (r) => r.url().includes('/api/maps') && r.request().method() === 'GET' + ); +} diff --git a/e2e/helpers/zip.ts b/e2e/helpers/zip.ts new file mode 100644 index 0000000..1696d30 --- /dev/null +++ b/e2e/helpers/zip.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test'; +import * as unzipper from 'unzipper'; +import { MapFixture } from '../fixtures'; + +/** + * Asserts a downloaded map zip is the one we uploaded: it opens as a valid archive, contains every + * expected file entry, and its .rlrr still carries the fixture's title. The download path stores and + * serves the uploaded bytes verbatim, so this round-trips the whole upload -> S3 -> download flow. + */ +export async function assertZipMatchesFixture(zipBuffer: Buffer, fixture: MapFixture) { + const directory = await unzipper.Open.buffer(zipBuffer); + const filePaths = directory.files.filter((f) => f.type === 'File').map((f) => f.path); + + for (const entry of fixture.expectedEntries) { + expect(filePaths, `downloaded zip should contain ${entry}`).toContain(entry); + } + + const rlrr = directory.files.find((f) => f.path.endsWith('.rlrr')); + expect(rlrr, 'downloaded zip should contain a .rlrr').toBeTruthy(); + const metadata = JSON.parse((await rlrr!.buffer()).toString()); + expect(metadata.recordingMetadata.title).toBe(fixture.title); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..f3cdfb4 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "//": "Standalone config for the Playwright E2E sources. Kept out of the app tsconfig (which only includes src/) so these test-only files never enter the Next/Vercel build, while still being type-checkable on their own and picked up by Playwright's loader.", + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "module": "commonjs", + "moduleResolution": "node", + "types": ["node"], + "noEmit": true, + "plugins": [] + }, + "include": ["**/*.ts", "../playwright.config.ts"] +} diff --git a/package.json b/package.json index 3de0314..0739277 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "build": "sudo docker compose -f docker/docker-compose.yml --env-file .env build", "start": "sudo docker compose -f docker/docker-compose.yml --env-file .env up --build", "typecheck": "bun tsc", + "typecheck:e2e": "bun tsc -p e2e/tsconfig.json", "lint": "eslint .", "format": "prettier . --write", "schema": "tools/update_schema.sh", "test": "bun run test:unit", "test:unit": "bun jest --config jest.config.unit.ts", - "test:integration": "dotenv -e .env.test -- bash tools/test.sh" + "test:integration": "dotenv -e .env.test -- bash tools/test.sh", + "test:e2e": "dotenv -e .env.e2e -- bash tools/e2e.sh" }, "dependencies": { "@aws-sdk/client-s3": "^3.958.0", @@ -65,6 +67,7 @@ "@babel/plugin-syntax-typescript": "^7.28.6", "@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite-socket": "^0.1.6", + "@playwright/test": "^1.60.0", "@types/jest": "^29.5.5", "@types/pg": "^8.10.2", "@types/postcss-modules-values": "^4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b454715 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; + +/** + * Full-stack E2E config. These tests need the real Supabase stack + Minio already running and the + * env from .env.e2e (both orchestrated by tools/e2e.sh / `bun run test:e2e`). Playwright itself only + * boots the Next dev server via `webServer`. + * + * The suite is a single stateful user journey (one user uploads, a second sees it), so it runs + * serially in one worker with no retries - a retry would replay signups and duplicate state. + */ +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.e2e.ts', + tsconfig: './e2e/tsconfig.json', + fullyParallel: false, + workers: 1, + retries: 0, + // Generous per-test budget: the journey polls Inbucket for the confirmation email and does real + // uploads/downloads against S3. + timeout: 120_000, + expect: { timeout: 15_000 }, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'bun next dev', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/src/ui/base/textbox/textbox.tsx b/src/ui/base/textbox/textbox.tsx index 8af1206..dc37283 100644 --- a/src/ui/base/textbox/textbox.tsx +++ b/src/ui/base/textbox/textbox.tsx @@ -36,6 +36,9 @@ export const Textbox = (props: TextboxProps) => { const inputProps = { className: styles.textbox, type: props.inputType || 'text', + // Associate the visible label with the control for assistive tech (the label is rendered as a + // sibling span, not a <label htmlFor>), which also gives tests a stable accessible-name handle. + 'aria-label': props.label, readOnly: props.readOnly, value: props.value, placeholder: props.placeholder, diff --git a/supabase/config.toml b/supabase/config.toml index 9509d8c..00bab10 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -178,7 +178,9 @@ password_requirements = "" [auth.rate_limit] # Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 +# Raised from the default 2 so the local E2E suite (two signups per run, plus re-runs within the +# hour) doesn't trip the confirmation-email rate limit. Local-only; the hosted project is unaffected. +email_sent = 100 # Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. sms_sent = 30 # Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. diff --git a/tools/e2e.sh b/tools/e2e.sh new file mode 100644 index 0000000..ee45a81 --- /dev/null +++ b/tools/e2e.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Full-stack E2E orchestrator. Assumes the Supabase stack is already running (`supabase start`), +# then: pulls the live Supabase keys into the env, brings up Minio as a real S3, provisions the +# bucket, and runs Playwright (which boots `bun next dev` itself via its webServer config). +# +# Invoked as `dotenv -e .env.e2e -- bash tools/e2e.sh` (see the test:e2e script), so the static +# .env.e2e vars are already in the environment; we only layer the runtime Supabase keys on top. +set -uo pipefail + +COMPOSE_FILE="docker/docker-compose.e2e.yml" +MINIO_HEALTH_URL="http://localhost:9000/minio/health/live" +MINIO_READY_TIMEOUT_SECONDS=60 + +# Always tear Minio down (container + volume), even on failure/crash/Ctrl-C, so no uploaded-map +# state survives between runs. The Supabase DB is intentionally left alone: this script treats it as +# an externally-owned, already-running service, so re-runs rely on process-unique signup +# usernames/emails (see e2e/helpers/auth.ts) rather than resetting someone's local data. +cleanup() { + echo "Tearing down Minio..." + docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +# Prefer a `supabase` on PATH (CI installs it via setup-cli); fall back to the bun-global binary the +# repo's AGENTS.md documents (`bun supabase ...`). +if command -v supabase >/dev/null 2>&1; then + SUPABASE="supabase" +else + SUPABASE="bun supabase" +fi + +# Require the Supabase stack to be up. `status` exits non-zero when it isn't. +if ! $SUPABASE status >/dev/null 2>&1; then + echo "Supabase is not running. Start it first with: $SUPABASE start" >&2 + exit 1 +fi + +# Inject the live local keys so .env.e2e never goes stale against the CLI's generated keys. `set -a` +# auto-exports the `NAME="value"` assignments that `status -o env` prints. +set -a +eval "$($SUPABASE status -o env)" +set +a +# Newer CLIs expose PUBLISHABLE_KEY/SECRET_KEY; older ones ANON_KEY/SERVICE_ROLE_KEY. The nested +# `:-` defaults keep `set -u` happy when only one naming is present. +export NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY="${PUBLISHABLE_KEY:-${ANON_KEY:-}}" +export SUPABASE_SECRET_KEY="${SECRET_KEY:-${SERVICE_ROLE_KEY:-}}" +if [ -z "$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ] || [ -z "$SUPABASE_SECRET_KEY" ]; then + echo "Could not read Supabase keys from '$SUPABASE status -o env'" >&2 + exit 1 +fi + +# Bring up Minio (real S3) and wait for it to answer on the host. We poll ourselves rather than rely +# on the container healthcheck, since recent minio images ship without curl. +echo "Starting Minio..." +docker compose -f "$COMPOSE_FILE" up -d minio + +echo "Waiting for Minio to be ready..." +i=0 +until curl --output /dev/null --silent --fail "$MINIO_HEALTH_URL"; do + if [ "$i" -ge "$MINIO_READY_TIMEOUT_SECONDS" ]; then + echo "Minio did not become ready within ${MINIO_READY_TIMEOUT_SECONDS}s" >&2 + docker compose -f "$COMPOSE_FILE" logs minio >&2 + exit 1 + fi + sleep 1 + i=$((i + 1)) +done + +echo "Provisioning Minio bucket..." +bun tools/e2e_setup_s3.ts || exit 1 + +echo "Running Playwright E2E tests..." +bun playwright test +TEST_EXIT=$? +exit "$TEST_EXIT" diff --git a/tools/e2e_setup_s3.ts b/tools/e2e_setup_s3.ts new file mode 100644 index 0000000..8332537 --- /dev/null +++ b/tools/e2e_setup_s3.ts @@ -0,0 +1,58 @@ +/** + * Prepares the Minio bucket the E2E suite uploads to. Run once after Minio is up and before the + * tests (see tools/e2e.sh): creates the maps bucket if missing and grants anonymous read on its + * objects, because the map download route redirects the browser straight to the public object URL. + * + * Reads S3 config straight from the environment (provided by .env.e2e) rather than the app's + * getEnvVars() so it stays a standalone script with no app singletons. + */ +import { + CreateBucketCommand, + HeadBucketCommand, + PutBucketPolicyCommand, + S3Client, +} from '@aws-sdk/client-s3'; + +const endpoint = process.env.S3_ENDPOINT!; +const region = process.env.S3_REGION!; +const bucket = process.env.S3_MAPS_BUCKET!; +const accessKeyId = process.env.S3_ACCESS_KEY_ID!; +const secretAccessKey = process.env.S3_ACCESS_KEY_SECRET!; + +async function main() { + const client = new S3Client({ + endpoint, + region, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: true, + }); + + try { + await client.send(new HeadBucketCommand({ Bucket: bucket })); + console.log(`Bucket ${bucket} already exists`); + } catch { + await client.send(new CreateBucketCommand({ Bucket: bucket })); + console.log(`Created bucket ${bucket}`); + } + + // Anonymous read on objects so the download redirect (browser -> Minio public URL) works. + const policy = { + Version: '2012-10-17', + Statement: [ + { + Sid: 'PublicRead', + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${bucket}/*`], + }, + ], + }; + await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) })); + console.log(`Set public-read policy on ${bucket}`); +} + +main().catch((e) => { + console.error('Failed to set up Minio bucket:', e); + process.exit(1); +});