chore: perauset customer project
This commit is contained in:
58
e2e/tests/support/api-client.ts
Normal file
58
e2e/tests/support/api-client.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
function buildHeaders(cookies?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (cookies) {
|
||||
headers['Cookie'] = cookies
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function apiGet(
|
||||
url: string,
|
||||
path: string,
|
||||
cookies?: string
|
||||
): Promise<Response> {
|
||||
return fetch(`${url}${path}`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(cookies),
|
||||
})
|
||||
}
|
||||
|
||||
export async function apiPost(
|
||||
url: string,
|
||||
path: string,
|
||||
body: any,
|
||||
cookies?: string
|
||||
): Promise<Response> {
|
||||
return fetch(`${url}${path}`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(cookies),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function apiPut(
|
||||
url: string,
|
||||
path: string,
|
||||
body: any,
|
||||
cookies?: string
|
||||
): Promise<Response> {
|
||||
return fetch(`${url}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: buildHeaders(cookies),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function apiDelete(
|
||||
url: string,
|
||||
path: string,
|
||||
cookies?: string
|
||||
): Promise<Response> {
|
||||
return fetch(`${url}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(cookies),
|
||||
})
|
||||
}
|
||||
22
e2e/tests/support/browser-config.ts
Normal file
22
e2e/tests/support/browser-config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// `pikku db reset` only operates on files inside .pikku-runtime/, so the e2e db
|
||||
// lives there (not e2e/.tmp).
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
|
||||
// Browser e2e config.
|
||||
//
|
||||
// The harness (browser-hooks.ts) starts both servers itself:
|
||||
// - backend → backend/bin/start-e2e.ts (SQLite) on backendPort
|
||||
// - frontend → apps/app vite dev server on 6001, configured to proxy /api to
|
||||
// the backend (VITE_BACKEND_URL) and to treat its own origin as the API
|
||||
// base (VITE_API_URL=<appUrl>/api) so auth + RPC stay same-origin.
|
||||
export const browserConfig = {
|
||||
appUrl: process.env.APP_URL || 'http://localhost:6001',
|
||||
backendUrl: process.env.E2E_BACKEND_URL || 'http://localhost:6002',
|
||||
/** Isolated SQLite file the e2e backend serves. */
|
||||
dbFile: process.env.E2E_DB_FILE || resolve(repoRoot, '.pikku-runtime/browser-e2e.db'),
|
||||
timeout: Number(process.env.E2E_TIMEOUT ?? 60_000),
|
||||
headed: process.env.HEADED === '1',
|
||||
}
|
||||
136
e2e/tests/support/browser-hooks.ts
Normal file
136
e2e/tests/support/browser-hooks.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { BeforeAll, AfterAll, Before, After, setDefaultTimeout } from '@cucumber/cucumber'
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process'
|
||||
import { rmSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { browserConfig } from './browser-config.js'
|
||||
import type { BrowserWorld } from './browser-world.js'
|
||||
|
||||
setDefaultTimeout(browserConfig.timeout)
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../../..')
|
||||
|
||||
// Provision the isolated e2e db with `pikku db reset` (migrations + seed);
|
||||
// PIKKU_SQLITE_DB points createConfig at this file.
|
||||
function provisionDb(dbFile: string): void {
|
||||
const res = spawnSync('npx', ['pikku', 'db', 'reset'], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, PIKKU_SQLITE_DB: dbFile },
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
throw new Error(
|
||||
`pikku db reset failed (${res.status}):\n${res.stdout ?? ''}\n${res.stderr ?? ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let backendProcess: ChildProcess | undefined
|
||||
let frontendProcess: ChildProcess | undefined
|
||||
|
||||
async function waitForServer(url: string, label: string, timeoutMs = 90_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok || res.status < 500) {
|
||||
console.log(`[${label}] ready on ${url}`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`[browser-e2e] ${label} not ready within ${timeoutMs / 1000}s on ${url}`)
|
||||
}
|
||||
|
||||
function startProcess(cmd: string, cwd: string, env: Record<string, string>, label: string) {
|
||||
const p = spawn(cmd, { cwd, env, stdio: 'pipe', shell: true, detached: true })
|
||||
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
|
||||
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
|
||||
return p
|
||||
}
|
||||
|
||||
function stopProcess(p: ChildProcess | undefined) {
|
||||
if (!p?.pid) return
|
||||
try {
|
||||
process.kill(-p.pid, 'SIGTERM')
|
||||
} catch {
|
||||
try {
|
||||
p.kill('SIGTERM')
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BeforeAll(async function () {
|
||||
const backendPort = new URL(browserConfig.backendUrl).port || '6002'
|
||||
|
||||
// Fresh SQLite file per run, provisioned by `pikku db reset`.
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
rmSync(`${browserConfig.dbFile}${suffix}`, { force: true })
|
||||
}
|
||||
mkdirSync(dirname(browserConfig.dbFile), { recursive: true })
|
||||
provisionDb(browserConfig.dbFile)
|
||||
|
||||
console.log('[browser-e2e] starting backend…')
|
||||
backendProcess = startProcess(
|
||||
'npx tsx backend/bin/start-e2e.ts',
|
||||
repoRoot,
|
||||
{
|
||||
...(process.env as Record<string, string>),
|
||||
PORT: backendPort,
|
||||
E2E_DB_FILE: browserConfig.dbFile,
|
||||
// 32+ chars keeps better-auth quiet; BETTER_AUTH_URL = the app origin so
|
||||
// better-auth trusts the browser's Origin (requests arrive via the vite
|
||||
// same-origin proxy).
|
||||
BETTER_AUTH_SECRET: 'e2e-browser-secret-32-characters-long',
|
||||
BETTER_AUTH_URL: browserConfig.appUrl,
|
||||
AUTH_SECRET: 'e2e-browser-secret-32-characters-long',
|
||||
},
|
||||
'backend'
|
||||
)
|
||||
await waitForServer(`${browserConfig.backendUrl}/health-check`, 'backend')
|
||||
// Admin (admin@perauset.org / "test") comes from db/sqlite-seed.sql via
|
||||
// `pikku db reset`.
|
||||
|
||||
console.log('[browser-e2e] starting frontend…')
|
||||
frontendProcess = startProcess(
|
||||
'yarn dev',
|
||||
resolve(repoRoot, 'apps/app'),
|
||||
{
|
||||
...(process.env as Record<string, string>),
|
||||
// Absolute, app-origin API base: SSR-safe and keeps browser calls
|
||||
// same-origin (no CORS). The vite proxy maps /api/auth → backend/api/auth
|
||||
// and /api/* → backend root.
|
||||
VITE_API_URL: `${browserConfig.appUrl}/api`,
|
||||
VITE_BACKEND_URL: browserConfig.backendUrl,
|
||||
},
|
||||
'frontend'
|
||||
)
|
||||
await waitForServer(`${browserConfig.appUrl}/login`, 'frontend')
|
||||
})
|
||||
|
||||
AfterAll(async function () {
|
||||
stopProcess(backendProcess)
|
||||
stopProcess(frontendProcess)
|
||||
backendProcess = undefined
|
||||
frontendProcess = undefined
|
||||
})
|
||||
|
||||
Before(async function (this: BrowserWorld) {
|
||||
await this.openBrowser()
|
||||
})
|
||||
|
||||
After(async function (this: BrowserWorld, scenario) {
|
||||
if (scenario.result?.status === 'FAILED' && this.page) {
|
||||
const safe = scenario.pickle.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()
|
||||
await this.page
|
||||
.screenshot({ path: `/tmp/e2e-fail-${safe}.png`, fullPage: true })
|
||||
.catch(() => {})
|
||||
}
|
||||
await this.closeBrowser()
|
||||
})
|
||||
147
e2e/tests/support/browser-world.ts
Normal file
147
e2e/tests/support/browser-world.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber'
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
|
||||
import { expect } from '@playwright/test'
|
||||
import { browserConfig } from './browser-config.js'
|
||||
|
||||
export class BrowserWorld extends World {
|
||||
browser: Browser | null = null
|
||||
context: BrowserContext | null = null
|
||||
page: Page | null = null
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
async openBrowser(): Promise<void> {
|
||||
this.browser = await chromium.launch({
|
||||
headless: !browserConfig.headed,
|
||||
})
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
})
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(browserConfig.timeout)
|
||||
}
|
||||
|
||||
async closeBrowser(): Promise<void> {
|
||||
if (this.page) {
|
||||
await this.page.close().catch(() => {})
|
||||
this.page = null
|
||||
}
|
||||
if (this.context) {
|
||||
await this.context.close().catch(() => {})
|
||||
this.context = null
|
||||
}
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {})
|
||||
this.browser = null
|
||||
}
|
||||
}
|
||||
|
||||
async login(email: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await page.goto(`${browserConfig.appUrl}/login`, { waitUntil: 'networkidle' })
|
||||
|
||||
// The login page pre-fills demo credentials; overwrite with the test user.
|
||||
const emailInput = page.locator('input[type="email"]')
|
||||
await emailInput.fill(email)
|
||||
const passwordInput = page.locator('input[type="password"]')
|
||||
await passwordInput.fill('test')
|
||||
|
||||
await page.getByRole('button', { name: /sign in/i }).click()
|
||||
|
||||
// Wait for redirect away from /login, then for the app shell heading.
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 })
|
||||
await page.getByRole('heading', { name: 'Dashboard' }).first().waitFor({ state: 'visible', timeout: 30_000 })
|
||||
}
|
||||
|
||||
async navigateTo(path: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await page.goto(`${browserConfig.appUrl}${path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
||||
// Wait for client components to hydrate
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
async clickLink(name: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await page.getByRole('link', { name }).click()
|
||||
// Small wait for navigation
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async clickButton(name: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
const btn = page.getByRole('button', { name, exact: true })
|
||||
const count = await btn.count()
|
||||
if (count > 1) {
|
||||
// Multiple buttons — close any open dropdowns first, then click the last one (in drawer)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(300)
|
||||
// Re-query after Escape (drawer might have closed)
|
||||
const btn2 = page.getByRole('button', { name, exact: true })
|
||||
const count2 = await btn2.count()
|
||||
const target = count2 > 1 ? btn2.nth(count2 - 1) : btn2
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
await target.click({ timeout: 10_000 })
|
||||
} else {
|
||||
await btn.scrollIntoViewIfNeeded()
|
||||
await btn.click({ timeout: 10_000 })
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async fillField(label: string, value: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
const scope =
|
||||
(await page.getByRole('dialog').count()) > 0
|
||||
? page.getByRole('dialog')
|
||||
: page
|
||||
const control = page.locator('input:not([type="hidden"]), textarea')
|
||||
let input = scope.getByLabel(label, { exact: true }).and(control)
|
||||
if ((await input.count()) === 0) {
|
||||
input = scope.getByLabel(label).and(control)
|
||||
}
|
||||
const field = input.first()
|
||||
await field.clear()
|
||||
await field.fill(value)
|
||||
}
|
||||
|
||||
async selectOption(label: string, value: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
const scope =
|
||||
(await page.getByRole('dialog').count()) > 0
|
||||
? page.getByRole('dialog')
|
||||
: page
|
||||
const control = page.locator('input, [role="combobox"], select')
|
||||
let input = scope.getByLabel(label, { exact: true }).and(control)
|
||||
if ((await input.count()) === 0) {
|
||||
input = scope.getByLabel(label).and(control)
|
||||
}
|
||||
await input.first().click()
|
||||
await page.getByRole('option', { name: value, exact: true }).first().click()
|
||||
}
|
||||
|
||||
async expectText(text: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await expect(page.getByText(text, { exact: false }).first()).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async expectNoText(text: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await expect(page.getByText(text, { exact: false })).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async expectHeading(text: string): Promise<void> {
|
||||
const page = this.getPage()
|
||||
await expect(page.getByRole('heading', { name: text }).first()).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
getPage(): Page {
|
||||
if (!this.page) {
|
||||
throw new Error('Browser not open — did the Before hook run?')
|
||||
}
|
||||
return this.page
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(BrowserWorld)
|
||||
19
e2e/tests/support/config.ts
Normal file
19
e2e/tests/support/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
export interface TestConfig {
|
||||
apiUrl: string
|
||||
/** Path to the SQLite file the e2e backend serves and the steps seed into. */
|
||||
dbFile: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
// `pikku db reset` only operates on files inside .pikku-runtime/, so the e2e db
|
||||
// lives there (not e2e/.tmp).
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
|
||||
export const config: TestConfig = {
|
||||
apiUrl: process.env.API_URL || 'http://localhost:6099',
|
||||
dbFile: process.env.E2E_DB_FILE || resolve(repoRoot, '.pikku-runtime/e2e-api.db'),
|
||||
timeout: 30_000,
|
||||
}
|
||||
66
e2e/tests/support/db.ts
Normal file
66
e2e/tests/support/db.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import Database from 'better-sqlite3'
|
||||
import { config } from './config.js'
|
||||
|
||||
// better-auth scrypt hash for the password "test" (salt:hash, self-contained).
|
||||
// Every e2e user gets this credential so `I login as "<email>"` works.
|
||||
export const TEST_PASSWORD = 'test'
|
||||
const TEST_PASSWORD_HASH =
|
||||
'0b27de3eeeab546bf7bce4a94511417b:9f68ffc9cd63e21104cd033699ac9c695ddcb4cb982ea30def1b12751de1012aa12de46cd17c1e4c79276299dc8498e7d177ee59156ab7833d1d026e9d9647b6'
|
||||
|
||||
/** Open a short-lived connection to the e2e SQLite file the backend serves. */
|
||||
export function openDb(): Database.Database {
|
||||
const db = new Database(config.dbFile)
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.pragma('busy_timeout = 5000')
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a better-auth user exists with the given roles and a credential
|
||||
* account (password "test"). Idempotent — updates roles if the user exists.
|
||||
* Returns the user id.
|
||||
*/
|
||||
export function ensureUser(email: string, roles: string[] = []): string {
|
||||
const db = openDb()
|
||||
try {
|
||||
const existing = db
|
||||
.prepare('SELECT id FROM user WHERE email = ?')
|
||||
.get(email) as { id: string } | undefined
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE user SET member_roles = ? WHERE id = ?').run(
|
||||
JSON.stringify(roles),
|
||||
existing.id
|
||||
)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
const id = randomUUID()
|
||||
db.prepare(
|
||||
`INSERT INTO user (id, email, name, display_name, email_verified, member_roles)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`
|
||||
).run(id, email, email, email, JSON.stringify(roles))
|
||||
db.prepare(
|
||||
`INSERT INTO account (id, account_id, provider_id, user_id, password)
|
||||
VALUES (?, ?, 'credential', ?, ?)`
|
||||
).run(randomUUID(), id, id, TEST_PASSWORD_HASH)
|
||||
return id
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up a user id by email. Throws if not found. */
|
||||
export function getUserId(email: string): string {
|
||||
const db = openDb()
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM user WHERE email = ?')
|
||||
.get(email) as { id: string } | undefined
|
||||
if (!row) throw new Error(`User ${email} not found in e2e DB`)
|
||||
return row.id
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
95
e2e/tests/support/hooks.ts
Normal file
95
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { BeforeAll, AfterAll, setDefaultTimeout } from '@cucumber/cucumber'
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { config } from './config.js'
|
||||
|
||||
setDefaultTimeout(config.timeout)
|
||||
|
||||
let serverProcess: ChildProcess | undefined
|
||||
|
||||
// Provision the isolated e2e db with the real tooling: `pikku db reset` applies
|
||||
// every migration in db/sqlite/ and the db/sqlite-seed.sql seed. PIKKU_SQLITE_DB
|
||||
// points createConfig at this file (see packages/functions/src/config.ts).
|
||||
function provisionDb(projectDir: string, dbFile: string): void {
|
||||
const res = spawnSync('npx', ['pikku', 'db', 'reset'], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, PIKKU_SQLITE_DB: dbFile },
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
throw new Error(
|
||||
`pikku db reset failed (${res.status}):\n${res.stdout ?? ''}\n${res.stderr ?? ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok || res.status < 500) {
|
||||
console.log(`[backend] ready on ${url}`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`Backend did not start within ${timeoutMs / 1000}s on ${url}`)
|
||||
}
|
||||
|
||||
BeforeAll(async function () {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectDir = resolve(__dirname, '../../..')
|
||||
const port = new URL(config.apiUrl).port
|
||||
|
||||
// Fresh SQLite file per run, provisioned by `pikku db reset`.
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
rmSync(`${config.dbFile}${suffix}`, { force: true })
|
||||
}
|
||||
mkdirSync(dirname(config.dbFile), { recursive: true })
|
||||
provisionDb(projectDir, config.dbFile)
|
||||
|
||||
const serverEnv: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
PORT: port,
|
||||
E2E_DB_FILE: config.dbFile,
|
||||
BETTER_AUTH_SECRET: 'e2e-test-secret',
|
||||
AUTH_SECRET: 'e2e-test-secret',
|
||||
}
|
||||
|
||||
// Start the isolated SQLite backend.
|
||||
serverProcess = spawn('npx', ['tsx', 'backend/bin/start-e2e.ts'], {
|
||||
cwd: projectDir,
|
||||
env: serverEnv,
|
||||
stdio: 'pipe',
|
||||
detached: true,
|
||||
})
|
||||
|
||||
serverProcess.stderr?.on('data', (d: Buffer) =>
|
||||
process.stderr.write(`[backend] ${d}`)
|
||||
)
|
||||
serverProcess.stdout?.on('data', (d: Buffer) =>
|
||||
process.stdout.write(`[backend] ${d}`)
|
||||
)
|
||||
|
||||
await waitForServer(`${config.apiUrl}/health-check`)
|
||||
// The admin (admin@perauset.org / "test") comes from db/sqlite-seed.sql via
|
||||
// `pikku db reset`. Per-scenario users are created at runtime by the
|
||||
// `a user ... exists` steps (ensureUser).
|
||||
})
|
||||
|
||||
AfterAll(async function () {
|
||||
if (serverProcess?.pid) {
|
||||
try {
|
||||
process.kill(-serverProcess.pid, 'SIGTERM')
|
||||
} catch {
|
||||
// process may already be dead
|
||||
}
|
||||
serverProcess = undefined
|
||||
}
|
||||
})
|
||||
124
e2e/tests/support/world.ts
Normal file
124
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber'
|
||||
import { strict as assert } from 'assert'
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from './api-client.js'
|
||||
import { config } from './config.js'
|
||||
import { getUserId, TEST_PASSWORD } from './db.js'
|
||||
|
||||
export class PerausetWorld extends World {
|
||||
lastResponse: Response | null = null
|
||||
lastBody: any = null
|
||||
cookies: string = ''
|
||||
currentUserId: string = ''
|
||||
currentEmail: string = ''
|
||||
createdUsers: Record<string, string> = {}
|
||||
lastAssignmentId: string = ''
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate as the given user via better-auth's email/password sign-in.
|
||||
* POST /api/auth/sign-in/email with JSON {email, password} and capture the
|
||||
* session cookie from the response.
|
||||
*/
|
||||
async login(email: string): Promise<void> {
|
||||
const res = await fetch(`${config.apiUrl}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
// better-auth requires a trusted Origin for state-changing auth calls.
|
||||
Origin: config.apiUrl,
|
||||
},
|
||||
body: JSON.stringify({ email, password: TEST_PASSWORD }),
|
||||
redirect: 'manual',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Login failed for ${email}: ${res.status} ${text}`)
|
||||
}
|
||||
this.cookies = this.extractSetCookies(res)
|
||||
this.currentEmail = email
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the current user's id from the SQLite DB using the stored email.
|
||||
*/
|
||||
async getSessionUserId(): Promise<string> {
|
||||
assert.ok(this.currentEmail, 'No current email set -- did you call login() first?')
|
||||
return getUserId(this.currentEmail)
|
||||
}
|
||||
|
||||
async get(path: string): Promise<void> {
|
||||
this.lastResponse = await apiGet(config.apiUrl, path, this.cookies)
|
||||
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
|
||||
this.captureCookies()
|
||||
}
|
||||
|
||||
async post(path: string, body: any): Promise<void> {
|
||||
this.lastResponse = await apiPost(config.apiUrl, path, body, this.cookies)
|
||||
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
|
||||
this.captureCookies()
|
||||
}
|
||||
|
||||
async put(path: string, body: any): Promise<void> {
|
||||
this.lastResponse = await apiPut(config.apiUrl, path, body, this.cookies)
|
||||
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
|
||||
this.captureCookies()
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
this.lastResponse = await apiDelete(config.apiUrl, path, this.cookies)
|
||||
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
|
||||
this.captureCookies()
|
||||
}
|
||||
|
||||
expectStatus(status: number): void {
|
||||
assert.ok(this.lastResponse, 'No response received')
|
||||
assert.equal(
|
||||
this.lastResponse.status,
|
||||
status,
|
||||
`Expected status ${status} but got ${this.lastResponse.status}. Body: ${JSON.stringify(this.lastBody)}`
|
||||
)
|
||||
}
|
||||
|
||||
private captureCookies(): void {
|
||||
const setCookie = this.lastResponse?.headers.get('set-cookie')
|
||||
if (setCookie) {
|
||||
this.cookies = this.mergeCookies(this.cookies, setCookie)
|
||||
}
|
||||
}
|
||||
|
||||
private extractSetCookies(res: Response): string {
|
||||
// getSetCookie() returns individual set-cookie values as an array
|
||||
const raw = (res.headers as any).getSetCookie?.() as string[] | undefined
|
||||
if (raw && raw.length > 0) {
|
||||
return raw.map((c: string) => c.split(';')[0]).join('; ')
|
||||
}
|
||||
// Fallback: use get('set-cookie') which may be comma-joined
|
||||
const header = res.headers.get('set-cookie')
|
||||
if (!header) return ''
|
||||
return header
|
||||
.split(/,(?=\s*\w+=)/)
|
||||
.map((c: string) => c.split(';')[0].trim())
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
private mergeCookies(existing: string, incoming: string): string {
|
||||
if (!existing) return incoming
|
||||
if (!incoming) return existing
|
||||
const map = new Map<string, string>()
|
||||
for (const part of existing.split(';').map((s) => s.trim()).filter(Boolean)) {
|
||||
const [name] = part.split('=', 1)
|
||||
map.set(name, part)
|
||||
}
|
||||
for (const part of incoming.split(';').map((s) => s.trim()).filter(Boolean)) {
|
||||
const [name] = part.split('=', 1)
|
||||
map.set(name, part)
|
||||
}
|
||||
return [...map.values()].join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(PerausetWorld)
|
||||
Reference in New Issue
Block a user