diff --git a/.env b/.env index 653c84e..02222bb 100644 --- a/.env +++ b/.env @@ -19,5 +19,6 @@ 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 #START_SCRIPT=:ssr diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 53d8101..07cb11f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,25 +1,25 @@ # Copilot Instructions -## Common Instructions +This workspace uses scoped instructions with YAML front matter in `.github/instructions/*.instructions.md`. +Keep this file minimal to avoid duplicate or conflicting guidance. -- Look in the problems tab for any errors or warnings in the code -- Follow the existing code style and conventions used in the project -- Write clear and concise comments where necessary to explain complex logic -- Ensure code is modular and reusable where possible -- Write unit tests for new functionality and ensure existing tests pass, but only if there is a configured testing framework -- Avoid introducing new dependencies unless absolutely necessary, but ask the user if there is a specific library they want to use -- If you are unsure about any requirements or details, ask the user for clarification before proceeding -- Respect a11y and localization best practices if applicable, optimize for WCAG AA standards +## Quick Reference + +- **General workflow**: See `.github/instructions/general.instructions.md` +- **Frontend (Svelte)**: See `.github/instructions/frontend.instructions.md` +- **API Hooks (tibi-server)**: See `.github/instructions/api-hooks.instructions.md` +- **SSR/Caching**: See `.github/instructions/ssr.instructions.md` +- **Testing (Playwright)**: See `.github/instructions/testing.instructions.md` ## Toolchain -- See .env in root for project specific environment variables -- See Makefile for starting up the development environment with Docker -- If development environment is running, access the website at: https://${PROJECT_NAME}.code.testversion.online/ or ask the user for the correct URL +- See `.env` in root for project-specific environment variables +- See `Makefile` for starting up the development environment with Docker +- If development environment is running, access the website at: `https://${PROJECT_NAME}.code.testversion.online/` or ask the user for the correct URL - You can also use Browser MCP, so ask user to connect if needed - Esbuild is used, watching for changes in files to rebuild automatically -- To force a restart of the frontend build and dev-server run: `make restart-frontend` -- Backend is tibi-server configured in /api/ folder and also restarted if changes are detected in this folder -- To show last X lines of docker logs run: `make docker-logs-X` where X is the number - of lines you want to see +- To force a restart of the frontend build and dev-server run: `make docker-restart-frontend` +- Backend is tibi-server configured in `/api/` folder and also restarted if changes are detected in this folder +- To show last X lines of docker logs run: `make docker-logs-X` where X is the number of lines you want to see - For a11y testing use the MCP a11y tools if available +- For testing run: `yarn test` (all), `yarn test:e2e`, `yarn test:api`, `yarn test:visual` diff --git a/.github/instructions/api-hooks.instructions.md b/.github/instructions/api-hooks.instructions.md new file mode 100644 index 0000000..bc2f14f --- /dev/null +++ b/.github/instructions/api-hooks.instructions.md @@ -0,0 +1,51 @@ +--- +name: API Hooks +description: tibi-server hook conventions and typing. +applyTo: "api/hooks/**" +--- + +# API Hooks (tibi-server) + +- Wrap hook files in an IIFE: `;(function () { ... })()`. +- Always return a HookResponse type or throw a HookException type. +- Use inline type casting with `/** @type {TypeName} */ (value)` and typed collection entries from `types/global.d.ts`. +- Avoid `@ts-ignore`; use proper casting instead. +- Use `const` and `let` instead of `var`. The tibi-server runtime supports modern JS declarations. + +## context.filter – Go object quirk + +`context.filter` is not a regular JS object but a Go object. Even when empty, it is **truthy**. +Always check with `Object.keys()`: + +```js +const requestedFilter = + context.filter && + typeof context.filter === "object" && + !Array.isArray(context.filter) && + Object.keys(context.filter).length > 0 + ? context.filter + : null +``` + +**Never** use `context.filter || null` – it is always truthy and results in an empty filter object inside `$and`, which crashes the Go server. + +## Single-item vs. list retrieval + +For single-item retrieval (`GET /:collection/:id`), the Go server sets `_id` automatically from the URL parameter. +GET read hooks should therefore **not set their own `_id` filter** for `req.param("id")`; +instead, only add authorization filters (e.g. `{ userId: userId }`). + +## HookResponse fields for GET hooks + +- `filter` – MongoDB filter (list retrieval only, or to restrict single-item retrieval) +- `selector` – MongoDB projection (`{ fieldName: 0 }` to exclude, `{ fieldName: 1 }` to include) +- `offset`, `limit`, `sort` – pagination/sorting +- `pipelineMod` – function to manipulate the aggregation pipeline + +## API Tests (Playwright) + +- When creating or modifying collections/hooks: extend or create corresponding API tests in `tests/api/`. +- Test files live under `tests/api/` and use fixtures from `tests/api/fixtures.ts`. +- Helpers: `ensureTestUser()` (`tests/api/helpers/test-user.ts`), Admin API (`tests/api/helpers/admin-api.ts`), MailDev (`tests/api/helpers/maildev.ts`). +- After hook changes, run only the affected API tests: `npx playwright test tests/api/filename.spec.ts`. +- When tests fail, clarify whether the hook or the test needs adjustment – coordinate with the user. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md new file mode 100644 index 0000000..3b7cf6e --- /dev/null +++ b/.github/instructions/frontend.instructions.md @@ -0,0 +1,28 @@ +--- +name: Frontend +description: Svelte SPA structure and conventions. +applyTo: "frontend/src/**" +--- + +# Frontend + +- SPA entry point is `frontend/src/index.ts`, main component is `frontend/src/App.svelte`. +- Component organization: `lib/` for utilities and stores, keep route components in a `routes/` folder when needed, `css/` for styles. +- Use PascalCase component names and export props at the top of the `
-
- Tibi Svelte Starter -
+ +
+ {#if getRoutePath($location.path) === "/about"} +

{$_("page.about.title")}

+

{$_("page.about.text")}

+ {:else if getRoutePath($location.path) === "/contact"} +

{$_("page.contact.title")}

+

{$_("page.contact.text")}

+ {:else} +

{$_("page.home.title")}

+

{$_("page.home.text")}

+ {/if} +
diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 97c90ee..2da77a0 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,9 +1,11 @@ import "./css/style.css" import App from "./App.svelte" import { hydrate } from "svelte" +import { setupI18n } from "./lib/i18n/index" -let appContainer = document?.getElementById("appContainer") +// Initialize i18n before mounting the app +setupI18n().then(() => { + let appContainer = document?.getElementById("appContainer") + hydrate(App, { target: appContainer }) +}) -const app = hydrate(App, { target: appContainer }) - -export default app diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000..2f8eb38 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,168 @@ +import { writable, derived, get } from "svelte/store" +import { location } from "./store" + +/** + * Supported languages configuration. + * Add more languages as needed for your project. + */ +export const SUPPORTED_LANGUAGES = ["de", "en"] as const +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number] + +export const DEFAULT_LANGUAGE: SupportedLanguage = "de" + +export const LANGUAGE_LABELS: Record = { + de: "Deutsch", + en: "English", +} + +/** + * Route translations for localized URLs. + * Add entries for routes that need translated slugs. + * Example: { about: { de: "ueber-uns", en: "about" } } + */ +export const ROUTE_TRANSLATIONS: Record> = { + // Add your route translations here: + // about: { de: "ueber-uns", en: "about" }, +} + +export const getLocalizedRoute = (canonicalRoute: string, lang: SupportedLanguage): string => { + const translations = ROUTE_TRANSLATIONS[canonicalRoute] + if (translations && translations[lang]) { + return translations[lang] + } + return canonicalRoute +} + +export const getCanonicalRoute = (localizedSegment: string): string => { + for (const [canonical, translations] of Object.entries(ROUTE_TRANSLATIONS)) { + for (const translated of Object.values(translations)) { + if (translated === localizedSegment) { + return canonical + } + } + } + return localizedSegment +} + +/** + * Extract the language code from a URL path. + * Returns null if no valid language prefix is found. + */ +export const extractLanguageFromPath = (path: string): SupportedLanguage | null => { + const match = path.match(/^\/([a-z]{2})(\/|$)/) + if (match && SUPPORTED_LANGUAGES.includes(match[1] as SupportedLanguage)) { + return match[1] as SupportedLanguage + } + return null +} + +export const stripLanguageFromPath = (path: string): string => { + const lang = extractLanguageFromPath(path) + if (lang) { + const stripped = path.slice(3) + return stripped || "/" + } + return path +} + +export const getRoutePath = (fullPath: string): string => { + const stripped = stripLanguageFromPath(fullPath) + if (stripped === "/" || stripped === "") { + return "/" + } + const segments = stripped.split("/").filter(Boolean) + if (segments.length > 0) { + const canonicalFirst = getCanonicalRoute(segments[0]) + if (canonicalFirst !== segments[0]) { + segments[0] = canonicalFirst + return "/" + segments.join("/") + } + } + return stripped +} + +/** + * Derived store: current language based on URL. + */ +export const currentLanguage = derived(location, ($location) => { + const path = $location.path || "/" + return extractLanguageFromPath(path) || DEFAULT_LANGUAGE +}) + +/** + * Writable store for the selected language. + */ +export const selectedLanguage = writable(DEFAULT_LANGUAGE) + +if (typeof window !== "undefined") { + const initialLang = extractLanguageFromPath(window.location.pathname) + if (initialLang) { + selectedLanguage.set(initialLang) + } +} + +if (typeof window !== "undefined") { + location.subscribe(($loc) => { + const lang = extractLanguageFromPath($loc.path) + if (lang) { + selectedLanguage.set(lang) + } + }) +} + +/** + * Get the localized path for a given route and language. + */ +export const localizedPath = (path: string, lang?: SupportedLanguage): string => { + const language = lang || get(currentLanguage) + if (path === "/" || path === "") { + return `/${language}` + } + let cleanPath = stripLanguageFromPath(path) + cleanPath = cleanPath.startsWith("/") ? cleanPath : `/${cleanPath}` + const segments = cleanPath.split("/").filter(Boolean) + if (segments.length > 0) { + const canonicalFirst = getCanonicalRoute(segments[0]) + const localizedFirst = getLocalizedRoute(canonicalFirst, language) + segments[0] = localizedFirst + } + const translatedPath = "/" + segments.join("/") + return `/${language}${translatedPath}` +} + +/** + * Derived store for localized href. + */ +export const localizedHref = (path: string) => { + return derived(currentLanguage, ($lang) => localizedPath(path, $lang)) +} + +export const isValidLanguage = (lang: string): lang is SupportedLanguage => { + return SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage) +} + +/** + * Get the browser's preferred language, falling back to default. + */ +export const getBrowserLanguage = (): SupportedLanguage => { + if (typeof navigator === "undefined") { + return DEFAULT_LANGUAGE + } + const browserLangs = navigator.languages || [navigator.language] + for (const lang of browserLangs) { + const code = lang.split("-")[0].toLowerCase() + if (isValidLanguage(code)) { + return code + } + } + return DEFAULT_LANGUAGE +} + +/** + * Get URL for switching to a different language. + */ +export const getLanguageSwitchUrl = (newLang: SupportedLanguage): string => { + const currentPath = typeof window !== "undefined" ? window.location.pathname : "/" + const canonicalPath = getRoutePath(currentPath) + return localizedPath(canonicalPath, newLang) +} diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts new file mode 100644 index 0000000..72bb443 --- /dev/null +++ b/frontend/src/lib/i18n/index.ts @@ -0,0 +1,75 @@ +import { register, init, locale, waitLocale, getLocaleFromNavigator } from "svelte-i18n" +import { get } from "svelte/store" +import { + SUPPORTED_LANGUAGES, + DEFAULT_LANGUAGE, + extractLanguageFromPath, + selectedLanguage, + type SupportedLanguage, +} from "../i18n" + +// Re-export svelte-i18n utilities for convenience +export { _, format, time, date, number, json } from "svelte-i18n" +export { locale, isLoading, addMessages } from "svelte-i18n" + +// Register locale files for lazy loading +register("de", () => import("./locales/de.json")) +register("en", () => import("./locales/en.json")) + +/** + * Determine the initial locale from URL, browser, or fallback. + */ +function getInitialLocale(url?: string): SupportedLanguage { + // 1. Try URL path + if (url) { + const langFromUrl = extractLanguageFromPath(url) + if (langFromUrl) return langFromUrl + } + if (typeof window !== "undefined") { + const langFromPath = extractLanguageFromPath(window.location.pathname) + if (langFromPath) return langFromPath + } + // 2. Try browser settings + if (typeof navigator !== "undefined") { + const browserLocale = getLocaleFromNavigator() + if (browserLocale) { + const langCode = browserLocale.split("-")[0] as SupportedLanguage + if (SUPPORTED_LANGUAGES.includes(langCode)) { + return langCode + } + } + } + // 3. Fallback + return DEFAULT_LANGUAGE +} + +/** + * Initialize i18n for client-side rendering. + * Call this before mounting the Svelte app. + */ +export async function setupI18n(url?: string): Promise { + const initialLocale = getInitialLocale(url) + + init({ + fallbackLocale: DEFAULT_LANGUAGE, + initialLocale, + }) + + selectedLanguage.set(initialLocale) + + // Keep svelte-i18n locale and selectedLanguage store in sync + locale.subscribe((newLocale) => { + if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) { + selectedLanguage.set(newLocale as SupportedLanguage) + } + }) + + selectedLanguage.subscribe((newLang) => { + const currentLocale = get(locale) + if (newLang && newLang !== currentLocale) { + locale.set(newLang) + } + }) + + await waitLocale() +} diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json new file mode 100644 index 0000000..a66da77 --- /dev/null +++ b/frontend/src/lib/i18n/locales/de.json @@ -0,0 +1,23 @@ +{ + "nav": { + "home": "Startseite", + "about": "Über uns", + "contact": "Kontakt" + }, + "page": { + "home": { + "title": "Willkommen", + "text": "Dies ist der Tibi Svelte Starter. Passe diese Seite an dein Projekt an." + }, + "about": { + "title": "Über uns", + "text": "Hier kannst du Informationen über dein Projekt darstellen." + }, + "contact": { + "title": "Kontakt", + "text": "Hier kannst du ein Kontaktformular oder Kontaktdaten anzeigen." + } + }, + "welcome": "Willkommen", + "language": "Sprache" +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json new file mode 100644 index 0000000..dbc5664 --- /dev/null +++ b/frontend/src/lib/i18n/locales/en.json @@ -0,0 +1,23 @@ +{ + "nav": { + "home": "Home", + "about": "About", + "contact": "Contact" + }, + "page": { + "home": { + "title": "Welcome", + "text": "This is the Tibi Svelte Starter. Customise this page for your project." + }, + "about": { + "title": "About", + "text": "Use this page to present information about your project." + }, + "contact": { + "title": "Contact", + "text": "Use this page to display a contact form or contact details." + } + }, + "welcome": "Welcome", + "language": "Language" +} \ No newline at end of file diff --git a/frontend/src/ssr.ts b/frontend/src/ssr.ts index 1add5ee..bcb227a 100644 --- a/frontend/src/ssr.ts +++ b/frontend/src/ssr.ts @@ -1,3 +1,16 @@ +import { addMessages, init } from "svelte-i18n" +import { DEFAULT_LANGUAGE } from "./lib/i18n" +import deLocale from "./lib/i18n/locales/de.json" +import enLocale from "./lib/i18n/locales/en.json" import App from "./App.svelte" +// SSR: load messages synchronously (Babel transforms import → require) +addMessages("de", deLocale) +addMessages("en", enLocale) + +init({ + fallbackLocale: DEFAULT_LANGUAGE, + initialLocale: DEFAULT_LANGUAGE, +}) + export default App diff --git a/package.json b/package.json index 62a8593..a0ffb9e 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,19 @@ "build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js", "build:legacy": "node scripts/esbuild-wrapper.js build esbuild.config.legacy.js && babel _temp/index.js -o _temp/index.babeled.js && esbuild _temp/index.babeled.js --outfile=frontend/dist/index.es5.js --target=es5 --bundle --minify --sourcemap", "build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel --config-file ./babel.config.server.json _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --bundle --sourcemap --platform=node", - "build:test": "node scripts/esbuild-wrapper.js build esbuild.config.test.js && babel --config-file ./babel.config.test.json _temp/hook.test.js -o _temp/hook.test.babeled.js && esbuild _temp/hook.test.babeled.js --outfile=api/hooks/lib/hook.test.js --target=es5 --bundle --sourcemap --platform=node" + "build:test": "node scripts/esbuild-wrapper.js build esbuild.config.test.js && babel --config-file ./babel.config.test.json _temp/hook.test.js -o _temp/hook.test.babeled.js && esbuild _temp/hook.test.babeled.js --outfile=api/hooks/lib/hook.test.js --target=es5 --bundle --sourcemap --platform=node", + "test": "playwright test", + "test:e2e": "playwright test tests/e2e", + "test:api": "playwright test tests/api", + "test:visual": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad", + "test:visual:update": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad --update-snapshots", + "test:report": "playwright show-report" }, "devDependencies": { "@babel/cli": "^7.28.6", "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", + "@playwright/test": "^1.50.0", "@tailwindcss/postcss": "^4.1.18", "@tsconfig/svelte": "^5.0.7", "browser-sync": "^3.0.4", @@ -47,7 +54,8 @@ "@sentry/cli": "^3.2.0", "@sentry/svelte": "^10.38.0", "core-js": "3.48.0", - "cryptcha": "ssh://git@gitbase.de:2222/cms/cryptcha.git" + "cryptcha": "ssh://git@gitbase.de:2222/cms/cryptcha.git", + "svelte-i18n": "^4.0.1" }, "packageManager": "yarn@4.7.0" -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d121d65 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,113 @@ +import { defineConfig, devices } from "@playwright/test" + +/** + * Playwright configuration for tibi-svelte projects. + * + * Run against the CODING_URL (externally reachable via HTTPS). + * Override with: CODING_URL=https://... yarn test + */ + +/** + * Traefik skips basic-auth for requests whose User-Agent contains + * "Playwright" (see docker-compose-local.yml, priority-100 router). + * Append the marker to every device preset so the original UA is preserved + * (important for mobile/responsive detection) while bypassing auth. + */ +function withPlaywrightUA(device: (typeof devices)[string]) { + return { ...device, userAgent: `${device.userAgent} Playwright` } +} + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : 16, + reporter: [["html", { open: "never" }]], + globalSetup: "./tests/global-setup.ts", + globalTeardown: "./tests/global-teardown.ts", + + /* ── Visual Regression defaults ──────────────────────────────────── */ + snapshotPathTemplate: "{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}", + expect: { + toHaveScreenshot: { + /* 2 % pixel tolerance – accounts for cross-OS font rendering */ + maxDiffPixelRatio: 0.02, + /* per-pixel colour distance threshold (0 = exact, 1 = any) */ + threshold: 0.2, + /* animation settling time before capture */ + animations: "disabled", + }, + }, + + use: { + /* Read from .env PROJECT_NAME or override via CODING_URL env var */ + baseURL: process.env.CODING_URL || "https://localhost:3000", + headless: true, + ignoreHTTPSErrors: true, + trace: "on", + /* BrowserSync keeps a WebSocket open permanently, preventing + "networkidle" and "load" from resolving reliably. + Default all navigations to "domcontentloaded". */ + navigationTimeout: 30000, + }, + + projects: [ + /* ── Desktop E2E ───────────────────────────────────────────────── */ + { + name: "chromium", + testDir: "./tests/e2e", + use: { ...withPlaywrightUA(devices["Desktop Chrome"]) }, + }, + + /* ── API Tests ─────────────────────────────────────────────────── */ + { + name: "api", + testDir: "./tests/api", + use: { + ...withPlaywrightUA(devices["Desktop Chrome"]), + }, + }, + + /* ── Mobile E2E ────────────────────────────────────────────────── */ + { + name: "mobile-iphonese", + testDir: "./tests/e2e-mobile", + use: { + ...withPlaywrightUA(devices["iPhone SE"]), + defaultBrowserType: "chromium", + }, + }, + { + name: "mobile-ipad", + testDir: "./tests/e2e-mobile", + use: { + ...withPlaywrightUA(devices["iPad Mini"]), + defaultBrowserType: "chromium", + }, + }, + + /* ── Visual Regression ─────────────────────────────────────────── */ + { + name: "visual-desktop", + testDir: "./tests/e2e-visual", + use: { ...withPlaywrightUA(devices["Desktop Chrome"]) }, + }, + { + name: "visual-iphonese", + testDir: "./tests/e2e-visual", + use: { + ...withPlaywrightUA(devices["iPhone SE"]), + defaultBrowserType: "chromium", + }, + }, + { + name: "visual-ipad", + testDir: "./tests/e2e-visual", + use: { + ...withPlaywrightUA(devices["iPad Mini"]), + defaultBrowserType: "chromium", + }, + }, + ], +}) diff --git a/scripts/export-visual-screenshots.sh b/scripts/export-visual-screenshots.sh new file mode 100755 index 0000000..6b4f943 --- /dev/null +++ b/scripts/export-visual-screenshots.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# export-visual-screenshots.sh +# +# Collects visual regression screenshots and test-results (diffs) into a +# structured folder for AI review. +# +# Usage: +# ./scripts/export-visual-screenshots.sh [output-dir] +# +# Default output: visual-review/ +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="${1:-$PROJECT_ROOT/visual-review}" + +# Read project name from .env +PROJECT_NAME="tibi-project" +if [[ -f "$PROJECT_ROOT/.env" ]]; then + PROJECT_NAME=$(grep -E '^PROJECT_NAME=' "$PROJECT_ROOT/.env" | cut -d= -f2 || echo "tibi-project") +fi + +SCREENSHOTS_DIR="$PROJECT_ROOT/tests/e2e-visual/__screenshots__" +TEST_RESULTS_DIR="$PROJECT_ROOT/test-results" + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR/baselines" "$OUTPUT_DIR/diffs" + +echo "📸 Exporting visual regression data to $OUTPUT_DIR" +echo "" + +if [[ -d "$SCREENSHOTS_DIR" ]]; then + echo " ✓ Copying baseline screenshots..." + cp -r "$SCREENSHOTS_DIR"/* "$OUTPUT_DIR/baselines/" 2>/dev/null || true +else + echo " ⚠ No baseline screenshots found at $SCREENSHOTS_DIR" + echo " Run: yarn test:visual:update to generate baseline screenshots first." +fi + +DIFF_COUNT=0 +if [[ -d "$TEST_RESULTS_DIR" ]]; then + while IFS= read -r -d '' file; do + rel="${file#$TEST_RESULTS_DIR/}" + dest_dir="$OUTPUT_DIR/diffs/$(dirname "$rel")" + mkdir -p "$dest_dir" + cp "$file" "$dest_dir/" + ((DIFF_COUNT++)) || true + done < <(find "$TEST_RESULTS_DIR" \( -name "*-actual.png" -o -name "*-expected.png" -o -name "*-diff.png" \) -print0 2>/dev/null) +fi + +echo " ✓ Generating manifest.json..." + +MANIFEST="$OUTPUT_DIR/manifest.json" +echo "{" > "$MANIFEST" +echo ' "generated": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'",' >> "$MANIFEST" +echo ' "project": "'"$PROJECT_NAME"'",' >> "$MANIFEST" +echo ' "viewports": {' >> "$MANIFEST" +echo ' "visual-desktop": { "width": 1280, "height": 720, "device": "Desktop Chrome" },' >> "$MANIFEST" +echo ' "visual-iphonese": { "width": 375, "height": 667, "device": "iPhone SE" },' >> "$MANIFEST" +echo ' "visual-ipad": { "width": 768, "height": 1024, "device": "iPad Mini" }' >> "$MANIFEST" +echo ' },' >> "$MANIFEST" + +echo ' "baselines": [' >> "$MANIFEST" +FIRST=true +if [[ -d "$OUTPUT_DIR/baselines" ]]; then + while IFS= read -r -d '' file; do + rel="${file#$OUTPUT_DIR/}" + project=$(echo "$rel" | cut -d'/' -f2) + if [[ "$FIRST" == "true" ]]; then + FIRST=false + else + echo "," >> "$MANIFEST" + fi + printf ' { "path": "%s", "project": "%s" }' "$rel" "$project" >> "$MANIFEST" + done < <(find "$OUTPUT_DIR/baselines" -name "*.png" -print0 2>/dev/null | sort -z) +fi +echo "" >> "$MANIFEST" +echo ' ],' >> "$MANIFEST" + +echo ' "diffs": [' >> "$MANIFEST" +FIRST=true +if [[ -d "$OUTPUT_DIR/diffs" ]] && [[ $DIFF_COUNT -gt 0 ]]; then + while IFS= read -r -d '' file; do + rel="${file#$OUTPUT_DIR/}" + if [[ "$FIRST" == "true" ]]; then + FIRST=false + else + echo "," >> "$MANIFEST" + fi + printf ' { "path": "%s" }' "$rel" >> "$MANIFEST" + done < <(find "$OUTPUT_DIR/diffs" -name "*.png" -print0 2>/dev/null | sort -z) +fi +echo "" >> "$MANIFEST" +echo ' ],' >> "$MANIFEST" + +echo " \"diffCount\": $DIFF_COUNT" >> "$MANIFEST" +echo "}" >> "$MANIFEST" + +BASELINE_COUNT=$(find "$OUTPUT_DIR/baselines" -name "*.png" 2>/dev/null | wc -l) +echo "" +echo " 📊 Summary:" +echo " Baselines: $BASELINE_COUNT screenshots" +echo " Diffs: $DIFF_COUNT images (actual/expected/diff)" +echo " Manifest: $MANIFEST" +echo "" +echo " 💡 Review files in $OUTPUT_DIR/" +echo " Or attach screenshots to a Copilot Chat for AI review." diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..d2b0b49 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,5 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./frontend/src/**/*.{html,js,svelte,ts}", "./frontend/spa.html"], + plugins: [], +} diff --git a/tests/api/fixtures.ts b/tests/api/fixtures.ts new file mode 100644 index 0000000..a511789 --- /dev/null +++ b/tests/api/fixtures.ts @@ -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({ + 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 } diff --git a/tests/api/health.spec.ts b/tests/api/health.spec.ts new file mode 100644 index 0000000..47a4a5c --- /dev/null +++ b/tests/api/health.spec.ts @@ -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) + }) +}) diff --git a/tests/api/helpers/admin-api.ts b/tests/api/helpers/admin-api.ts new file mode 100644 index 0000000..5823654 --- /dev/null +++ b/tests/api/helpers/admin-api.ts @@ -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 { + 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 { + 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 { + 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 { + if (adminContext) { + await adminContext.dispose() + adminContext = null + } +} diff --git a/tests/api/helpers/maildev.ts b/tests/api/helpers/maildev.ts new file mode 100644 index 0000000..5b0e272 --- /dev/null +++ b/tests/api/helpers/maildev.ts @@ -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 +} + +let maildevContext: APIRequestContext | null = null + +async function getContext(): Promise { + 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 { + 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 { + const ctx = await getContext() + await ctx.delete("/email/all") +} + +/** + * Delete a specific email by ID. + */ +export async function deleteEmail(id: string): Promise { + 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 { + 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, + options: { subject?: string | RegExp; timeout?: number } = {} +): Promise { + 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 { + if (maildevContext) { + await maildevContext.dispose() + maildevContext = null + } +} diff --git a/tests/api/helpers/test-user.ts b/tests/api/helpers/test-user.ts new file mode 100644 index 0000000..5b1a091 --- /dev/null +++ b/tests/api/helpers/test-user.ts @@ -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 { + 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 { + 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, + } +} diff --git a/tests/e2e-mobile/fixtures.ts b/tests/e2e-mobile/fixtures.ts new file mode 100644 index 0000000..033e38e --- /dev/null +++ b/tests/e2e-mobile/fixtures.ts @@ -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({ + /** + * 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 { + 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 { + 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 { + 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("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 { + await page.keyboard.press("Escape") + await page + .waitForFunction( + () => { + const btn = document.querySelector("header button[aria-expanded='true']") + return btn === null + }, + { timeout: 5000 } + ) + .catch(() => {}) +} diff --git a/tests/e2e-mobile/home.mobile.spec.ts b/tests/e2e-mobile/home.mobile.spec.ts new file mode 100644 index 0000000..21ac3c0 --- /dev/null +++ b/tests/e2e-mobile/home.mobile.spec.ts @@ -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() + // }) +}) diff --git a/tests/e2e-visual/__screenshots__/visual-desktop/home.visual.spec.ts/homepage.png b/tests/e2e-visual/__screenshots__/visual-desktop/home.visual.spec.ts/homepage.png new file mode 100644 index 0000000..54e4bf2 Binary files /dev/null and b/tests/e2e-visual/__screenshots__/visual-desktop/home.visual.spec.ts/homepage.png differ diff --git a/tests/e2e-visual/__screenshots__/visual-ipad/home.visual.spec.ts/homepage.png b/tests/e2e-visual/__screenshots__/visual-ipad/home.visual.spec.ts/homepage.png new file mode 100644 index 0000000..d953979 Binary files /dev/null and b/tests/e2e-visual/__screenshots__/visual-ipad/home.visual.spec.ts/homepage.png differ diff --git a/tests/e2e-visual/__screenshots__/visual-iphonese/home.visual.spec.ts/homepage.png b/tests/e2e-visual/__screenshots__/visual-iphonese/home.visual.spec.ts/homepage.png new file mode 100644 index 0000000..3ad4d36 Binary files /dev/null and b/tests/e2e-visual/__screenshots__/visual-iphonese/home.visual.spec.ts/homepage.png differ diff --git a/tests/e2e-visual/fixtures.ts b/tests/e2e-visual/fixtures.ts new file mode 100644 index 0000000..3c7fb1a --- /dev/null +++ b/tests/e2e-visual/fixtures.ts @@ -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({ + /** + * 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 { + 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 { + 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 { + 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 { + 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 { + const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])] + await pwExpect(page).toHaveScreenshot(name, { + fullPage: opts?.fullPage ?? false, + mask: masks, + maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02, + }) +} diff --git a/tests/e2e-visual/home.visual.spec.ts b/tests/e2e-visual/home.visual.spec.ts new file mode 100644 index 0000000..2f42ed1 --- /dev/null +++ b/tests/e2e-visual/home.visual.spec.ts @@ -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 }) + }) +}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..213678f --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -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({ + /** + * 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 { + 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 { + 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 { + 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 } diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts new file mode 100644 index 0000000..5d58a1a --- /dev/null +++ b/tests/e2e/home.spec.ts @@ -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") + } + }) +}) diff --git a/tests/fixtures/test-constants.ts b/tests/fixtures/test-constants.ts new file mode 100644 index 0000000..c7d6d2f --- /dev/null +++ b/tests/fixtures/test-constants.ts @@ -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 diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..f991ed3 --- /dev/null +++ b/tests/global-setup.ts @@ -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 diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts new file mode 100644 index 0000000..95c120d --- /dev/null +++ b/tests/global-teardown.ts @@ -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 diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..f49f6f7 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "verbatimModuleSyntax": false, + "noEmit": true + }, + "include": ["tests/**/*", "playwright.config.ts"] +} diff --git a/yarn.lock b/yarn.lock index 358dd78..bbe450b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/aix-ppc64@npm:0.19.12" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/aix-ppc64@npm:0.27.3" @@ -1401,6 +1408,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-arm64@npm:0.19.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-arm64@npm:0.27.3" @@ -1408,6 +1422,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-arm@npm:0.19.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-arm@npm:0.27.3" @@ -1415,6 +1436,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-x64@npm:0.19.12" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-x64@npm:0.27.3" @@ -1422,6 +1450,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/darwin-arm64@npm:0.19.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/darwin-arm64@npm:0.27.3" @@ -1429,6 +1464,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/darwin-x64@npm:0.19.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/darwin-x64@npm:0.27.3" @@ -1436,6 +1478,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/freebsd-arm64@npm:0.19.12" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/freebsd-arm64@npm:0.27.3" @@ -1443,6 +1492,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/freebsd-x64@npm:0.19.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/freebsd-x64@npm:0.27.3" @@ -1450,6 +1506,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-arm64@npm:0.19.12" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-arm64@npm:0.27.3" @@ -1457,6 +1520,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-arm@npm:0.19.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-arm@npm:0.27.3" @@ -1464,6 +1534,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-ia32@npm:0.19.12" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-ia32@npm:0.27.3" @@ -1471,6 +1548,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-loong64@npm:0.19.12" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-loong64@npm:0.27.3" @@ -1478,6 +1562,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-mips64el@npm:0.19.12" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-mips64el@npm:0.27.3" @@ -1485,6 +1576,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-ppc64@npm:0.19.12" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-ppc64@npm:0.27.3" @@ -1492,6 +1590,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-riscv64@npm:0.19.12" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-riscv64@npm:0.27.3" @@ -1499,6 +1604,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-s390x@npm:0.19.12" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-s390x@npm:0.27.3" @@ -1506,6 +1618,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-x64@npm:0.19.12" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-x64@npm:0.27.3" @@ -1520,6 +1639,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/netbsd-x64@npm:0.19.12" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/netbsd-x64@npm:0.27.3" @@ -1534,6 +1660,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/openbsd-x64@npm:0.19.12" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/openbsd-x64@npm:0.27.3" @@ -1548,6 +1681,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/sunos-x64@npm:0.19.12" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/sunos-x64@npm:0.27.3" @@ -1555,6 +1695,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-arm64@npm:0.19.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-arm64@npm:0.27.3" @@ -1562,6 +1709,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-ia32@npm:0.19.12" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-ia32@npm:0.27.3" @@ -1569,6 +1723,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-x64@npm:0.19.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-x64@npm:0.27.3" @@ -1576,6 +1737,57 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:2.3.6": + version: 2.3.6 + resolution: "@formatjs/ecma402-abstract@npm:2.3.6" + dependencies: + "@formatjs/fast-memoize": "npm:2.2.7" + "@formatjs/intl-localematcher": "npm:0.6.2" + decimal.js: "npm:^10.4.3" + tslib: "npm:^2.8.0" + checksum: 10/30b1b5cd6b62ba46245f934429936592df5500bc1b089dc92dd49c826757b873dd92c305dcfe370701e4df6b057bf007782113abb9b65db550d73be4961718bc + languageName: node + linkType: hard + +"@formatjs/fast-memoize@npm:2.2.7": + version: 2.2.7 + resolution: "@formatjs/fast-memoize@npm:2.2.7" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43 + languageName: node + linkType: hard + +"@formatjs/icu-messageformat-parser@npm:2.11.4": + version: 2.11.4 + resolution: "@formatjs/icu-messageformat-parser@npm:2.11.4" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.6" + "@formatjs/icu-skeleton-parser": "npm:1.8.16" + tslib: "npm:^2.8.0" + checksum: 10/2acb100c06c2ade666d72787fb9f9795b1ace41e8e73bfadc2b1a7b8562e81f655e484f0f33d8c39473aa17bf0ad96fb2228871806a9b3dc4f5f876754a0de3a + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:1.8.16": + version: 1.8.16 + resolution: "@formatjs/icu-skeleton-parser@npm:1.8.16" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.6" + tslib: "npm:^2.8.0" + checksum: 10/428001e5bed81889b276a2356a1393157af91dc59220b765a1a132f6407ac5832b7ac6ae9737674ac38e44035295c0c1c310b2630f383f2b5779ea90bf2849e6 + languageName: node + linkType: hard + +"@formatjs/intl-localematcher@npm:0.6.2": + version: 0.6.2 + resolution: "@formatjs/intl-localematcher@npm:0.6.2" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10/eb12a7f5367bbecdfafc20d7f005559ce840f420e970f425c5213d35e94e86dfe75bde03464971a26494bf8427d4961269db22ecad2834f2a19d888b5d9cc064 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1725,6 +1937,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.50.0": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" + dependencies: + playwright: "npm:1.58.2" + bin: + playwright: cli.js + checksum: 10/58bf90139280a0235eeeb6049e9fb4db6425e98be1bf0cc17913b068eef616cf67be57bfb36dc4cb56bcf116f498ffd0225c4916e85db404b343ea6c5efdae13 + languageName: node + linkType: hard + "@sentry-internal/browser-utils@npm:10.38.0": version: 10.38.0 resolution: "@sentry-internal/browser-utils@npm:10.38.0" @@ -2529,6 +2752,19 @@ __metadata: languageName: node linkType: hard +"cli-color@npm:^2.0.3": + version: 2.0.4 + resolution: "cli-color@npm:2.0.4" + dependencies: + d: "npm:^1.0.1" + es5-ext: "npm:^0.10.64" + es6-iterator: "npm:^2.0.3" + memoizee: "npm:^0.4.15" + timers-ext: "npm:^0.1.7" + checksum: 10/6706fbb98f5db62c47deaba7116a1e37470c936dc861b84a180b5ce1a58fbf50ae6582b30a65e4b30ddb39e0469d3bac6851a9d925ded02b7e0c1c00858ef14b + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -2688,6 +2924,16 @@ __metadata: languageName: node linkType: hard +"d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": + version: 1.0.2 + resolution: "d@npm:1.0.2" + dependencies: + es5-ext: "npm:^0.10.64" + type: "npm:^2.7.2" + checksum: 10/a3f45ef964622f683f6a1cb9b8dcbd75ce490cd2f4ac9794099db3d8f0e2814d412d84cd3fe522e58feb1f273117bb480f29c5381f6225f0abca82517caaa77a + languageName: node + linkType: hard + "data-uri-to-buffer@npm:^4.0.0": version: 4.0.1 resolution: "data-uri-to-buffer@npm:4.0.1" @@ -2740,6 +2986,20 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.3": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 + languageName: node + linkType: hard + +"deepmerge@npm:^4.2.2": + version: 4.3.1 + resolution: "deepmerge@npm:4.3.1" + checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 + languageName: node + linkType: hard + "depd@npm:2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -2939,6 +3199,51 @@ __metadata: languageName: node linkType: hard +"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.46, es5-ext@npm:^0.10.62, es5-ext@npm:^0.10.64, es5-ext@npm:~0.10.14, es5-ext@npm:~0.10.2": + version: 0.10.64 + resolution: "es5-ext@npm:0.10.64" + dependencies: + es6-iterator: "npm:^2.0.3" + es6-symbol: "npm:^3.1.3" + esniff: "npm:^2.0.1" + next-tick: "npm:^1.1.0" + checksum: 10/0c5d8657708b1695ddc4b06f4e0b9fbdda4d2fe46d037b6bedb49a7d1931e542ec9eecf4824d59e1d357e93229deab014bb4b86485db2d41b1d68e54439689ce + languageName: node + linkType: hard + +"es6-iterator@npm:^2.0.3": + version: 2.0.3 + resolution: "es6-iterator@npm:2.0.3" + dependencies: + d: "npm:1" + es5-ext: "npm:^0.10.35" + es6-symbol: "npm:^3.1.1" + checksum: 10/dbadecf3d0e467692815c2b438dfa99e5a97cbbecf4a58720adcb467a04220e0e36282399ba297911fd472c50ae4158fffba7ed0b7d4273fe322b69d03f9e3a5 + languageName: node + linkType: hard + +"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": + version: 3.1.4 + resolution: "es6-symbol@npm:3.1.4" + dependencies: + d: "npm:^1.0.2" + ext: "npm:^1.7.0" + checksum: 10/3743119fe61f89e2f049a6ce52bd82fab5f65d13e2faa72453b73f95c15292c3cb9bdf3747940d504517e675e45fd375554c6b5d35d2bcbefd35f5489ecba546 + languageName: node + linkType: hard + +"es6-weak-map@npm:^2.0.3": + version: 2.0.3 + resolution: "es6-weak-map@npm:2.0.3" + dependencies: + d: "npm:1" + es5-ext: "npm:^0.10.46" + es6-iterator: "npm:^2.0.3" + es6-symbol: "npm:^3.1.1" + checksum: 10/5958a321cf8dfadc82b79eeaa57dc855893a4afd062b4ef5c9ded0010d3932099311272965c3d3fdd3c85df1d7236013a570e704fa6c1f159bbf979c203dd3a3 + languageName: node + linkType: hard + "esbuild-postcss@npm:^0.0.4": version: 0.0.4 resolution: "esbuild-postcss@npm:0.0.4" @@ -2963,6 +3268,86 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.19.2": + version: 0.19.12 + resolution: "esbuild@npm:0.19.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.19.12" + "@esbuild/android-arm": "npm:0.19.12" + "@esbuild/android-arm64": "npm:0.19.12" + "@esbuild/android-x64": "npm:0.19.12" + "@esbuild/darwin-arm64": "npm:0.19.12" + "@esbuild/darwin-x64": "npm:0.19.12" + "@esbuild/freebsd-arm64": "npm:0.19.12" + "@esbuild/freebsd-x64": "npm:0.19.12" + "@esbuild/linux-arm": "npm:0.19.12" + "@esbuild/linux-arm64": "npm:0.19.12" + "@esbuild/linux-ia32": "npm:0.19.12" + "@esbuild/linux-loong64": "npm:0.19.12" + "@esbuild/linux-mips64el": "npm:0.19.12" + "@esbuild/linux-ppc64": "npm:0.19.12" + "@esbuild/linux-riscv64": "npm:0.19.12" + "@esbuild/linux-s390x": "npm:0.19.12" + "@esbuild/linux-x64": "npm:0.19.12" + "@esbuild/netbsd-x64": "npm:0.19.12" + "@esbuild/openbsd-x64": "npm:0.19.12" + "@esbuild/sunos-x64": "npm:0.19.12" + "@esbuild/win32-arm64": "npm:0.19.12" + "@esbuild/win32-ia32": "npm:0.19.12" + "@esbuild/win32-x64": "npm:0.19.12" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/861fa8eb2428e8d6521a4b7c7930139e3f45e8d51a86985cc29408172a41f6b18df7b3401e7e5e2d528cdf83742da601ddfdc77043ddc4f1c715a8ddb2d8a255 + languageName: node + linkType: hard + "esbuild@npm:^0.27.3": version: 0.27.3 resolution: "esbuild@npm:0.27.3" @@ -3073,6 +3458,18 @@ __metadata: languageName: node linkType: hard +"esniff@npm:^2.0.1": + version: 2.0.1 + resolution: "esniff@npm:2.0.1" + dependencies: + d: "npm:^1.0.1" + es5-ext: "npm:^0.10.62" + event-emitter: "npm:^0.3.5" + type: "npm:^2.7.2" + checksum: 10/f6a2abd2f8c5fe57c5fcf53e5407c278023313d0f6c3a92688e7122ab9ac233029fd424508a196ae5bc561aa1f67d23f4e2435b1a0d378030f476596129056ac + languageName: node + linkType: hard + "esrap@npm:^2.2.2": version: 2.2.3 resolution: "esrap@npm:2.2.3" @@ -3082,6 +3479,13 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^2": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2 + languageName: node + linkType: hard + "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -3096,6 +3500,16 @@ __metadata: languageName: node linkType: hard +"event-emitter@npm:^0.3.5": + version: 0.3.5 + resolution: "event-emitter@npm:0.3.5" + dependencies: + d: "npm:1" + es5-ext: "npm:~0.10.14" + checksum: 10/a7f5ea80029193f4869782d34ef7eb43baa49cd397013add1953491b24588468efbe7e3cc9eb87d53f33397e7aab690fd74c079ec440bf8b12856f6bdb6e9396 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -3110,6 +3524,15 @@ __metadata: languageName: node linkType: hard +"ext@npm:^1.7.0": + version: 1.7.0 + resolution: "ext@npm:1.7.0" + dependencies: + type: "npm:^2.7.2" + checksum: 10/666a135980b002df0e75c8ac6c389140cdc59ac953db62770479ee2856d58ce69d2f845e5f2586716350b725400f6945e51e9159573158c39f369984c72dcd84 + languageName: node + linkType: hard + "fdir@npm:^6.2.0": version: 6.4.3 resolution: "fdir@npm:6.4.3" @@ -3226,6 +3649,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/6b5b6f5692372446ff81cf9501c76e3e0459a4852b3b5f1fc72c103198c125a6b8c72f5f166bdd76ffb2fca261e7f6ee5565daf80dca6e571e55bcc589cc1256 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -3236,6 +3669,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -3312,6 +3754,20 @@ __metadata: languageName: node linkType: hard +"globalyzer@npm:0.1.0": + version: 0.1.0 + resolution: "globalyzer@npm:0.1.0" + checksum: 10/419a0f95ba542534fac0842964d31b3dc2936a479b2b1a8a62bad7e8b61054faa9b0a06ad9f2e12593396b9b2621cac93358d9b3071d33723fb1778608d358a1 + languageName: node + linkType: hard + +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10/81ce62ee6f800d823d6b7da7687f841676d60ee8f51f934ddd862e4057316d26665c4edc0358d4340a923ac00a514f8b67c787e28fe693aae16350f4e60d55e9 + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -3477,6 +3933,18 @@ __metadata: languageName: node linkType: hard +"intl-messageformat@npm:^10.5.3": + version: 10.7.18 + resolution: "intl-messageformat@npm:10.7.18" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.6" + "@formatjs/fast-memoize": "npm:2.2.7" + "@formatjs/icu-messageformat-parser": "npm:2.11.4" + tslib: "npm:^2.8.0" + checksum: 10/96650d673912763d21bbfa14b50749b992d45f1901092a020e3155961e3c70f4644dd1731c3ecb1207a1eb94d84bedf4c34b1ac8127c29ad6b015b6a2a4045cb + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -3551,6 +4019,13 @@ __metadata: languageName: node linkType: hard +"is-promise@npm:^2.2.2": + version: 2.2.2 + resolution: "is-promise@npm:2.2.2" + checksum: 10/18bf7d1c59953e0ad82a1ed963fb3dc0d135c8f299a14f89a17af312fc918373136e56028e8831700e1933519630cc2fd4179a777030330fde20d34e96f40c78 + languageName: node + linkType: hard + "is-reference@npm:^3.0.3": version: 3.0.3 resolution: "is-reference@npm:3.0.3" @@ -3883,6 +4358,15 @@ __metadata: languageName: node linkType: hard +"lru-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "lru-queue@npm:0.1.0" + dependencies: + es5-ext: "npm:~0.10.2" + checksum: 10/55b08ee3a7dbefb7d8ee2d14e0a97c69a887f78bddd9e28a687a1944b57e09513d4b401db515279e8829d52331df12a767f3ed27ca67c3322c723cc25c06403f + languageName: node + linkType: hard + "magic-string@npm:^0.30.0, magic-string@npm:^0.30.11": version: 0.30.17 resolution: "magic-string@npm:0.30.17" @@ -3937,6 +4421,22 @@ __metadata: languageName: node linkType: hard +"memoizee@npm:^0.4.15": + version: 0.4.17 + resolution: "memoizee@npm:0.4.17" + dependencies: + d: "npm:^1.0.2" + es5-ext: "npm:^0.10.64" + es6-weak-map: "npm:^2.0.3" + event-emitter: "npm:^0.3.5" + is-promise: "npm:^2.2.2" + lru-queue: "npm:^0.1.0" + next-tick: "npm:^1.1.0" + timers-ext: "npm:^0.1.7" + checksum: 10/b7abda74d1057878f3570c45995f24da8a4f8636e0e9a7c29a6709be2314bf40c7d78e3be93c0b1660ba419de5740fa5e447c400ab5df407ffbd236421066380 + languageName: node + linkType: hard + "micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" @@ -4152,6 +4652,13 @@ __metadata: languageName: node linkType: hard +"next-tick@npm:^1.1.0": + version: 1.1.0 + resolution: "next-tick@npm:1.1.0" + checksum: 10/83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b + languageName: node + linkType: hard + "node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" @@ -4352,6 +4859,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" + bin: + playwright-core: cli.js + checksum: 10/8a98fcf122167e8703d525db2252de0e3da4ab9110ab6ea9951247e52d846310eb25ea2c805e1b7ccb54b4010c44e5adc3a76aae6da02f34324ccc3e76683bb1 + languageName: node + linkType: hard + +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.58.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/d89d6c8a32388911b9aff9ee0f1a90076219f15c804f2b287db048b9e9cde182aea3131fac1959051d25189ed4218ec4272b137c83cd7f9cd24781cbc77edd86 + languageName: node + linkType: hard + "portscanner@npm:2.2.0": version: 2.2.0 resolution: "portscanner@npm:2.2.0" @@ -4647,7 +5178,7 @@ __metadata: languageName: node linkType: hard -"sade@npm:^1.7.4": +"sade@npm:^1.7.4, sade@npm:^1.8.1": version: 1.8.1 resolution: "sade@npm:1.8.1" dependencies: @@ -5036,6 +5567,25 @@ __metadata: languageName: node linkType: hard +"svelte-i18n@npm:^4.0.1": + version: 4.0.1 + resolution: "svelte-i18n@npm:4.0.1" + dependencies: + cli-color: "npm:^2.0.3" + deepmerge: "npm:^4.2.2" + esbuild: "npm:^0.19.2" + estree-walker: "npm:^2" + intl-messageformat: "npm:^10.5.3" + sade: "npm:^1.8.1" + tiny-glob: "npm:^0.2.9" + peerDependencies: + svelte: ^3 || ^4 || ^5 + bin: + svelte-i18n: dist/cli.js + checksum: 10/683f921429b62b2cf53bcb56d5744cdd1e3ce2448a7be6e1e50c78f06ae895d434a8fa9f485fbbe5aad7dab771a6678600d7dc5a45d05a4a92348ffc70476ff2 + languageName: node + linkType: hard + "svelte-preprocess-esbuild@npm:^3.0.1": version: 3.0.1 resolution: "svelte-preprocess-esbuild@npm:3.0.1" @@ -5144,6 +5694,7 @@ __metadata: "@babel/cli": "npm:^7.28.6" "@babel/core": "npm:^7.29.0" "@babel/preset-env": "npm:^7.29.0" + "@playwright/test": "npm:^1.50.0" "@sentry/cli": "npm:^3.2.0" "@sentry/svelte": "npm:^10.38.0" "@tailwindcss/postcss": "npm:^4.1.18" @@ -5165,6 +5716,7 @@ __metadata: prettier-plugin-svelte: "npm:^3.4.1" svelte: "npm:^5.50.1" svelte-check: "npm:^4.3.6" + svelte-i18n: "npm:^4.0.1" svelte-preprocess: "npm:^6.0.3" svelte-preprocess-esbuild: "npm:^3.0.1" tailwindcss: "npm:^4.1.18" @@ -5173,6 +5725,26 @@ __metadata: languageName: unknown linkType: soft +"timers-ext@npm:^0.1.7": + version: 0.1.8 + resolution: "timers-ext@npm:0.1.8" + dependencies: + es5-ext: "npm:^0.10.64" + next-tick: "npm:^1.1.0" + checksum: 10/8abd168c57029e25d1fa4b7e101b053e261479e43ba4a32ead76e601e7037f74f850c311e22dc3dbb50dc211b34b092e0a349274d3997a493295e9ec725e6395 + languageName: node + linkType: hard + +"tiny-glob@npm:^0.2.9": + version: 0.2.9 + resolution: "tiny-glob@npm:0.2.9" + dependencies: + globalyzer: "npm:0.1.0" + globrex: "npm:^0.1.2" + checksum: 10/5fb773747f6a8fcae4b8884642901fa7b884879695186c422eb24b2213dfe90645f34225ced586329b3080d850472ea938646ab1c8b3a2989f9fa038fef8eee3 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -5189,13 +5761,20 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 languageName: node linkType: hard +"type@npm:^2.7.2": + version: 2.7.3 + resolution: "type@npm:2.7.3" + checksum: 10/82e99e7795b3de3ecfe685680685e79a77aea515fad9f60b7c55fbf6d43a5c360b1e6e9443354ec8906b38cdf5325829c69f094cb7cd2a1238e85bef9026dc04 + languageName: node + linkType: hard + "typescript@npm:^5.9.3": version: 5.9.3 resolution: "typescript@npm:5.9.3"