feat: implement new feature for enhanced user experience

This commit is contained in:
2026-02-11 16:36:56 +00:00
parent 62f1906276
commit dc00d24899
75 changed files with 2456 additions and 35 deletions

1
.env
View File

@@ -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

View File

@@ -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`

View File

@@ -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.

View File

@@ -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 `<script>` tag; keep code/comments in English.
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
## i18n
- `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files.
- Locale files live in `frontend/src/lib/i18n/locales/{lang}.json`.
- URL-based language routing: `/{lang}/...` (e.g. `/de/`, `/en/about`).
- Language utilities in `frontend/src/lib/i18n.ts`: `extractLanguageFromPath()`, `localizedPath()`, `getLanguageSwitchUrl()`.
- Use `$_("key")` from `svelte-i18n` for translations in components.
## E2E Tests (Playwright)
- When developing frontend features: extend or create corresponding E2E tests in `tests/e2e/`.
- Shared fixtures and helpers in `tests/e2e/fixtures.ts`: `waitForSpaReady(page)`, `navigateToRoute(page, path)`, `clickSpaLink(page, selector)`, `authedPage` fixture.
- After frontend changes, run only the affected E2E tests: `npx playwright test tests/e2e/filename.spec.ts` or `-g "test name"`.
- When tests fail, clarify whether the frontend or the test needs adjustment coordinate with the user.

View File

@@ -0,0 +1,38 @@
---
name: General
description: Workspace-wide guidance and workflows.
applyTo: "**/*"
---
# General
## Code Style & Conventions
- 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
- 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
## Development Workflow
- Default dev flow is Docker/Makefile: `make docker-up`, `make docker-start`, `make docker-logs`, `make docker-restart-frontend` (see Makefile).
- Local dev is secondary: `yarn dev` for watch, `yarn build` and `yarn build:server` for production builds (see package.json).
- Frontend code is automatically built by watcher and browser-sync; backend code is automatically built and reloaded by extension, so no manual restarts needed during development.
- Read `.env` for environment URLs and secrets.
- Keep `tibi-types/` read-only unless explicitly asked.
- `webserver/` is for staging/ops only; use BrowserSync/esbuild for day-to-day dev.
## API Access
- API access to collections uses the reverse proxy: `CODING_URL/api/<collection>` (e.g. `CODING_URL/api/content`).
- Auth via `Token` header with ADMIN_TOKEN from `api/config.yml.env`.
## Testing
- Write unit tests for new functionality and ensure existing tests pass.
- Playwright is configured for E2E, API, mobile, and visual regression tests.
- Run tests via `yarn test` (all), `yarn test:e2e`, `yarn test:api`, `yarn test:visual`.
- After code changes, run only affected spec files: `npx playwright test tests/e2e/filename.spec.ts`.

View File

@@ -0,0 +1,12 @@
---
name: SSR
description: Server-side rendering flow and caching.
applyTo: "api/hooks/ssr/**"
---
# SSR and Caching
- SSR request flow: `api/hooks/ssr/get_read.js` calls `api/hooks/lib/ssr-server.js` and injects `window.__SSR_CACHE__` used by `api/hooks/lib/ssr.js` on the client.
- SSR cache HTML is stored in the `ssr` collection.
- SSR builds output to `api/hooks/lib/app.server.js` via `yarn build:server`.
- SSR route validation is currently disabled and returns -1 in `api/hooks/config.js`; update this when enabling SSR per route.

View File

