jamtrack/e2e/flows.mjs

281 lines
12 KiB
JavaScript

/**
* 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');
}
})();