| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- /**
- * jamtrack E2E flow verification using Playwright (Node ESM).
- *
- * Requires: `npm install playwright` in the working directory,
- * and `npx playwright install chromium` to download the browser.
- *
- * Setup notes discovered during first run:
- * - App frontend at http://localhost:7331 (templ dev proxy, port 8090 backend).
- * Login uses username (not email): input[name="username"].
- * Location select on /jams/new is named "location" (not "location_id").
- * - Location delete: the app UI has NO delete button for locations.
- * The backend hook (main.go:OnRecordDelete) rejects the API call with HTTP 400.
- * PocketBase's firstApiError() recognises router.NewBadRequestError, so the
- * custom message surfaces. Test via the PocketBase REST API with a superuser token.
- * - App users are created/deleted via the PocketBase REST API using a superuser
- * token. No admin UI interaction is needed.
- * - All test-created data carries an "[E2E]" prefix in its name/artist fields.
- * This makes leftover data from failed runs easy to spot and delete by hand in
- * the PocketBase admin UI: filter locations by name ~ "[E2E]", songs by
- * artist ~ "[E2E]". cleanupTestData() also runs at test start to purge any
- * leftovers before starting fresh.
- *
- * Credentials: only ADMIN_EMAIL / ADMIN_PASS are needed in .env.
- * The suite generates a throwaway app user per run and deletes it on exit.
- *
- * Run:
- * just e2e (reads credentials from .env via go tool envrun)
- */
- import { chromium } from 'playwright';
- import { randomBytes } from 'node:crypto';
- const PB_API = 'http://localhost:8090';
- const APP_URL = 'http://localhost:7331';
- const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? (() => { throw new Error('ADMIN_EMAIL required'); })();
- const ADMIN_PASS = process.env.ADMIN_PASS ?? (() => { throw new Error('ADMIN_PASS required'); })();
- // Telltale marker on all test-created records — easy to spot in the admin UI.
- const E2E_PREFIX = '[E2E]';
- const E2E_LOCATION = `${E2E_PREFIX} Test Location`;
- // Throwaway credentials generated fresh each run.
- const APP_USER = `e2e_${Date.now()}`;
- const APP_PASS = randomBytes(16).toString('hex');
- const sleep = ms => new Promise(r => setTimeout(r, ms));
- const log = msg => console.log(`[${new Date().toISOString()}] ${msg}`);
- // Returns a superuser JWT for PocketBase API calls.
- async function pbToken() {
- const r = await fetch(`${PB_API}/api/collections/_superusers/auth-with-password`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASS }),
- });
- const body = await r.json();
- if (!r.ok) throw new Error(`Admin auth failed (${r.status}): ${body.message} — check ADMIN_EMAIL and ADMIN_PASS in .env`);
- return body.token;
- }
- async function createAppUser(token) {
- const r = await fetch(`${PB_API}/api/collections/users/records`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: token },
- body: JSON.stringify({
- username: APP_USER,
- email: `${APP_USER}@test.example`,
- password: APP_PASS,
- passwordConfirm: APP_PASS,
- }),
- });
- const body = await r.json();
- if (!r.ok) throw new Error(`Failed to create app user: ${body.message}`);
- log(`app user "${APP_USER}" created`);
- return body.id;
- }
- async function deleteAppUser(token, userId) {
- const r = await fetch(`${PB_API}/api/collections/users/records/${userId}`, {
- method: 'DELETE',
- headers: { Authorization: token },
- });
- if (!r.ok) {
- const body = await r.json();
- throw new Error(`Failed to delete app user: ${body.message}`);
- }
- log(`app user "${APP_USER}" deleted`);
- }
- // Creates the [E2E] test location. cleanupTestData() removes it including any
- // jams attached to it, so this is idempotent across runs as long as cleanup runs.
- async function createE2ELocation(token) {
- const r = await fetch(`${PB_API}/api/collections/locations/records`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: token },
- body: JSON.stringify({ name: E2E_LOCATION }),
- });
- const body = await r.json();
- if (!r.ok) throw new Error(`Failed to create test location: ${body.message}`);
- log(`test location "${E2E_LOCATION}" created`);
- return body.id;
- }
- // Deletes all [E2E]-marked test data: jams at the E2E location (cascade removes
- // their setlist entries), remaining [E2E] songs and any dangling setlist entries,
- // and finally the E2E location itself. Safe to call at start and end of each run.
- async function cleanupTestData(token) {
- // Find all [E2E] locations (should be at most one, but handle stragglers).
- const locFilter = encodeURIComponent(`name ~ '${E2E_PREFIX}'`);
- const locsR = await fetch(`${PB_API}/api/collections/locations/records?filter=${locFilter}&perPage=200`, {
- headers: { Authorization: token },
- });
- const locs = await locsR.json();
- for (const loc of locs.items ?? []) {
- // Delete all jams at this location; cascade removes their setlist entries.
- const jamFilter = encodeURIComponent(`location='${loc.id}'`);
- const jamsR = await fetch(`${PB_API}/api/collections/jams/records?filter=${jamFilter}&perPage=200`, {
- headers: { Authorization: token },
- });
- const jams = await jamsR.json();
- await Promise.all((jams.items ?? []).map(j =>
- fetch(`${PB_API}/api/collections/jams/records/${j.id}`, {
- method: 'DELETE', headers: { Authorization: token },
- })
- ));
- if (jams.items?.length) log(`${jams.items.length} test jam(s) at "${loc.name}" deleted`);
- // Now the location has no jams, so the OnRecordDelete hook allows deletion.
- await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
- method: 'DELETE', headers: { Authorization: token },
- });
- log(`test location "${loc.name}" deleted`);
- }
- // Delete [E2E] songs and any remaining setlist entries referencing them
- // (e.g. songs added to existing jams in Flow 2).
- const songFilter = encodeURIComponent(`artist ~ '${E2E_PREFIX}'`);
- const songsR = await fetch(`${PB_API}/api/collections/songs/records?filter=${songFilter}&perPage=200`, {
- headers: { Authorization: token },
- });
- const songsList = await songsR.json();
- for (const song of songsList.items ?? []) {
- const slFilter = encodeURIComponent(`song='${song.id}'`);
- const slR = await fetch(`${PB_API}/api/collections/setlist/records?filter=${slFilter}&perPage=200`, {
- headers: { Authorization: token },
- });
- const sl = await slR.json();
- await Promise.all((sl.items ?? []).map(s =>
- fetch(`${PB_API}/api/collections/setlist/records/${s.id}`, {
- method: 'DELETE', headers: { Authorization: token },
- })
- ));
- await fetch(`${PB_API}/api/collections/songs/records/${song.id}`, {
- method: 'DELETE', headers: { Authorization: token },
- });
- }
- if (songsList.items?.length) log(`${songsList.items.length} [E2E] song(s) and dangling setlist entries deleted`);
- }
- (async () => {
- const token = await pbToken();
- // Purge leftovers from any previously failed run before starting fresh.
- await cleanupTestData(token);
- await createE2ELocation(token);
- const browser = await chromium.launch({ headless: true });
- const userId = await createAppUser(token);
- try {
- // ── App session ────────────────────────────────────────────────────────
- const app = await browser.newContext().then(c => c.newPage());
- await app.goto(`${APP_URL}/login`);
- await app.fill('input[name="username"]', APP_USER);
- await app.fill('input[name="password"]', APP_PASS);
- await app.click('button[type="submit"]');
- await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
- log('app login OK');
- const dashBefore = await app.textContent('body');
- // ── Flow 1: Logout → /login ────────────────────────────────────────────
- log('Flow 1: logout');
- await app.locator('a[href="/logout"], a:has-text("Logout")').first().click();
- await app.waitForURL(/\/login/, { timeout: 5000 });
- const flow1 = app.url().includes('/login') ? 'PASS' : `FAIL — landed at ${app.url()}`;
- console.log(`FLOW1: ${flow1}`);
- // Re-login for remaining flows.
- await app.fill('input[name="username"]', APP_USER);
- await app.fill('input[name="password"]', APP_PASS);
- await app.click('button[type="submit"]');
- await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
- // ── Flow 2: Focus-after-add ────────────────────────────────────────────
- log('Flow 2: focus-after-add');
- await app.goto(`${APP_URL}/jams`);
- await sleep(500);
- // Exclude /jams/new and only look at real (non-E2E) jams.
- const jamLinks = app.locator('a[href^="/jams/"]:not([href="/jams/new"])');
- const jamCount = await jamLinks.count();
- let flow2;
- if (jamCount > 0) {
- await jamLinks.first().click();
- await app.waitForURL(/\/jams\/\w+/, { timeout: 5000 });
- await app.fill('input[name="artist"]', `${E2E_PREFIX} Artist`);
- await app.fill('input[name="title"]', `${E2E_PREFIX} Song`);
- await app.locator('button[type="submit"]').last().click();
- await sleep(700); // wait for HTMX swap + setTimeout(0) focus
- const focused = await app.evaluate(() => document.activeElement?.getAttribute('name') ?? document.activeElement?.tagName);
- flow2 = focused === 'artist' ? 'PASS' : `FAIL — focused="${focused}"`;
- } else {
- flow2 = 'SKIP — no existing jams';
- }
- console.log(`FLOW2: ${flow2}`);
- // ── Flow 3: Location delete blocked (PocketBase API) ──────────────────
- // The app UI exposes no delete button for locations. The OnRecordDelete hook
- // in main.go returns router.NewBadRequestError, surfaced by firstApiError().
- log('Flow 3: location delete blocked');
- let flow3;
- try {
- const locsR = await fetch(`${PB_API}/api/collections/locations/records?perPage=1`, {
- headers: { Authorization: token },
- });
- const locs = await locsR.json();
- // Use the first non-E2E location to ensure it has real jams attached.
- const loc = (locs.items ?? []).find(l => !l.name.startsWith(E2E_PREFIX));
- if (!loc) throw new Error('no non-E2E location found to test deletion');
- const delR = await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
- method: 'DELETE',
- headers: { Authorization: token },
- });
- const status = delR.status;
- const body = await delR.text();
- const blocked = status === 400 && /cannot delete a location that still has jams/i.test(body);
- flow3 = blocked
- ? `PASS — DELETE returned ${status}`
- : `FAIL — expected 400 block, got ${status}: ${body.slice(0, 100)}`;
- } catch (err) {
- flow3 = `ERROR — ${err.message}`;
- }
- console.log(`FLOW3: ${flow3}`);
- // ── Flow 4: Dashboard ranking update ──────────────────────────────────
- log('Flow 4: dashboard ranking');
- await app.goto(`${APP_URL}/jams/new`);
- await sleep(400);
- await app.fill('input[name="date"]', new Date().toISOString().slice(0, 10));
- const locSel = app.locator('select[name="location"]'); // NOTE: field is "location", not "location_id"
- let flow4;
- try {
- // Select the [E2E] location by label — no guessing by index.
- await locSel.selectOption({ label: E2E_LOCATION });
- await app.click('button[type="submit"]');
- await app.waitForURL(/\/jams\/\w+/, { timeout: 8000 });
- await app.fill('input[name="artist"]', `${E2E_PREFIX} Rank Artist`);
- await app.fill('input[name="title"]', `${E2E_PREFIX} Rank Song`);
- await app.locator('button[type="submit"]').last().click();
- await sleep(700);
- await app.goto(`${APP_URL}/`);
- await sleep(500);
- const dashAfter = await app.textContent('body');
- const changed = dashBefore.trim() !== dashAfter.trim();
- const visible = dashAfter.includes(`${E2E_PREFIX} Rank`);
- flow4 = (changed || visible)
- ? `PASS — changed=${changed}, new song visible=${visible}`
- : 'UNCERTAIN — dashboard unchanged';
- } catch (err) {
- flow4 = `ERROR — ${err.message}`;
- }
- console.log(`FLOW4: ${flow4}`);
- } finally {
- await browser.close();
- await cleanupTestData(token);
- await deleteAppUser(token, userId);
- log('done');
- }
- })();
|