✨ 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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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$/)
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+44
-1
@@ -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
@@ -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
@@ -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()])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user