diff --git a/.agents/skills/live-mongodb/SKILL.md b/.agents/skills/live-mongodb/SKILL.md index 5949565..9c59787 100644 --- a/.agents/skills/live-mongodb/SKILL.md +++ b/.agents/skills/live-mongodb/SKILL.md @@ -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 | diff --git a/.env b/.env index 66ba2cd..2c5a92a 100644 --- a/.env +++ b/.env @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2ed031e..36e0a4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` (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 diff --git a/api/collections/content.yml b/api/collections/content.yml index ffd7506..0f8bb69 100644 --- a/api/collections/content.yml +++ b/api/collections/content.yml @@ -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 diff --git a/frontend/.htaccess b/frontend/.htaccess index b2ed3ff..c6af6a4 100644 --- a/frontend/.htaccess +++ b/frontend/.htaccess @@ -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] diff --git a/playwright.config.ts b/playwright.config.ts index d121d65..6e1ae7d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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", diff --git a/tests/api/fixtures.ts b/tests/api/fixtures.ts index a511789..6cef778 100644 --- a/tests/api/fixtures.ts +++ b/tests/api/fixtures.ts @@ -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({ - 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({ 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 } diff --git a/tests/api/health.spec.ts b/tests/api/health.spec.ts index 47a4a5c..61439fc 100644 --- a/tests/api/health.spec.ts +++ b/tests/api/health.spec.ts @@ -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): Promise { + 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() - test("should reject invalid action commands", async ({ api }) => { - const res = await api.post(`${API_BASE}/action`, { - params: { cmd: "nonexistent_action" }, - data: {}, + 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, }) - // Should return an error, not crash - expect(res.status()).toBeGreaterThanOrEqual(400) - expect(res.status()).toBeLessThan(500) + const enEntries = await getContentEntries(api, { + lang: "en", + path: SEEDED_TEST_CONTENT.home.path, + active: true, + }) + + 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"]) + }) + + 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) }) }) diff --git a/tests/api/helpers/admin-api.ts b/tests/api/helpers/admin-api.ts index 5823654..85ff040 100644 --- a/tests/api/helpers/admin-api.ts +++ b/tests/api/helpers/admin-api.ts @@ -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 + 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( + res: Awaited>, + contextLabel: string +): Promise { + 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 { +export async function getAdminContext(baseURL: string): Promise { + 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 { return adminContext } -/** - * Delete a user by ID via admin API. - */ -export async function deleteUser(baseURL: string, userId: string): Promise { +export async function listCollectionEntries( + baseURL: string, + collection: string, + options: CollectionQueryOptions = {} +): Promise { 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(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( + baseURL: string, + collection: string, + entry: Record +): Promise { + 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(res, `Creating ${collection}`) +} + +export async function updateCollectionEntry( + baseURL: string, + collection: string, + id: string, + entry: Record +): Promise { + 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(res, `Updating ${collection}/${id}`) +} + +export async function deleteCollectionEntry(baseURL: string, collection: string, id: string): Promise { + 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 { - const ctx = await getAdminContext(baseURL) - const res = await ctx.get(`${API_BASE}/user`) - if (!res.ok()) return 0 - - const users: { id?: string; _id?: string; email?: string }[] = await res.json() - if (!Array.isArray(users)) return 0 - - let deleted = 0 - for (const user of users) { - const email = user.email || "" - const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email) - - if (matches) { - const userId = user.id || user._id - if (userId) { - const ok = await deleteUser(baseURL, userId) - if (ok) deleted++ - } - } - } - - return deleted +export async function findFirstEntry( + baseURL: string, + collection: string, + options: CollectionQueryOptions = {} +): Promise { + const entries = await listCollectionEntries(baseURL, collection, { ...options, limit: 1 }) + return entries[0] || null +} + +export async function upsertCollectionEntry( + baseURL: string, + collection: string, + filter: Record, + entry: Record +): Promise { + const existing = await findFirstEntry(baseURL, collection, { filter }) + const existingId = existing ? getEntryId(existing) : undefined + + if (existingId) { + return updateCollectionEntry(baseURL, collection, existingId, entry) + } + + return createCollectionEntry(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 } } /** diff --git a/tests/api/helpers/seed-data.ts b/tests/api/helpers/seed-data.ts new file mode 100644 index 0000000..2b99f48 --- /dev/null +++ b/tests/api/helpers/seed-data.ts @@ -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(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: "

Dieser Richtext-Block prueft, dass formatierter HTML-Inhalt im SPA gerendert wird.

", + }, + { + type: "accordion", + anchorId: "seed-faq", + headline: "Hauefige Fragen", + tagline: "Verhalten", + padding: { top: "md", bottom: "md" }, + accordionItems: [ + { + question: "Warum Seed-Daten?", + answer: "

Damit Tests nicht blind auf bestehende Inhalte oder Demo-Routen vertrauen.

", + open: true, + }, + { + question: "Was wird geprueft?", + answer: "

API-Antworten, Routing, Sprachwechsel und Block-Rendering.

", + 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: "

This richtext block proves that formatted HTML content renders in the SPA.

", + }, + { + type: "accordion", + anchorId: "seed-faq", + headline: "Common questions", + tagline: "Behavior", + padding: { top: "md", bottom: "md" }, + accordionItems: [ + { + question: "Why seeded data?", + answer: "

So the tests do not depend on existing demo pages or editorial content.

", + open: true, + }, + { + question: "What is covered?", + answer: "

API responses, routing, locale switching and block rendering.

", + 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: "

Diese Seite ist absichtlich inaktiv und darf im Frontend nicht erscheinen.

", + }, + ], + }, +] as const + +export async function cleanupSeededTestContent(baseURL: string): Promise { + const contentEntries = await listCollectionEntries(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 { + let created = 0 + for (const entry of SEEDED_CONTENT_ENTRIES) { + await createCollectionEntry(baseURL, "content", entry as unknown as Record) + 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 } +} diff --git a/tests/e2e-mobile/home.mobile.spec.ts b/tests/e2e-mobile/home.mobile.spec.ts index 21ac3c0..d25c2bb 100644 --- a/tests/e2e-mobile/home.mobile.spec.ts +++ b/tests/e2e-mobile/home.mobile.spec.ts @@ -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() + }) }) diff --git a/tests/e2e/demo.spec.ts b/tests/e2e/demo.spec.ts index b9f0186..7bbfc82 100644 --- a/tests/e2e/demo.spec.ts +++ b/tests/e2e/demo.spec.ts @@ -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() - }) -}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 3d229e0..2a8b25a 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -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({ - /** - * 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({ 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 { await page.waitForLoadState("domcontentloaded") await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 }) @@ -110,9 +32,6 @@ export async function waitForSpaReady(page: Page): Promise { return match?.[1] || "de" } -/** - * Navigate to a route, prepending the current language prefix. - */ export async function navigateToRoute(page: Page, routePath: string): Promise { const url = page.url() const match = url.match(/\/([a-z]{2})(\/|$)/) @@ -123,9 +42,6 @@ export async function navigateToRoute(page: Page, routePath: string): Promise { await page.evaluate(() => { ;(window as any).__spa_navigation_marker = true diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts index ceec541..92b53e9 100644 --- a/tests/e2e/home.spec.ts +++ b/tests/e2e/home.spec.ts @@ -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$/) }) }) diff --git a/tests/fixtures/test-constants.ts b/tests/fixtures/test-constants.ts index c7d6d2f..536fb63 100644 --- a/tests/fixtures/test-constants.ts +++ b/tests/fixtures/test-constants.ts @@ -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", diff --git a/tests/global-setup.ts b/tests/global-setup.ts index f991ed3..df96169 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -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() } diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index 95c120d..d74d34d 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -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()]) }