From dc00d2489996908a904b0c1e8294248c8872460d Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Wed, 11 Feb 2026 16:36:56 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20new=20feature?= =?UTF-8?q?=20for=20enhanced=20user=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 1 + .github/copilot-instructions.md | 32 +- .../instructions/api-hooks.instructions.md | 51 ++ .github/instructions/frontend.instructions.md | 28 + .github/instructions/general.instructions.md | 38 ++ .github/instructions/ssr.instructions.md | 12 + .github/instructions/testing.instructions.md | 41 ++ .gitignore | 4 + .vscode/settings.json | 10 +- ...ld-linux-x64-npm-0.19.12-59062fdb38-10.zip | 3 + ...stract-npm-2.3.6-b28618e55c-30b1b5cd6b.zip | 3 + ...emoize-npm-2.2.7-bc909b3b5a-e7e6efc677.zip | 3 + ...arser-npm-2.11.4-b051248584-2acb100c06.zip | 3 + ...arser-npm-1.8.16-e9d6e923fd-428001e5be.zip | 3 + ...atcher-npm-0.6.2-984821923e-eb12a7f536.zip | 3 + ...-test-npm-1.58.2-03a96deb3c-58bf901392.zip | 3 + ...-color-npm-2.0.4-d35494cfd7-6706fbb98f.zip | 3 + .../d-npm-1.0.2-7abbb6ae36-a3f45ef964.zip | 3 + ...al.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zip | 3 + ...pmerge-npm-4.3.1-4f751a0844-058d9e1b0f.zip | 3 + ...-ext-npm-0.10.64-c30cdc3d60-0c5d865770.zip | 3 + ...erator-npm-2.0.3-4dadb0ccc1-dbadecf3d0.zip | 3 + ...symbol-npm-3.1.4-7d67ac432c-3743119fe6.zip | 3 + ...ak-map-npm-2.0.3-5e57e0b4e6-5958a321cf.zip | 3 + ...uild-npm-0.19.12-fb5a3a4313-861fa8eb24.zip | 3 + ...esniff-npm-2.0.1-26cea8766c-f6a2abd2f8.zip | 3 + ...walker-npm-2.0.2-dfab42f65c-b02109c5d4.zip | 3 + ...mitter-npm-0.3.5-f1e8b8edb5-a7f5ea8002.zip | 3 + .../ext-npm-1.7.0-580588ab93-666a135980.zip | 3 + ...events-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip | 3 + ...alyzer-npm-0.1.0-3982d25961-419a0f95ba.zip | 3 + ...lobrex-npm-0.1.2-ddda94f2d0-81ce62ee6f.zip | 3 + ...rmat-npm-10.7.18-b63c15bddc-96650d6739.zip | 3 + ...romise-npm-2.2.2-afbf94db67-18bf7d1c59.zip | 3 + ...-queue-npm-0.1.0-8e1c90dde8-55b08ee3a7.zip | 3 + ...oizee-npm-0.4.17-95e9fda366-b7abda74d1.zip | 3 + ...t-tick-npm-1.1.0-e0eb60d6a4-83b5cf3602.zip | 3 + ...-core-npm-1.58.2-7d85ddc78a-8a98fcf122.zip | 3 + ...right-npm-1.58.2-0c12daad27-d89d6c8a32.zip | 3 + ...e-i18n-npm-4.0.1-e8d73fe51f-683f921429.zip | 3 + ...rs-ext-npm-0.1.8-1fa0ad5365-8abd168c57.zip | 3 + ...y-glob-npm-0.2.9-068f4ab3f8-5fb773747f.zip | 3 + .../type-npm-2.7.3-509458c133-82e99e7795.zip | 3 + .yarn/install-state.gz | Bin 524467 -> 572819 bytes Makefile | 17 +- frontend/src/App.svelte | 70 ++- frontend/src/index.ts | 10 +- frontend/src/lib/i18n.ts | 168 +++++ frontend/src/lib/i18n/index.ts | 75 +++ frontend/src/lib/i18n/locales/de.json | 23 + frontend/src/lib/i18n/locales/en.json | 23 + frontend/src/ssr.ts | 13 + package.json | 14 +- playwright.config.ts | 113 ++++ scripts/export-visual-screenshots.sh | 109 ++++ tailwind.config.js | 5 + tests/api/fixtures.ts | 57 ++ tests/api/health.spec.ts | 26 + tests/api/helpers/admin-api.ts | 76 +++ tests/api/helpers/maildev.ts | 162 +++++ tests/api/helpers/test-user.ts | 72 +++ tests/e2e-mobile/fixtures.ts | 109 ++++ tests/e2e-mobile/home.mobile.spec.ts | 28 + .../home.visual.spec.ts/homepage.png | Bin 0 -> 19783 bytes .../home.visual.spec.ts/homepage.png | Bin 0 -> 20895 bytes .../home.visual.spec.ts/homepage.png | Bin 0 -> 17642 bytes tests/e2e-visual/fixtures.ts | 113 ++++ tests/e2e-visual/home.visual.spec.ts | 11 + tests/e2e/fixtures.ts | 139 +++++ tests/e2e/home.spec.ts | 39 ++ tests/fixtures/test-constants.ts | 23 + tests/global-setup.ts | 49 ++ tests/global-teardown.ts | 37 ++ tsconfig.test.json | 8 + yarn.lock | 583 +++++++++++++++++- 75 files changed, 2456 insertions(+), 35 deletions(-) create mode 100644 .github/instructions/api-hooks.instructions.md create mode 100644 .github/instructions/frontend.instructions.md create mode 100644 .github/instructions/general.instructions.md create mode 100644 .github/instructions/ssr.instructions.md create mode 100644 .github/instructions/testing.instructions.md create mode 100644 .yarn/cache/@esbuild-linux-x64-npm-0.19.12-59062fdb38-10.zip create mode 100644 .yarn/cache/@formatjs-ecma402-abstract-npm-2.3.6-b28618e55c-30b1b5cd6b.zip create mode 100644 .yarn/cache/@formatjs-fast-memoize-npm-2.2.7-bc909b3b5a-e7e6efc677.zip create mode 100644 .yarn/cache/@formatjs-icu-messageformat-parser-npm-2.11.4-b051248584-2acb100c06.zip create mode 100644 .yarn/cache/@formatjs-icu-skeleton-parser-npm-1.8.16-e9d6e923fd-428001e5be.zip create mode 100644 .yarn/cache/@formatjs-intl-localematcher-npm-0.6.2-984821923e-eb12a7f536.zip create mode 100644 .yarn/cache/@playwright-test-npm-1.58.2-03a96deb3c-58bf901392.zip create mode 100644 .yarn/cache/cli-color-npm-2.0.4-d35494cfd7-6706fbb98f.zip create mode 100644 .yarn/cache/d-npm-1.0.2-7abbb6ae36-a3f45ef964.zip create mode 100644 .yarn/cache/decimal.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zip create mode 100644 .yarn/cache/deepmerge-npm-4.3.1-4f751a0844-058d9e1b0f.zip create mode 100644 .yarn/cache/es5-ext-npm-0.10.64-c30cdc3d60-0c5d865770.zip create mode 100644 .yarn/cache/es6-iterator-npm-2.0.3-4dadb0ccc1-dbadecf3d0.zip create mode 100644 .yarn/cache/es6-symbol-npm-3.1.4-7d67ac432c-3743119fe6.zip create mode 100644 .yarn/cache/es6-weak-map-npm-2.0.3-5e57e0b4e6-5958a321cf.zip create mode 100644 .yarn/cache/esbuild-npm-0.19.12-fb5a3a4313-861fa8eb24.zip create mode 100644 .yarn/cache/esniff-npm-2.0.1-26cea8766c-f6a2abd2f8.zip create mode 100644 .yarn/cache/estree-walker-npm-2.0.2-dfab42f65c-b02109c5d4.zip create mode 100644 .yarn/cache/event-emitter-npm-0.3.5-f1e8b8edb5-a7f5ea8002.zip create mode 100644 .yarn/cache/ext-npm-1.7.0-580588ab93-666a135980.zip create mode 100644 .yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip create mode 100644 .yarn/cache/globalyzer-npm-0.1.0-3982d25961-419a0f95ba.zip create mode 100644 .yarn/cache/globrex-npm-0.1.2-ddda94f2d0-81ce62ee6f.zip create mode 100644 .yarn/cache/intl-messageformat-npm-10.7.18-b63c15bddc-96650d6739.zip create mode 100644 .yarn/cache/is-promise-npm-2.2.2-afbf94db67-18bf7d1c59.zip create mode 100644 .yarn/cache/lru-queue-npm-0.1.0-8e1c90dde8-55b08ee3a7.zip create mode 100644 .yarn/cache/memoizee-npm-0.4.17-95e9fda366-b7abda74d1.zip create mode 100644 .yarn/cache/next-tick-npm-1.1.0-e0eb60d6a4-83b5cf3602.zip create mode 100644 .yarn/cache/playwright-core-npm-1.58.2-7d85ddc78a-8a98fcf122.zip create mode 100644 .yarn/cache/playwright-npm-1.58.2-0c12daad27-d89d6c8a32.zip create mode 100644 .yarn/cache/svelte-i18n-npm-4.0.1-e8d73fe51f-683f921429.zip create mode 100644 .yarn/cache/timers-ext-npm-0.1.8-1fa0ad5365-8abd168c57.zip create mode 100644 .yarn/cache/tiny-glob-npm-0.2.9-068f4ab3f8-5fb773747f.zip create mode 100644 .yarn/cache/type-npm-2.7.3-509458c133-82e99e7795.zip create mode 100644 frontend/src/lib/i18n.ts create mode 100644 frontend/src/lib/i18n/index.ts create mode 100644 frontend/src/lib/i18n/locales/de.json create mode 100644 frontend/src/lib/i18n/locales/en.json create mode 100644 playwright.config.ts create mode 100755 scripts/export-visual-screenshots.sh create mode 100644 tailwind.config.js create mode 100644 tests/api/fixtures.ts create mode 100644 tests/api/health.spec.ts create mode 100644 tests/api/helpers/admin-api.ts create mode 100644 tests/api/helpers/maildev.ts create mode 100644 tests/api/helpers/test-user.ts create mode 100644 tests/e2e-mobile/fixtures.ts create mode 100644 tests/e2e-mobile/home.mobile.spec.ts create mode 100644 tests/e2e-visual/__screenshots__/visual-desktop/home.visual.spec.ts/homepage.png create mode 100644 tests/e2e-visual/__screenshots__/visual-ipad/home.visual.spec.ts/homepage.png create mode 100644 tests/e2e-visual/__screenshots__/visual-iphonese/home.visual.spec.ts/homepage.png create mode 100644 tests/e2e-visual/fixtures.ts create mode 100644 tests/e2e-visual/home.visual.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/home.spec.ts create mode 100644 tests/fixtures/test-constants.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/global-teardown.ts create mode 100644 tsconfig.test.json 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 `
-
+ +
+ {#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 0000000000000000000000000000000000000000..54e4bf224b8212f9331ce42cbff2f40f32a090f1 GIT binary patch literal 19783 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d9539794e03d460477abfabd3e88608b74050cd4 GIT binary patch literal 20895 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 Z4{A(5Ri&Jk2mVFC;2uP$^zOqK{|jpGW()uT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3ad4d362a7af0caf7b328a49e8c1bed60c584e01 GIT binary patch literal 17642 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 + /** + * 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"