|
|
@@ -0,0 +1,281 @@
|
|
|
+/**
|
|
|
+ * 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');
|
|
|
+ }
|
|
|
+})();
|