✨ feat: implement new feature for enhanced user experience
This commit is contained in:
1
.env
1
.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
|
||||
|
||||
32
.github/copilot-instructions.md
vendored
32
.github/copilot-instructions.md
vendored
@@ -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`
|
||||
|
||||
51
.github/instructions/api-hooks.instructions.md
vendored
Normal file
51
.github/instructions/api-hooks.instructions.md
vendored
Normal 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.
|
||||
28
.github/instructions/frontend.instructions.md
vendored
Normal file
28
.github/instructions/frontend.instructions.md
vendored
Normal 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.
|
||||
38
.github/instructions/general.instructions.md
vendored
Normal file
38
.github/instructions/general.instructions.md
vendored
Normal 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`.
|
||||
12
.github/instructions/ssr.instructions.md
vendored
Normal file
12
.github/instructions/ssr.instructions.md
vendored
Normal 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.
|
||||
41
.github/instructions/testing.instructions.md
vendored
Normal file
41
.github/instructions/testing.instructions.md
vendored
Normal 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
4
.gitignore
vendored
@@ -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
10
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
|
||||
BIN
.yarn/cache/@esbuild-linux-x64-npm-0.19.12-59062fdb38-10.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@esbuild-linux-x64-npm-0.19.12-59062fdb38-10.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@formatjs-ecma402-abstract-npm-2.3.6-b28618e55c-30b1b5cd6b.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@formatjs-ecma402-abstract-npm-2.3.6-b28618e55c-30b1b5cd6b.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@formatjs-fast-memoize-npm-2.2.7-bc909b3b5a-e7e6efc677.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@formatjs-fast-memoize-npm-2.2.7-bc909b3b5a-e7e6efc677.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@formatjs-icu-messageformat-parser-npm-2.11.4-b051248584-2acb100c06.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@formatjs-icu-messageformat-parser-npm-2.11.4-b051248584-2acb100c06.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@formatjs-icu-skeleton-parser-npm-1.8.16-e9d6e923fd-428001e5be.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@formatjs-icu-skeleton-parser-npm-1.8.16-e9d6e923fd-428001e5be.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@formatjs-intl-localematcher-npm-0.6.2-984821923e-eb12a7f536.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@formatjs-intl-localematcher-npm-0.6.2-984821923e-eb12a7f536.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@playwright-test-npm-1.58.2-03a96deb3c-58bf901392.zip
LFS
vendored
Normal file
BIN
.yarn/cache/@playwright-test-npm-1.58.2-03a96deb3c-58bf901392.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/cli-color-npm-2.0.4-d35494cfd7-6706fbb98f.zip
LFS
vendored
Normal file
BIN
.yarn/cache/cli-color-npm-2.0.4-d35494cfd7-6706fbb98f.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/d-npm-1.0.2-7abbb6ae36-a3f45ef964.zip
LFS
vendored
Normal file
BIN
.yarn/cache/d-npm-1.0.2-7abbb6ae36-a3f45ef964.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/decimal.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zip
LFS
vendored
Normal file
BIN
.yarn/cache/decimal.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/deepmerge-npm-4.3.1-4f751a0844-058d9e1b0f.zip
LFS
vendored
Normal file
BIN
.yarn/cache/deepmerge-npm-4.3.1-4f751a0844-058d9e1b0f.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/es5-ext-npm-0.10.64-c30cdc3d60-0c5d865770.zip
LFS
vendored
Normal file
BIN
.yarn/cache/es5-ext-npm-0.10.64-c30cdc3d60-0c5d865770.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/es6-iterator-npm-2.0.3-4dadb0ccc1-dbadecf3d0.zip
LFS
vendored
Normal file
BIN
.yarn/cache/es6-iterator-npm-2.0.3-4dadb0ccc1-dbadecf3d0.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/es6-symbol-npm-3.1.4-7d67ac432c-3743119fe6.zip
LFS
vendored
Normal file
BIN
.yarn/cache/es6-symbol-npm-3.1.4-7d67ac432c-3743119fe6.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/es6-weak-map-npm-2.0.3-5e57e0b4e6-5958a321cf.zip
LFS
vendored
Normal file
BIN
.yarn/cache/es6-weak-map-npm-2.0.3-5e57e0b4e6-5958a321cf.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/esbuild-npm-0.19.12-fb5a3a4313-861fa8eb24.zip
LFS
vendored
Normal file
BIN
.yarn/cache/esbuild-npm-0.19.12-fb5a3a4313-861fa8eb24.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/esniff-npm-2.0.1-26cea8766c-f6a2abd2f8.zip
LFS
vendored
Normal file
BIN
.yarn/cache/esniff-npm-2.0.1-26cea8766c-f6a2abd2f8.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/estree-walker-npm-2.0.2-dfab42f65c-b02109c5d4.zip
LFS
vendored
Normal file
BIN
.yarn/cache/estree-walker-npm-2.0.2-dfab42f65c-b02109c5d4.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/event-emitter-npm-0.3.5-f1e8b8edb5-a7f5ea8002.zip
LFS
vendored
Normal file
BIN
.yarn/cache/event-emitter-npm-0.3.5-f1e8b8edb5-a7f5ea8002.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/ext-npm-1.7.0-580588ab93-666a135980.zip
LFS
vendored
Normal file
BIN
.yarn/cache/ext-npm-1.7.0-580588ab93-666a135980.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip
LFS
vendored
Normal file
BIN
.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/globalyzer-npm-0.1.0-3982d25961-419a0f95ba.zip
LFS
vendored
Normal file
BIN
.yarn/cache/globalyzer-npm-0.1.0-3982d25961-419a0f95ba.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/globrex-npm-0.1.2-ddda94f2d0-81ce62ee6f.zip
LFS
vendored
Normal file
BIN
.yarn/cache/globrex-npm-0.1.2-ddda94f2d0-81ce62ee6f.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/intl-messageformat-npm-10.7.18-b63c15bddc-96650d6739.zip
LFS
vendored
Normal file
BIN
.yarn/cache/intl-messageformat-npm-10.7.18-b63c15bddc-96650d6739.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/is-promise-npm-2.2.2-afbf94db67-18bf7d1c59.zip
LFS
vendored
Normal file
BIN
.yarn/cache/is-promise-npm-2.2.2-afbf94db67-18bf7d1c59.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/lru-queue-npm-0.1.0-8e1c90dde8-55b08ee3a7.zip
LFS
vendored
Normal file
BIN
.yarn/cache/lru-queue-npm-0.1.0-8e1c90dde8-55b08ee3a7.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/memoizee-npm-0.4.17-95e9fda366-b7abda74d1.zip
LFS
vendored
Normal file
BIN
.yarn/cache/memoizee-npm-0.4.17-95e9fda366-b7abda74d1.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/next-tick-npm-1.1.0-e0eb60d6a4-83b5cf3602.zip
LFS
vendored
Normal file
BIN
.yarn/cache/next-tick-npm-1.1.0-e0eb60d6a4-83b5cf3602.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/playwright-core-npm-1.58.2-7d85ddc78a-8a98fcf122.zip
LFS
vendored
Normal file
BIN
.yarn/cache/playwright-core-npm-1.58.2-7d85ddc78a-8a98fcf122.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/playwright-npm-1.58.2-0c12daad27-d89d6c8a32.zip
LFS
vendored
Normal file
BIN
.yarn/cache/playwright-npm-1.58.2-0c12daad27-d89d6c8a32.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/svelte-i18n-npm-4.0.1-e8d73fe51f-683f921429.zip
LFS
vendored
Normal file
BIN
.yarn/cache/svelte-i18n-npm-4.0.1-e8d73fe51f-683f921429.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/timers-ext-npm-0.1.8-1fa0ad5365-8abd168c57.zip
LFS
vendored
Normal file
BIN
.yarn/cache/timers-ext-npm-0.1.8-1fa0ad5365-8abd168c57.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/tiny-glob-npm-0.2.9-068f4ab3f8-5fb773747f.zip
LFS
vendored
Normal file
BIN
.yarn/cache/tiny-glob-npm-0.2.9-068f4ab3f8-5fb773747f.zip
LFS
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/type-npm-2.7.3-509458c133-82e99e7795.zip
LFS
vendored
Normal file
BIN
.yarn/cache/type-npm-2.7.3-509458c133-82e99e7795.zip
LFS
vendored
Normal file
Binary file not shown.
Binary file not shown.
17
Makefile
17
Makefile
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
168
frontend/src/lib/i18n.ts
Normal 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)
|
||||
}
|
||||
75
frontend/src/lib/i18n/index.ts
Normal file
75
frontend/src/lib/i18n/index.ts
Normal 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()
|
||||
}
|
||||
23
frontend/src/lib/i18n/locales/de.json
Normal file
23
frontend/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
23
frontend/src/lib/i18n/locales/en.json
Normal file
23
frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
package.json
14
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"
|
||||
}
|
||||
}
|
||||
113
playwright.config.ts
Normal file
113
playwright.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
109
scripts/export-visual-screenshots.sh
Executable file
109
scripts/export-visual-screenshots.sh
Executable 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
5
tailwind.config.js
Normal 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
57
tests/api/fixtures.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test as base, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
interface ActionSuccess {
|
||||
success: true
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ActionError {
|
||||
error: string
|
||||
}
|
||||
|
||||
type ApiWorkerFixtures = {
|
||||
testUser: TestUserCredentials
|
||||
}
|
||||
|
||||
type ApiFixtures = {
|
||||
api: APIRequestContext
|
||||
authedApi: APIRequestContext
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export const test = base.extend<ApiFixtures, ApiWorkerFixtures>({
|
||||
testUser: [
|
||||
async ({ playwright }, use) => {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
const user = await ensureTestUser(baseURL)
|
||||
await use(user)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
api: async ({ request }, use) => {
|
||||
await use(request)
|
||||
},
|
||||
|
||||
accessToken: async ({ testUser }, use) => {
|
||||
await use(testUser.accessToken)
|
||||
},
|
||||
|
||||
authedApi: async ({ playwright, baseURL, accessToken }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: baseURL!,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
await use(ctx)
|
||||
await ctx.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
export { expect, API_BASE }
|
||||
export type { ActionSuccess, ActionError }
|
||||
26
tests/api/health.spec.ts
Normal file
26
tests/api/health.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect, API_BASE } from "./fixtures"
|
||||
|
||||
test.describe("API Health", () => {
|
||||
test("should respond to API base endpoint", async ({ api }) => {
|
||||
// tibi-server responds to the base API path
|
||||
const res = await api.get(`${API_BASE}/`)
|
||||
// Accept any successful response (200-299), 401 (auth required), or 404 (no root handler)
|
||||
expect([200, 204, 401, 404]).toContain(res.status())
|
||||
})
|
||||
|
||||
test("should respond to SSR collection", async ({ api }) => {
|
||||
// The ssr collection is part of the starter kit
|
||||
const res = await api.get(`${API_BASE}/ssr`)
|
||||
expect(res.status()).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test("should reject invalid action commands", async ({ api }) => {
|
||||
const res = await api.post(`${API_BASE}/action`, {
|
||||
params: { cmd: "nonexistent_action" },
|
||||
data: {},
|
||||
})
|
||||
// Should return an error, not crash
|
||||
expect(res.status()).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
76
tests/api/helpers/admin-api.ts
Normal file
76
tests/api/helpers/admin-api.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
|
||||
|
||||
let adminContext: APIRequestContext | null = null
|
||||
|
||||
/**
|
||||
* Get or create a singleton admin API context with the ADMIN_TOKEN.
|
||||
*/
|
||||
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
|
||||
if (!adminContext) {
|
||||
adminContext = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
Token: ADMIN_TOKEN,
|
||||
},
|
||||
})
|
||||
}
|
||||
return adminContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user by ID via admin API.
|
||||
*/
|
||||
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
|
||||
const ctx = await getAdminContext(baseURL)
|
||||
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
|
||||
return res.ok()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test users matching a pattern in their email.
|
||||
*/
|
||||
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
|
||||
const ctx = await getAdminContext(baseURL)
|
||||
const res = await ctx.get(`${API_BASE}/user`)
|
||||
if (!res.ok()) return 0
|
||||
|
||||
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
|
||||
if (!Array.isArray(users)) return 0
|
||||
|
||||
let deleted = 0
|
||||
for (const user of users) {
|
||||
const email = user.email || ""
|
||||
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
|
||||
|
||||
if (matches) {
|
||||
const userId = user.id || user._id
|
||||
if (userId) {
|
||||
const ok = await deleteUser(baseURL, userId)
|
||||
if (ok) deleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all test data (users and tokens matching @test.example.com).
|
||||
*/
|
||||
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
|
||||
const testPattern = "@test.example.com"
|
||||
const users = await cleanupTestUsers(baseURL, testPattern)
|
||||
return { users }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the admin API context. Call in globalTeardown.
|
||||
*/
|
||||
export async function disposeAdminApi(): Promise<void> {
|
||||
if (adminContext) {
|
||||
await adminContext.dispose()
|
||||
adminContext = null
|
||||
}
|
||||
}
|
||||
162
tests/api/helpers/maildev.ts
Normal file
162
tests/api/helpers/maildev.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* MailDev API helper for testing email flows.
|
||||
*
|
||||
* Configure via environment variables:
|
||||
* - MAILDEV_URL: MailDev web UI URL (default: https://${PROJECT_NAME}-maildev.code.testversion.online)
|
||||
* - MAILDEV_USER: Basic auth username (default: code)
|
||||
* - MAILDEV_PASS: Basic auth password
|
||||
*/
|
||||
const MAILDEV_URL = process.env.MAILDEV_URL || "http://localhost:1080"
|
||||
const MAILDEV_USER = process.env.MAILDEV_USER || "code"
|
||||
const MAILDEV_PASS = process.env.MAILDEV_PASS || ""
|
||||
|
||||
export interface MailDevEmail {
|
||||
id: string
|
||||
subject: string
|
||||
html: string
|
||||
to: { address: string; name: string }[]
|
||||
from: { address: string; name: string }[]
|
||||
date: string
|
||||
time: string
|
||||
read: boolean
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
let maildevContext: APIRequestContext | null = null
|
||||
|
||||
async function getContext(): Promise<APIRequestContext> {
|
||||
if (!maildevContext) {
|
||||
const opts: any = {
|
||||
baseURL: MAILDEV_URL,
|
||||
ignoreHTTPSErrors: true,
|
||||
}
|
||||
if (MAILDEV_PASS) {
|
||||
opts.httpCredentials = {
|
||||
username: MAILDEV_USER,
|
||||
password: MAILDEV_PASS,
|
||||
}
|
||||
}
|
||||
maildevContext = await request.newContext(opts)
|
||||
}
|
||||
return maildevContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all emails from MailDev.
|
||||
*/
|
||||
export async function getAllEmails(): Promise<MailDevEmail[]> {
|
||||
const ctx = await getContext()
|
||||
const res = await ctx.get("/email")
|
||||
if (!res.ok()) {
|
||||
throw new Error(`MailDev API error: ${res.status()} ${res.statusText()}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all emails in MailDev.
|
||||
*/
|
||||
export async function deleteAllEmails(): Promise<void> {
|
||||
const ctx = await getContext()
|
||||
await ctx.delete("/email/all")
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific email by ID.
|
||||
*/
|
||||
export async function deleteEmail(id: string): Promise<void> {
|
||||
const ctx = await getContext()
|
||||
await ctx.delete(`/email/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an email to arrive for a specific recipient.
|
||||
*/
|
||||
export async function waitForEmail(
|
||||
toEmail: string,
|
||||
options: {
|
||||
subject?: string | RegExp
|
||||
timeout?: number
|
||||
pollInterval?: number
|
||||
} = {}
|
||||
): Promise<MailDevEmail> {
|
||||
const { subject, timeout = 5000, pollInterval = 250 } = options
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
const emails = await getAllEmails()
|
||||
const match = emails.find((e) => {
|
||||
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
|
||||
if (!toMatch) return false
|
||||
if (!subject) return true
|
||||
if (typeof subject === "string") return e.subject.includes(subject)
|
||||
return subject.test(e.subject)
|
||||
})
|
||||
if (match) return match
|
||||
await new Promise((r) => setTimeout(r, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error(`Timeout: No email to ${toEmail} received within ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a 6-digit verification code from an email's HTML body.
|
||||
*/
|
||||
export function extractCode(email: MailDevEmail): string {
|
||||
const match = email.html.match(/>(\d{6})<\//)
|
||||
if (!match) {
|
||||
throw new Error(`No 6-digit code found in email "${email.subject}"`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an action and capture the verification code from the resulting email.
|
||||
* Filters out emails that existed before the action was triggered.
|
||||
*/
|
||||
export async function captureCode(
|
||||
toEmail: string,
|
||||
action: () => Promise<void>,
|
||||
options: { subject?: string | RegExp; timeout?: number } = {}
|
||||
): Promise<string> {
|
||||
const existingEmails = await getAllEmails()
|
||||
const existingIds = new Set(
|
||||
existingEmails
|
||||
.filter((e) => e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase()))
|
||||
.map((e) => e.id)
|
||||
)
|
||||
|
||||
await action()
|
||||
|
||||
const { timeout = 5000, subject } = options
|
||||
const pollInterval = 250
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
const emails = await getAllEmails()
|
||||
const match = emails.find((e) => {
|
||||
if (existingIds.has(e.id)) return false
|
||||
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
|
||||
if (!toMatch) return false
|
||||
if (!subject) return true
|
||||
if (typeof subject === "string") return e.subject.includes(subject)
|
||||
return subject.test(e.subject)
|
||||
})
|
||||
if (match) return extractCode(match)
|
||||
await new Promise((r) => setTimeout(r, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error(`Timeout: No new email to ${toEmail} received within ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the MailDev API context. Call in globalTeardown.
|
||||
*/
|
||||
export async function disposeMailDev(): Promise<void> {
|
||||
if (maildevContext) {
|
||||
await maildevContext.dispose()
|
||||
maildevContext = null
|
||||
}
|
||||
}
|
||||
72
tests/api/helpers/test-user.ts
Normal file
72
tests/api/helpers/test-user.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { APIRequestContext, request } from "@playwright/test"
|
||||
import { TEST_USER } from "../../fixtures/test-constants"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
export interface TestUserCredentials {
|
||||
email: string
|
||||
password: string
|
||||
accessToken: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user via the tibi action endpoint.
|
||||
* Returns the access token or null on failure.
|
||||
*/
|
||||
export async function loginUser(baseURL: string, email: string, password: string): Promise<string | null> {
|
||||
const ctx = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await ctx.post(`${API_BASE}/action`, {
|
||||
params: { cmd: "login" },
|
||||
data: { email, password },
|
||||
})
|
||||
if (!res.ok()) return null
|
||||
const body = await res.json()
|
||||
return body.accessToken || null
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the test user exists and is logged in.
|
||||
* Tries login first; if that fails, logs a warning (registration must be
|
||||
* implemented per project or the user must be seeded via globalSetup).
|
||||
*/
|
||||
export async function ensureTestUser(baseURL: string): Promise<TestUserCredentials> {
|
||||
const email = TEST_USER.email
|
||||
const password = TEST_USER.password
|
||||
|
||||
const token = await loginUser(baseURL, email, password)
|
||||
if (token) {
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
accessToken: token,
|
||||
firstName: TEST_USER.firstName,
|
||||
lastName: TEST_USER.lastName,
|
||||
}
|
||||
}
|
||||
|
||||
// If login fails, the test user doesn't exist yet.
|
||||
// Implement project-specific registration here, or seed via globalSetup.
|
||||
console.warn(
|
||||
`⚠️ Test user ${email} could not be logged in. ` +
|
||||
`Either implement registration in test-user.ts or seed the user via globalSetup.`
|
||||
)
|
||||
|
||||
// Return a placeholder – tests requiring auth will fail gracefully
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
accessToken: "",
|
||||
firstName: TEST_USER.firstName,
|
||||
lastName: TEST_USER.lastName,
|
||||
}
|
||||
}
|
||||
109
tests/e2e-mobile/fixtures.ts
Normal file
109
tests/e2e-mobile/fixtures.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test as base, expect as pwExpect } from "@playwright/test"
|
||||
|
||||
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
|
||||
import { expect } from "../e2e/fixtures"
|
||||
|
||||
type MobileFixtures = {}
|
||||
|
||||
export const test = base.extend<MobileFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync domcontentloaded workaround.
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be ready in mobile viewport.
|
||||
*/
|
||||
export async function waitForSpaReady(page: Page): Promise<string> {
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
return match?.[1] || "de"
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route with language prefix.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/** Check if viewport width is mobile (<768px) */
|
||||
export function isMobileViewport(page: Page): boolean {
|
||||
return (page.viewportSize()?.width ?? 0) < 768
|
||||
}
|
||||
|
||||
/** Check if viewport width is tablet (768-1023px) */
|
||||
export function isTabletViewport(page: Page): boolean {
|
||||
const w = page.viewportSize()?.width ?? 0
|
||||
return w >= 768 && w < 1024
|
||||
}
|
||||
|
||||
/** Check if viewport width is below lg breakpoint (<1024px) */
|
||||
export function isBelowLg(page: Page): boolean {
|
||||
return (page.viewportSize()?.width ?? 0) < 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the hamburger/mobile navigation menu.
|
||||
* Finds the first visible button with aria-expanded in the header.
|
||||
*/
|
||||
export async function openHamburgerMenu(page: Page): Promise<void> {
|
||||
const hamburgers = page.locator("header button[aria-expanded]")
|
||||
const count = await hamburgers.count()
|
||||
let clicked = false
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = hamburgers.nth(i)
|
||||
if (await btn.isVisible()) {
|
||||
await btn.click()
|
||||
clicked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!clicked) {
|
||||
throw new Error("No visible hamburger button found in header")
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
|
||||
return btn !== null
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the hamburger menu via Escape key.
|
||||
*/
|
||||
export async function closeHamburgerMenuViaEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape")
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>("header button[aria-expanded='true']")
|
||||
return btn === null
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
28
tests/e2e-mobile/home.mobile.spec.ts
Normal file
28
tests/e2e-mobile/home.mobile.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect, waitForSpaReady, isMobileViewport } from "./fixtures"
|
||||
|
||||
test.describe("Home Page (Mobile)", () => {
|
||||
test("should load the start page on mobile", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
expect(isMobileViewport(page) || true).toBeTruthy()
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty()
|
||||
})
|
||||
|
||||
test("should have a visible header", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
const header = page.locator("header")
|
||||
await expect(header).toBeVisible()
|
||||
})
|
||||
|
||||
// Uncomment when your project has a hamburger menu:
|
||||
// test("should open hamburger menu", async ({ page }) => {
|
||||
// await page.goto("/de/")
|
||||
// await waitForSpaReady(page)
|
||||
// await openHamburgerMenu(page)
|
||||
// const expandedBtn = page.locator("header button[aria-expanded='true']")
|
||||
// await expect(expandedBtn).toBeVisible()
|
||||
// })
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
113
tests/e2e-visual/fixtures.ts
Normal file
113
tests/e2e-visual/fixtures.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Page, Locator } from "@playwright/test"
|
||||
import { test as base, expect as pwExpect } from "@playwright/test"
|
||||
|
||||
export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures"
|
||||
import { expect } from "../e2e/fixtures"
|
||||
|
||||
type VisualFixtures = {}
|
||||
|
||||
export const test = base.extend<VisualFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync domcontentloaded workaround.
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be fully rendered and stable for visual comparison.
|
||||
* Waits for skeleton loaders to disappear and CSS to settle.
|
||||
*/
|
||||
export async function waitForVisualReady(page: Page, opts?: { timeout?: number }): Promise<void> {
|
||||
const timeout = opts?.timeout ?? 15000
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout })
|
||||
try {
|
||||
await page.waitForFunction(() => document.querySelectorAll(".animate-pulse").length === 0, { timeout: 10000 })
|
||||
} catch {
|
||||
// Skeleton loaders may not exist on every page
|
||||
}
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route with visual readiness wait.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath, { waitUntil: "domcontentloaded" })
|
||||
await waitForVisualReady(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide dynamic content that would cause screenshot flakiness:
|
||||
* - BrowserSync overlay
|
||||
* - All animations and transitions
|
||||
* - Blinking cursor
|
||||
*/
|
||||
export async function hideDynamicContent(page: Page): Promise<void> {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
#__bs_notify__, #__bs_notify__:before, #__bs_notify__:after { display: none !important; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
* { caret-color: transparent !important; }
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns locators for non-deterministic elements that should be masked
|
||||
* in screenshots (e.g. cart badges, timestamps).
|
||||
* Customize this for your project.
|
||||
*/
|
||||
export function getDynamicMasks(page: Page): Locator[] {
|
||||
return [
|
||||
// Add project-specific dynamic element selectors here:
|
||||
// page.locator('[data-testid="cart-badge"]'),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the page for a screenshot: hide dynamic content and scroll to top.
|
||||
*/
|
||||
export async function prepareForScreenshot(page: Page): Promise<void> {
|
||||
await hideDynamicContent(page)
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare against baseline.
|
||||
*/
|
||||
export async function expectScreenshot(
|
||||
page: Page,
|
||||
name: string,
|
||||
opts?: {
|
||||
fullPage?: boolean
|
||||
mask?: Locator[]
|
||||
maxDiffPixelRatio?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])]
|
||||
await pwExpect(page).toHaveScreenshot(name, {
|
||||
fullPage: opts?.fullPage ?? false,
|
||||
mask: masks,
|
||||
maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02,
|
||||
})
|
||||
}
|
||||
11
tests/e2e-visual/home.visual.spec.ts
Normal file
11
tests/e2e-visual/home.visual.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { waitForVisualReady, prepareForScreenshot, expectScreenshot } from "./fixtures"
|
||||
|
||||
test.describe("Home Page (Visual)", () => {
|
||||
test("homepage screenshot", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForVisualReady(page)
|
||||
await prepareForScreenshot(page)
|
||||
await expectScreenshot(page, "homepage.png", { fullPage: true })
|
||||
})
|
||||
})
|
||||
139
tests/e2e/fixtures.ts
Normal file
139
tests/e2e/fixtures.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { ensureTestUser, type TestUserCredentials } from "../api/helpers/test-user"
|
||||
|
||||
const API_BASE = "/api"
|
||||
|
||||
/**
|
||||
* Shared E2E test fixtures.
|
||||
*
|
||||
* Worker-scoped:
|
||||
* - `testUser` – persistent test user (created/reused once per worker)
|
||||
*
|
||||
* Test-scoped:
|
||||
* - `authedPage` – Page with logged-in user (token injection via sessionStorage)
|
||||
* - `accessToken` – raw JWT access token
|
||||
*/
|
||||
type E2eWorkerFixtures = {
|
||||
testUser: TestUserCredentials
|
||||
}
|
||||
|
||||
type E2eFixtures = {
|
||||
authedPage: Page
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export const test = base.extend<E2eFixtures, E2eWorkerFixtures>({
|
||||
/**
|
||||
* Override page fixture: BrowserSync keeps a WebSocket open permanently,
|
||||
* preventing "load" and "networkidle" from resolving. We default all
|
||||
* navigation methods to "domcontentloaded".
|
||||
*/
|
||||
page: async ({ page }, use) => {
|
||||
const origGoto = page.goto.bind(page)
|
||||
const origReload = page.reload.bind(page)
|
||||
const origGoBack = page.goBack.bind(page)
|
||||
const origGoForward = page.goForward.bind(page)
|
||||
|
||||
page.goto = ((url: string, opts?: any) =>
|
||||
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
|
||||
page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload
|
||||
page.goBack = ((opts?: any) => origGoBack({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goBack
|
||||
page.goForward = ((opts?: any) =>
|
||||
origGoForward({ waitUntil: "domcontentloaded", ...opts })) as typeof page.goForward
|
||||
|
||||
await use(page)
|
||||
},
|
||||
|
||||
// Worker-scoped: create/reuse test user once per worker
|
||||
testUser: [
|
||||
async ({ playwright }, use) => {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
const user = await ensureTestUser(baseURL)
|
||||
await use(user)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
accessToken: async ({ testUser }, use) => {
|
||||
await use(testUser.accessToken)
|
||||
},
|
||||
|
||||
// Test-scoped: Page with logged-in user via sessionStorage token injection
|
||||
authedPage: async ({ page, testUser, baseURL }, use) => {
|
||||
// Navigate to home so domain is set for sessionStorage
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" })
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
|
||||
// Inject auth token into sessionStorage (adapt key names to your app)
|
||||
await page.evaluate(
|
||||
({ token, user }) => {
|
||||
sessionStorage.setItem("auth_token", token)
|
||||
sessionStorage.setItem(
|
||||
"auth_user",
|
||||
JSON.stringify({
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
token: testUser.accessToken,
|
||||
user: testUser,
|
||||
}
|
||||
)
|
||||
|
||||
// Reload so the app reads the token from sessionStorage
|
||||
await page.reload({ waitUntil: "domcontentloaded" })
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the SPA to be ready (appContainer rendered).
|
||||
* Returns the detected language prefix from the URL.
|
||||
*/
|
||||
export async function waitForSpaReady(page: Page): Promise<string> {
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
return match?.[1] || "de"
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route, prepending the current language prefix.
|
||||
*/
|
||||
export async function navigateToRoute(page: Page, routePath: string): Promise<void> {
|
||||
const url = page.url()
|
||||
const match = url.match(/\/([a-z]{2})(\/|$)/)
|
||||
const lang = match?.[1] || "de"
|
||||
const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}`
|
||||
await page.goto(fullPath)
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an SPA link and verify no full page reload occurred.
|
||||
*/
|
||||
export async function clickSpaLink(page: Page, linkSelector: string): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__spa_navigation_marker = true
|
||||
})
|
||||
await page.locator(linkSelector).first().click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
const markerExists = await page.evaluate(() => {
|
||||
return (window as any).__spa_navigation_marker === true
|
||||
})
|
||||
if (!markerExists) {
|
||||
throw new Error(`SPA navigation failed: full page reload detected when clicking "${linkSelector}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export { expect, API_BASE, type Page }
|
||||
39
tests/e2e/home.spec.ts
Normal file
39
tests/e2e/home.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect, waitForSpaReady } from "./fixtures"
|
||||
|
||||
test.describe("Home Page", () => {
|
||||
test("should load the start page", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
const lang = await waitForSpaReady(page)
|
||||
expect(lang).toBe("de")
|
||||
})
|
||||
|
||||
test("should have a visible header with navigation", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
const header = page.locator("header")
|
||||
await expect(header).toBeVisible()
|
||||
|
||||
const nav = header.locator("nav")
|
||||
await expect(nav).toBeVisible()
|
||||
})
|
||||
|
||||
test("should have language prefix in URL", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
expect(page.url()).toContain("/de")
|
||||
})
|
||||
|
||||
test("should switch language to English", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
// Click on English language link
|
||||
const enLink = page.locator('a[href*="/en"]').first()
|
||||
if (await enLink.isVisible()) {
|
||||
await enLink.click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
expect(page.url()).toContain("/en")
|
||||
}
|
||||
})
|
||||
})
|
||||
23
tests/fixtures/test-constants.ts
vendored
Normal file
23
tests/fixtures/test-constants.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Zentrale Test-Konstanten für alle Tests.
|
||||
*
|
||||
* Passe diese Werte an dein Projekt an:
|
||||
* - TEST_USER: E-Mail/Passwort für den E2E-Test-User
|
||||
* - ADMIN_TOKEN: Token aus api/config.yml.env
|
||||
* - API_BASE: API-Pfad (Standard: /api)
|
||||
*/
|
||||
|
||||
export const TEST_USER = {
|
||||
email: "playwright-e2e@test.example.com",
|
||||
password: "PlaywrightTest1",
|
||||
firstName: "Playwright",
|
||||
lastName: "E2E",
|
||||
} as const
|
||||
|
||||
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "CHANGE_ME"
|
||||
export const API_BASE = "/api"
|
||||
|
||||
export const BASIC_AUTH = {
|
||||
username: process.env.BASIC_AUTH_USER || "web",
|
||||
password: process.env.BASIC_AUTH_PASS || "web",
|
||||
} as const
|
||||
49
tests/global-setup.ts
Normal file
49
tests/global-setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Playwright Global Setup
|
||||
*
|
||||
* Runs once before all tests. Use this to:
|
||||
* - Seed test data via API action hooks
|
||||
* - Create test users
|
||||
* - Verify the dev environment is accessible
|
||||
*
|
||||
* Customize the setup_testing action call for your project,
|
||||
* or remove it if not needed.
|
||||
*/
|
||||
import { request } from "@playwright/test"
|
||||
import { API_BASE } from "./fixtures/test-constants"
|
||||
|
||||
async function globalSetup() {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
|
||||
// Verify dev environment is reachable
|
||||
const ctx = await request.newContext({
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
"User-Agent": "Playwright",
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await ctx.get("/")
|
||||
if (!res.ok()) {
|
||||
console.warn(`⚠️ Dev environment at ${baseURL} returned ${res.status()}`)
|
||||
} else {
|
||||
console.log(`✅ Dev environment reachable at ${baseURL}`)
|
||||
}
|
||||
|
||||
// Uncomment and adapt for project-specific test data seeding:
|
||||
// const seedRes = await ctx.post(`${API_BASE}/action`, {
|
||||
// params: { cmd: "setup_testing" },
|
||||
// data: { scope: "all" },
|
||||
// headers: { Token: ADMIN_TOKEN },
|
||||
// })
|
||||
// if (seedRes.ok()) {
|
||||
// console.log("✅ Test data seeded")
|
||||
// }
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
37
tests/global-teardown.ts
Normal file
37
tests/global-teardown.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Playwright Global Teardown
|
||||
*
|
||||
* Runs once after all tests. Use this to:
|
||||
* - Clean up test data
|
||||
* - Dispose singleton API contexts
|
||||
*
|
||||
* Customize for your project's cleanup needs.
|
||||
*/
|
||||
import { cleanupAllTestData, disposeAdminApi } from "./api/helpers/admin-api"
|
||||
import { deleteAllEmails, disposeMailDev } from "./api/helpers/maildev"
|
||||
|
||||
async function globalTeardown() {
|
||||
const baseURL = process.env.CODING_URL || "https://localhost:3000"
|
||||
|
||||
// Clean up test users
|
||||
try {
|
||||
const result = await cleanupAllTestData(baseURL)
|
||||
if (result.users > 0) {
|
||||
console.log(`🧹 Cleanup: ${result.users} test users deleted`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Test data cleanup failed:", err)
|
||||
}
|
||||
|
||||
// Clean up MailDev emails (optional)
|
||||
try {
|
||||
await deleteAllEmails()
|
||||
} catch {
|
||||
// MailDev cleanup is optional
|
||||
}
|
||||
|
||||
// Dispose singleton API contexts
|
||||
await Promise.all([disposeAdminApi(), disposeMailDev()])
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
8
tsconfig.test.json
Normal file
8
tsconfig.test.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["tests/**/*", "playwright.config.ts"]
|
||||
}
|
||||
583
yarn.lock
583
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<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"
|
||||
|
||||
Reference in New Issue
Block a user