@@ -0,0 +1,41 @@
---
name: Testing
description: Playwright test conventions, fixtures, and visual regression.
applyTo: "tests/**"
---
# Testing
- Playwright for API tests (`tests/api/`), E2E tests (`tests/e2e/`), mobile tests (`tests/e2e-mobile/`), and Visual Regression tests (`tests/e2e-visual/`). Config in `playwright.config.ts`.
- Self-test after code changes: run only affected spec files (`npx playwright test tests/e2e/filename.spec.ts` or `-g "test name"`), not a full test suite run saves time.
- Always coordinate test adjustments with the user: do not fix broken tests to match buggy frontend or vice versa. When tests fail, first clarify whether the test or the code is wrong.
## BrowserSync Workaround
BrowserSync keeps a WebSocket open permanently, preventing `networkidle` and `load` from resolving. All fixture files override `page.goto()` and `page.reload()` to use `waitUntil: "domcontentloaded"` by default. Always use `domcontentloaded` for navigation waits.
## Fixtures & Helpers
- `tests/e2e/fixtures.ts` Shared fixtures (`authedPage`, `testUser`) and helpers (`waitForSpaReady`, `navigateToRoute`, `clickSpaLink`).
- `tests/e2e-visual/fixtures.ts` Visual test helpers (`waitForVisualReady`, `hideDynamicContent`, `prepareForScreenshot`, `expectScreenshot`, `getDynamicMasks`).
- `tests/e2e-mobile/fixtures.ts` Mobile helpers (`openHamburgerMenu`, `isMobileViewport`, `isTabletViewport`, `isBelowLg`).
- `tests/api/fixtures.ts` API fixtures (`api`, `authedApi`, `accessToken`).
- `tests/api/helpers/` API test utilities (`test-user.ts`, `admin-api.ts`, `maildev.ts`).
- `tests/fixtures/test-constants.ts` Central constants (`TEST_USER`, `ADMIN_TOKEN`, `API_BASE`).
## Visual Regression Tests
- Visual regression tests live in `tests/e2e-visual/` with separate Playwright projects (`visual-desktop`, `visual-iphonese`, `visual-ipad`).
- Run: `yarn test:visual`. Update baselines: `yarn test:visual:update`.
- Screenshots are stored in `tests/e2e-visual/__screenshots__/{projectName}/` and MUST be committed to the repo.
- Tolerance: `maxDiffPixelRatio: 0.02` (2%) for cross-OS/hardware rendering differences.
- Always call `prepareForScreenshot(page)` before `expectScreenshot()`.
- Use `waitForVisualReady(page)` instead of `waitForSpaReady()` it additionally waits for skeleton loaders and CSS settling.
- Dynamic content: `hideDynamicContent(page)` disables BrowserSync overlay and animations; `getDynamicMasks(page)` returns locators for non-deterministic elements.
- For AI review of screenshots, run `./scripts/export-visual-screenshots.sh`.
## API Tests
- API tests use `tests/api/fixtures.ts` with `api` (unauthenticated) and `authedApi` (with Bearer token) fixtures.
- 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 affected API tests: `npx playwright test tests/api/filename.spec.ts`.

4
.gitignore vendored
View File

