feat: add admin smoke tests and enhance testing documentation with new strategies and configurations

This commit is contained in:
2026-05-12 21:01:25 +00:00
parent c058ec760f
commit 53ad012657
14 changed files with 388 additions and 51 deletions
+9
View File
@@ -9,11 +9,13 @@ For the full current workflow, prefer the `playwright-testing` skill.
- All tests: `yarn test`
- E2E: `yarn test:e2e`
- API: `yarn test:api`
- Admin smoke: `npx playwright test tests/e2e-admin/smoke.spec.ts --project=admin`
- Visual regression: `yarn test:visual`
- Single file: `npx playwright test tests/e2e/filename.spec.ts`
- Single test: `npx playwright test -g "test name"`
- After code changes, run only affected spec files — not the full suite.
- Prefer the configured `CODING_URL` from `.env` whenever it serves both `/` and `/api/...`.
- Admin browser tests use `CODING_TIBIADMIN_URL` from `.env` and default to `admin/admin` unless overridden via env vars.
- The current test baseline is deterministic and seed-driven, not demo-content-driven.
## BrowserSync workaround
@@ -32,16 +34,23 @@ BrowserSync keeps a WebSocket open permanently, preventing `networkidle` and `lo
- `tests/global-teardown.ts` removes seeded content again and disposes shared API contexts.
- Seed data lives in `tests/api/helpers/seed-data.ts`.
- Seeded route constants live in `tests/fixtures/test-constants.ts`.
- Prefer a hidden `_testdata` boolean field as the last field per collection as the primary marker for seeded test data.
- Cleanup belongs both in `globalSetup` and `globalTeardown`.
- Seed creation/cleanup must stay run-scoped so the suite works with many workers; do not create or delete shared seeded data in per-test hooks.
- If tests write to a collection through `ADMIN_TOKEN`, that collection must define explicit permissions like `"token:${ADMIN_TOKEN}":`.
## Fixtures & helpers
- `e2e/fixtures.ts` — Shared desktop helpers (`waitForSpaReady`, `navigateToRoute`, `clickSpaLink`) with BrowserSync-safe navigation defaults.
- `e2e-admin/fixtures.ts` — Shared admin helpers (`loginToAdmin`, `openNovaProjectDashboard`) for committed admin smoke coverage.
- `e2e-admin/content-config.spec.ts` — Checks that collection config is actually reflected in Nova: sensible list columns/previews, usable widgets, and working pagebuilder preview.
- `e2e-visual/fixtures.ts` — Visual test helpers (`waitForVisualReady`, `hideDynamicContent`, `prepareForScreenshot`, `expectScreenshot`, `getDynamicMasks`).
- `e2e-mobile/fixtures.ts` — Mobile helpers (`openHamburgerMenu`, `isMobileViewport`, `isTabletViewport`, `isBelowLg`).
- `api/fixtures.ts` — API fixtures (`api`, `adminApi`).
- `api/helpers/` — API test utilities (`admin-api.ts`, `seed-data.ts`, `maildev.ts`).
- `fixtures/test-constants.ts` — Central constants (`ADMIN_TOKEN`, `API_BASE`, `TEST_BASE_URL`, `SEEDED_TEST_CONTENT`).
- Use committed admin smoke tests for stable admin contracts. Use one-shot MCP/browser checks only as exploratory supplements, not as the sole regression guard for important admin paths.
- Use admin tests specifically to catch broken collection configuration: empty/bad list previews, missing widgets, broken dependsOn behavior, or non-rendering pagebuilder previews.
- `api/helpers/test-user.ts` is legacy starter scaffolding and should only be reused if the project really needs JWT-user coverage again.
## Visual regression
+27 -3
View File
@@ -4,7 +4,9 @@ import { createCollectionEntry, deleteCollectionEntry, listCollectionEntries } f
type ContentEntry = {
id?: string
_id?: string | { $oid?: string }
_testdata?: boolean
translationKey?: string
path?: string
[key: string]: unknown
}
@@ -16,9 +18,27 @@ function getEntryId(entry: ContentEntry): string | undefined {
}
const SEEDED_TRANSLATION_KEYS = new Set<string>(Object.values(SEEDED_TEST_CONTENT).map((entry) => entry.translationKey))
const SEEDED_PATHS = new Set<string>(Object.values(SEEDED_TEST_CONTENT).map((entry) => entry.path))
function isSeededContentEntry(entry: ContentEntry): boolean {
if (entry._testdata === true) {
return true
}
if (typeof entry.translationKey === "string" && SEEDED_TRANSLATION_KEYS.has(entry.translationKey)) {
return true
}
if (typeof entry.path === "string" && SEEDED_PATHS.has(entry.path)) {
return true
}
return false
}
const SEEDED_CONTENT_ENTRIES = [
{
_testdata: true,
active: true,
type: "page",
lang: "de",
@@ -99,6 +119,7 @@ const SEEDED_CONTENT_ENTRIES = [
],
},
{
_testdata: true,
active: true,
type: "page",
lang: "en",
@@ -179,6 +200,7 @@ const SEEDED_CONTENT_ENTRIES = [
],
},
{
_testdata: true,
active: true,
type: "page",
lang: "de",
@@ -208,6 +230,7 @@ const SEEDED_CONTENT_ENTRIES = [
],
},
{
_testdata: true,
active: true,
type: "page",
lang: "en",
@@ -237,6 +260,7 @@ const SEEDED_CONTENT_ENTRIES = [
],
},
{
_testdata: true,
active: false,
type: "page",
lang: "de",
@@ -265,9 +289,9 @@ const SEEDED_CONTENT_ENTRIES = [
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)
)
// Cleanup runs before every seed pass so leftovers from aborted test runs
// are removed on the next successful global setup.
const seededEntries = contentEntries.filter((entry) => isSeededContentEntry(entry))
let deleted = 0
for (const entry of seededEntries) {
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, openContentCollection, openNewContentEntry } from "./fixtures"
test.describe("Admin content collection config", () => {
test("renders a meaningful content list with configured columns and pagebuilder summaries", async ({ page }) => {
await openContentCollection(page)
const main = page.locator("main")
const table = page.getByRole("table")
const homeRow = page.getByRole("row", { name: /Startseite\s+\// }).first()
await expect(table).toBeVisible()
await expect(main).toContainText("Sprache")
await expect(main).toContainText("Pfad")
await expect(main).toContainText("Inhaltsblöcke")
await expect(homeRow).toContainText("Startseite")
await expect(homeRow).toContainText("Hero")
await expect(homeRow).toContainText("Features")
await expect(table.locator("tbody img").first()).toBeVisible()
})
test("shows the configured content widgets in the new entry form", async ({ page }) => {
await openNewContentEntry(page)
await expect(page.getByLabel("Name")).toBeVisible()
await expect(page.getByLabel("Pfad")).toBeVisible()
await expect(page.getByLabel("Teasertext")).toBeVisible()
await expect(page.getByText("Alternative Pfade")).toBeVisible()
await expect(page.getByText("Inhaltsblöcke").first()).toBeVisible()
await expect(page.getByRole("button", { name: /Desktop \(1280px\)/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Tablet \(768px\)/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Mobil \(375px\)/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Block hinzufügen/ }).first()).toBeVisible()
})
test("loads the pagebuilder block chooser and renders the hero preview live", async ({ page }) => {
await openNewContentEntry(page)
await page
.getByRole("button", { name: /Block hinzufügen/ })
.first()
.click()
await expect(page.getByRole("button", { name: /Hero .*Call-to-Action\./ })).toBeVisible()
await expect(page.getByRole("button", { name: /Features .*strukturierten Boxen\./ })).toBeVisible()
await expect(page.getByRole("button", { name: /Richtext .*Bild\./ })).toBeVisible()
await expect(page.getByRole("button", { name: /Akkordeon .*Fragen und Antworten\./ })).toBeVisible()
await expect(page.getByRole("button", { name: /Kontaktformular .*Intro-Text\./ })).toBeVisible()
await page.getByRole("button", { name: /Hero .*Call-to-Action\./ }).click()
const dialog = page.getByRole("dialog", { name: /Hero #1 bearbeiten/ })
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel("Blocktyp")).toBeVisible()
await expect(dialog.getByLabel("Unterzeile")).toBeVisible()
await expect(dialog.getByLabel("Containerbreite")).toBeVisible()
await expect(dialog.getByRole("button", { name: /Vorhandene durchsuchen/ })).toBeVisible()
await dialog.getByLabel("Überschrift").fill("Admin Preview Test")
await dialog.getByLabel("Unterzeile").fill("Pagebuilder Vorschau aktualisiert")
await expect(dialog.getByRole("heading", { name: "Admin Preview Test" })).toBeVisible()
await expect(dialog).toContainText("Pagebuilder Vorschau aktualisiert")
await expect(page.getByRole("button", { name: /Admin Preview Test/ })).toBeVisible()
})
})
+69
View File
@@ -0,0 +1,69 @@
import { test as base, expect, type Page } from "@playwright/test"
import { attachConsoleMonitor } from "../fixtures/console-monitor"
import { ADMIN_UI_CREDENTIALS, TEST_ADMIN_BASE_URL } from "../fixtures/test-constants"
export const test = base.extend({
page: async ({ page }, use) => {
const monitor = attachConsoleMonitor(page)
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
await use(page)
monitor.assertNoErrors()
},
})
export async function loginToAdmin(page: Page): Promise<void> {
await page.goto(`${TEST_ADMIN_BASE_URL}/login`)
await page.getByLabel(/Benutzername|Username/i).fill(ADMIN_UI_CREDENTIALS.username)
await page.getByLabel(/Passwort|Password/i).fill(ADMIN_UI_CREDENTIALS.password)
await page.getByRole("button", { name: /Anmelden|Sign in|Login/i }).click()
await expect(page).toHaveURL(/\/projects\//, { timeout: 15000 })
await expect(page.locator("main")).toBeVisible()
}
export async function openNovaProjectDashboard(page: Page): Promise<void> {
await loginToAdmin(page)
const germanLocaleButton = page.getByRole("button", { name: /^Deutsch$/ })
if ((await germanLocaleButton.count()) > 0) {
await germanLocaleButton.first().click()
}
const openNovaButton = page.getByRole("button", { name: /Nova öffnen|Open Nova/i })
if ((await openNovaButton.count()) > 0 && (await openNovaButton.first().isVisible())) {
await openNovaButton.first().click()
}
await expect(page.getByRole("textbox", { name: /Kollektionen durchsuchen|Search collections/i })).toBeVisible({
timeout: 15000,
})
}
export async function openContentCollection(page: Page): Promise<void> {
await openNovaProjectDashboard(page)
await page
.getByRole("link", { name: /Inhalte/ })
.first()
.click()
await expect(page).toHaveURL(/\/collections\/content$/)
await expect(page.locator("main h1")).toHaveText("Inhalte")
}
export async function openNewContentEntry(page: Page): Promise<void> {
await openContentCollection(page)
await page.getByRole("button", { name: /Neuer Eintrag|New Entry/i }).click()
await expect(page).toHaveURL(/\/collections\/content\/entries\/new/)
await expect(page.getByRole("heading", { level: 1, name: /Neuer Eintrag|New Entry/i })).toBeVisible()
}
export { expect, type Page }
+30
View File
@@ -0,0 +1,30 @@
import { test, expect, openNovaProjectDashboard } from "./fixtures"
test.describe("Admin smoke", () => {
test("logs in and shows the core collection groups", async ({ page }) => {
await openNovaProjectDashboard(page)
await expect(page.getByRole("heading", { level: 2, name: "Inhalte" })).toBeVisible()
await expect(page.getByRole("heading", { level: 2, name: "Medien" })).toBeVisible()
await expect(page.getByRole("heading", { level: 2, name: "Struktur" })).toBeVisible()
await expect(page.getByRole("link", { name: /Inhalte/ }).first()).toBeVisible()
await expect(page.getByRole("link", { name: /Mediathek/ }).first()).toBeVisible()
await expect(page.getByRole("link", { name: /Navigation/ }).first()).toBeVisible()
})
test("opens the content collection with the current admin configuration", async ({ page }) => {
await openNovaProjectDashboard(page)
await page
.getByRole("link", { name: /Inhalte/ })
.first()
.click()
await expect(page).toHaveURL(/\/collections\/content$/)
await expect(page.locator("main h1")).toHaveText("Inhalte")
await expect(page.getByRole("button", { name: /Neuer Eintrag|New Entry/i })).toBeVisible()
await expect(page.locator("main")).toContainText("Sprache")
await expect(page.locator("main")).toContainText("Inhaltsblöcke")
})
})
+7
View File
@@ -44,6 +44,13 @@ function loadProjectEnvValue(key: string): string | undefined {
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 TEST_ADMIN_BASE_URL =
process.env.CODING_TIBIADMIN_URL || loadProjectEnvValue("CODING_TIBIADMIN_URL") || "http://localhost:3000"
export const ADMIN_UI_CREDENTIALS = {
username: process.env.ADMIN_UI_USERNAME || "admin",
password: process.env.ADMIN_UI_PASSWORD || "admin",
} as const
export const SEEDED_TEST_CONTENT = {
home: {
+3
View File
@@ -5,6 +5,9 @@ import { TEST_BASE_URL } from "./fixtures/test-constants"
async function globalSetup() {
const baseURL = TEST_BASE_URL
// Seed cleanup/creation stays run-scoped here so all workers consume the
// same deterministic dataset instead of racing on shared test records.
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
+3
View File
@@ -6,6 +6,9 @@ import { TEST_BASE_URL } from "./fixtures/test-constants"
async function globalTeardown() {
const baseURL = TEST_BASE_URL
// Final seed cleanup also stays run-scoped here. Per-test or per-worker
// cleanup would race with parallel workers against the shared seeded data.
try {
const probeContext = await import("@playwright/test").then(({ request }) =>
request.newContext({ baseURL, ignoreHTTPSErrors: true })