chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:35:17 +02:00
commit 37eea09bf0
293 changed files with 28412 additions and 0 deletions

View 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),
})
}

View 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',
}

View 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()
})

View 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)

View 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
View 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()
}
}

View 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
View 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)