also looking at this
test(e2e): isolate mutating runs and account cleanup
#12
4 files
+209
-27
| @@ -1,11 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Shared plumbing for the end-to-end suite. |
| 3 | 3 | * |
| 4 | * These tests drive the app over HTTP against a running dev server. That is | |
| 5 | * possible at all because every form in the app handles its own POST now | |
| 6 | * (stx#1847, see resources/views/login.stx) — sign-in, sign-up, password reset | |
| 7 | * and project creation are ordinary form submissions, so a browser is not | |
| 8 | * needed to exercise them and this suite carries no browser dependency. | |
| 4 | * These tests drive an explicitly opted-in, isolated local server over HTTP. | |
| 5 | * See README.md for the disposable database requirement and the legacy auth | |
| 6 | * expectations that still need migrating to the current JSON API. | |
| 9 | 7 | * |
| 10 | 8 | * What that does NOT cover is anything whose behaviour only exists after |
| 11 | 9 | * hydration: SPA navigation, the theme toggle, clipboard buttons, the delete |
| @@ -13,7 +11,10 @@ | ||
| 13 | 11 | * deliberately out of scope here rather than faked. |
| 14 | 12 | */ |
| 15 | 13 | |
| 16 | const BASE = Bun.env.E2E_BASE_URL || 'http://localhost:3000' | |
| 14 | import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from './safety' | |
| 15 | ||
| 16 | const target = validateE2ETarget(Bun.env) | |
| 17 | const createdEmails = new Set<string>() | |
| 17 | 18 | |
| 18 | 19 | /** A password that satisfies the app's 8-character minimum. */ |
| 19 | 20 | export const TEST_PASSWORD = 'e2e-test-password' |
| @@ -21,14 +22,16 @@ export const TEST_PASSWORD = 'e2e-test-password' | ||
| 21 | 22 | /** |
| 22 | 23 | * Whether a server is reachable. |
| 23 | 24 | * |
| 24 | * The suite SKIPS rather than fails when nothing is listening, so `bun test` | |
| 25 | * The suite SKIPS unless explicitly enabled, and when nothing is listening, so `bun test` | |
| 25 | 26 | * stays green on a machine that has not started the app. A failing E2E suite |
| 26 | 27 | * should mean the app is broken, not that the developer did not run `bun run |
| 27 | 28 | * dev` — otherwise the signal gets ignored, which is worse than not having it. |
| 28 | 29 | */ |
| 29 | 30 | export async function serverIsUp(): Promise<boolean> { |
| 31 | if (!target) | |
| 32 | return false | |
| 30 | 33 | try { |
| 31 | const res = await fetch(BASE, { signal: AbortSignal.timeout(3000) }) | |
| 34 | const res = await fetch(target.baseUrl, { redirect: 'manual', signal: AbortSignal.timeout(3000) }) | |
| 32 | 35 | return res.ok |
| 33 | 36 | } |
| 34 | 37 | catch { |
| @@ -46,7 +49,7 @@ export async function serverIsUp(): Promise<boolean> { | ||
| 46 | 49 | export const SERVER_UP: boolean = await serverIsUp() |
| 47 | 50 | |
| 48 | 51 | export function url(path: string): string { |
| 49 | return BASE + path | |
| 52 | return e2eUrl(target, path) | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | /** |
| @@ -88,12 +91,13 @@ export function sessionCookie(res: Response): string { | ||
| 88 | 91 | /** |
| 89 | 92 | * An email nobody else is using. |
| 90 | 93 | * |
| 91 | * Unique per call so a rerun cannot collide with its own leftovers, and | |
| 92 | * prefixed so the cleanup below can find every account this suite ever made — | |
| 93 | * including from a run that crashed before its own teardown. | |
| 94 | * Unique per call so a rerun cannot collide with its own leftovers. Track the | |
| 95 | * exact address for this run's cleanup; the prefix is only a readable QA label. | |
| 94 | 96 | */ |
| 95 | 97 | export function freshEmail(label: string): string { |
| 96 | return `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test` | |
| 98 | const email = `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test` | |
| 99 | createdEmails.add(email) | |
| 100 | return email | |
| 97 | 101 | } |
| 98 | 102 | |
| 99 | 103 | /** Register through the real form and return the session cookie it mints. */ |
| @@ -109,20 +113,11 @@ export async function registerAndSignIn(label: string): Promise<{ email: string, | ||
| 109 | 113 | } |
| 110 | 114 | |
| 111 | 115 | /** |
| 112 | * Remove every account this suite has ever created, and anything hanging off | |
| 113 | * one. | |
| 114 | * | |
| 115 | * Matches on the `e2e-%@loghq.test` prefix rather than tracking ids, so a run | |
| 116 | * that died before teardown is cleaned up by the next one. Deletes children | |
| 117 | * first — projects and tokens reference users. | |
| 116 | * Remove only this process's generated accounts from the explicit disposable | |
| 117 | * SQLite file. Never import the app's default database connection for cleanup. | |
| 118 | * Failed-run leftovers remain confined to the disposable test database. | |
| 118 | 119 | */ |
| 119 | 120 | export async function cleanupTestAccounts(): Promise<void> { |
| 120 | const { db } = await import('@stacksjs/database') | |
| 121 | await db.unsafe( | |
| 122 | `DELETE FROM projects WHERE owner_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`, | |
| 123 | ).execute() | |
| 124 | await db.unsafe( | |
| 125 | `DELETE FROM oauth_access_tokens WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`, | |
| 126 | ).execute() | |
| 127 | await db.unsafe(`DELETE FROM users WHERE email LIKE 'e2e-%@loghq.test'`).execute() | |
| 121 | cleanupE2EAccounts(target, createdEmails) | |
| 122 | createdEmails.clear() | |
| 128 | 123 | } |