@@ -5,6 +5,10 @@ tmp
_temp
frontend/dist
yarn-error.log
test-results/
playwright-report/
playwright/.cache/
visual-review/
.yarn/*
!.yarn/cache
!.yarn/patches

10
.vscode/settings.json vendored
View File

@@ -21,13 +21,19 @@
"event": "onFileChange"
}
],
"i18n-ally.localesPaths": ["frontend/locales"],
"i18n-ally.localesPaths": ["frontend/src/lib/i18n/locales"],
"i18n-ally.sourceLanguage": "de",
"i18n-ally.keystyle": "nested",
"i18n-ally.enabledFrameworks": ["svelte"],
"i18n-ally.displayLanguage": "de",
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
},
"files.associations": {
"css": "tailwindcss"
}
},
"css.validate": true,
"css.lint.unknownAtRules": "ignore",
"playwright.reuseBrowser": false,
"playwright.showTrace": true
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,7 @@ DOCKER_COMPOSE=docker compose -f docker-compose-local.yml
.DEFAULT_GOAL := help
.PHONY: init docker-up docker-restart-frontend docker-up-tibi-dev docker-start docker-start-tibi-dev docker-down docker-ps docker-logs yarn-upgrade fix-permissions mongo-sync-master-to-local media-sync-master-to-local mongo-sync-local-to-staging media-sync-local-to-staging
.PHONY: init docker-up docker-restart-frontend docker-up-tibi-dev docker-start docker-start-tibi-dev docker-down docker-ps docker-logs yarn-upgrade fix-permissions mongo-sync-master-to-local media-sync-master-to-local mongo-sync-local-to-staging media-sync-local-to-staging test test-e2e test-api test-visual
include ./.env
@@ -49,6 +49,21 @@ docker-pull: ## pull docker images
docker-%:
$(DOCKER_COMPOSE) $*
test: ## run all Playwright tests
yarn test
test-e2e: ## run E2E Playwright tests
yarn test:e2e
test-api: ## run API Playwright tests
yarn test:api
test-visual: ## run visual regression tests
yarn test:visual
test-visual-update: ## update visual regression baselines
yarn test:visual:update
yarn-upgrade: ## interactive yarn upgrade
$(DOCKER_COMPOSE) run --rm yarnstart yarn upgrade-interactive
$(DOCKER_COMPOSE) restart yarnstart

View File

@@ -1,6 +1,18 @@
<script lang="ts">
import { metricCall } from "./config"
import { location } from "./lib/store"
import { _, locale } from "./lib/i18n/index"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
currentLanguage,
localizedPath,
getLanguageSwitchUrl,
getBrowserLanguage,
extractLanguageFromPath,
DEFAULT_LANGUAGE,
getRoutePath,
} from "./lib/i18n"
export let url = ""
if (url) {
@@ -13,8 +25,19 @@
push: false,
pop: false,
}
// Set svelte-i18n locale from URL for SSR rendering
const lang = extractLanguageFromPath(l[0]) || DEFAULT_LANGUAGE
$locale = lang
}
// Redirect root "/" to default language
$effect(() => {
if (typeof window !== "undefined" && $location.path === "/") {
const lang = getBrowserLanguage()
history.replaceState(null, "", `/${lang}/`)
}
})
// metrics
let oldPath = $state("")
$effect(() => {
@@ -38,14 +61,47 @@
</script>
<header class="text-white p-4 bg-red-900">
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-xl font-bold">Tibi Svelte Starter</a>
<nav>
<ul class="flex space-x-4">
<li><a href="/" class="hover:underline">Home</a></li>
<li><a href="/about" class="hover:underline">About</a></li>
<li><a href="/contact" class="hover:underline">Contact</a></li>
<div class="container mx-auto flex flex-wrap items-center justify-between gap-2">
<a href={localizedPath("/")} class="text-xl font-bold shrink-0">Tibi Svelte Starter</a>
<nav class="flex items-center gap-4">
<ul class="hidden sm:flex space-x-4">
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
</ul>
<div class="flex space-x-2 text-sm sm:border-l sm:border-white/30 sm:pl-4">
{#each SUPPORTED_LANGUAGES as lang}
<a
href={getLanguageSwitchUrl(lang)}
class="hover:underline px-1"
class:font-bold={$currentLanguage === lang}
class:opacity-60={$currentLanguage !== lang}
>
{LANGUAGE_LABELS[lang]}
</a>
{/each}
</div>
</nav>
</div>
<!-- Mobile nav -->
<nav class="sm:hidden mt-2 border-t border-white/20 pt-2">
<ul class="flex space-x-4 text-sm">
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
</ul>
</nav>
</header>
<main class="container mx-auto p-4">
{#if getRoutePath($location.path) === "/about"}
<h1 class="text-2xl font-bold mb-4">{$_("page.about.title")}</h1>
<p>{$_("page.about.text")}</p>
{:else if getRoutePath($location.path) === "/contact"}
<h1 class="text-2xl font-bold mb-4">{$_("page.contact.title")}</h1>
<p>{$_("page.contact.text")}</p>
{:else}
<h1 class="text-2xl font-bold mb-4">{$_("page.home.title")}</h1>
<p>{$_("page.home.text")}</p>
{/if}
</main>

View File

@@ -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

168
frontend/src/lib/i18n.ts Normal file
View File

@@ -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<SupportedLanguage, string> = {
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<string, Record<SupportedLanguage, string>> = {
// 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<SupportedLanguage>(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)
}

View File

@@ -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<void> {
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()
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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"
}
}

113
playwright.config.ts Normal file
View File

@@ -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",
},
},
],
})

View File

@@ -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."

5
tailwind.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./frontend/src/**/*.{html,js,svelte,ts}", "./frontend/spa.html"],
plugins: [],
}

57
tests/api/fixtures.ts Normal file
View File

