281 lines
12 KiB
JavaScript
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');
|
|
}
|
|
})();
|