chore: germantax customer project
This commit is contained in:
20
e2e/package.json
Normal file
20
e2e/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@germantax/e2e",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "End-to-end test harness — Cucumber + Playwright.",
|
||||
"scripts": {
|
||||
"test": "E2E_TIMEOUT=90000 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:headed": "HEADED=1 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
7
e2e/tests/cucumber.mjs
Normal file
7
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
24
e2e/tests/features/auth.feature
Normal file
24
e2e/tests/features/auth.feature
Normal file
@@ -0,0 +1,24 @@
|
||||
Feature: Authentication
|
||||
|
||||
Scenario: Redirect unauthenticated user to login
|
||||
Given I visit "/companies"
|
||||
Then the URL contains "/login"
|
||||
|
||||
Scenario: Successful login redirects to companies
|
||||
Given I visit "/login"
|
||||
When I fill "input[type='email']" with "founder@example.com"
|
||||
And I fill "input[type='password']" with "test1234"
|
||||
And I click "Sign in"
|
||||
Then the URL contains "/companies"
|
||||
|
||||
Scenario: Wrong password shows an error
|
||||
Given I visit "/login"
|
||||
When I fill "input[type='email']" with "founder@example.com"
|
||||
And I fill "input[type='password']" with "wrongpassword"
|
||||
And I click "Sign in"
|
||||
Then I see "Email or password is incorrect"
|
||||
|
||||
Scenario: Logged-in user can log out
|
||||
Given I am logged in as "founder@example.com" with password "test1234"
|
||||
When I log out
|
||||
Then I see "Signed out"
|
||||
20
e2e/tests/features/companies.feature
Normal file
20
e2e/tests/features/companies.feature
Normal file
@@ -0,0 +1,20 @@
|
||||
Feature: Companies
|
||||
|
||||
Background:
|
||||
Given I am logged in as "founder@example.com" with password "test1234"
|
||||
|
||||
Scenario: Companies page loads with seed data
|
||||
Given I visit "/companies"
|
||||
Then I see "Your companies"
|
||||
And I see "Beispiel GmbH"
|
||||
|
||||
Scenario: Navigate to a company detail page
|
||||
Given I visit "/companies"
|
||||
When I click "Beispiel GmbH"
|
||||
Then the URL contains "/companies/"
|
||||
And I see "Beispiel GmbH"
|
||||
|
||||
Scenario: Company detail shows resolutions section
|
||||
Given I visit "/companies"
|
||||
When I click "Beispiel GmbH"
|
||||
Then I see "Resolutions"
|
||||
20
e2e/tests/features/resolutions.feature
Normal file
20
e2e/tests/features/resolutions.feature
Normal file
@@ -0,0 +1,20 @@
|
||||
Feature: Resolutions
|
||||
|
||||
Background:
|
||||
Given I am logged in as "founder@example.com" with password "test1234"
|
||||
|
||||
Scenario: Open new resolution modal from company page
|
||||
Given I visit "/companies"
|
||||
When I click "Beispiel GmbH"
|
||||
And I click "New resolution"
|
||||
Then I see "Select template"
|
||||
|
||||
Scenario: Create a new resolution and land on its detail page
|
||||
Given I visit "/companies"
|
||||
When I click "Beispiel GmbH"
|
||||
And I click "New resolution"
|
||||
And I select "Annual budget approval" from "Select template"
|
||||
And I fill "Title" with "Budget Review 2025"
|
||||
And I click "Create draft"
|
||||
Then the URL contains "/resolutions/"
|
||||
And I see "Budget Review 2025"
|
||||
50
e2e/tests/reports/cucumber-report.html
Normal file
50
e2e/tests/reports/cucumber-report.html
Normal file
File diff suppressed because one or more lines are too long
234
e2e/tests/steps/common.steps.ts
Normal file
234
e2e/tests/steps/common.steps.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
Given('I visit {string}', async function (this: AppWorld, path: string) {
|
||||
await this.gotoApp(path)
|
||||
})
|
||||
|
||||
Given(
|
||||
'I am logged in as {string} with password {string}',
|
||||
async function (this: AppWorld, email: string, password: string) {
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When('I log in as {string} with password {string}', async function (
|
||||
this: AppWorld,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await this.login(email, password)
|
||||
})
|
||||
|
||||
When('I log out', async function (this: AppWorld) {
|
||||
await this.logout()
|
||||
})
|
||||
|
||||
const looksLikeCss = (s: string) => /[\[#.>:]/.test(s) || s.startsWith('input')
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
async function field(world: AppWorld, name: string) {
|
||||
if (looksLikeCss(name)) return world.page.locator(name).first()
|
||||
const byId = world.page.locator(`[data-testid="${name}"]`).first()
|
||||
try {
|
||||
await byId.waitFor({ state: 'attached', timeout: 5000 })
|
||||
const inner = byId.locator('input, textarea, select').first()
|
||||
if (await inner.count()) return inner
|
||||
return byId
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const byLabel = world.page.getByLabel(name, { exact: false })
|
||||
if (await byLabel.count()) return byLabel.first()
|
||||
return world.page.getByPlaceholder(name, { exact: false }).first()
|
||||
}
|
||||
|
||||
async function isVisibleWithin(
|
||||
target: ReturnType<AppWorld['page']['locator']>,
|
||||
timeout = 5000,
|
||||
) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
|
||||
try {
|
||||
await target.click({ timeout })
|
||||
return
|
||||
} catch {
|
||||
await target.evaluate((node) => {
|
||||
;(node as HTMLElement).click()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
|
||||
if (previousUrl) {
|
||||
await world.page
|
||||
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(750)
|
||||
}
|
||||
|
||||
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
|
||||
await activate(target)
|
||||
await settleAfterClick(world)
|
||||
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
return
|
||||
}
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
|
||||
When(
|
||||
'I fill {string} with {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await target.fill('')
|
||||
await target.fill(value)
|
||||
},
|
||||
)
|
||||
|
||||
When('I check {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.check()
|
||||
})
|
||||
|
||||
When('I uncheck {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.uncheck()
|
||||
})
|
||||
|
||||
When(
|
||||
'I select {string} from {string}',
|
||||
async function (this: AppWorld, value: string, name: string) {
|
||||
const target = await field(this, name)
|
||||
const tag = await target
|
||||
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
|
||||
.catch(() => '')
|
||||
if (tag === 'select') {
|
||||
await target.selectOption({ label: value })
|
||||
return
|
||||
}
|
||||
await target.click()
|
||||
await this.page.getByRole('option', { name: value, exact: false }).first().click()
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'in row {string} I do not see {string}',
|
||||
async function (this: AppWorld, rowText: string, text: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'in row {string} I click {string}',
|
||||
async function (this: AppWorld, rowText: string, label: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
const roles = ['button', 'link', 'menuitem'] as const
|
||||
const previousUrl = this.page.url()
|
||||
for (const role of roles) {
|
||||
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await activate(candidate)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
const byText = row.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await activate(byText)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
|
||||
},
|
||||
)
|
||||
|
||||
When('I click {string}', async function (this: AppWorld, label: string) {
|
||||
const byId = this.page.getByTestId(label).first()
|
||||
if (await isVisibleWithin(byId)) {
|
||||
await clickAndConfirmIfNeeded(this, byId)
|
||||
return
|
||||
}
|
||||
const fuzzyName = new RegExp(escapeRegex(label), 'i')
|
||||
const roles = ['button', 'tab', 'link', 'menuitem'] as const
|
||||
for (const role of roles) {
|
||||
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await clickAndConfirmIfNeeded(this, candidate)
|
||||
return
|
||||
}
|
||||
}
|
||||
const byText = this.page.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await clickAndConfirmIfNeeded(this, byText)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}"`)
|
||||
})
|
||||
|
||||
When('I accept the next confirmation', async function (this: AppWorld) {
|
||||
this.page.once('dialog', (d) => void d.accept())
|
||||
})
|
||||
|
||||
Then('I see {string}', async function (this: AppWorld, text: string) {
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the field {string} has value {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await expect(target).toHaveValue(value)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I do not see {string}', async function (this: AppWorld, text: string) {
|
||||
await expect(
|
||||
this.page.getByText(text, { exact: false }).first(),
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
})
|
||||
|
||||
Then(
|
||||
'the URL does not contain {string}',
|
||||
async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => !url.toString().includes(fragment))
|
||||
},
|
||||
)
|
||||
248
e2e/tests/support/hooks.ts
Normal file
248
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Before,
|
||||
After,
|
||||
BeforeAll,
|
||||
AfterAll,
|
||||
setDefaultTimeout,
|
||||
} from '@cucumber/cucumber'
|
||||
import { cp, mkdir, rm } from 'fs/promises'
|
||||
import { spawn, execFileSync, type ChildProcess } from 'child_process'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import { createRequire } from 'module'
|
||||
import type { AppWorld } from './world.js'
|
||||
import { config } from './types.js'
|
||||
|
||||
setDefaultTimeout(config.responseTimeout)
|
||||
|
||||
let backendProcess: ChildProcess | undefined
|
||||
let frontendProcess: ChildProcess | undefined
|
||||
let goldenDbPath: string | undefined
|
||||
let scenarioDbPath: string | undefined
|
||||
|
||||
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.status < 500) return
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
function startProcess({
|
||||
cmd,
|
||||
cwd,
|
||||
env,
|
||||
label,
|
||||
detached = true,
|
||||
}: {
|
||||
cmd: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
label: string
|
||||
detached?: boolean
|
||||
}) {
|
||||
const p = spawn(cmd, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached,
|
||||
})
|
||||
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
|
||||
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
|
||||
return p
|
||||
}
|
||||
|
||||
async function stopProcess(p: ChildProcess | undefined) {
|
||||
if (!p?.pid) return
|
||||
try {
|
||||
process.kill(-p.pid, 'SIGTERM')
|
||||
} catch {
|
||||
try {
|
||||
p.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
|
||||
function killPort(port: number) {
|
||||
try {
|
||||
const out = execFileSync('lsof', ['-ti', `tcp:${port}`], {
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
if (!out) return
|
||||
for (const pid of out.split(/\s+/)) {
|
||||
if (!pid) continue
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no listeners / lsof unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../../..')
|
||||
const runtimeDir = resolve(repoRoot, '.e2e-runtime')
|
||||
const functionsOutDir = resolve(repoRoot, 'packages/functions/.pikku')
|
||||
const defaultFrontendCmd = 'bun run dev'
|
||||
|
||||
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
|
||||
// importable by bare specifier. Resolve the package dir via the `.` export and
|
||||
// dynamic-import the file by absolute path — the exports map only governs bare
|
||||
// specifiers, not absolute paths. Lazy (not top-level) because cucumber
|
||||
// transpiles this module to CJS, which bans top-level await.
|
||||
async function loadDbHelpers() {
|
||||
const cliBinDir = dirname(createRequire(import.meta.url).resolve('@pikku/cli'))
|
||||
return import(
|
||||
pathToFileURL(resolve(cliBinDir, '../src/functions/db/local-db.js')).href
|
||||
)
|
||||
}
|
||||
|
||||
// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
|
||||
// registered ESM loader the CLI's memoized sqlite runtime reads an empty schema;
|
||||
// a fresh DatabaseSync against the same file sees the tables correctly.
|
||||
async function seedGoldenDb(dbFile: string, seedFile: string) {
|
||||
const { readFileSync, existsSync } = await import('fs')
|
||||
if (!seedFile || !existsSync(seedFile)) return
|
||||
const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
|
||||
DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void }
|
||||
}>)
|
||||
const db = new DatabaseSync(dbFile)
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.exec(readFileSync(seedFile, 'utf8'))
|
||||
db.exec('COMMIT')
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function buildGoldenDb() {
|
||||
const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers()
|
||||
await mkdir(runtimeDir, { recursive: true })
|
||||
goldenDbPath = resolve(runtimeDir, 'golden.db')
|
||||
await rm(goldenDbPath, { force: true })
|
||||
const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir)
|
||||
if (!resolved) {
|
||||
throw new Error('[e2e] failed to resolve local DB config')
|
||||
}
|
||||
reset(resolved, repoRoot)
|
||||
await migrateAndCodegen(resolved)
|
||||
await seedGoldenDb(resolved.dbFile, resolved.seedFile)
|
||||
}
|
||||
|
||||
async function startScenarioBackend(scenarioId: string) {
|
||||
if (!goldenDbPath) {
|
||||
throw new Error('[e2e] golden DB missing')
|
||||
}
|
||||
await stopProcess(backendProcess)
|
||||
killPort(4003)
|
||||
scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`)
|
||||
await rm(scenarioDbPath, { force: true })
|
||||
await cp(goldenDbPath, scenarioDbPath)
|
||||
backendProcess = startProcess({
|
||||
cmd: 'bun run dev:backend',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
PIKKU_DEV_DB_FILE: scenarioDbPath,
|
||||
DATABASE_URL: `file:${scenarioDbPath}`,
|
||||
},
|
||||
label: 'backend',
|
||||
})
|
||||
await waitForUrl(config.apiUrl, 'backend')
|
||||
}
|
||||
|
||||
BeforeAll(async function () {
|
||||
process.env.API_URL = process.env.API_URL ?? 'http://localhost:4003'
|
||||
process.env.APP_URL = process.env.APP_URL ?? 'http://localhost:7104'
|
||||
|
||||
if (config.isolatedDb) {
|
||||
await buildGoldenDb()
|
||||
}
|
||||
|
||||
if (!config.manageServers) {
|
||||
if (config.isolatedDb) {
|
||||
throw new Error('[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd
|
||||
killPort(7104)
|
||||
|
||||
frontendProcess = startProcess({
|
||||
cmd: frontendCmd,
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
VITE_API_URL: process.env.API_URL,
|
||||
},
|
||||
label: 'frontend',
|
||||
})
|
||||
await waitForUrl(config.appUrl, 'frontend')
|
||||
})
|
||||
|
||||
AfterAll(async function () {
|
||||
await stopProcess(backendProcess)
|
||||
await stopProcess(frontendProcess)
|
||||
if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {})
|
||||
if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
Before(async function (this: AppWorld, { pickle }) {
|
||||
if (config.isolatedDb) {
|
||||
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
await startScenarioBackend(`scenario-${scenarioId}`)
|
||||
}
|
||||
await this.openBrowser()
|
||||
if (!config.isolatedDb && config.resetUrl) {
|
||||
const body = config.resetRpcName
|
||||
? JSON.stringify({ rpcName: config.resetRpcName, data: {} })
|
||||
: undefined
|
||||
await fetch(config.resetUrl, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
process.stderr.write(
|
||||
`[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
After(async function (this: AppWorld) {
|
||||
await this.closeBrowser()
|
||||
if (config.isolatedDb) {
|
||||
await stopProcess(backendProcess)
|
||||
backendProcess = undefined
|
||||
if (scenarioDbPath) {
|
||||
await rm(scenarioDbPath, { force: true }).catch(() => {})
|
||||
scenarioDbPath = undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
38
e2e/tests/support/types.ts
Normal file
38
e2e/tests/support/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Project-level e2e configuration. Override via env vars per environment.
|
||||
//
|
||||
// Local defaults:
|
||||
// apps/app → vite on 7104
|
||||
// backend → Pikku API on 4003
|
||||
|
||||
export const config = {
|
||||
/** URL of the running frontend (vite dev server or built preview). */
|
||||
get appUrl() {
|
||||
return process.env.APP_URL ?? 'http://localhost:7104'
|
||||
},
|
||||
/** URL of the running backend (Pikku API). */
|
||||
get apiUrl() {
|
||||
return process.env.API_URL ?? 'http://localhost:4003'
|
||||
},
|
||||
/** Per-step Playwright timeout (ms). */
|
||||
get responseTimeout() {
|
||||
return Number(process.env.E2E_TIMEOUT ?? 30_000)
|
||||
},
|
||||
/** Whether the harness should spawn the dev servers itself. */
|
||||
get manageServers() {
|
||||
return process.env.E2E_MANAGE_SERVERS === '1'
|
||||
},
|
||||
/** Whether the harness should isolate each scenario with a copied golden DB. */
|
||||
get isolatedDb() {
|
||||
return process.env.E2E_ISOLATED_DB === '1'
|
||||
},
|
||||
/**
|
||||
* Reset hook URL — POST here to reset the DB to seed state between scenarios.
|
||||
* Project must implement this as a sessionless RPC gated behind a test-only flag.
|
||||
*/
|
||||
get resetUrl() {
|
||||
return process.env.E2E_RESET_URL
|
||||
},
|
||||
get resetRpcName() {
|
||||
return process.env.E2E_RESET_RPC_NAME
|
||||
},
|
||||
} as const
|
||||
109
e2e/tests/support/world.ts
Normal file
109
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test'
|
||||
import { config } from './types.js'
|
||||
|
||||
export class AppWorld extends World {
|
||||
browser!: Browser
|
||||
context!: BrowserContext
|
||||
page!: Page
|
||||
|
||||
async openBrowser() {
|
||||
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
|
||||
this.browser = await chromium.launch({
|
||||
headless: !headed,
|
||||
slowMo: headed ? 150 : 0,
|
||||
})
|
||||
this.context = await this.browser.newContext({
|
||||
locale: process.env.E2E_LOCALE ?? 'de-DE',
|
||||
})
|
||||
await this.context.addInitScript((apiUrl) => {
|
||||
;(window as typeof window & { __E2E_API_URL?: string }).__E2E_API_URL = apiUrl
|
||||
}, config.apiUrl)
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(config.responseTimeout)
|
||||
}
|
||||
|
||||
async closeBrowser() {
|
||||
await this.context?.close()
|
||||
await this.browser?.close()
|
||||
}
|
||||
|
||||
async gotoApp(path: string) {
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||
await this.page.goto(url)
|
||||
await this.page.waitForFunction(
|
||||
() => document.documentElement.dataset.appHydrated === 'true',
|
||||
undefined,
|
||||
{ timeout: config.responseTimeout },
|
||||
)
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
// Use Playwright's request API (same cookie jar, no SameSite restrictions)
|
||||
// so CSRF double-submit works across the auth-js proxy boundary.
|
||||
const csrfRes = await this.page.request.get(`${config.apiUrl}/auth/csrf`)
|
||||
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string }
|
||||
const body = new URLSearchParams({ email, password, csrfToken, callbackUrl: '/' })
|
||||
await this.page.request.post(`${config.apiUrl}/auth/callback/credentials`, {
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
data: body.toString(),
|
||||
})
|
||||
// Navigate to the app after auth cookies are set
|
||||
await this.gotoApp('/companies')
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.gotoApp('/logout')
|
||||
}
|
||||
|
||||
async callRpc(name: string, data: Record<string, unknown> = {}) {
|
||||
const apiUrl = config.apiUrl
|
||||
const result = await this.page.evaluate(
|
||||
async ({ apiUrl, name, data }) => {
|
||||
const res = await fetch(`${apiUrl}/rpc/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`RPC ${name} failed (${res.status}): ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
{ apiUrl, name, data },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async expectText(text: string) {
|
||||
const locator = this.page.getByText(text, { exact: false })
|
||||
const deadline = Date.now() + config.responseTimeout
|
||||
while (Date.now() < deadline) {
|
||||
const count = await locator.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (await locator.nth(i).isVisible().catch(() => false)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await this.page.waitForTimeout(100)
|
||||
}
|
||||
throw new Error(`Timed out waiting for visible text: ${text}`)
|
||||
}
|
||||
|
||||
async getPageText(): Promise<string> {
|
||||
return this.page.innerText('body')
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(AppWorld)
|
||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user