feat: enhance admin API helpers with CRUD operations for collections and seed data management

- Added functions for creating, updating, deleting, and listing collection entries in admin API.
- Introduced seed data management for consistent test content across tests.
- Updated global setup and teardown processes to ensure seeded content is created and cleaned up.
- Refactored existing tests to utilize seeded content for improved reliability and maintainability.
This commit is contained in:
2026-05-12 20:36:06 +00:00
parent 491f495c66
commit 1b24bb2157
17 changed files with 697 additions and 444 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Die Produktions-MongoDB läuft auf dem Server aus `PRODUCTION_SERVER` in `.env`.
| Variable | Beschreibung |
| ------------------------ | ----------------------------------------------- |
| `PRODUCTION_SERVER` | Produktionsserver (z.B. `dock4.basehosts.de`) |
| `PRODUCTION_TIBI_PREFIX` | DB-Prefix auf Produktion (z.B. `wmbasic`) |
| `PRODUCTION_TIBI_PREFIX` | DB-Prefix auf Produktion (z.B. `tibi`) |
| `TIBI_NAMESPACE` | Projekt-Namespace |
| **Live DB Name** | = `${PRODUCTION_TIBI_PREFIX}_${TIBI_NAMESPACE}` |
| Chisel-Port (Remote) | `10987` — Chisel-Server auf dem Produktionshost |
+3 -1
View File
@@ -12,7 +12,7 @@ RSYNC_HOST=ftp1.webmakers.de
RSYNC_PORT=22223
PRODUCTION_SERVER=dock4.basehosts.de
PRODUCTION_TIBI_PREFIX=wmbasic
PRODUCTION_TIBI_PREFIX=tibi
PRODUCTION_PATH=/webroots2/customers/_CUSTOMER_ID_/____
STAGING_PATH=/staging/__ORG__/__PROJECT__/dev
@@ -20,6 +20,8 @@ STAGING_PATH=/staging/__ORG__/__PROJECT__/dev
LIVE_URL=https://www
STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online
CODING_URL=https://__PROJECT_NAME__.code.testversion.online
CODING_TIBIADMIN_URL=https://__PROJECT_NAME__-tibiadmin.code.testversion.online
#START_SCRIPT=:ssr
#MOCK=1
+4
View File
@@ -26,6 +26,7 @@ Derive these values from the real repo path `gitbase.de/ORG/REPO`:
- `api/hooks/config-client.js`: replace `https://__PROJECT__.code.testversion.online` with the real origin URL.
- `package.json`: adapt starter metadata like `name` and `repository` when creating the real project repo.
- Docker and local URLs derive from `.env`, so `PROJECT_NAME` and `TIBI_NAMESPACE` must be correct before `make docker-up`.
- Playwright seed/API tests read `CODING_URL` from `.env` first. Use the configured host from `.env` whenever it serves both `/` and `/api/...`, even in starter-style local bootstrap setups.
- Recommended check: search for remaining starter placeholders with `rg '__[A-Z0-9_]+__' .`.
## Setup commands
@@ -57,6 +58,7 @@ Derive these values from the real repo path `gitbase.de/ORG/REPO`:
- E2E tests: `yarn test:e2e`
- API tests: `yarn test:api`
- Visual regression: `yarn test:visual`
- Before running Playwright, ensure the `CODING_URL` from `.env` actually serves both `/` and `/api/...` for this repo.
- After code changes, run only affected spec files: `npx playwright test tests/e2e/filename.spec.ts`.
- Write unit tests for new functionality and ensure existing tests pass.
@@ -74,6 +76,8 @@ Derive these values from the real repo path `gitbase.de/ORG/REPO`:
- API access to collections uses the reverse proxy: `CODING_URL/api/<collection>` (e.g. `CODING_URL/api/content`).
- Auth via `Token` header with `ADMIN_TOKEN` from `api/config.yml.env` when a configured token with the required permissions is needed.
- Collection permissions for `user:` apply to JWT-authenticated users (`X-Auth-Token`), not to the static `Token:` header.
- If a collection should allow writes via `ADMIN_TOKEN`, define an explicit permission block like `"token:${ADMIN_TOKEN}":` with the required methods.
## Required secrets and credentials
+6
View File
@@ -39,6 +39,12 @@ permissions:
post: true
put: true
delete: true
"token:${ADMIN_TOKEN}":
methods:
get: true
post: true
put: true
delete: true
fields:
- name: active
+2 -2
View File
@@ -10,7 +10,7 @@ SetEnv MATOMO no
RewriteEngine On
RewriteBase /
RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/$1 [P,QSA,L]
RewriteRule ^/?api/(.*)$ http://tibi-server-nova:8080/api/v1/_/__TIBI_NAMESPACE__/$1 [P,QSA,L]
# Set the Host header for requests to sentry
RequestHeader set Host sentry.basehosts.de env=proxy-sentry
@@ -36,7 +36,7 @@ SetEnv MATOMO no
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
RewriteRule ^/?(.*)$ http://tibi-server-nova:8080/api/v1/_/__TIBI_NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
# RewriteRule (.*) /spa.html [QSA,L]
</ifModule>
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test"
import { TEST_BASE_URL } from "./tests/fixtures/test-constants"
/**
* Playwright configuration for tibi-svelte projects.
@@ -42,7 +43,7 @@ export default defineConfig({
use: {
/* Read from .env PROJECT_NAME or override via CODING_URL env var */
baseURL: process.env.CODING_URL || "https://localhost:3000",
baseURL: TEST_BASE_URL,
headless: true,
ignoreHTTPSErrors: true,
trace: "on",
+7 -41
View File
@@ -1,57 +1,23 @@
import { test as base, expect, APIRequestContext } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
import { test as base, expect, type APIRequestContext } from "@playwright/test"
import { getAdminContext } from "./helpers/admin-api"
import { TEST_BASE_URL } from "../fixtures/test-constants"
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
adminApi: APIRequestContext
}
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" },
],
export const test = base.extend<ApiFixtures>({
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}`,
},
})
adminApi: async ({ baseURL }, use) => {
const ctx = await getAdminContext(baseURL || TEST_BASE_URL)
await use(ctx)
await ctx.dispose()
},
})
export { expect, API_BASE }
export type { ActionSuccess, ActionError }
+77 -17
View File
@@ -1,26 +1,86 @@
import { test, expect, API_BASE } from "./fixtures"
import { SEEDED_TEST_CONTENT } from "../fixtures/test-constants"
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())
type ContentApiEntry = {
id?: string
translationKey?: string
lang?: string
path?: string
active?: boolean
blocks?: Array<{ type?: string }>
}
async function getContentEntries(api: { get: Function }, filter: Record<string, unknown>): Promise<ContentApiEntry[]> {
const res = await api.get(`${API_BASE}/content`, {
params: {
filter: JSON.stringify(filter),
},
})
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)
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as unknown
return Array.isArray(body) ? (body as ContentApiEntry[]) : []
}
test.describe("Seeded Content API", () => {
test("returns the seeded localized page variants through the public content endpoint", async ({ api }) => {
const deEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.home.path,
active: true,
})
const enEntries = await getContentEntries(api, {
lang: "en",
path: SEEDED_TEST_CONTENT.home.path,
active: true,
})
test("should reject invalid action commands", async ({ api }) => {
const res = await api.post(`${API_BASE}/action`, {
params: { cmd: "nonexistent_action" },
data: {},
expect(deEntries).toHaveLength(1)
expect(enEntries).toHaveLength(1)
expect(deEntries[0].translationKey).toBe(SEEDED_TEST_CONTENT.home.translationKey)
expect(enEntries[0].translationKey).toBe(SEEDED_TEST_CONTENT.home.translationKey)
expect(deEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "features", "richtext", "accordion"])
expect(enEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "features", "richtext", "accordion"])
})
// Should return an error, not crash
expect(res.status()).toBeGreaterThanOrEqual(400)
expect(res.status()).toBeLessThan(500)
test("returns the seeded contact page and an empty result for missing active routes", async ({ api }) => {
const contactEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.contact.path,
active: true,
})
const inactiveEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.inactive.path,
active: true,
})
const missingEntries = await getContentEntries(api, {
lang: "de",
path: "/playwright-e2e-missing",
active: true,
})
expect(contactEntries).toHaveLength(1)
expect(contactEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "contact-form"])
expect(inactiveEntries).toHaveLength(0)
expect(missingEntries).toHaveLength(0)
})
test("allows the admin API to inspect the inactive seeded entry", async ({ adminApi }) => {
const res = await adminApi.get(`${API_BASE}/content`, {
params: {
filter: JSON.stringify({
translationKey: SEEDED_TEST_CONTENT.inactive.translationKey,
lang: "de",
}),
},
})
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as ContentApiEntry[]
expect(body).toHaveLength(1)
expect(body[0].active).toBe(false)
expect(body[0].path).toBe(SEEDED_TEST_CONTENT.inactive.path)
})
})
+125 -36
View File
@@ -3,10 +3,63 @@ import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
let adminContext: APIRequestContext | null = null
type CollectionEntry = {
id?: string
_id?: string | { $oid?: string }
[key: string]: unknown
}
type CollectionQueryOptions = {
filter?: Record<string, unknown>
sort?: string
limit?: number
offset?: number
projection?: string
lookup?: string
}
function getEntryId(entry: CollectionEntry): string | undefined {
if (typeof entry.id === "string") return entry.id
if (typeof entry._id === "string") return entry._id
if (entry._id && typeof entry._id === "object" && typeof entry._id.$oid === "string") return entry._id.$oid
return undefined
}
function buildCollectionUrl(collection: string, options: CollectionQueryOptions = {}, id?: string): string {
const searchParams = new URLSearchParams()
if (options.filter) searchParams.set("filter", JSON.stringify(options.filter))
if (options.sort) searchParams.set("sort", options.sort)
if (typeof options.limit === "number") searchParams.set("limit", String(options.limit))
if (typeof options.offset === "number") searchParams.set("offset", String(options.offset))
if (options.projection) searchParams.set("projection", options.projection)
if (options.lookup) searchParams.set("lookup", options.lookup)
const path = `${API_BASE}/${collection}${id ? `/${id}` : ""}`
const query = searchParams.toString()
return query ? `${path}?${query}` : path
}
async function parseJsonResponse<T>(
res: Awaited<ReturnType<APIRequestContext["get"]>>,
contextLabel: string
): Promise<T> {
const contentType = res.headers()["content-type"] || ""
if (contentType.includes("text/html")) {
throw new Error(`${contextLabel} returned HTML instead of JSON. Check CODING_URL or API proxy setup.`)
}
return (await res.json()) as T
}
/**
* Get or create a singleton admin API context with the ADMIN_TOKEN.
*/
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
export async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
if (ADMIN_TOKEN === "CHANGE_ME") {
throw new Error("ADMIN_TOKEN is not configured for Playwright seed and cleanup")
}
if (!adminContext) {
adminContext = await request.newContext({
baseURL,
@@ -19,50 +72,86 @@ async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
return adminContext
}
/**
* Delete a user by ID via admin API.
*/
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
export async function listCollectionEntries<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
options: CollectionQueryOptions = {}
): Promise<T[]> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
const res = await ctx.get(buildCollectionUrl(collection, options))
if (!res.ok()) {
throw new Error(`Failed to list ${collection}: ${res.status()} ${res.statusText()}`)
}
const body = await parseJsonResponse<unknown>(res, `Listing ${collection}`)
if (Array.isArray(body)) return body as T[]
if (body && typeof body === "object" && "data" in body && Array.isArray(body.data)) return body.data as T[]
return []
}
export async function createCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
entry: Record<string, unknown>
): Promise<T> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.post(`${API_BASE}/${collection}`, { data: entry })
if (!res.ok()) {
throw new Error(`Failed to create ${collection}: ${res.status()} ${res.statusText()}`)
}
return await parseJsonResponse<T>(res, `Creating ${collection}`)
}
export async function updateCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
id: string,
entry: Record<string, unknown>
): Promise<T> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.put(`${API_BASE}/${collection}/${id}`, { data: entry })
if (!res.ok()) {
throw new Error(`Failed to update ${collection}/${id}: ${res.status()} ${res.statusText()}`)
}
return await parseJsonResponse<T>(res, `Updating ${collection}/${id}`)
}
export async function deleteCollectionEntry(baseURL: string, collection: string, id: string): Promise<boolean> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/${collection}/${id}`)
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++
}
}
export async function findFirstEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
options: CollectionQueryOptions = {}
): Promise<T | null> {
const entries = await listCollectionEntries<T>(baseURL, collection, { ...options, limit: 1 })
return entries[0] || null
}
return deleted
export async function upsertCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
filter: Record<string, unknown>,
entry: Record<string, unknown>
): Promise<T> {
const existing = await findFirstEntry<T>(baseURL, collection, { filter })
const existingId = existing ? getEntryId(existing) : undefined
if (existingId) {
return updateCollectionEntry<T>(baseURL, collection, existingId, entry)
}
return createCollectionEntry<T>(baseURL, collection, entry)
}
/**
* 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 }
void baseURL
return { users: 0 }
}
/**
+297
View File
@@ -0,0 +1,297 @@
import { SEEDED_TEST_CONTENT } from "../../fixtures/test-constants"
import { createCollectionEntry, deleteCollectionEntry, listCollectionEntries } from "./admin-api"
type ContentEntry = {
id?: string
_id?: string | { $oid?: string }
translationKey?: string
[key: string]: unknown
}
function getEntryId(entry: ContentEntry): string | undefined {
if (typeof entry.id === "string") return entry.id
if (typeof entry._id === "string") return entry._id
if (entry._id && typeof entry._id === "object" && typeof entry._id.$oid === "string") return entry._id.$oid
return undefined
}
const SEEDED_TRANSLATION_KEYS = new Set<string>(Object.values(SEEDED_TEST_CONTENT).map((entry) => entry.translationKey))
const SEEDED_CONTENT_ENTRIES = [
{
active: true,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.home.translationKey,
name: "Playwright Startseite",
path: SEEDED_TEST_CONTENT.home.path,
teaserText: "Deterministisch erzeugte Testseite fuer API- und E2E-Tests.",
meta: {
title: "Playwright Startseite",
description: "Seeded Startseite fuer stabile Playwright-Tests.",
keywords: ["playwright", "seed", "e2e"],
},
blocks: [
{
type: "hero",
headline: "Playwright Seed Startseite",
headlineH1: true,
tagline: "Deterministische Testdaten",
subline: "Diese Seite wird vor dem Testlauf frisch ueber die Admin-API angelegt.",
containerWidth: "wide",
callToAction: {
buttonText: "Zum Kontakt",
buttonLink: `/de${SEEDED_TEST_CONTENT.contact.path}`,
},
},
{
type: "features",
anchorId: "seed-features",
headline: "Stabile Grundlage fuer Frontend-Tests",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
featureBoxes: [
{
icon: "lightning",
title: "Frisch angelegt",
text: "Die Inhalte werden in globalSetup erstellt statt aus Demo-Daten uebernommen.",
},
{
icon: "database",
title: "API-nah",
text: "Die Seed-Daten kommen ueber dieselben Collection-Endpunkte wie das CMS.",
},
{
icon: "globe",
title: "Mehrsprachig",
text: "DE und EN teilen sich denselben translationKey fuer Routing-Checks.",
},
],
},
{
type: "richtext",
anchorId: "seed-richtext",
headline: "Mehr Kontext",
tagline: "API + UI",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>Dieser Richtext-Block prueft, dass formatierter HTML-Inhalt im SPA gerendert wird.</p>",
},
{
type: "accordion",
anchorId: "seed-faq",
headline: "Hauefige Fragen",
tagline: "Verhalten",
padding: { top: "md", bottom: "md" },
accordionItems: [
{
question: "Warum Seed-Daten?",
answer: "<p>Damit Tests nicht blind auf bestehende Inhalte oder Demo-Routen vertrauen.</p>",
open: true,
},
{
question: "Was wird geprueft?",
answer: "<p>API-Antworten, Routing, Sprachwechsel und Block-Rendering.</p>",
open: false,
},
],
},
],
},
{
active: true,
type: "page",
lang: "en",
translationKey: SEEDED_TEST_CONTENT.home.translationKey,
name: "Playwright Home",
path: SEEDED_TEST_CONTENT.home.path,
teaserText: "Deterministically seeded page for API and E2E coverage.",
meta: {
title: "Playwright Home",
description: "Seeded home page for stable Playwright tests.",
keywords: ["playwright", "seed", "home"],
},
blocks: [
{
type: "hero",
headline: "Playwright Seed Home",
headlineH1: true,
tagline: "Deterministic fixtures",
subline: "This page is recreated before every test run through the admin API.",
containerWidth: "wide",
callToAction: {
buttonText: "Go to contact",
buttonLink: `/en${SEEDED_TEST_CONTENT.contact.path}`,
},
},
{
type: "features",
anchorId: "seed-features",
headline: "Stable frontend coverage",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
featureBoxes: [
{
icon: "lightning",
title: "Freshly created",
text: "The content is created during globalSetup instead of relying on demo data.",
},
{
icon: "database",
title: "API-backed",
text: "The seed uses the same collection endpoints as the CMS itself.",
},
{
icon: "globe",
title: "Localized",
text: "DE and EN share the same translationKey for route switching checks.",
},
],
},
{
type: "richtext",
anchorId: "seed-richtext",
headline: "More context",
tagline: "API + UI",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>This richtext block proves that formatted HTML content renders in the SPA.</p>",
},
{
type: "accordion",
anchorId: "seed-faq",
headline: "Common questions",
tagline: "Behavior",
padding: { top: "md", bottom: "md" },
accordionItems: [
{
question: "Why seeded data?",
answer: "<p>So the tests do not depend on existing demo pages or editorial content.</p>",
open: true,
},
{
question: "What is covered?",
answer: "<p>API responses, routing, locale switching and block rendering.</p>",
open: false,
},
],
},
],
},
{
active: true,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.contact.translationKey,
name: "Playwright Kontakt",
path: SEEDED_TEST_CONTENT.contact.path,
teaserText: "Seeded Kontaktseite fuer Formular- und Routing-Tests.",
meta: {
title: "Playwright Kontakt",
description: "Seeded Kontaktseite fuer Playwright.",
keywords: ["playwright", "kontakt"],
},
blocks: [
{
type: "hero",
headline: "Kontakt fuer Testlauf",
headlineH1: true,
tagline: "Seed",
subline: "Diese Seite prueft das aktuelle ContactForm-Rendering.",
},
{
type: "contact-form",
anchorId: "kontaktformular",
headline: "Schreibe uns",
padding: { top: "md", bottom: "md" },
},
],
},
{
active: true,
type: "page",
lang: "en",
translationKey: SEEDED_TEST_CONTENT.contact.translationKey,
name: "Playwright Contact",
path: SEEDED_TEST_CONTENT.contact.path,
teaserText: "Seeded contact page for form and routing tests.",
meta: {
title: "Playwright Contact",
description: "Seeded contact page for Playwright.",
keywords: ["playwright", "contact"],
},
blocks: [
{
type: "hero",
headline: "Contact for the test run",
headlineH1: true,
tagline: "Seed",
subline: "This page verifies the current contact form rendering.",
},
{
type: "contact-form",
anchorId: "contact-form",
headline: "Write to us",
padding: { top: "md", bottom: "md" },
},
],
},
{
active: false,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.inactive.translationKey,
name: "Playwright Inaktiv",
path: SEEDED_TEST_CONTENT.inactive.path,
teaserText: "Nicht aktive Seed-Seite fuer 404-Checks.",
meta: {
title: "Playwright Inaktiv",
description: "Nicht aktive Seed-Seite fuer Routing-Tests.",
keywords: ["playwright", "inactive"],
},
blocks: [
{
type: "richtext",
anchorId: "inactive",
headline: "Sollte nicht sichtbar sein",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>Diese Seite ist absichtlich inaktiv und darf im Frontend nicht erscheinen.</p>",
},
],
},
] as const
export async function cleanupSeededTestContent(baseURL: string): Promise<number> {
const contentEntries = await listCollectionEntries<ContentEntry>(baseURL, "content")
const seededEntries = contentEntries.filter(
(entry) => typeof entry.translationKey === "string" && SEEDED_TRANSLATION_KEYS.has(entry.translationKey)
)
let deleted = 0
for (const entry of seededEntries) {
const entryId = getEntryId(entry)
if (!entryId) continue
const ok = await deleteCollectionEntry(baseURL, "content", entryId)
if (ok) deleted++
}
return deleted
}
export async function seedTestContent(baseURL: string): Promise<number> {
let created = 0
for (const entry of SEEDED_CONTENT_ENTRIES) {
await createCollectionEntry(baseURL, "content", entry as unknown as Record<string, unknown>)
created++
}
return created
}
export async function ensureSeededTestContent(baseURL: string): Promise<{ deleted: number; created: number }> {
const deleted = await cleanupSeededTestContent(baseURL)
const created = await seedTestContent(baseURL)
return { deleted, created }
}
+14 -15
View File
@@ -1,28 +1,27 @@
import { test, expect, waitForSpaReady, isMobileViewport } from "./fixtures"
import { test, expect, waitForSpaReady, isMobileViewport, openHamburgerMenu } from "./fixtures"
import { SEEDED_TEST_CONTENT } from "../fixtures/test-constants"
test.describe("Home Page (Mobile)", () => {
test("should load the start page on mobile", async ({ page }) => {
await page.goto("/de/")
test.describe("Seeded Home Page (Mobile)", () => {
test("loads the seeded page on mobile and keeps the current route prefix", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
expect(isMobileViewport(page) || true).toBeTruthy()
await expect(page.locator("#appContainer")).not.toBeEmpty()
await expect(page).toHaveURL(new RegExp(`/de${SEEDED_TEST_CONTENT.home.path}$`))
await expect(page.getByRole("heading", { level: 1, name: "Playwright Seed Startseite" })).toBeVisible()
})
test("should have a visible header", async ({ page }) => {
await page.goto("/de/")
test("opens the mobile menu and exposes the language switcher", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
const header = page.locator("header")
await expect(header).toBeVisible()
})
await openHamburgerMenu(page)
// 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()
// })
await expect(page.locator("header button[aria-expanded='true']")).toBeVisible()
await expect(header.getByRole("link", { name: "Deutsch" })).toBeVisible()
await expect(header.getByRole("link", { name: "English" })).toBeVisible()
})
})
+35 -170
View File
@@ -1,218 +1,83 @@
import { test, expect, waitForSpaReady, navigateToRoute, clickSpaLink } from "./fixtures"
import { test, expect, clickSpaLink, waitForSpaReady } from "./fixtures"
import { SEEDED_TEST_CONTENT } from "../fixtures/test-constants"
/**
* Helper: Force all scroll-reveal elements to be visible.
* The `.reveal` class starts with opacity:0 and only animates in
* when the IntersectionObserver fires — which doesn't happen
* reliably in headless Playwright screenshots/assertions.
*/
async function revealAll(page: import("@playwright/test").Page) {
await page.evaluate(() => document.querySelectorAll(".reveal").forEach((e) => e.classList.add("revealed")))
await page.evaluate(() => document.querySelectorAll(".reveal").forEach((entry) => entry.classList.add("revealed")))
}
test.describe("Demo — Homepage", () => {
test.describe("Seeded Public Frontend", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/de/")
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
await revealAll(page)
})
test("should render hero section with headline and CTA", async ({ page }) => {
test("renders hero, features, richtext and accordion blocks on the seeded home page", async ({ page }) => {
const hero = page.locator("[data-block='hero']").first()
await expect(hero).toBeVisible()
await expect(hero.locator("h1")).toHaveText("Playwright Seed Startseite")
await expect(hero.locator("a").first()).toBeVisible()
const h1 = hero.locator("h1")
await expect(h1).toBeVisible()
await expect(h1).not.toBeEmpty()
const cta = hero.locator("a[href*='#']")
await expect(cta).toBeVisible()
})
test("should render features section with cards", async ({ page }) => {
const features = page.locator("[data-block='features']")
await expect(features).toBeVisible()
await expect(features.locator(".feature-card")).toHaveCount(3)
const heading = features.locator("h2")
await expect(heading).toBeVisible()
const cards = features.locator(".feature-card")
await expect(cards).toHaveCount(6)
})
test("should render richtext section with image", async ({ page }) => {
const richtext = page.locator("[data-block='richtext']").first()
await expect(richtext).toBeVisible()
await expect(richtext).toContainText("formatierter HTML-Inhalt")
const img = richtext.locator("img")
await expect(img).toBeVisible()
})
test("should render accordion with expandable items", async ({ page }) => {
const accordion = page.locator("[data-block='accordion']")
await expect(accordion).toBeVisible()
const buttons = accordion.locator("button")
const count = await buttons.count()
expect(count).toBeGreaterThanOrEqual(3)
// First item should be expanded by default
const firstButton = buttons.first()
await expect(firstButton).toHaveAttribute("aria-expanded", "true")
// Click a collapsed item to expand it
await expect(buttons.first()).toHaveAttribute("aria-expanded", "true")
const secondButton = buttons.nth(1)
await expect(secondButton).toHaveAttribute("aria-expanded", "false")
await secondButton.click()
await expect(secondButton).toHaveAttribute("aria-expanded", "true")
})
test("should have footer with navigation and language selector", async ({ page }) => {
const footer = page.locator("footer")
await expect(footer).toBeVisible()
const navLinks = footer.locator("nav a, ul a")
const linkCount = await navLinks.count()
expect(linkCount).toBeGreaterThanOrEqual(3)
// Language links in footer
const deLangLink = footer.locator('a:has-text("Deutsch")')
const enLangLink = footer.locator('a:has-text("English")')
await expect(deLangLink).toBeVisible()
await expect(enLangLink).toBeVisible()
})
})
test.describe("Demo — About Page", () => {
test("should load about page with hero and content", async ({ page }) => {
await page.goto("/en/about")
await waitForSpaReady(page)
await revealAll(page)
const hero = page.locator("[data-block='hero']").first()
await expect(hero).toBeVisible()
const h1 = hero.locator("h1")
await expect(h1).toContainText("About")
const richtextBlocks = page.locator("[data-block='richtext']")
const count = await richtextBlocks.count()
expect(count).toBeGreaterThanOrEqual(2)
})
})
test.describe("Demo — Contact Page", () => {
test("should load contact page with form", async ({ page }) => {
await page.goto("/en/contact")
test("renders the seeded contact page and validates the current form UI", async ({ page }) => {
await page.goto(`/en${SEEDED_TEST_CONTENT.contact.path}`)
await waitForSpaReady(page)
await revealAll(page)
const hero = page.locator("[data-block='hero']").first()
await expect(hero).toBeVisible()
await expect(hero.locator("h1")).toHaveText("Contact for the test run")
const form = page.locator("[data-block='contact-form']")
await expect(form).toBeVisible()
})
test("should have all form fields", async ({ page }) => {
await page.goto("/en/contact")
await waitForSpaReady(page)
await revealAll(page)
await expect(page.getByLabel("Name")).toBeVisible()
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.locator("select, [role='combobox']").first()).toBeVisible()
await expect(page.getByLabel("Message")).toBeVisible()
await expect(page.locator("button[type='submit'], button:has-text('Send')")).toBeVisible()
const submitBtn = page.locator("button[type='submit'], button:has-text('Send')").first()
await expect(submitBtn).toBeVisible()
await submitBtn.click()
await expect(page.locator("[data-block='contact-form']")).toBeVisible()
})
test("should validate required fields", async ({ page }) => {
await page.goto("/en/contact")
test("shows the 404 state for inactive seeded routes and can return home", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.inactive.path}`)
await waitForSpaReady(page)
await revealAll(page)
// Click send without filling fields
const submitBtn = page.locator("button[type='submit'], button:has-text('Send')").first()
await submitBtn.click()
await expect(page.locator("main")).toContainText("404")
// Should show validation errors (form stays, no success toast)
const homeLink = page.locator("main").getByRole("link", { name: "Zur Startseite" })
await expect(homeLink).toBeVisible()
await homeLink.click()
await waitForSpaReady(page)
await expect(page).toHaveURL(/\/de\/?$/)
})
test("uses SPA navigation for the seeded CTA without a full reload", async ({ page }) => {
await page.goto(`/en${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
await clickSpaLink(page, "[data-block='hero'] a")
await expect(page).toHaveURL(new RegExp(`/en${SEEDED_TEST_CONTENT.contact.path}$`))
await expect(page.locator("[data-block='contact-form']")).toBeVisible()
})
})
test.describe("Demo — Navigation", () => {
test("should navigate between pages via header links", async ({ page }) => {
await page.goto("/en/")
await waitForSpaReady(page)
// Navigate to About
await page.locator('header nav a[href*="/about"]').click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/about")
await expect(page.locator("[data-block='hero'] h1")).toContainText("About")
// Navigate to Contact
await page.locator('header nav a[href*="/contact"]').click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/contact")
await expect(page.locator("[data-block='hero'] h1")).toContainText("Contact")
// Navigate back to Home
await page.locator('header a[href="/en"]').first().click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toMatch(/\/en\/?$/)
})
test("should switch language with route translation", async ({ page }) => {
// Start on English about page
await page.goto("/en/about")
await waitForSpaReady(page)
expect(page.url()).toContain("/en/about")
// Click German language link — should translate route to /de/ueber-uns
const deLink = page.locator('a[href*="/de/ueber-uns"]').first()
await expect(deLink).toBeVisible()
await deLink.click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/de/ueber-uns")
// Verify hero updated to German
await expect(page.locator("[data-block='hero'] h1")).toBeVisible()
})
test("should switch language on contact page with route translation", async ({ page }) => {
await page.goto("/en/contact")
await waitForSpaReady(page)
const deLink = page.locator('a[href*="/de/kontakt"]').first()
await expect(deLink).toBeVisible()
await deLink.click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/de/kontakt")
})
})
test.describe("Demo — 404 Page", () => {
test("should show 404 for unknown routes", async ({ page }) => {
await page.goto("/en/nonexistent-page")
await waitForSpaReady(page)
await expect(page.locator("text=404")).toBeVisible()
await expect(page.locator("h1")).toContainText("Page not found")
// Use the specific "Back to Home" link in the 404 main content, not header/footer
const homeLink = page.getByRole("link", { name: "Back to Home" })
await expect(homeLink).toBeVisible()
})
test("should navigate back from 404 to home", async ({ page }) => {
await page.goto("/en/nonexistent-page")
await waitForSpaReady(page)
const homeLink = page.getByRole("link", { name: "Back to Home" })
await homeLink.click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toMatch(/\/en\/?$/)
await expect(page.locator("[data-block='hero']")).toBeVisible()
})
})
+1 -85
View File
@@ -1,34 +1,9 @@
import { test as base, expect, type Page } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "../api/helpers/test-user"
import { attachConsoleMonitor } from "../fixtures/console-monitor"
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".
*/
export const test = base.extend({
page: async ({ page }, use) => {
const monitor = attachConsoleMonitor(page)
@@ -47,61 +22,8 @@ export const test = base.extend<E2eFixtures, E2eWorkerFixtures>({
await use(page)
monitor.assertNoErrors()
},
// 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 })
@@ -110,9 +32,6 @@ export async function waitForSpaReady(page: Page): Promise<string> {
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})(\/|$)/)
@@ -123,9 +42,6 @@ export async function navigateToRoute(page: Page, routePath: string): Promise<vo
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
+35 -29
View File
@@ -1,44 +1,51 @@
import { test, expect, waitForSpaReady } from "./fixtures"
import { test, expect, clickSpaLink, waitForSpaReady } from "./fixtures"
import { SEEDED_TEST_CONTENT } from "../fixtures/test-constants"
test.describe("Home Page", () => {
test("should load the start page", async ({ page }) => {
await page.goto("/de/")
test.describe("Seeded Home Page", () => {
test("renders the seeded German page with the expected block content", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
const lang = await waitForSpaReady(page)
expect(lang).toBe("de")
await expect(page.getByRole("heading", { level: 1, name: "Playwright Seed Startseite" })).toBeVisible()
await expect(page.locator("[data-block='features'] .feature-card")).toHaveCount(3)
await expect(page.locator("[data-block='richtext']")).toContainText(
"Dieser Richtext-Block prueft, dass formatierter HTML-Inhalt im SPA gerendert wird."
)
await expect(page.locator("[data-block='accordion'] button[aria-expanded]").first()).toHaveAttribute(
"aria-expanded",
"true"
)
})
test("should have a visible header with navigation", async ({ page }) => {
await page.goto("/de/")
test("keeps the route stable when switching the language", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
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 header.getByRole("link", { name: "en", exact: true }).click()
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")
}
await expect(page).toHaveURL(new RegExp(`/en${SEEDED_TEST_CONTENT.home.path}$`))
await expect(page.getByRole("heading", { level: 1, name: "Playwright Seed Home" })).toBeVisible()
})
test("should allow skipping directly to main content", async ({ page }) => {
await page.goto("/de/")
test("navigates to the seeded contact page via SPA CTA and keeps the form usable", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
await clickSpaLink(page, "[data-block='hero'] a")
await expect(page).toHaveURL(new RegExp(`/de${SEEDED_TEST_CONTENT.contact.path}$`))
await expect(page.locator("[data-block='contact-form']")).toBeVisible()
await expect(page.getByLabel("Name")).toBeVisible()
await expect(page.getByLabel("E-Mail")).toBeVisible()
await expect(page.locator("textarea[name='message']")).toBeVisible()
})
test("moves focus to the main content via the skip link", async ({ page }) => {
await page.goto(`/de${SEEDED_TEST_CONTENT.home.path}`)
await waitForSpaReady(page)
await page.keyboard.press("Tab")
@@ -50,6 +57,5 @@ test.describe("Home Page", () => {
const mainContent = page.locator("main#main-content")
await expect(mainContent).toBeFocused()
await expect(page).toHaveURL(/#main-content$/)
})
})
+44 -1
View File
@@ -1,3 +1,6 @@
import fs from "node:fs"
import path from "node:path"
/**
* Zentrale Test-Konstanten für alle Tests.
*
@@ -14,8 +17,48 @@ export const TEST_USER = {
lastName: "E2E",
} as const
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "CHANGE_ME"
function loadAdminTokenFromEnvFile(): string | undefined {
const envFilePath = path.resolve(process.cwd(), "api/config.yml.env")
if (!fs.existsSync(envFilePath)) return undefined
const raw = fs.readFileSync(envFilePath, "utf8")
const line = raw
.split(/\r?\n/)
.find((entry) => entry.startsWith("ADMIN_TOKEN=") && entry.trim().length > "ADMIN_TOKEN=".length)
return line ? line.slice("ADMIN_TOKEN=".length).trim() : undefined
}
function loadProjectEnvValue(key: string): string | undefined {
const envFilePath = path.resolve(process.cwd(), ".env")
if (!fs.existsSync(envFilePath)) return undefined
const raw = fs.readFileSync(envFilePath, "utf8")
const prefix = `${key}=`
const line = raw.split(/\r?\n/).find((entry) => entry.startsWith(prefix) && entry.trim().length > prefix.length)
if (!line) return undefined
return line.slice(prefix.length).trim()
}
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || loadAdminTokenFromEnvFile() || "CHANGE_ME"
export const API_BASE = "/api"
export const TEST_BASE_URL = process.env.CODING_URL || loadProjectEnvValue("CODING_URL") || "http://localhost:3000"
export const SEEDED_TEST_CONTENT = {
home: {
translationKey: "pw-e2e-home",
path: "/playwright-e2e-home",
},
contact: {
translationKey: "pw-e2e-contact",
path: "/playwright-e2e-contact",
},
inactive: {
translationKey: "pw-e2e-inactive",
path: "/playwright-e2e-inactive",
},
} as const
export const BASIC_AUTH = {
username: process.env.BASIC_AUTH_USER || "web",
+17 -25
View File
@@ -1,21 +1,10 @@
/**
* 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"
import { ensureSeededTestContent } from "./api/helpers/seed-data"
import { TEST_BASE_URL } from "./fixtures/test-constants"
async function globalSetup() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const baseURL = TEST_BASE_URL
// Verify dev environment is reachable
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
@@ -27,20 +16,23 @@ async function globalSetup() {
try {
const res = await ctx.get("/")
if (!res.ok()) {
console.warn(`⚠️ Dev environment at ${baseURL} returned ${res.status()}`)
throw new Error(`Dev environment at ${baseURL} returned ${res.status()}`)
} else {
console.log(`✅ Dev environment reachable at ${baseURL}`)
console.log(`Playwright setup: 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")
// }
const apiProbe = await ctx.get("/api/content?limit=1")
const contentType = apiProbe.headers()["content-type"] || ""
if (!apiProbe.ok() || contentType.includes("text/html")) {
throw new Error(
`Playwright seed setup needs a base URL with working /api access. Current base URL ${baseURL} returned ${apiProbe.status()} ${contentType || "without content-type"} for /api/content.`
)
}
const result = await ensureSeededTestContent(baseURL)
console.log(
`Playwright setup: seeded deterministic content (deleted ${result.deleted}, created ${result.created})`
)
} finally {
await ctx.dispose()
}
+24 -17
View File
@@ -1,36 +1,43 @@
/**
* 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"
import { cleanupSeededTestContent } from "./api/helpers/seed-data"
import { TEST_BASE_URL } from "./fixtures/test-constants"
async function globalTeardown() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const baseURL = TEST_BASE_URL
// Clean up test users
try {
const probeContext = await import("@playwright/test").then(({ request }) =>
request.newContext({ baseURL, ignoreHTTPSErrors: true })
)
const apiProbe = await probeContext.get("/api/content?limit=1")
const contentType = apiProbe.headers()["content-type"] || ""
await probeContext.dispose()
if (!apiProbe.ok() || contentType.includes("text/html")) {
console.warn(
`Playwright teardown: skipped seeded cleanup because ${baseURL} has no usable /api endpoint (${apiProbe.status()} ${contentType || "without content-type"})`
)
return
}
const deletedSeedEntries = await cleanupSeededTestContent(baseURL)
const result = await cleanupAllTestData(baseURL)
if (result.users > 0) {
console.log(`🧹 Cleanup: ${result.users} test users deleted`)
if (deletedSeedEntries > 0 || result.users > 0) {
console.log(
`Playwright teardown: deleted ${deletedSeedEntries} seeded content entries and ${result.users} test users`
)
}
} catch (err) {
console.warn("⚠️ Test data cleanup failed:", err)
console.warn("Playwright teardown: test data cleanup failed:", err)
}
// Clean up MailDev emails (optional)
try {
await deleteAllEmails()
} catch {
// MailDev cleanup is optional
// MailDev cleanup is optional.
}
// Dispose singleton API contexts
await Promise.all([disposeAdminApi(), disposeMailDev()])
}