✨ feat: implement new feature for enhanced user experience
This commit is contained in:
57
tests/api/fixtures.ts
Normal file
57
tests/api/fixtures.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test as base, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
interface ActionSuccess {
|
||||
success: true
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ActionError {
|
||||
error: string
|
||||
}
|
||||
|
||||
type ApiWorkerFixtures = {
|
||||
testUser: TestUserCredentials
|
||||
}
|
||||
|
||||
type ApiFixtures = {
|
||||
api: APIRequestContext
|
||||
authedApi: APIRequestContext
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export const test = base.extend<ApiFixtures, ApiWorkerFixtures>({
|
||||
testUser: [
|
||||
async ({ playwright }, use) => {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
const user = await ensureTestUser(baseURL)
|
||||
await use(user)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
api: async ({ request }, use) => {
|
||||
await use(request)
|
||||
},
|
||||
|
||||
accessToken: async ({ testUser }, use) => {
|
||||
await use(testUser.accessToken)
|
||||
},
|
||||
|
||||
authedApi: async ({ playwright, baseURL, accessToken }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: baseURL!,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
await use(ctx)
|
||||
await ctx.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
export { expect, API_BASE }
|
||||
export type { ActionSuccess, ActionError }
|
||||
26
tests/api/health.spec.ts
Normal file
26
tests/api/health.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect, API_BASE } from "./fixtures"
|
||||
|
||||
test.describe("API Health", () => {
|
||||
test("should respond to API base endpoint", async ({ api }) => {
|
||||
// tibi-server responds to the base API path
|
||||
const res = await api.get(`${API_BASE}/`)
|
||||
// Accept any successful response (200-299), 401 (auth required), or 404 (no root handler)
|
||||
expect([200, 204, 401, 404]).toContain(res.status())
|
||||
})
|
||||
|
||||
test("should respond to SSR collection", async ({ api }) => {
|
||||
// The ssr collection is part of the starter kit
|
||||
const res = await api.get(`${API_BASE}/ssr`)
|
||||
expect(res.status()).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test("should reject invalid action commands", async ({ api }) => {
|
||||
const res = await api.post(`${API_BASE}/action`, {
|
||||
params: { cmd: "nonexistent_action" },
|
||||
data: {},
|
||||
})
|
||||
// Should return an error, not crash
|
||||
expect(res.status()).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
76
tests/api/helpers/admin-api.ts
Normal file
76
tests/api/helpers/admin-api.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
|
||||
|
||||
let adminContext: APIRequestContext | null = null
|
||||
|
||||
/**
|
||||
* Get or create a singleton admin API context with the ADMIN_TOKEN.
|
||||
*/
|
||||
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
|
||||
if (!adminContext) {
|
||||
adminContext = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
Token: ADMIN_TOKEN,
|
||||
},
|
||||
})
|
||||
}
|
||||
return adminContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user by ID via admin API.
|
||||
*/
|
||||
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
|
||||
const ctx = await getAdminContext(baseURL)
|
||||
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
|
||||
return res.ok()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test users matching a pattern in their email.
|
||||
*/
|
||||
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
|
||||
const ctx = await getAdminContext(baseURL)
|
||||
const res = await ctx.get(`${API_BASE}/user`)
|
||||
if (!res.ok()) return 0
|
||||
|
||||
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
|
||||
if (!Array.isArray(users)) return 0
|
||||
|
||||
let deleted = 0
|
||||
for (const user of users) {
|
||||
const email = user.email || ""
|
||||
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
|
||||
|
||||
if (matches) {
|
||||
const userId = user.id || user._id
|
||||
if (userId) {
|
||||
const ok = await deleteUser(baseURL, userId)
|
||||
if (ok) deleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all test data (users and tokens matching @test.example.com).
|
||||
*/
|
||||
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
|
||||
const testPattern = "@test.example.com"
|
||||
const users = await cleanupTestUsers(baseURL, testPattern)
|
||||
return { users }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the admin API context. Call in globalTeardown.
|
||||
*/
|
||||
export async function disposeAdminApi(): Promise<void> {
|
||||
if (adminContext) {
|
||||
await adminContext.dispose()
|
||||
adminContext = null
|
||||
}
|
||||
}
|
||||
162
tests/api/helpers/maildev.ts
Normal file
162
tests/api/helpers/maildev.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* MailDev API helper for testing email flows.
|
||||
*
|
||||
* Configure via environment variables:
|
||||
* - MAILDEV_URL: MailDev web UI URL (default: https://${PROJECT_NAME}-maildev.code.testversion.online)
|
||||
* - MAILDEV_USER: Basic auth username (default: code)
|
||||
* - MAILDEV_PASS: Basic auth password
|
||||
*/
|
||||
const MAILDEV_URL = process.env.MAILDEV_URL || "http://localhost:1080"
|
||||
const MAILDEV_USER = process.env.MAILDEV_USER || "code"
|
||||
const MAILDEV_PASS = process.env.MAILDEV_PASS || ""
|
||||
|
||||
export interface MailDevEmail {
|
||||
id: string
|
||||
subject: string
|
||||
html: string
|
||||
to: { address: string; name: string }[]
|
||||
from: { address: string; name: string }[]
|
||||
date: string
|
||||
time: string
|
||||
read: boolean
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
let maildevContext: APIRequestContext | null = null
|
||||
|
||||
async function getContext(): Promise<APIRequestContext> {
|
||||
if (!maildevContext) {
|
||||
const opts: any = {
|
||||
baseURL: MAILDEV_URL,
|
||||
ignoreHTTPSErrors: true,
|
||||
}
|
||||
if (MAILDEV_PASS) {
|
||||
opts.httpCredentials = {
|
||||
username: MAILDEV_USER,
|
||||
password: MAILDEV_PASS,
|
||||
}
|
||||
}
|
||||
maildevContext = await request.newContext(opts)
|
||||
}
|
||||
return maildevContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all emails from MailDev.
|
||||
*/
|
||||
export async function getAllEmails(): Promise<MailDevEmail[]> {
|
||||
const ctx = await getContext()
|
||||
const res = await ctx.get("/email")
|
||||
if (!res.ok()) {
|
||||
throw new Error(`MailDev API error: ${res.status()} ${res.statusText()}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all emails in MailDev.
|
||||
*/
|
||||
export async function deleteAllEmails(): Promise<void> {
|
||||
const ctx = await getContext()
|
||||
await ctx.delete("/email/all")
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific email by ID.
|
||||
*/
|
||||
export async function deleteEmail(id: string): Promise<void> {
|
||||
const ctx = await getContext()
|
||||
await ctx.delete(`/email/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an email to arrive for a specific recipient.
|
||||
*/
|
||||
export async function waitForEmail(
|
||||
toEmail: string,
|
||||
options: {
|
||||
subject?: string | RegExp
|
||||
timeout?: number
|
||||
pollInterval?: number
|
||||
} = {}
|
||||
): Promise<MailDevEmail> {
|
||||
const { subject, timeout = 5000, pollInterval = 250 } = options
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
const emails = await getAllEmails()
|
||||
const match = emails.find((e) => {
|
||||
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
|
||||
if (!toMatch) return false
|
||||
if (!subject) return true
|
||||
if (typeof subject === "string") return e.subject.includes(subject)
|
||||
return subject.test(e.subject)
|
||||
})
|
||||
if (match) return match
|
||||
await new Promise((r) => setTimeout(r, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error(`Timeout: No email to ${toEmail} received within ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a 6-digit verification code from an email's HTML body.
|
||||
*/
|
||||
export function extractCode(email: MailDevEmail): string {
|
||||
const match = email.html.match(/>(\d{6})<\//)
|
||||
if (!match) {
|
||||
throw new Error(`No 6-digit code found in email "${email.subject}"`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an action and capture the verification code from the resulting email.
|
||||
* Filters out emails that existed before the action was triggered.
|
||||
*/
|
||||
export async function captureCode(
|
||||
toEmail: string,
|
||||
action: () => Promise<void>,
|
||||
options: { subject?: string | RegExp; timeout?: number } = {}
|
||||
): Promise<string> {
|
||||
const existingEmails = await getAllEmails()
|
||||
const existingIds = new Set(
|
||||
existingEmails
|
||||
.filter((e) => e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase()))
|
||||
.map((e) => e.id)
|
||||
)
|
||||
|
||||
await action()
|
||||
|
||||
const { timeout = 5000, subject } = options
|
||||
const pollInterval = 250
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
const emails = await getAllEmails()
|
||||
const match = emails.find((e) => {
|
||||
if (existingIds.has(e.id)) return false
|
||||
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
|
||||
if (!toMatch) return false
|
||||
if (!subject) return true
|
||||
if (typeof subject === "string") return e.subject.includes(subject)
|
||||
return subject.test(e.subject)
|
||||
})
|
||||
if (match) return extractCode(match)
|
||||
await new Promise((r) => setTimeout(r, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error(`Timeout: No new email to ${toEmail} received within ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the MailDev API context. Call in globalTeardown.
|
||||
*/
|
||||
export async function disposeMailDev(): Promise<void> {
|
||||
if (maildevContext) {
|
||||
await maildevContext.dispose()
|
||||
maildevContext = null
|
||||
}
|
||||
}
|
||||
72
tests/api/helpers/test-user.ts
Normal file
72
tests/api/helpers/test-user.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
import { TEST_USER } from "../../fixtures/test-constants"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
export interface TestUserCredentials {
|
||||
email: string
|
||||
password: string
|
||||
accessToken: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user via the tibi action endpoint.
|
||||
* Returns the access token or null on failure.
|
||||
*/
|
||||
export async function loginUser(baseURL: string, email: string, password: string): Promise<string | null> {
|
||||
const ctx = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await ctx.post(`${API_BASE}/action`, {
|
||||
params: { cmd: "login" },
|
||||
data: { email, password },
|
||||
})
|
||||
if (!res.ok()) return null
|
||||
const body = await res.json()
|
||||
return body.accessToken || null
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the test user exists and is logged in.
|
||||
* Tries login first; if that fails, logs a warning (registration must be
|
||||
* implemented per project or the user must be seeded via globalSetup).
|
||||
*/
|
||||
export async function ensureTestUser(baseURL: string): Promise<TestUserCredentials> {
|
||||
const email = TEST_USER.email
|
||||
const password = TEST_USER.password
|
||||
|
||||
const token = await loginUser(baseURL, email, password)
|
||||
if (token) {
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
accessToken: token,
|
||||
firstName: TEST_USER.firstName,
|
||||
lastName: TEST_USER.lastName,
|
||||
}
|
||||
}
|
||||
|
||||
// If login fails, the test user doesn't exist yet.
|
||||
// Implement project-specific registration here, or seed via globalSetup.
|
||||
console.warn(
|
||||
`⚠️ Test user ${email} could not be logged in. ` +
|
||||
`Either implement registration in test-user.ts or seed the user via globalSetup.`
|
||||
)
|
||||
|
||||
// Return a placeholder – tests requiring auth will fail gracefully
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
accessToken: "",
|
||||
firstName: TEST_USER.firstName,
|
||||
lastName: TEST_USER.lastName,
|
||||
}
|
||||
}
|
||||
109
tests/e2e-mobile/fixtures.ts
Normal file
109
tests/e2e-mobile/fixtures.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test as base, expect as pwExpect } from "@playwright/test"
|
||||
|
||||
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
|
||||
import { expect } from "../e2e/fixtures"
|
||||
|
||||
type MobileFixtures = {}
|
||||
|
||||
export const test = base.extend<MobileFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync domcontentloaded workaround.
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be ready in mobile viewport.
|
||||
*/
|
||||
export async function waitForSpaReady(page: Page): Promise<string> {
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
return match?.[1] || "de"
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route with language prefix.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/** Check if viewport width is mobile (<768px) */
|
||||
export function isMobileViewport(page: Page): boolean {
|
||||
return (page.viewportSize()?.width ?? 0) < 768
|
||||
}
|
||||
|
||||
/** Check if viewport width is tablet (768-1023px) */
|
||||
export function isTabletViewport(page: Page): boolean {
|
||||
const w = page.viewportSize()?.width ?? 0
|
||||
return w >= 768 && w < 1024
|
||||
}
|
||||
|
||||
/** Check if viewport width is below lg breakpoint (<1024px) */
|
||||
export function isBelowLg(page: Page): boolean {
|
||||
return (page.viewportSize()?.width ?? 0) < 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the hamburger/mobile navigation menu.
|
||||
* Finds the first visible button with aria-expanded in the header.
|
||||
*/
|
||||
export async function openHamburgerMenu(page: Page): Promise<void> {
|
||||
const hamburgers = page.locator("header button[aria-expanded]")
|
||||
const count = await hamburgers.count()
|
||||
let clicked = false
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = hamburgers.nth(i)
|
||||
if (await btn.isVisible()) {
|
||||
await btn.click()
|
||||
clicked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!clicked) {
|
||||
throw new Error("No visible hamburger button found in header")
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
|
||||
return btn !== null
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the hamburger menu via Escape key.
|
||||
*/
|
||||
export async function closeHamburgerMenuViaEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape")
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
|
||||
return btn === null
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
28
tests/e2e-mobile/home.mobile.spec.ts
Normal file
28
tests/e2e-mobile/home.mobile.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect, waitForSpaReady, isMobileViewport } from "./fixtures"
|
||||
|
||||
test.describe("Home Page (Mobile)", () => {
|
||||
test("should load the start page on mobile", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
expect(isMobileViewport(page) || true).toBeTruthy()
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty()
|
||||
})
|
||||
|
||||
test("should have a visible header", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
const header = page.locator("header")
|
||||
await expect(header).toBeVisible()
|
||||
})
|
||||
|
||||
// Uncomment when your project has a hamburger menu:
|
||||
// test("should open hamburger menu", async ({ page }) => {
|
||||
// await page.goto("/de/")
|
||||
// await waitForSpaReady(page)
|
||||
// await openHamburgerMenu(page)
|
||||
// const expandedBtn = page.locator("header button[aria-expanded='true']")
|
||||
// await expect(expandedBtn).toBeVisible()
|
||||
// })
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
113
tests/e2e-visual/fixtures.ts
Normal file
113
tests/e2e-visual/fixtures.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Page, Locator } from "@playwright/test"
|
||||
import { test as base, expect as pwExpect } from "@playwright/test"
|
||||
|
||||
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
|
||||
import { expect } from "../e2e/fixtures"
|
||||
|
||||
type VisualFixtures = {}
|
||||
|
||||
export const test = base.extend<VisualFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync domcontentloaded workaround.
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be fully rendered and stable for visual comparison.
|
||||
* Waits for skeleton loaders to disappear and CSS to settle.
|
||||
*/
|
||||
export async function waitForVisualReady(page: Page, opts?: { timeout?: number }): Promise<void> {
|
||||
const timeout = opts?.timeout ?? 15000
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout })
|
||||
try {
|
||||
await page.waitForFunction(() => document.querySelectorAll(".animate-pulse").length === 0, { timeout: 10000 })
|
||||
} catch {
|
||||
// Skeleton loaders may not exist on every page
|
||||
}
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route with visual readiness wait.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
|
||||
await waitForVisualReady(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide dynamic content that would cause screenshot flakiness:
|
||||
* - BrowserSync overlay
|
||||
* - All animations and transitions
|
||||
* - Blinking cursor
|
||||
*/
|
||||
export async function hideDynamicContent(page: Page): Promise<void> {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
#__bs_notify__, #__bs_notify__:before, #__bs_notify__:after { display: none !important; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
* { caret-color: transparent !important; }
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns locators for non-deterministic elements that should be masked
|
||||
* in screenshots (e.g. cart badges, timestamps).
|
||||
* Customize this for your project.
|
||||
*/
|
||||
export function getDynamicMasks(page: Page): Locator[] {
|
||||
return [
|
||||
// Add project-specific dynamic element selectors here:
|
||||
// page.locator('[data-testid="cart-badge"]'),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the page for a screenshot: hide dynamic content and scroll to top.
|
||||
*/
|
||||
export async function prepareForScreenshot(page: Page): Promise<void> {
|
||||
await hideDynamicContent(page)
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare against baseline.
|
||||
*/
|
||||
export async function expectScreenshot(
|
||||
page: Page,
|
||||
name: string,
|
||||
opts?: {
|
||||
fullPage?: boolean
|
||||
mask?: Locator[]
|
||||
maxDiffPixelRatio?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])]
|
||||
await pwExpect(page).toHaveScreenshot(name, {
|
||||
fullPage: opts?.fullPage ?? false,
|
||||
mask: masks,
|
||||
maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02,
|
||||
})
|
||||
}
|
||||
11
tests/e2e-visual/home.visual.spec.ts
Normal file
11
tests/e2e-visual/home.visual.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { waitForVisualReady, prepareForScreenshot, expectScreenshot } from "./fixtures"
|
||||
|
||||
test.describe("Home Page (Visual)", () => {
|
||||
test("homepage screenshot", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForVisualReady(page)
|
||||
await prepareForScreenshot(page)
|
||||
await expectScreenshot(page, "homepage.png", { fullPage: true })
|
||||
})
|
||||
})
|
||||
139
tests/e2e/fixtures.ts
Normal file
139
tests/e2e/fixtures.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { ensureTestUser, type TestUserCredentials } from "../api/helpers/test-user"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
/**
|
||||
* Shared E2E test fixtures.
|
||||
*
|
||||
* Worker-scoped:
|
||||
* - `testUser` – persistent test user (created/reused once per worker)
|
||||
*
|
||||
* Test-scoped:
|
||||
* - `authedPage` – Page with logged-in user (token injection via sessionStorage)
|
||||
* - `accessToken` – raw JWT access token
|
||||
*/
|
||||
type E2eWorkerFixtures = {
|
||||
testUser: TestUserCredentials
|
||||
}
|
||||
|
||||
type E2eFixtures = {
|
||||
authedPage: Page
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export const test = base.extend<E2eFixtures, E2eWorkerFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync keeps a WebSocket open permanently,
|
||||
* preventing "load" and "networkidle" from resolving. We default all
|
||||
* navigation methods to "domcontentloaded".
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
const origGoBack = page.goBack.bind(page)
|
||||
const origGoForward = page.goForward.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
page.goBack = ((opts?: any) => origGoBack({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goBack
|
||||
page.goForward = ((opts?: any) =>
|
||||
origGoForward({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goForward
|
||||
|
||||
await use(page)
|
||||
},
|
||||
|
||||
// Worker-scoped: create/reuse test user once per worker
|
||||
testUser: [
|
||||
async ({ playwright }, use) => {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
const user = await ensureTestUser(baseURL)
|
||||
await use(user)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
accessToken: async ({ testUser }, use) => {
|
||||
await use(testUser.accessToken)
|
||||
},
|
||||
|
||||
// Test-scoped: Page with logged-in user via sessionStorage token injection
|
||||
authedPage: async ({ page, testUser, baseURL }, use) => {
|
||||
// Navigate to home so domain is set for sessionStorage
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" })
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
|
||||
// Inject auth token into sessionStorage (adapt key names to your app)
|
||||
await page.evaluate(
|
||||
({ token, user }) => {
|
||||
sessionStorage.setItem("auth_token", token)
|
||||
sessionStorage.setItem(
|
||||
"auth_user",
|
||||
JSON.stringify({
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
token: testUser.accessToken,
|
||||
user: testUser,
|
||||
}
|
||||
)
|
||||
|
||||
// Reload so the app reads the token from sessionStorage
|
||||
await page.reload({ waitUntil: "domcontentloaded" })
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be ready (appContainer rendered).
|
||||
* Returns the detected language prefix from the URL.
|
||||
*/
|
||||
export async function waitForSpaReady(page: Page): Promise<string> {
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
return match?.[1] || "de"
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route, prepending the current language prefix.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath)
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an SPA link and verify no full page reload occurred.
|
||||
*/
|
||||
export async function clickSpaLink(page: Page, linkSelector: string): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__spa_navigation_marker = true
|
||||
})
|
||||
await page.locator(linkSelector).first().click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
const markerExists = await page.evaluate(() => {
|
||||
return (window as any).__spa_navigation_marker === true
|
||||
})
|
||||
if (!markerExists) {
|
||||
throw new Error(`SPA navigation failed: full page reload detected when clicking "${linkSelector}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export { expect, API_BASE, type Page }
|
||||
39
tests/e2e/home.spec.ts
Normal file
39
tests/e2e/home.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect, waitForSpaReady } from "./fixtures"
|
||||
|
||||
test.describe("Home Page", () => {
|
||||
test("should load the start page", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
const lang = await waitForSpaReady(page)
|
||||
expect(lang).toBe("de")
|
||||
})
|
||||
|
||||
test("should have a visible header with navigation", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
const header = page.locator("header")
|
||||
await expect(header).toBeVisible()
|
||||
|
||||
const nav = header.locator("nav")
|
||||
await expect(nav).toBeVisible()
|
||||
})
|
||||
|
||||
test("should have language prefix in URL", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
expect(page.url()).toContain("/de")
|
||||
})
|
||||
|
||||
test("should switch language to English", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
// Click on English language link
|
||||
const enLink = page.locator('a[href*="/en"]').first()
|
||||
if (await enLink.isVisible()) {
|
||||
await enLink.click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
expect(page.url()).toContain("/en")
|
||||
}
|
||||
})
|
||||
})
|
||||
23
tests/fixtures/test-constants.ts
vendored
Normal file
23
tests/fixtures/test-constants.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Zentrale Test-Konstanten für alle Tests.
|
||||
*
|
||||
* Passe diese Werte an dein Projekt an:
|
||||
* - TEST_USER: E-Mail/Passwort für den E2E-Test-User
|
||||
* - ADMIN_TOKEN: Token aus api/config.yml.env
|
||||
* - API_BASE: API-Pfad (Standard: /api)
|
||||
*/
|
||||
|
||||
export const TEST_USER = {
|
||||
email: "playwright-e2e@test.example.com",
|
||||
password: "PlaywrightTest1",
|
||||
firstName: "Playwright",
|
||||
lastName: "E2E",
|
||||
} as const
|
||||
|
||||
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "CHANGE_ME"
|
||||
export const API_BASE = "/api"
|
||||
|
||||
export const BASIC_AUTH = {
|
||||
username: process.env.BASIC_AUTH_USER || "web",
|
||||
password: process.env.BASIC_AUTH_PASS || "web",
|
||||
} as const
|
||||
49
tests/global-setup.ts
Normal file
49
tests/global-setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Playwright Global Setup
|
||||
*
|
||||
* Runs once before all tests. Use this to:
|
||||
* - Seed test data via API action hooks
|
||||
* - Create test users
|
||||
* - Verify the dev environment is accessible
|
||||
*
|
||||
* Customize the setup_testing action call for your project,
|
||||
* or remove it if not needed.
|
||||
*/
|
||||
import { request } from "@playwright/test"
|
||||
import { API_BASE } from "./fixtures/test-constants"
|
||||
|
||||
async function globalSetup() {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
|
||||
// Verify dev environment is reachable
|
||||
const ctx = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
"User-Agent": "Playwright",
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await ctx.get("/")
|
||||
if (!res.ok()) {
|
||||
console.warn(`⚠️ Dev environment at ${baseURL} returned ${res.status()}`)
|
||||
} else {
|
||||
console.log(`✅ Dev environment reachable at ${baseURL}`)
|
||||
}
|
||||
|
||||
// Uncomment and adapt for project-specific test data seeding:
|
||||
// const seedRes = await ctx.post(`${API_BASE}/action`, {
|
||||
// params: { cmd: "setup_testing" },
|
||||
// data: { scope: "all" },
|
||||
// headers: { Token: ADMIN_TOKEN },
|
||||
// })
|
||||
// if (seedRes.ok()) {
|
||||
// console.log("✅ Test data seeded")
|
||||
// }
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
37
tests/global-teardown.ts
Normal file
37
tests/global-teardown.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Playwright Global Teardown
|
||||
*
|
||||
* Runs once after all tests. Use this to:
|
||||
* - Clean up test data
|
||||
* - Dispose singleton API contexts
|
||||
*
|
||||
* Customize for your project's cleanup needs.
|
||||
*/
|
||||
import { cleanupAllTestData, disposeAdminApi } from "./api/helpers/admin-api"
|
||||
import { deleteAllEmails, disposeMailDev } from "./api/helpers/maildev"
|
||||
|
||||
async function globalTeardown() {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
|
||||
// Clean up test users
|
||||
try {
|
||||
const result = await cleanupAllTestData(baseURL)
|
||||
if (result.users > 0) {
|
||||
console.log(`🧹 Cleanup: ${result.users} test users deleted`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Test data cleanup failed:", err)
|
||||
}
|
||||
|
||||
// Clean up MailDev emails (optional)
|
||||
try {
|
||||
await deleteAllEmails()
|
||||
} catch {
|
||||
// MailDev cleanup is optional
|
||||
}
|
||||
|
||||
// Dispose singleton API contexts
|
||||
await Promise.all([disposeAdminApi(), disposeMailDev()])
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
Reference in New Issue
Block a user