feat: implement new feature for enhanced user experience

This commit is contained in:
2026-02-11 16:36:56 +00:00
parent 62f1906276
commit dc00d24899
75 changed files with 2456 additions and 35 deletions

57
tests/api/fixtures.ts Normal file
View File

@@ -0,0 +1,57 @@
import { test as base, expect, APIRequestContext } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
const API_BASE = "/api"
interface ActionSuccess {
success: true
[key: string]: unknown
}
interface ActionError {
error: string
}
type ApiWorkerFixtures = {
testUser: TestUserCredentials
}
type ApiFixtures = {
api: APIRequestContext
authedApi: APIRequestContext
accessToken: string
}
export const test = base.extend<ApiFixtures, ApiWorkerFixtures>({
testUser: [
async ({ playwright }, use) => {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const user = await ensureTestUser(baseURL)
await use(user)
},
{ scope: "worker" },
],
api: async ({ request }, use) => {
await use(request)
},
accessToken: async ({ testUser }, use) => {
await use(testUser.accessToken)
},
authedApi: async ({ playwright, baseURL, accessToken }, use) => {
const ctx = await playwright.request.newContext({
baseURL: baseURL!,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Bearer ${accessToken}`,
},
})
await use(ctx)
await ctx.dispose()
},
})
export { expect, API_BASE }
export type { ActionSuccess, ActionError }

26
tests/api/health.spec.ts Normal file
View File

@@ -0,0 +1,26 @@
import { test, expect, API_BASE } from "./fixtures"
test.describe("API Health", () => {
test("should respond to API base endpoint", async ({ api }) => {
// tibi-server responds to the base API path
const res = await api.get(`${API_BASE}/`)
// Accept any successful response (200-299), 401 (auth required), or 404 (no root handler)
expect([200, 204, 401, 404]).toContain(res.status())
})
test("should respond to SSR collection", async ({ api }) => {
// The ssr collection is part of the starter kit
const res = await api.get(`${API_BASE}/ssr`)
expect(res.status()).toBeLessThan(500)
})
test("should reject invalid action commands", async ({ api }) => {
const res = await api.post(`${API_BASE}/action`, {
params: { cmd: "nonexistent_action" },
data: {},
})
// Should return an error, not crash
expect(res.status()).toBeGreaterThanOrEqual(400)
expect(res.status()).toBeLessThan(500)
})
})

View File

@@ -0,0 +1,76 @@
import { APIRequestContext, request } from "@playwright/test"
import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
let adminContext: APIRequestContext | null = null
/**
* Get or create a singleton admin API context with the ADMIN_TOKEN.
*/
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
if (!adminContext) {
adminContext = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Token: ADMIN_TOKEN,
},
})
}
return adminContext
}
/**
* Delete a user by ID via admin API.
*/
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
return res.ok()
}
/**
* Cleanup test users matching a pattern in their email.
*/
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.get(`${API_BASE}/user`)
if (!res.ok()) return 0
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
if (!Array.isArray(users)) return 0
let deleted = 0
for (const user of users) {
const email = user.email || ""
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
if (matches) {
const userId = user.id || user._id
if (userId) {
const ok = await deleteUser(baseURL, userId)
if (ok) deleted++
}
}
}
return deleted
}
/**
* Cleanup all test data (users and tokens matching @test.example.com).
*/
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
const testPattern = "@test.example.com"
const users = await cleanupTestUsers(baseURL, testPattern)
return { users }
}
/**
* Dispose the admin API context. Call in globalTeardown.
*/
export async function disposeAdminApi(): Promise<void> {
if (adminContext) {
await adminContext.dispose()
adminContext = null
}
}

View File

@@ -0,0 +1,162 @@
import { APIRequestContext, request } from "@playwright/test"
/**
* MailDev API helper for testing email flows.
*
* Configure via environment variables:
* - MAILDEV_URL: MailDev web UI URL (default: https://${PROJECT_NAME}-maildev.code.testversion.online)
* - MAILDEV_USER: Basic auth username (default: code)
* - MAILDEV_PASS: Basic auth password
*/
const MAILDEV_URL = process.env.MAILDEV_URL || "http://localhost:1080"
const MAILDEV_USER = process.env.MAILDEV_USER || "code"
const MAILDEV_PASS = process.env.MAILDEV_PASS || ""
export interface MailDevEmail {
id: string
subject: string
html: string
to: { address: string; name: string }[]
from: { address: string; name: string }[]
date: string
time: string
read: boolean
headers: Record<string, string>
}
let maildevContext: APIRequestContext | null = null
async function getContext(): Promise<APIRequestContext> {
if (!maildevContext) {
const opts: any = {
baseURL: MAILDEV_URL,
ignoreHTTPSErrors: true,
}
if (MAILDEV_PASS) {
opts.httpCredentials = {
username: MAILDEV_USER,
password: MAILDEV_PASS,
}
}
maildevContext = await request.newContext(opts)
}
return maildevContext
}
/**
* Get all emails from MailDev.
*/
export async function getAllEmails(): Promise<MailDevEmail[]> {
const ctx = await getContext()
const res = await ctx.get("/email")
if (!res.ok()) {
throw new Error(`MailDev API error: ${res.status()} ${res.statusText()}`)
}
return res.json()
}
/**
* Delete all emails in MailDev.
*/
export async function deleteAllEmails(): Promise<void> {
const ctx = await getContext()
await ctx.delete("/email/all")
}
/**
* Delete a specific email by ID.
*/
export async function deleteEmail(id: string): Promise<void> {
const ctx = await getContext()
await ctx.delete(`/email/${id}`)
}
/**
* Wait for an email to arrive for a specific recipient.
*/
export async function waitForEmail(
toEmail: string,
options: {
subject?: string | RegExp
timeout?: number
pollInterval?: number
} = {}
): Promise<MailDevEmail> {
const { subject, timeout = 5000, pollInterval = 250 } = options
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return match
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No email to ${toEmail} received within ${timeout}ms`)
}
/**
* Extract a 6-digit verification code from an email's HTML body.
*/
export function extractCode(email: MailDevEmail): string {
const match = email.html.match(/>(\d{6})<\//)
if (!match) {
throw new Error(`No 6-digit code found in email "${email.subject}"`)
}
return match[1]
}
/**
* Perform an action and capture the verification code from the resulting email.
* Filters out emails that existed before the action was triggered.
*/
export async function captureCode(
toEmail: string,
action: () => Promise<void>,
options: { subject?: string | RegExp; timeout?: number } = {}
): Promise<string> {
const existingEmails = await getAllEmails()
const existingIds = new Set(
existingEmails
.filter((e) => e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase()))
.map((e) => e.id)
)
await action()
const { timeout = 5000, subject } = options
const pollInterval = 250
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
if (existingIds.has(e.id)) return false
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return extractCode(match)
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No new email to ${toEmail} received within ${timeout}ms`)
}
/**
* Dispose the MailDev API context. Call in globalTeardown.
*/
export async function disposeMailDev(): Promise<void> {
if (maildevContext) {
await maildevContext.dispose()
maildevContext = null
}
}

View File

@@ -0,0 +1,72 @@
import { APIRequestContext, request } from "@playwright/test"
import { TEST_USER } from "../../fixtures/test-constants"
const API_BASE = "/api"
export interface TestUserCredentials {
email: string
password: string
accessToken: string
firstName: string
lastName: string
}
/**
* Login a user via the tibi action endpoint.
* Returns the access token or null on failure.
*/
export async function loginUser(baseURL: string, email: string, password: string): Promise<string | null> {
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
})
try {
const res = await ctx.post(`${API_BASE}/action`, {
params: { cmd: "login" },
data: { email, password },
})
if (!res.ok()) return null
const body = await res.json()
return body.accessToken || null
} finally {
await ctx.dispose()
}
}
/**
* Ensure the test user exists and is logged in.
* Tries login first; if that fails, logs a warning (registration must be
* implemented per project or the user must be seeded via globalSetup).
*/
export async function ensureTestUser(baseURL: string): Promise<TestUserCredentials> {
const email = TEST_USER.email
const password = TEST_USER.password
const token = await loginUser(baseURL, email, password)
if (token) {
return {
email,
password,
accessToken: token,
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}
// If login fails, the test user doesn't exist yet.
// Implement project-specific registration here, or seed via globalSetup.
console.warn(
`⚠️ Test user ${email} could not be logged in. ` +
`Either implement registration in test-user.ts or seed the user via globalSetup.`
)
// Return a placeholder tests requiring auth will fail gracefully
return {
email,
password,
accessToken: "",
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}

View File

@@ -0,0 +1,109 @@
import type { Page } from "@playwright/test"
import { test as base, expect as pwExpect } from "@playwright/test"
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
import { expect } from "../e2e/fixtures"
type MobileFixtures = {}
export const test = base.extend<MobileFixtures>({
/**
* Override page fixture: BrowserSync domcontentloaded workaround.
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
await use(page)
},
})
/**
* Wait for the SPA to be ready in mobile viewport.
*/
export async function waitForSpaReady(page: Page): Promise<string> {
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
return match?.[1] || "de"
}
/**
* Navigate to a route with language prefix.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
}
/** Check if viewport width is mobile (<768px) */
export function isMobileViewport(page: Page): boolean {
return (page.viewportSize()?.width ?? 0) < 768
}
/** Check if viewport width is tablet (768-1023px) */
export function isTabletViewport(page: Page): boolean {
const w = page.viewportSize()?.width ?? 0
return w >= 768 && w < 1024
}
/** Check if viewport width is below lg breakpoint (<1024px) */
export function isBelowLg(page: Page): boolean {
return (page.viewportSize()?.width ?? 0) < 1024
}
/**
* Open the hamburger/mobile navigation menu.
* Finds the first visible button with aria-expanded in the header.
*/
export async function openHamburgerMenu(page: Page): Promise<void> {
const hamburgers = page.locator("header button[aria-expanded]")
const count = await hamburgers.count()
let clicked = false
for (let i = 0; i < count; i++) {
const btn = hamburgers.nth(i)
if (await btn.isVisible()) {
await btn.click()
clicked = true
break
}
}
if (!clicked) {
throw new Error("No visible hamburger button found in header")
}
await page.waitForTimeout(500)
await page
.waitForFunction(
() => {
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
return btn !== null
},
{ timeout: 5000 }
)
.catch(() => {})
}
/**
* Close the hamburger menu via Escape key.
*/
export async function closeHamburgerMenuViaEscape(page: Page): Promise<void> {
await page.keyboard.press("Escape")
await page
.waitForFunction(
() => {
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
return btn === null
},
{ timeout: 5000 }
)
.catch(() => {})
}

View File

@@ -0,0 +1,28 @@
import { test, expect, waitForSpaReady, isMobileViewport } from "./fixtures"
test.describe("Home Page (Mobile)", () => {
test("should load the start page on mobile", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
expect(isMobileViewport(page) || true).toBeTruthy()
await expect(page.locator("#appContainer")).not.toBeEmpty()
})
test("should have a visible header", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
const header = page.locator("header")
await expect(header).toBeVisible()
})
// Uncomment when your project has a hamburger menu:
// test("should open hamburger menu", async ({ page }) => {
// await page.goto("/de/")
// await waitForSpaReady(page)
// await openHamburgerMenu(page)
// const expandedBtn = page.locator("header button[aria-expanded='true']")
// await expect(expandedBtn).toBeVisible()
// })
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,113 @@
import type { Page, Locator } from "@playwright/test"
import { test as base, expect as pwExpect } from "@playwright/test"
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
import { expect } from "../e2e/fixtures"
type VisualFixtures = {}
export const test = base.extend<VisualFixtures>({
/**
* Override page fixture: BrowserSync domcontentloaded workaround.
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
await use(page)
},
})
/**
* Wait for the SPA to be fully rendered and stable for visual comparison.
* Waits for skeleton loaders to disappear and CSS to settle.
*/
export async function waitForVisualReady(page: Page, opts?: { timeout?: number }): Promise<void> {
const timeout = opts?.timeout ?? 15000
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout })
try {
await page.waitForFunction(() => document.querySelectorAll(".animate-pulse").length === 0, { timeout: 10000 })
} catch {
// Skeleton loaders may not exist on every page
}
await page.waitForTimeout(800)
}
/**
* Navigate to a route with visual readiness wait.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
await waitForVisualReady(page)
}
/**
* Hide dynamic content that would cause screenshot flakiness:
* - BrowserSync overlay
* - All animations and transitions
* - Blinking cursor
*/
export async function hideDynamicContent(page: Page): Promise<void> {
await page.addStyleTag({
content: `
#__bs_notify__, #__bs_notify__:before, #__bs_notify__:after { display: none !important; }
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
* { caret-color: transparent !important; }
`,
})
}
/**
* Returns locators for non-deterministic elements that should be masked
* in screenshots (e.g. cart badges, timestamps).
* Customize this for your project.
*/
export function getDynamicMasks(page: Page): Locator[] {
return [
// Add project-specific dynamic element selectors here:
// page.locator('[data-testid="cart-badge"]'),
]
}
/**
* Prepare the page for a screenshot: hide dynamic content and scroll to top.
*/
export async function prepareForScreenshot(page: Page): Promise<void> {
await hideDynamicContent(page)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(300)
}
/**
* Take a screenshot and compare against baseline.
*/
export async function expectScreenshot(
page: Page,
name: string,
opts?: {
fullPage?: boolean
mask?: Locator[]
maxDiffPixelRatio?: number
}
): Promise<void> {
const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])]
await pwExpect(page).toHaveScreenshot(name, {
fullPage: opts?.fullPage ?? false,
mask: masks,
maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02,
})
}

View File

@@ -0,0 +1,11 @@
import { test, expect } from "./fixtures"
import { waitForVisualReady, prepareForScreenshot, expectScreenshot } from "./fixtures"
test.describe("Home Page (Visual)", () => {
test("homepage screenshot", async ({ page }) => {
await page.goto("/de/")
await waitForVisualReady(page)
await prepareForScreenshot(page)
await expectScreenshot(page, "homepage.png", { fullPage: true })
})
})

139
tests/e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,139 @@
import { test as base, expect, type Page } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "../api/helpers/test-user"
const API_BASE = "/api"
/**
* Shared E2E test fixtures.
*
* Worker-scoped:
* - `testUser` persistent test user (created/reused once per worker)
*
* Test-scoped:
* - `authedPage` Page with logged-in user (token injection via sessionStorage)
* - `accessToken` raw JWT access token
*/
type E2eWorkerFixtures = {
testUser: TestUserCredentials
}
type E2eFixtures = {
authedPage: Page
accessToken: string
}
export const test = base.extend<E2eFixtures, E2eWorkerFixtures>({
/**
* Override page fixture: BrowserSync keeps a WebSocket open permanently,
* preventing "load" and "networkidle" from resolving. We default all
* navigation methods to "domcontentloaded".
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
const origGoBack = page.goBack.bind(page)
const origGoForward = page.goForward.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
page.goBack = ((opts?: any) => origGoBack({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goBack
page.goForward = ((opts?: any) =>
origGoForward({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goForward
await use(page)
},
// Worker-scoped: create/reuse test user once per worker
testUser: [
async ({ playwright }, use) => {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const user = await ensureTestUser(baseURL)
await use(user)
},
{ scope: "worker" },
],
accessToken: async ({ testUser }, use) => {
await use(testUser.accessToken)
},
// Test-scoped: Page with logged-in user via sessionStorage token injection
authedPage: async ({ page, testUser, baseURL }, use) => {
// Navigate to home so domain is set for sessionStorage
await page.goto("/", { waitUntil: "domcontentloaded" })
await page.waitForLoadState("domcontentloaded")
// Inject auth token into sessionStorage (adapt key names to your app)
await page.evaluate(
({ token, user }) => {
sessionStorage.setItem("auth_token", token)
sessionStorage.setItem(
"auth_user",
JSON.stringify({
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
)
},
{
token: testUser.accessToken,
user: testUser,
}
)
// Reload so the app reads the token from sessionStorage
await page.reload({ waitUntil: "domcontentloaded" })
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
await use(page)
},
})
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Wait for the SPA to be ready (appContainer rendered).
* Returns the detected language prefix from the URL.
*/
export async function waitForSpaReady(page: Page): Promise<string> {
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
return match?.[1] || "de"
}
/**
* Navigate to a route, prepending the current language prefix.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath)
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
}
/**
* Click an SPA link and verify no full page reload occurred.
*/
export async function clickSpaLink(page: Page, linkSelector: string): Promise<void> {
await page.evaluate(() => {
;(window as any).__spa_navigation_marker = true
})
await page.locator(linkSelector).first().click()
await page.waitForLoadState("domcontentloaded")
const markerExists = await page.evaluate(() => {
return (window as any).__spa_navigation_marker === true
})
if (!markerExists) {
throw new Error(`SPA navigation failed: full page reload detected when clicking "${linkSelector}"`)
}
}
export { expect, API_BASE, type Page }

39
tests/e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,39 @@
import { test, expect, waitForSpaReady } from "./fixtures"
test.describe("Home Page", () => {
test("should load the start page", async ({ page }) => {
await page.goto("/de/")
const lang = await waitForSpaReady(page)
expect(lang).toBe("de")
})
test("should have a visible header with navigation", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
const header = page.locator("header")
await expect(header).toBeVisible()
const nav = header.locator("nav")
await expect(nav).toBeVisible()
})
test("should have language prefix in URL", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
expect(page.url()).toContain("/de")
})
test("should switch language to English", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
// Click on English language link
const enLink = page.locator('a[href*="/en"]').first()
if (await enLink.isVisible()) {
await enLink.click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/en")
}
})
})

23
tests/fixtures/test-constants.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/**
* Zentrale Test-Konstanten für alle Tests.
*
* Passe diese Werte an dein Projekt an:
* - TEST_USER: E-Mail/Passwort für den E2E-Test-User
* - ADMIN_TOKEN: Token aus api/config.yml.env
* - API_BASE: API-Pfad (Standard: /api)
*/
export const TEST_USER = {
email: "playwright-e2e@test.example.com",
password: "PlaywrightTest1",
firstName: "Playwright",
lastName: "E2E",
} as const
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "CHANGE_ME"
export const API_BASE = "/api"
export const BASIC_AUTH = {
username: process.env.BASIC_AUTH_USER || "web",
password: process.env.BASIC_AUTH_PASS || "web",
} as const

49
tests/global-setup.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Playwright Global Setup
*
* Runs once before all tests. Use this to:
* - Seed test data via API action hooks
* - Create test users
* - Verify the dev environment is accessible
*
* Customize the setup_testing action call for your project,
* or remove it if not needed.
*/
import { request } from "@playwright/test"
import { API_BASE } from "./fixtures/test-constants"
async function globalSetup() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
// Verify dev environment is reachable
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
"User-Agent": "Playwright",
},
})
try {
const res = await ctx.get("/")
if (!res.ok()) {
console.warn(`⚠️ Dev environment at ${baseURL} returned ${res.status()}`)
} else {
console.log(`✅ Dev environment reachable at ${baseURL}`)
}
// Uncomment and adapt for project-specific test data seeding:
// const seedRes = await ctx.post(`${API_BASE}/action`, {
// params: { cmd: "setup_testing" },
// data: { scope: "all" },
// headers: { Token: ADMIN_TOKEN },
// })
// if (seedRes.ok()) {
// console.log("✅ Test data seeded")
// }
} finally {
await ctx.dispose()
}
}
export default globalSetup

37
tests/global-teardown.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Playwright Global Teardown
*
* Runs once after all tests. Use this to:
* - Clean up test data
* - Dispose singleton API contexts
*
* Customize for your project's cleanup needs.
*/
import { cleanupAllTestData, disposeAdminApi } from "./api/helpers/admin-api"
import { deleteAllEmails, disposeMailDev } from "./api/helpers/maildev"
async function globalTeardown() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
// Clean up test users
try {
const result = await cleanupAllTestData(baseURL)
if (result.users > 0) {
console.log(`🧹 Cleanup: ${result.users} test users deleted`)
}
} catch (err) {
console.warn("⚠️ Test data cleanup failed:", err)
}
// Clean up MailDev emails (optional)
try {
await deleteAllEmails()
} catch {
// MailDev cleanup is optional
}
// Dispose singleton API contexts
await Promise.all([disposeAdminApi(), disposeMailDev()])
}
export default globalTeardown