@@ -0,0 +1,57 @@
import { test as base, expect, APIRequestContext } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
const API_BASE = "/api"
interface ActionSuccess {
success: true
[key: string]: unknown
}
interface ActionError {
error: string
}
type ApiWorkerFixtures = {
testUser: TestUserCredentials
}
type ApiFixtures = {
api: APIRequestContext
authedApi: APIRequestContext
accessToken: string
}
export const test = base.extend<ApiFixtures, ApiWorkerFixtures>({
testUser: [
async ({ playwright }, use) => {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const user = await ensureTestUser(baseURL)
await use(user)
},
{ scope: "worker" },
],
api: async ({ request }, use) => {
await use(request)
},
accessToken: async ({ testUser }, use) => {
await use(testUser.accessToken)
},
authedApi: async ({ playwright, baseURL, accessToken }, use) => {
const ctx = await playwright.request.newContext({
baseURL: baseURL!,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Bearer ${accessToken}`,
},
})
await use(ctx)
await ctx.dispose()
},
})
export { expect, API_BASE }
export type { ActionSuccess, ActionError }

26
tests/api/health.spec.ts Normal file
View File

@@ -0,0 +1,26 @@
import { test, expect, API_BASE } from "./fixtures"
test.describe("API Health", () => {
test("should respond to API base endpoint", async ({ api }) => {
// tibi-server responds to the base API path
const res = await api.get(`${API_BASE}/`)
// Accept any successful response (200-299), 401 (auth required), or 404 (no root handler)
expect([200, 204, 401, 404]).toContain(res.status())
})
test("should respond to SSR collection", async ({ api }) => {
// The ssr collection is part of the starter kit
const res = await api.get(`${API_BASE}/ssr`)
expect(res.status()).toBeLessThan(500)
})
test("should reject invalid action commands", async ({ api }) => {
const res = await api.post(`${API_BASE}/action`, {
params: { cmd: "nonexistent_action" },
data: {},
})
// Should return an error, not crash
expect(res.status()).toBeGreaterThanOrEqual(400)
expect(res.status()).toBeLessThan(500)
})
})

View File

@@ -0,0 +1,76 @@
import { APIRequestContext, request } from "@playwright/test"
import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
let adminContext: APIRequestContext | null = null
/**
* Get or create a singleton admin API context with the ADMIN_TOKEN.
*/
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
if (!adminContext) {
adminContext = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Token: ADMIN_TOKEN,
},
})
}
return adminContext
}
/**
* Delete a user by ID via admin API.
*/
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
return res.ok()
}
/**
* Cleanup test users matching a pattern in their email.
*/
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.get(`${API_BASE}/user`)
if (!res.ok()) return 0
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
if (!Array.isArray(users)) return 0
let deleted = 0
for (const user of users) {
const email = user.email || ""
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
if (matches) {
const userId = user.id || user._id
if (userId) {
const ok = await deleteUser(baseURL, userId)
if (ok) deleted++
}
}
}
return deleted
}
/**
* Cleanup all test data (users and tokens matching @test.example.com).
*/
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
const testPattern = "@test.example.com"
const users = await cleanupTestUsers(baseURL, testPattern)
return { users }
}
/**
* Dispose the admin API context. Call in globalTeardown.
*/
export async function disposeAdminApi(): Promise<void> {
if (adminContext) {
await adminContext.dispose()
adminContext = null
}
}

View File

@@ -0,0 +1,162 @@
import { APIRequestContext, request } from "@playwright/test"
/**
* MailDev API helper for testing email flows.
*
* Configure via environment variables:
* - MAILDEV_URL: MailDev web UI URL (default: https://${PROJECT_NAME}-maildev.code.testversion.online)
* - MAILDEV_USER: Basic auth username (default: code)
* - MAILDEV_PASS: Basic auth password
*/
const MAILDEV_URL = process.env.MAILDEV_URL || "http://localhost:1080"
const MAILDEV_USER = process.env.MAILDEV_USER || "code"
const MAILDEV_PASS = process.env.MAILDEV_PASS || ""
export interface MailDevEmail {
id: string
subject: string
html: string
to: { address: string; name: string }[]
from: { address: string; name: string }[]
date: string
time: string
read: boolean
headers: Record<string, string>
}
let maildevContext: APIRequestContext | null = null
async function getContext(): Promise<APIRequestContext> {
if (!maildevContext) {
const opts: any = {
baseURL: MAILDEV_URL,
ignoreHTTPSErrors: true,
}
if (MAILDEV_PASS) {
opts.httpCredentials = {
username: MAILDEV_USER,
password: MAILDEV_PASS,
}
}
maildevContext = await request.newContext(opts)
}
return maildevContext
}
/**
* Get all emails from MailDev.
*/
export async function getAllEmails(): Promise<MailDevEmail[]> {
const ctx = await getContext()
const res = await ctx.get("/email")
if (!res.ok()) {
throw new Error(`MailDev API error: ${res.status()} ${res.statusText()}`)
}
return res.json()
}
/**
* Delete all emails in MailDev.
*/
export async function deleteAllEmails(): Promise<void> {
const ctx = await getContext()
await ctx.delete("/email/all")
}
/**
* Delete a specific email by ID.
*/
export async function deleteEmail(id: string): Promise<void> {
const ctx = await getContext()
await ctx.delete(`/email/${id}`)
}
/**
* Wait for an email to arrive for a specific recipient.
*/
export async function waitForEmail(
toEmail: string,
options: {
subject?: string | RegExp
timeout?: number
pollInterval?: number
} = {}
): Promise<MailDevEmail> {
const { subject, timeout = 5000, pollInterval = 250 } = options
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return match
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No email to ${toEmail} received within ${timeout}ms`)
}
/**
* Extract a 6-digit verification code from an email's HTML body.
*/
export function extractCode(email: MailDevEmail): string {
const match = email.html.match(/>(\d{6})<\//)
if (!match) {
throw new Error(`No 6-digit code found in email "${email.subject}"`)
}
return match[1]
}
/**
* Perform an action and capture the verification code from the resulting email.
* Filters out emails that existed before the action was triggered.
*/
export async function captureCode(
toEmail: string,
action: () => Promise<void>,
options: { subject?: string | RegExp; timeout?: number } = {}
): Promise<string> {
const existingEmails = await getAllEmails()
const existingIds = new Set(
existingEmails
.filter((e) => e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase()))
.map((e) => e.id)
)
await action()
const { timeout = 5000, subject } = options
const pollInterval = 250
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
if (existingIds.has(e.id)) return false
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return extractCode(match)
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No new email to ${toEmail} received within ${timeout}ms`)
}
/**
* Dispose the MailDev API context. Call in globalTeardown.
*/
export async function disposeMailDev(): Promise<void> {
if (maildevContext) {
await maildevContext.dispose()
maildevContext = null
}
}

View File

@@ -0,0 +1,72 @@
import { APIRequestContext, request } from "@playwright/test"
import { TEST_USER } from "../../fixtures/test-constants"
const API_BASE = "/api"
export interface TestUserCredentials {
email: string
password: string
accessToken: string
firstName: string
lastName: string
}
/**
* Login a user via the tibi action endpoint.
* Returns the access token or null on failure.
*/
export async function loginUser(baseURL: string, email: string, password: string): Promise<string | null> {
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
})
try {
const res = await ctx.post(`${API_BASE}/action`, {
params: { cmd: "login" },
data: { email, password },
})
if (!res.ok()) return null
const body = await res.json()
return body.accessToken || null
} finally {
await ctx.dispose()
}
}
/**
* Ensure the test user exists and is logged in.
* Tries login first; if that fails, logs a warning (registration must be
* implemented per project or the user must be seeded via globalSetup).
*/
export async function ensureTestUser(baseURL: string): Promise<TestUserCredentials> {
const email = TEST_USER.email
const password = TEST_USER.password
const token = await loginUser(baseURL, email, password)
if (token) {
return {
email,
password,
accessToken: token,
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}
// If login fails, the test user doesn't exist yet.
// Implement project-specific registration here, or seed via globalSetup.
console.warn(
`⚠️ Test user ${email} could not be logged in. ` +
`Either implement registration in test-user.ts or seed the user via globalSetup.`
)
// Return a placeholder tests requiring auth will fail gracefully
return {
email,
password,
accessToken: "",
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}

View File

@@ -0,0 +1,109 @@
import type { Page } from "@playwright/test"
import { test as base, expect as pwExpect } from "@playwright/test"
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
import { expect } from "../e2e/fixtures"
type MobileFixtures = {}
export const test = base.extend<MobileFixtures>({
/**
* Override page fixture: BrowserSync domcontentloaded workaround.
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
await use(page)
},
})
/**
* Wait for the SPA to be ready in mobile viewport.
*/
export async function waitForSpaReady(page: Page): Promise<string> {
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
return match?.[1] || "de"
}
/**
* Navigate to a route with language prefix.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
}
/** Check if viewport width is mobile (<768px) */
export function isMobileViewport(page: Page): boolean {
return (page.viewportSize()?.width ?? 0) < 768
}
/** Check if viewport width is tablet (768-1023px) */
export function isTabletViewport(page: Page): boolean {
const w = page.viewportSize()?.width ?? 0
return w >= 768 && w < 1024
}
/** Check if viewport width is below lg breakpoint (<1024px) */
export function isBelowLg(page: Page): boolean {
return (page.viewportSize()?.width ?? 0) < 1024
}
/**
* Open the hamburger/mobile navigation menu.
* Finds the first visible button with aria-expanded in the header.
*/
export async function openHamburgerMenu(page: Page): Promise<void> {
const hamburgers = page.locator("header button[aria-expanded]")
const count = await hamburgers.count()
let clicked = false
for (let i = 0; i < count; i++) {
const btn = hamburgers.nth(i)
if (await btn.isVisible()) {
await btn.click()
clicked = true
break
}
}
if (!clicked) {
throw new Error("No visible hamburger button found in header")
}
await page.waitForTimeout(500)
await page
.waitForFunction(
() => {
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
return btn !== null
},
{ timeout: 5000 }
)
.catch(() => {})
}
/**
* Close the hamburger menu via Escape key.
*/
export async function closeHamburgerMenuViaEscape(page: Page): Promise<void> {
await page.keyboard.press("Escape")
await page
.waitForFunction(
() => {
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
return btn === null
},
{ timeout: 5000 }
)
.catch(() => {})
}

View File

@@ -0,0 +1,28 @@
import { test, expect, waitForSpaReady, isMobileViewport } from "./fixtures"
test.describe("Home Page (Mobile)", () => {
test("should load the start page on mobile", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
expect(isMobileViewport(page) || true).toBeTruthy()
await expect(page.locator("#appContainer")).not.toBeEmpty()
})
test("should have a visible header", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
const header = page.locator("header")
await expect(header).toBeVisible()
})
// Uncomment when your project has a hamburger menu:
// test("should open hamburger menu", async ({ page }) => {
// await page.goto("/de/")
// await waitForSpaReady(page)
// await openHamburgerMenu(page)
// const expandedBtn = page.locator("header button[aria-expanded='true']")
// await expect(expandedBtn).toBeVisible()
// })
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,113 @@
import type { Page, Locator } from "@playwright/test"
import { test as base, expect as pwExpect } from "@playwright/test"
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
import { expect } from "../e2e/fixtures"
type VisualFixtures = {}
export const test = base.extend<VisualFixtures>({
/**
* Override page fixture: BrowserSync domcontentloaded workaround.
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
await use(page)
},
})
/**
* Wait for the SPA to be fully rendered and stable for visual comparison.
* Waits for skeleton loaders to disappear and CSS to settle.
*/
export async function waitForVisualReady(page: Page, opts?: { timeout?: number }): Promise<void> {
const timeout = opts?.timeout ?? 15000
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout })
try {
await page.waitForFunction(() => document.querySelectorAll(".animate-pulse").length === 0, { timeout: 10000 })
} catch {
// Skeleton loaders may not exist on every page
}
await page.waitForTimeout(800)
}
/**
* Navigate to a route with visual readiness wait.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
await waitForVisualReady(page)
}
/**
* Hide dynamic content that would cause screenshot flakiness:
* - BrowserSync overlay
* - All animations and transitions
* - Blinking cursor
*/
export async function hideDynamicContent(page: Page): Promise<void> {
await page.addStyleTag({
content: `
#__bs_notify__, #__bs_notify__:before, #__bs_notify__:after { display: none !important; }
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
* { caret-color: transparent !important; }
`,
})
}
/**
* Returns locators for non-deterministic elements that should be masked
* in screenshots (e.g. cart badges, timestamps).
* Customize this for your project.
*/
export function getDynamicMasks(page: Page): Locator[] {
return [
// Add project-specific dynamic element selectors here:
// page.locator('[data-testid="cart-badge"]'),
]
}
/**
* Prepare the page for a screenshot: hide dynamic content and scroll to top.
*/
export async function prepareForScreenshot(page: Page): Promise<void> {
await hideDynamicContent(page)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(300)
}
/**
* Take a screenshot and compare against baseline.
*/
export async function expectScreenshot(
page: Page,
name: string,
opts?: {
fullPage?: boolean
mask?: Locator[]
maxDiffPixelRatio?: number
}
): Promise<void> {
const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])]
await pwExpect(page).toHaveScreenshot(name, {
fullPage: opts?.fullPage ?? false,
mask: masks,
maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02,
})
}

View File

@@ -0,0 +1,11 @@
import { test, expect } from "./fixtures"
import { waitForVisualReady, prepareForScreenshot, expectScreenshot } from "./fixtures"
test.describe("Home Page (Visual)", () => {
test("homepage screenshot", async ({ page }) => {
await page.goto("/de/")
await waitForVisualReady(page)
await prepareForScreenshot(page)
await expectScreenshot(page, "homepage.png", { fullPage: true })
})
})

139
tests/e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,139 @@
import { test as base, expect, type Page } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "../api/helpers/test-user"
const API_BASE = "/api"
/**
* Shared E2E test fixtures.
*
* Worker-scoped:
* - `testUser` persistent test user (created/reused once per worker)
*
* Test-scoped:
* - `authedPage` Page with logged-in user (token injection via sessionStorage)
* - `accessToken` raw JWT access token
*/
type E2eWorkerFixtures = {
testUser: TestUserCredentials
}
type E2eFixtures = {
authedPage: Page
accessToken: string
}
export const test = base.extend<E2eFixtures, E2eWorkerFixtures>({
/**
* Override page fixture: BrowserSync keeps a WebSocket open permanently,
* preventing "load" and "networkidle" from resolving. We default all
* navigation methods to "domcontentloaded".
*/
page: async ({ page }, use) => {
const origGoto = page.goto.bind(page)
const origReload = page.reload.bind(page)
const origGoBack = page.goBack.bind(page)
const origGoForward = page.goForward.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
page.goBack = ((opts?: any) => origGoBack({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goBack
page.goForward = ((opts?: any) =>
origGoForward({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goForward
await use(page)
},
// Worker-scoped: create/reuse test user once per worker
testUser: [
async ({ playwright }, use) => {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const user = await ensureTestUser(baseURL)
await use(user)
},
{ scope: "worker" },
],
accessToken: async ({ testUser }, use) => {
await use(testUser.accessToken)
},
// Test-scoped: Page with logged-in user via sessionStorage token injection
authedPage: async ({ page, testUser, baseURL }, use) => {
// Navigate to home so domain is set for sessionStorage
await page.goto("/", { waitUntil: "domcontentloaded" })
await page.waitForLoadState("domcontentloaded")
// Inject auth token into sessionStorage (adapt key names to your app)
await page.evaluate(
({ token, user }) => {
sessionStorage.setItem("auth_token", token)
sessionStorage.setItem(
"auth_user",
JSON.stringify({
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
)
},
{
token: testUser.accessToken,
user: testUser,
}
)
// Reload so the app reads the token from sessionStorage
await page.reload({ waitUntil: "domcontentloaded" })
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
await use(page)
},
})
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Wait for the SPA to be ready (appContainer rendered).
* Returns the detected language prefix from the URL.
*/
export async function waitForSpaReady(page: Page): Promise<string> {
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
return match?.[1] || "de"
}
/**
* Navigate to a route, prepending the current language prefix.
*/
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
const url = page.url()
const match = url.match(/\/([a-z]{2})(\/|$)/)
const lang = match?.[1] || "de"
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
await page.goto(fullPath)
await page.waitForLoadState("domcontentloaded")
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
}
/**
* Click an SPA link and verify no full page reload occurred.
*/
export async function clickSpaLink(page: Page, linkSelector: string): Promise<void> {
await page.evaluate(() => {
;(window as any).__spa_navigation_marker = true
})
await page.locator(linkSelector).first().click()
await page.waitForLoadState("domcontentloaded")
const markerExists = await page.evaluate(() => {
return (window as any).__spa_navigation_marker === true
})
if (!markerExists) {
throw new Error(`SPA navigation failed: full page reload detected when clicking "${linkSelector}"`)
}
}
export { expect, API_BASE, type Page }

39
tests/e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,39 @@
import { test, expect, waitForSpaReady } from "./fixtures"
test.describe("Home Page", () => {
test("should load the start page", async ({ page }) => {
await page.goto("/de/")
const lang = await waitForSpaReady(page)
expect(lang).toBe("de")
})
test("should have a visible header with navigation", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
const header = page.locator("header")
await expect(header).toBeVisible()
const nav = header.locator("nav")
await expect(nav).toBeVisible()
})
test("should have language prefix in URL", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
expect(page.url()).toContain("/de")
})
test("should switch language to English", async ({ page }) => {
await page.goto("/de/")
await waitForSpaReady(page)
// Click on English language link
const enLink = page.locator('a[href*="/en"]').first()
if (await enLink.isVisible()) {
await enLink.click()
await page.waitForLoadState("domcontentloaded")
expect(page.url()).toContain("/en")
}
})
})

23
tests/fixtures/test-constants.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/**
* Zentrale Test-Konstanten für alle Tests.
*
* Passe diese Werte an dein Projekt an:
* - TEST_USER: E-Mail/Passwort für den E2E-Test-User
* - ADMIN_TOKEN: Token aus api/config.yml.env
* - API_BASE: API-Pfad (Standard: /api)
*/
export const TEST_USER = {
email: "playwright-e2e@test.example.com",
password: "PlaywrightTest1",
firstName: "Playwright",
lastName: "E2E",
} as const
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "CHANGE_ME"
export const API_BASE = "/api"
export const BASIC_AUTH = {
username: process.env.BASIC_AUTH_USER || "web",
password: process.env.BASIC_AUTH_PASS || "web",
} as const

49
tests/global-setup.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Playwright Global Setup
*
* Runs once before all tests. Use this to:
* - Seed test data via API action hooks
* - Create test users
* - Verify the dev environment is accessible
*
* Customize the setup_testing action call for your project,
* or remove it if not needed.
*/
import { request } from "@playwright/test"
import { API_BASE } from "./fixtures/test-constants"
async function globalSetup() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
// Verify dev environment is reachable
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
"User-Agent": "Playwright",
},
})
try {
const res = await ctx.get("/")
if (!res.ok()) {
console.warn(`⚠️ Dev environment at ${baseURL} returned ${res.status()}`)
} else {
console.log(`✅ Dev environment reachable at ${baseURL}`)
}
// Uncomment and adapt for project-specific test data seeding:
// const seedRes = await ctx.post(`${API_BASE}/action`, {
// params: { cmd: "setup_testing" },
// data: { scope: "all" },
// headers: { Token: ADMIN_TOKEN },
// })
// if (seedRes.ok()) {
// console.log("✅ Test data seeded")
// }
} finally {
await ctx.dispose()
}
}
export default globalSetup

37
tests/global-teardown.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Playwright Global Teardown
*
* Runs once after all tests. Use this to:
* - Clean up test data
* - Dispose singleton API contexts
*
* Customize for your project's cleanup needs.
*/
import { cleanupAllTestData, disposeAdminApi } from "./api/helpers/admin-api"
import { deleteAllEmails, disposeMailDev } from "./api/helpers/maildev"
async function globalTeardown() {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
// Clean up test users
try {
const result = await cleanupAllTestData(baseURL)
if (result.users > 0) {
console.log(`🧹 Cleanup: ${result.users} test users deleted`)
}
} catch (err) {
console.warn("⚠️ Test data cleanup failed:", err)
}
// Clean up MailDev emails (optional)
try {
await deleteAllEmails()
} catch {
// MailDev cleanup is optional
}
// Dispose singleton API contexts
await Promise.all([disposeAdminApi(), disposeMailDev()])
}
export default globalTeardown

8
tsconfig.test.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"verbatimModuleSyntax": false,
"noEmit": true
},
"include": ["tests/**/*", "playwright.config.ts"]
}

583
yarn.lock
View File

@@ -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<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::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<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::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"