flows.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. /**
  2. * jamtrack E2E flow verification using Playwright (Node ESM).
  3. *
  4. * Requires: `npm install playwright` in the working directory,
  5. * and `npx playwright install chromium` to download the browser.
  6. *
  7. * Setup notes discovered during first run:
  8. * - App frontend at http://localhost:7331 (templ dev proxy, port 8090 backend).
  9. * Login uses username (not email): input[name="username"].
  10. * Location select on /jams/new is named "location" (not "location_id").
  11. * - Location delete: the app UI has NO delete button for locations.
  12. * The backend hook (main.go:OnRecordDelete) rejects the API call with HTTP 400.
  13. * PocketBase's firstApiError() recognises router.NewBadRequestError, so the
  14. * custom message surfaces. Test via the PocketBase REST API with a superuser token.
  15. * - App users are created/deleted via the PocketBase REST API using a superuser
  16. * token. No admin UI interaction is needed.
  17. * - All test-created data carries an "[E2E]" prefix in its name/artist fields.
  18. * This makes leftover data from failed runs easy to spot and delete by hand in
  19. * the PocketBase admin UI: filter locations by name ~ "[E2E]", songs by
  20. * artist ~ "[E2E]". cleanupTestData() also runs at test start to purge any
  21. * leftovers before starting fresh.
  22. *
  23. * Credentials: only ADMIN_EMAIL / ADMIN_PASS are needed in .env.
  24. * The suite generates a throwaway app user per run and deletes it on exit.
  25. *
  26. * Run:
  27. * just e2e (reads credentials from .env via go tool envrun)
  28. */
  29. import { chromium } from 'playwright';
  30. import { randomBytes } from 'node:crypto';
  31. const PB_API = 'http://localhost:8090';
  32. const APP_URL = 'http://localhost:7331';
  33. const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? (() => { throw new Error('ADMIN_EMAIL required'); })();
  34. const ADMIN_PASS = process.env.ADMIN_PASS ?? (() => { throw new Error('ADMIN_PASS required'); })();
  35. // Telltale marker on all test-created records — easy to spot in the admin UI.
  36. const E2E_PREFIX = '[E2E]';
  37. const E2E_LOCATION = `${E2E_PREFIX} Test Location`;
  38. // Throwaway credentials generated fresh each run.
  39. const APP_USER = `e2e_${Date.now()}`;
  40. const APP_PASS = randomBytes(16).toString('hex');
  41. const sleep = ms => new Promise(r => setTimeout(r, ms));
  42. const log = msg => console.log(`[${new Date().toISOString()}] ${msg}`);
  43. // Returns a superuser JWT for PocketBase API calls.
  44. async function pbToken() {
  45. const r = await fetch(`${PB_API}/api/collections/_superusers/auth-with-password`, {
  46. method: 'POST',
  47. headers: { 'Content-Type': 'application/json' },
  48. body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASS }),
  49. });
  50. const body = await r.json();
  51. if (!r.ok) throw new Error(`Admin auth failed (${r.status}): ${body.message} — check ADMIN_EMAIL and ADMIN_PASS in .env`);
  52. return body.token;
  53. }
  54. async function createAppUser(token) {
  55. const r = await fetch(`${PB_API}/api/collections/users/records`, {
  56. method: 'POST',
  57. headers: { 'Content-Type': 'application/json', Authorization: token },
  58. body: JSON.stringify({
  59. username: APP_USER,
  60. email: `${APP_USER}@test.example`,
  61. password: APP_PASS,
  62. passwordConfirm: APP_PASS,
  63. }),
  64. });
  65. const body = await r.json();
  66. if (!r.ok) throw new Error(`Failed to create app user: ${body.message}`);
  67. log(`app user "${APP_USER}" created`);
  68. return body.id;
  69. }
  70. async function deleteAppUser(token, userId) {
  71. const r = await fetch(`${PB_API}/api/collections/users/records/${userId}`, {
  72. method: 'DELETE',
  73. headers: { Authorization: token },
  74. });
  75. if (!r.ok) {
  76. const body = await r.json();
  77. throw new Error(`Failed to delete app user: ${body.message}`);
  78. }
  79. log(`app user "${APP_USER}" deleted`);
  80. }
  81. // Creates the [E2E] test location. cleanupTestData() removes it including any
  82. // jams attached to it, so this is idempotent across runs as long as cleanup runs.
  83. async function createE2ELocation(token) {
  84. const r = await fetch(`${PB_API}/api/collections/locations/records`, {
  85. method: 'POST',
  86. headers: { 'Content-Type': 'application/json', Authorization: token },
  87. body: JSON.stringify({ name: E2E_LOCATION }),
  88. });
  89. const body = await r.json();
  90. if (!r.ok) throw new Error(`Failed to create test location: ${body.message}`);
  91. log(`test location "${E2E_LOCATION}" created`);
  92. return body.id;
  93. }
  94. // Deletes all [E2E]-marked test data: jams at the E2E location (cascade removes
  95. // their setlist entries), remaining [E2E] songs and any dangling setlist entries,
  96. // and finally the E2E location itself. Safe to call at start and end of each run.
  97. async function cleanupTestData(token) {
  98. // Find all [E2E] locations (should be at most one, but handle stragglers).
  99. const locFilter = encodeURIComponent(`name ~ '${E2E_PREFIX}'`);
  100. const locsR = await fetch(`${PB_API}/api/collections/locations/records?filter=${locFilter}&perPage=200`, {
  101. headers: { Authorization: token },
  102. });
  103. const locs = await locsR.json();
  104. for (const loc of locs.items ?? []) {
  105. // Delete all jams at this location; cascade removes their setlist entries.
  106. const jamFilter = encodeURIComponent(`location='${loc.id}'`);
  107. const jamsR = await fetch(`${PB_API}/api/collections/jams/records?filter=${jamFilter}&perPage=200`, {
  108. headers: { Authorization: token },
  109. });
  110. const jams = await jamsR.json();
  111. await Promise.all((jams.items ?? []).map(j =>
  112. fetch(`${PB_API}/api/collections/jams/records/${j.id}`, {
  113. method: 'DELETE', headers: { Authorization: token },
  114. })
  115. ));
  116. if (jams.items?.length) log(`${jams.items.length} test jam(s) at "${loc.name}" deleted`);
  117. // Now the location has no jams, so the OnRecordDelete hook allows deletion.
  118. await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
  119. method: 'DELETE', headers: { Authorization: token },
  120. });
  121. log(`test location "${loc.name}" deleted`);
  122. }
  123. // Delete [E2E] songs and any remaining setlist entries referencing them
  124. // (e.g. songs added to existing jams in Flow 2).
  125. const songFilter = encodeURIComponent(`artist ~ '${E2E_PREFIX}'`);
  126. const songsR = await fetch(`${PB_API}/api/collections/songs/records?filter=${songFilter}&perPage=200`, {
  127. headers: { Authorization: token },
  128. });
  129. const songsList = await songsR.json();
  130. for (const song of songsList.items ?? []) {
  131. const slFilter = encodeURIComponent(`song='${song.id}'`);
  132. const slR = await fetch(`${PB_API}/api/collections/setlist/records?filter=${slFilter}&perPage=200`, {
  133. headers: { Authorization: token },
  134. });
  135. const sl = await slR.json();
  136. await Promise.all((sl.items ?? []).map(s =>
  137. fetch(`${PB_API}/api/collections/setlist/records/${s.id}`, {
  138. method: 'DELETE', headers: { Authorization: token },
  139. })
  140. ));
  141. await fetch(`${PB_API}/api/collections/songs/records/${song.id}`, {
  142. method: 'DELETE', headers: { Authorization: token },
  143. });
  144. }
  145. if (songsList.items?.length) log(`${songsList.items.length} [E2E] song(s) and dangling setlist entries deleted`);
  146. }
  147. (async () => {
  148. const token = await pbToken();
  149. // Purge leftovers from any previously failed run before starting fresh.
  150. await cleanupTestData(token);
  151. await createE2ELocation(token);
  152. const browser = await chromium.launch({ headless: true });
  153. const userId = await createAppUser(token);
  154. try {
  155. // ── App session ────────────────────────────────────────────────────────
  156. const app = await browser.newContext().then(c => c.newPage());
  157. await app.goto(`${APP_URL}/login`);
  158. await app.fill('input[name="username"]', APP_USER);
  159. await app.fill('input[name="password"]', APP_PASS);
  160. await app.click('button[type="submit"]');
  161. await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
  162. log('app login OK');
  163. const dashBefore = await app.textContent('body');
  164. // ── Flow 1: Logout → /login ────────────────────────────────────────────
  165. log('Flow 1: logout');
  166. await app.locator('a[href="/logout"], a:has-text("Logout")').first().click();
  167. await app.waitForURL(/\/login/, { timeout: 5000 });
  168. const flow1 = app.url().includes('/login') ? 'PASS' : `FAIL — landed at ${app.url()}`;
  169. console.log(`FLOW1: ${flow1}`);
  170. // Re-login for remaining flows.
  171. await app.fill('input[name="username"]', APP_USER);
  172. await app.fill('input[name="password"]', APP_PASS);
  173. await app.click('button[type="submit"]');
  174. await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
  175. // ── Flow 2: Focus-after-add ────────────────────────────────────────────
  176. log('Flow 2: focus-after-add');
  177. await app.goto(`${APP_URL}/jams`);
  178. await sleep(500);
  179. // Exclude /jams/new and only look at real (non-E2E) jams.
  180. const jamLinks = app.locator('a[href^="/jams/"]:not([href="/jams/new"])');
  181. const jamCount = await jamLinks.count();
  182. let flow2;
  183. if (jamCount > 0) {
  184. await jamLinks.first().click();
  185. await app.waitForURL(/\/jams\/\w+/, { timeout: 5000 });
  186. await app.fill('input[name="artist"]', `${E2E_PREFIX} Artist`);
  187. await app.fill('input[name="title"]', `${E2E_PREFIX} Song`);
  188. await app.locator('button[type="submit"]').last().click();
  189. await sleep(700); // wait for HTMX swap + setTimeout(0) focus
  190. const focused = await app.evaluate(() => document.activeElement?.getAttribute('name') ?? document.activeElement?.tagName);
  191. flow2 = focused === 'artist' ? 'PASS' : `FAIL — focused="${focused}"`;
  192. } else {
  193. flow2 = 'SKIP — no existing jams';
  194. }
  195. console.log(`FLOW2: ${flow2}`);
  196. // ── Flow 3: Location delete blocked (PocketBase API) ──────────────────
  197. // The app UI exposes no delete button for locations. The OnRecordDelete hook
  198. // in main.go returns router.NewBadRequestError, surfaced by firstApiError().
  199. log('Flow 3: location delete blocked');
  200. let flow3;
  201. try {
  202. const locsR = await fetch(`${PB_API}/api/collections/locations/records?perPage=1`, {
  203. headers: { Authorization: token },
  204. });
  205. const locs = await locsR.json();
  206. // Use the first non-E2E location to ensure it has real jams attached.
  207. const loc = (locs.items ?? []).find(l => !l.name.startsWith(E2E_PREFIX));
  208. if (!loc) throw new Error('no non-E2E location found to test deletion');
  209. const delR = await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
  210. method: 'DELETE',
  211. headers: { Authorization: token },
  212. });
  213. const status = delR.status;
  214. const body = await delR.text();
  215. const blocked = status === 400 && /cannot delete a location that still has jams/i.test(body);
  216. flow3 = blocked
  217. ? `PASS — DELETE returned ${status}`
  218. : `FAIL — expected 400 block, got ${status}: ${body.slice(0, 100)}`;
  219. } catch (err) {
  220. flow3 = `ERROR — ${err.message}`;
  221. }
  222. console.log(`FLOW3: ${flow3}`);
  223. // ── Flow 4: Dashboard ranking update ──────────────────────────────────
  224. log('Flow 4: dashboard ranking');
  225. await app.goto(`${APP_URL}/jams/new`);
  226. await sleep(400);
  227. await app.fill('input[name="date"]', new Date().toISOString().slice(0, 10));
  228. const locSel = app.locator('select[name="location"]'); // NOTE: field is "location", not "location_id"
  229. let flow4;
  230. try {
  231. // Select the [E2E] location by label — no guessing by index.
  232. await locSel.selectOption({ label: E2E_LOCATION });
  233. await app.click('button[type="submit"]');
  234. await app.waitForURL(/\/jams\/\w+/, { timeout: 8000 });
  235. await app.fill('input[name="artist"]', `${E2E_PREFIX} Rank Artist`);
  236. await app.fill('input[name="title"]', `${E2E_PREFIX} Rank Song`);
  237. await app.locator('button[type="submit"]').last().click();
  238. await sleep(700);
  239. await app.goto(`${APP_URL}/`);
  240. await sleep(500);
  241. const dashAfter = await app.textContent('body');
  242. const changed = dashBefore.trim() !== dashAfter.trim();
  243. const visible = dashAfter.includes(`${E2E_PREFIX} Rank`);
  244. flow4 = (changed || visible)
  245. ? `PASS — changed=${changed}, new song visible=${visible}`
  246. : 'UNCERTAIN — dashboard unchanged';
  247. } catch (err) {
  248. flow4 = `ERROR — ${err.message}`;
  249. }
  250. console.log(`FLOW4: ${flow4}`);
  251. } finally {
  252. await browser.close();
  253. await cleanupTestData(token);
  254. await deleteAppUser(token, userId);
  255. log('done');
  256. }
  257. })();