From ea550114f236b8e0835959f965c050c49f373c92 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Wed, 25 Feb 2026 20:12:13 +0100 Subject: [PATCH 01/20] feat: misc changes for quicker build and dpeloy times --- .github/workflows/stack-tests.yml | 14 +- backend/.dockerignore | 15 ++ backend/Dockerfile.base | 38 +++- backend/Dockerfile.test | 28 --- backend/pyproject.toml | 4 - backend/uv.lock | 57 ------ docs/architecture/frontend-build.md | 2 +- docs/operations/cicd.md | 6 +- frontend/package-lock.json | 297 +++++++++++++++++++++++----- frontend/package.json | 29 ++- frontend/rollup.config.js | 4 +- 11 files changed, 328 insertions(+), 166 deletions(-) delete mode 100644 backend/Dockerfile.test diff --git a/.github/workflows/stack-tests.yml b/.github/workflows/stack-tests.yml index eb8c0a80..cba260c0 100644 --- a/.github/workflows/stack-tests.yml +++ b/.github/workflows/stack-tests.yml @@ -167,10 +167,18 @@ jobs: if: steps.base-cache.outputs.cache-hit != 'true' run: docker save integr8scode-base:latest | zstd -T0 -3 > /tmp/base-image.tar.zst - # ── Backend (depends on local base image) ─────────────── + # ── Backend (depends on local base image, GHA-cached) ── - name: Build backend image - run: | - docker build -t integr8scode-backend:latest --build-context base=docker-image://integr8scode-base:latest -f ./backend/Dockerfile ./backend + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + build-contexts: | + base=docker-image://integr8scode-base:latest + load: true + tags: integr8scode-backend:latest + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend # ── Utility images (GHA-cached, independent of base) ──────────── - name: Build cert-generator image diff --git a/backend/.dockerignore b/backend/.dockerignore index 5a9bec3f..75f3e4d7 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -23,3 +23,18 @@ htmlcov/ # Local dev files *.log .DS_Store + +# Tests and docs (not needed in production image) +tests/ +docs/ +*.md + +# Secrets and test/override configs (not needed in image) +secrets.toml +secrets.*.toml +config.test.toml +config.*.toml +!config.toml + +# Dead code detection whitelist +vulture_whitelist.py diff --git a/backend/Dockerfile.base b/backend/Dockerfile.base index 51556eb7..93f07fd7 100644 --- a/backend/Dockerfile.base +++ b/backend/Dockerfile.base @@ -1,14 +1,15 @@ # Shared base image for all backend services # Contains: Python, system deps, uv, and all Python dependencies -FROM python:3.12-slim +# Multi-stage build: gcc + dev headers only in builder, not in final image + +FROM python:3.12-slim AS builder WORKDIR /app -# Install OS security patches + system dependencies needed by any service -RUN apt-get update && apt-get upgrade -y \ +# Install build-time dependencies (gcc + dev headers for C extensions) +RUN apt-get update \ && apt-get install -y --no-install-recommends \ gcc \ - curl \ libsnappy-dev \ liblzma-dev \ && rm -rf /var/lib/apt/lists/* @@ -16,11 +17,36 @@ RUN apt-get update && apt-get upgrade -y \ # Install uv (using Docker Hub mirror - ghcr.io has rate limiting issues) COPY --from=astral/uv:latest /uv /uvx /bin/ +# Pre-compile bytecode for faster startup; copy mode avoids symlink issues with cache mounts +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + # Copy dependency files COPY pyproject.toml uv.lock ./ -# Install Python dependencies (production only) -RUN uv sync --locked --no-dev --no-install-project +# Install Python dependencies with BuildKit cache mount for faster rebuilds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + +FROM python:3.12-slim + +WORKDIR /app + +# Install only runtime dependencies (shared libs, no -dev headers, no gcc) +RUN apt-get update && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + curl \ + libsnappy1v5 \ + liblzma5 \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv from builder (avoids second Docker Hub pull, guarantees version consistency) +COPY --from=builder /bin/uv /bin/uvx /bin/ + +# Copy pre-built virtual environment from builder stage +COPY --from=builder /app/.venv /app/.venv + +# Copy dependency files (needed for uv to recognize the project) +COPY pyproject.toml uv.lock ./ # Set paths: PYTHONPATH for imports, PATH for venv binaries (no uv run needed at runtime) ENV PYTHONPATH=/app diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test deleted file mode 100644 index 515d95fa..00000000 --- a/backend/Dockerfile.test +++ /dev/null @@ -1,28 +0,0 @@ -# Test runner container - lightweight, uses same network as services -FROM python:3.12-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Install uv -COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /uvx /bin/ - -# Copy dependency files -COPY pyproject.toml uv.lock ./ - -# Install Python dependencies (including dev deps for testing) -RUN uv sync --frozen - -# Copy application code -COPY . . - -# Set Python path -ENV PYTHONPATH=/app - -# Default command runs all tests -CMD ["uv", "run", "pytest", "-v", "--tb=short"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9f0fa172..4464759c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -63,12 +63,10 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-http==1.39.1", "opentelemetry-exporter-prometheus==0.60b1", "opentelemetry-instrumentation==0.60b1", - "opentelemetry-instrumentation-asgi==0.60b1", "opentelemetry-instrumentation-fastapi==0.60b1", "opentelemetry-instrumentation-httpx==0.60b1", "opentelemetry-instrumentation-logging==0.60b1", "opentelemetry-instrumentation-pymongo==0.60b1", - "opentelemetry-instrumentation-redis==0.60b1", "opentelemetry-propagator-b3==1.39.1", "opentelemetry-proto==1.39.1", "opentelemetry-sdk==1.39.1", @@ -108,7 +106,6 @@ dependencies = [ "sortedcontainers==2.4.0", "sse-starlette==3.2.0", "starlette==0.49.1", - "tiktoken==0.11.0", "tomli==2.0.2", "typing_extensions==4.12.2", "urllib3==2.6.3", @@ -134,7 +131,6 @@ packages = ["app", "workers"] [dependency-groups] dev = [ - "async-asgi-testclient>=1.4.11", "coverage==7.13.0", "hypothesis==6.151.6", "iniconfig==2.3.0", diff --git a/backend/uv.lock b/backend/uv.lock index 3c799113..32fd2be1 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -210,16 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] -[[package]] -name = "async-asgi-testclient" -version = "1.4.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "multidict" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/9a/0eb3fd37d4f9ad1e9b2b6d6b91357d3ebf7534271c32e343185a5d204903/async-asgi-testclient-1.4.11.tar.gz", hash = "sha256:4449ac85d512d661998ec61f91c9ae01851639611d748d81ae7f816736551792", size = 11716, upload-time = "2022-06-13T09:30:07.279Z" } - [[package]] name = "async-timeout" version = "5.0.1" @@ -1104,12 +1094,10 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-pymongo" }, - { name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-propagator-b3" }, { name = "opentelemetry-proto" }, { name = "opentelemetry-sdk" }, @@ -1149,7 +1137,6 @@ dependencies = [ { name = "sse-starlette" }, { name = "starlette" }, { name = "structlog" }, - { name = "tiktoken" }, { name = "tomli" }, { name = "typing-extensions" }, { name = "urllib3" }, @@ -1163,7 +1150,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "async-asgi-testclient" }, { name = "coverage" }, { name = "hypothesis" }, { name = "iniconfig" }, @@ -1248,12 +1234,10 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.39.1" }, { name = "opentelemetry-exporter-prometheus", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation", specifier = "==0.60b1" }, - { name = "opentelemetry-instrumentation-asgi", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-fastapi", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-httpx", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-logging", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-pymongo", specifier = "==0.60b1" }, - { name = "opentelemetry-instrumentation-redis", specifier = "==0.60b1" }, { name = "opentelemetry-propagator-b3", specifier = "==1.39.1" }, { name = "opentelemetry-proto", specifier = "==1.39.1" }, { name = "opentelemetry-sdk", specifier = "==1.39.1" }, @@ -1293,7 +1277,6 @@ requires-dist = [ { name = "sse-starlette", specifier = "==3.2.0" }, { name = "starlette", specifier = "==0.49.1" }, { name = "structlog", specifier = "==25.5.0" }, - { name = "tiktoken", specifier = "==0.11.0" }, { name = "tomli", specifier = "==2.0.2" }, { name = "typing-extensions", specifier = "==4.12.2" }, { name = "urllib3", specifier = "==2.6.3" }, @@ -1307,7 +1290,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "async-asgi-testclient", specifier = ">=1.4.11" }, { name = "coverage", specifier = "==7.13.0" }, { name = "hypothesis", specifier = "==6.151.6" }, { name = "iniconfig", specifier = "==2.3.0" }, @@ -2074,21 +2056,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/9d/9fe3ccbec82c20d7ae8d14af47f630fdc6066afda5b18743ceb13f4be997/opentelemetry_instrumentation_pymongo-0.60b1-py3-none-any.whl", hash = "sha256:179cff51e4b018fa92f6acb7aea1dfc5440364e66561db9d5ca0dc0227e0a6dc", size = 11419, upload-time = "2025-12-11T13:36:17.073Z" }, ] -[[package]] -name = "opentelemetry-instrumentation-redis" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/1e/225364fab4db793f6f5024ed9f3dd53774fd7c7c21fa242460234dcdf8d9/opentelemetry_instrumentation_redis-0.60b1.tar.gz", hash = "sha256:ecafa8f81c88917b59f0d842fb3d157f3a8edc71fb4b85bebca3bc19432ce7b8", size = 14774, upload-time = "2025-12-11T13:37:11.201Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/bd/d55d3b34fd49df08d9d9fa3701dff0051b216e2c7e9adaaa4ff6aa1de8d7/opentelemetry_instrumentation_redis-0.60b1-py3-none-any.whl", hash = "sha256:33bef0ff9af6f2d88de90c1cd7e25675c10a16d4f9ee5ae7592b28bb08b78139", size = 15502, upload-time = "2025-12-11T13:36:21.481Z" }, -] - [[package]] name = "opentelemetry-propagator-b3" version = "1.39.1" @@ -2966,30 +2933,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] -[[package]] -name = "tiktoken" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/86/ad0155a37c4f310935d5ac0b1ccf9bdb635dcb906e0a9a26b616dd55825a/tiktoken-0.11.0.tar.gz", hash = "sha256:3c518641aee1c52247c2b97e74d8d07d780092af79d5911a6ab5e79359d9b06a", size = 37648, upload-time = "2025-08-08T23:58:08.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9e/eceddeffc169fc75fe0fd4f38471309f11cb1906f9b8aa39be4f5817df65/tiktoken-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fd9e6b23e860973cf9526544e220b223c60badf5b62e80a33509d6d40e6c8f5d", size = 1055199, upload-time = "2025-08-08T23:57:45.076Z" }, - { url = "https://files.pythonhosted.org/packages/4f/cf/5f02bfefffdc6b54e5094d2897bc80efd43050e5b09b576fd85936ee54bf/tiktoken-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76d53cee2da71ee2731c9caa747398762bda19d7f92665e882fef229cb0b5b", size = 996655, upload-time = "2025-08-08T23:57:46.304Z" }, - { url = "https://files.pythonhosted.org/packages/65/8e/c769b45ef379bc360c9978c4f6914c79fd432400a6733a8afc7ed7b0726a/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef72aab3ea240646e642413cb363b73869fed4e604dcfd69eec63dc54d603e8", size = 1128867, upload-time = "2025-08-08T23:57:47.438Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2d/4d77f6feb9292bfdd23d5813e442b3bba883f42d0ac78ef5fdc56873f756/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f929255c705efec7a28bf515e29dc74220b2f07544a8c81b8d69e8efc4578bd", size = 1183308, upload-time = "2025-08-08T23:57:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/7a/65/7ff0a65d3bb0fc5a1fb6cc71b03e0f6e71a68c5eea230d1ff1ba3fd6df49/tiktoken-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61f1d15822e4404953d499fd1dcc62817a12ae9fb1e4898033ec8fe3915fdf8e", size = 1244301, upload-time = "2025-08-08T23:57:49.642Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6e/5b71578799b72e5bdcef206a214c3ce860d999d579a3b56e74a6c8989ee2/tiktoken-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:45927a71ab6643dfd3ef57d515a5db3d199137adf551f66453be098502838b0f", size = 884282, upload-time = "2025-08-08T23:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/a9034bcee638716d9310443818d73c6387a6a96db93cbcb0819b77f5b206/tiktoken-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a5f3f25ffb152ee7fec78e90a5e5ea5b03b4ea240beed03305615847f7a6ace2", size = 1055339, upload-time = "2025-08-08T23:57:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/f1/91/9922b345f611b4e92581f234e64e9661e1c524875c8eadd513c4b2088472/tiktoken-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc6e9ad16a2a75b4c4be7208055a1f707c9510541d94d9cc31f7fbdc8db41d8", size = 997080, upload-time = "2025-08-08T23:57:53.442Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9d/49cd047c71336bc4b4af460ac213ec1c457da67712bde59b892e84f1859f/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a0517634d67a8a48fd4a4ad73930c3022629a85a217d256a6e9b8b47439d1e4", size = 1128501, upload-time = "2025-08-08T23:57:54.808Z" }, - { url = "https://files.pythonhosted.org/packages/52/d5/a0dcdb40dd2ea357e83cb36258967f0ae96f5dd40c722d6e382ceee6bba9/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fb4effe60574675118b73c6fbfd3b5868e5d7a1f570d6cc0d18724b09ecf318", size = 1182743, upload-time = "2025-08-08T23:57:56.307Z" }, - { url = "https://files.pythonhosted.org/packages/3b/17/a0fc51aefb66b7b5261ca1314afa83df0106b033f783f9a7bcbe8e741494/tiktoken-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94f984c9831fd32688aef4348803b0905d4ae9c432303087bae370dc1381a2b8", size = 1244057, upload-time = "2025-08-08T23:57:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/50/79/bcf350609f3a10f09fe4fc207f132085e497fdd3612f3925ab24d86a0ca0/tiktoken-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2177ffda31dec4023356a441793fed82f7af5291120751dee4d696414f54db0c", size = 883901, upload-time = "2025-08-08T23:57:59.359Z" }, -] - [[package]] name = "tomli" version = "2.0.2" diff --git a/docs/architecture/frontend-build.md b/docs/architecture/frontend-build.md index 0c971399..ee7e3aa4 100644 --- a/docs/architecture/frontend-build.md +++ b/docs/architecture/frontend-build.md @@ -185,7 +185,7 @@ When backend endpoints change, update the backend and restart it, fetch the new ## Production build -The production build runs `npm run build`, which compiles TypeScript with source maps, processes Svelte components in production mode without dev warnings, extracts and minifies CSS, splits code into chunks, minifies JavaScript with Terser removing console.log calls, and outputs everything to `public/build/`. The Docker build copies `public/` to nginx, which serves static files and proxies `/api/` to the backend. +The production build runs `npm run build`, which compiles TypeScript (without source maps — source maps are only generated in development), processes Svelte components in production mode without dev warnings, extracts and minifies CSS, splits code into chunks, minifies JavaScript with Terser removing console.log calls, and outputs everything to `public/build/`. The Docker build copies `public/` to nginx, which serves static files and proxies `/api/` to the backend. ## Troubleshooting diff --git a/docs/operations/cicd.md b/docs/operations/cicd.md index 87e764c4..3255069f 100644 --- a/docs/operations/cicd.md +++ b/docs/operations/cicd.md @@ -103,7 +103,7 @@ graph TD end subgraph "Phase 2: Build" - C["Build & Push 5 Images to GHCR"] + C["Build & Push 4 Images to GHCR"] end subgraph "Phase 3: E2E (parallel runners)" @@ -145,8 +145,8 @@ are needed. All 4 images are scanned by Trivy and promoted to `latest` in the [Docker Scan & Promote](#docker-scan-promote) workflow. The base image is cached separately as a zstd-compressed tarball since its dependencies rarely change. The backend -image depends on it via `--build-context base=docker-image://integr8scode-base:latest`. Utility and frontend images -use GHA layer caching. +image uses `docker/build-push-action@v6` with GHA layer cache (`scope=backend`, `mode=max`), so intermediate layers +are reused when only application code changes. Utility and frontend images also use GHA layer caching. All 4 images are pushed to GHCR in parallel, with each push tracked by PID so individual failures are reported: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7dd34f0b..6544b54b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "svelte-app", "version": "1.0.0", "dependencies": { - "@babel/runtime": "^7.28.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-go": "^6.0.1", @@ -21,11 +20,6 @@ "@codemirror/view": "^6.39.15", "@lucide/svelte": "^0.575.0", "@mateothegreat/svelte5-router": "^2.16.19", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-replace": "^6.0.1", - "@rollup/plugin-terser": "^0.4.4", "@uiw/codemirror-theme-bbedit": "^4.21.25", "@uiw/codemirror-theme-dracula": "^4.24.2", "@uiw/codemirror-theme-github": "^4.23.13", @@ -34,16 +28,7 @@ "ansi-to-html": "^0.7.2", "codemirror": "^6.0.1", "dompurify": "^3.2.0", - "dotenv": "^17.3.1", - "postcss": "^8.4.47", - "rollup": "^4.59.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-svelte": "^7.2.2", - "sirv-cli": "^3.0.1", "svelte": "^5.50.0", - "svelte-preprocess": "^6.0.3", "svelte-sonner": "^1.0.7" }, "devDependencies": { @@ -52,6 +37,11 @@ "@hey-api/openapi-ts": "^0.92.4", "@playwright/test": "^1.52.0", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", @@ -62,6 +52,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.17", + "dotenv": "^17.3.1", "eslint": "^10.0.1", "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", @@ -69,10 +60,18 @@ "http-proxy": "^1.18.1", "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", + "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "rollup": "^4.59.0", + "rollup-plugin-css-only": "^4.3.0", + "rollup-plugin-livereload": "^2.0.0", + "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-serve": "^3.0.0", + "rollup-plugin-svelte": "^7.2.2", + "sirv-cli": "^3.0.1", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^1.4.1", + "svelte-preprocess": "^6.0.3", "tailwindcss": "^4.1.13", "tslib": "^2.8.1", "typescript": "^5.7.2", @@ -1243,6 +1242,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1358,7 +1358,8 @@ "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true }, "node_modules/@rollup/plugin-alias": { "version": "6.0.0", @@ -1381,6 +1382,7 @@ "version": "29.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", @@ -1406,6 +1408,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.1.0" }, @@ -1425,6 +1428,7 @@ "version": "16.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", @@ -1448,6 +1452,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" @@ -1468,6 +1473,7 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", @@ -1515,6 +1521,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -1539,6 +1546,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -1551,6 +1559,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1563,6 +1572,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1575,6 +1585,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1587,6 +1598,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1599,6 +1611,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1611,6 +1624,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1623,6 +1637,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1635,6 +1650,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1647,6 +1663,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1659,6 +1676,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1671,6 +1689,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1683,6 +1702,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1695,6 +1715,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1707,6 +1728,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1719,6 +1741,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1731,6 +1754,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1743,6 +1767,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1755,6 +1780,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1767,6 +1793,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1779,6 +1806,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1791,6 +1819,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1803,6 +1832,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1815,6 +1845,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1827,6 +1858,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2259,6 +2291,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -2305,7 +2338,8 @@ "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true }, "node_modules/@types/trusted-types": { "version": "2.0.7", @@ -2855,6 +2889,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2883,6 +2918,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -2895,6 +2931,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -2969,6 +3006,7 @@ "version": "2.9.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz", "integrity": "sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==", + "dev": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2986,6 +3024,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "engines": { "node": ">=8" }, @@ -3020,7 +3059,8 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true }, "node_modules/brace-expansion": { "version": "2.0.2", @@ -3035,6 +3075,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -3046,6 +3087,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3077,7 +3119,8 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true }, "node_modules/bundle-name": { "version": "4.1.0", @@ -3192,6 +3235,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -3203,6 +3247,7 @@ "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3231,6 +3276,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3246,6 +3292,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3300,6 +3347,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3310,7 +3358,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/color-support": { "version": "1.1.3", @@ -3324,12 +3373,14 @@ "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "engines": { "node": ">= 10" } @@ -3337,12 +3388,14 @@ "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true }, "node_modules/concat-with-sourcemaps": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, "dependencies": { "source-map": "^0.6.1" } @@ -3366,6 +3419,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", + "dev": true, "engines": { "node": ">=4" } @@ -3452,6 +3506,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14" }, @@ -3463,6 +3518,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -3491,6 +3547,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, "engines": { "node": ">= 6" }, @@ -3508,6 +3565,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -3519,6 +3577,7 @@ "version": "5.1.15", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, "dependencies": { "cssnano-preset-default": "^5.2.14", "lilconfig": "^2.0.3", @@ -3539,6 +3598,7 @@ "version": "5.2.14", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, "dependencies": { "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", @@ -3581,6 +3641,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -3592,6 +3653,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { "node": ">= 6" } @@ -3600,6 +3662,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, "dependencies": { "css-tree": "^1.1.2" }, @@ -3611,6 +3674,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -3622,7 +3686,8 @@ "node_modules/csso/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/cssstyle": { "version": "6.0.1", @@ -3691,6 +3756,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -3805,6 +3871,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -3818,6 +3885,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -3829,6 +3897,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, "dependencies": { "domelementtype": "^2.2.0" }, @@ -3851,6 +3920,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -3864,6 +3934,7 @@ "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, "engines": { "node": ">=12" }, @@ -3900,7 +3971,8 @@ "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==" + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -4013,6 +4085,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "engines": { "node": ">=6" } @@ -4345,7 +4418,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true }, "node_modules/esutils": { "version": "2.0.3", @@ -4368,7 +4442,8 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true }, "node_modules/expect-type": { "version": "1.3.0", @@ -4450,6 +4525,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -4478,6 +4554,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -4599,6 +4676,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -4612,6 +4690,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4620,6 +4699,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "dev": true, "dependencies": { "loader-utils": "^3.2.0" } @@ -4652,6 +4732,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, "engines": { "node": ">=8" }, @@ -4693,6 +4774,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -4734,6 +4816,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4754,6 +4837,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -4905,12 +4989,14 @@ "node_modules/icss-replace-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", - "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==" + "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", + "dev": true }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -4931,6 +5017,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, "dependencies": { "import-from": "^3.0.0" }, @@ -4942,6 +5029,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, "dependencies": { "resolve-from": "^5.0.0" }, @@ -4986,6 +5074,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -4997,6 +5086,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -5026,6 +5116,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5034,6 +5125,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -5074,12 +5166,14 @@ "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -5100,6 +5194,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, "dependencies": { "@types/estree": "*" } @@ -5272,6 +5367,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, "engines": { "node": ">=6" } @@ -5653,6 +5749,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, "engines": { "node": ">=10" } @@ -5661,6 +5758,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dev": true, "dependencies": { "chokidar": "^3.5.0", "livereload-js": "^3.3.1", @@ -5677,12 +5775,14 @@ "node_modules/livereload-js": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", - "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==" + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", + "dev": true }, "node_modules/loader-utils": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, "engines": { "node": ">= 12.13.0" } @@ -5691,6 +5791,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", + "dev": true, "engines": { "node": ">=6" } @@ -5718,17 +5819,20 @@ "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true }, "node_modules/lru-cache": { "version": "11.2.6", @@ -5958,6 +6062,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, "engines": { "node": ">=4" } @@ -5966,6 +6071,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, "engines": { "node": ">=10" } @@ -5980,6 +6086,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -6017,7 +6124,8 @@ "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==" + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true }, "node_modules/nodemailer": { "version": "7.0.13", @@ -6032,6 +6140,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6040,6 +6149,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "engines": { "node": ">=10" }, @@ -6051,6 +6161,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -6179,12 +6290,14 @@ "node_modules/opts": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==" + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", + "dev": true }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, "engines": { "node": ">=4" } @@ -6223,6 +6336,7 @@ "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" @@ -6238,6 +6352,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, "dependencies": { "p-finally": "^1.0.0" }, @@ -6299,7 +6414,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-to-regexp": { "version": "8.3.0", @@ -6326,12 +6442,14 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "engines": { "node": ">=12" }, @@ -6343,6 +6461,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "dev": true, "engines": { "node": ">=10" }, @@ -6409,6 +6528,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6436,6 +6556,7 @@ "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" @@ -6448,6 +6569,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -6465,6 +6587,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -6480,6 +6603,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6491,6 +6615,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6502,6 +6627,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6513,6 +6639,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6540,6 +6667,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" @@ -6568,6 +6696,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { "node": ">= 6" } @@ -6576,6 +6705,7 @@ "version": "5.1.7", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^5.1.1" @@ -6591,6 +6721,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -6608,6 +6739,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6622,6 +6754,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, "dependencies": { "colord": "^2.9.1", "cssnano-utils": "^3.1.0", @@ -6638,6 +6771,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", @@ -6654,6 +6788,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -6668,6 +6803,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.3.1.tgz", "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", + "dev": true, "dependencies": { "generic-names": "^4.0.0", "icss-replace-symbols": "^1.1.0", @@ -6686,6 +6822,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -6697,6 +6834,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", @@ -6713,6 +6851,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6725,6 +6864,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -6739,6 +6879,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6751,6 +6892,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0" }, @@ -6765,6 +6907,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6776,6 +6919,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6790,6 +6934,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6804,6 +6949,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6818,6 +6964,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6832,6 +6979,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6846,6 +6994,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -6861,6 +7010,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, "dependencies": { "normalize-url": "^6.0.1", "postcss-value-parser": "^4.2.0" @@ -6876,6 +7026,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6890,6 +7041,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, "dependencies": { "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" @@ -6905,6 +7057,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" @@ -6920,6 +7073,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6986,6 +7140,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6998,6 +7153,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^2.7.0" @@ -7013,6 +7169,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -7026,7 +7183,8 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true }, "node_modules/powershell-utils": { "version": "0.1.0", @@ -7079,6 +7237,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", "integrity": "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==", + "dev": true, "engines": { "node": ">=0.12" } @@ -7124,6 +7283,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -7172,6 +7332,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -7183,6 +7344,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -7222,6 +7384,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", @@ -7241,6 +7404,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "engines": { "node": ">=8" } @@ -7249,6 +7413,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, "engines": { "node": ">=10" } @@ -7257,6 +7422,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7300,6 +7466,7 @@ "version": "4.5.5", "resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.5.tgz", "integrity": "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ==", + "dev": true, "dependencies": { "@rollup/pluginutils": "5" }, @@ -7314,6 +7481,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz", "integrity": "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA==", + "dev": true, "dependencies": { "livereload": "^0.9.1" }, @@ -7325,6 +7493,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", "integrity": "sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==", + "dev": true, "dependencies": { "chalk": "^4.1.0", "concat-with-sourcemaps": "^1.1.0", @@ -7361,6 +7530,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.3.tgz", "integrity": "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^4.1.0", "resolve.exports": "^2.0.0" @@ -7377,6 +7547,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" @@ -7389,6 +7560,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -7400,6 +7572,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, "dependencies": { "estree-walker": "^0.6.1" } @@ -7407,7 +7580,8 @@ "node_modules/rollup-pluginutils/node_modules/estree-walker": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==" + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true }, "node_modules/router": { "version": "2.2.0", @@ -7456,6 +7630,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, "dependencies": { "mri": "^1.1.0" }, @@ -7467,6 +7642,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -7485,7 +7661,8 @@ "node_modules/safe-identifier": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", - "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==" + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -7509,6 +7686,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", + "dev": true, "engines": { "node": ">=6" } @@ -7555,6 +7733,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -7699,6 +7878,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -7712,6 +7892,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", + "dev": true, "dependencies": { "console-clear": "^1.1.0", "get-port": "^5.1.1", @@ -7732,12 +7913,14 @@ "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "dev": true }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7746,6 +7929,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7754,6 +7938,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7763,7 +7948,8 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true }, "node_modules/stackback": { "version": "0.0.2", @@ -7789,7 +7975,8 @@ "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", - "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==" + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "dev": true }, "node_modules/strip-indent": { "version": "3.0.0", @@ -7806,7 +7993,8 @@ "node_modules/style-inject": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", - "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==" + "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", + "dev": true }, "node_modules/style-mod": { "version": "4.1.3", @@ -7817,6 +8005,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" @@ -7832,6 +8021,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7843,6 +8033,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -7984,6 +8175,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", "integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==", + "dev": true, "hasInstallScript": true, "engines": { "node": ">= 18.0.0" @@ -8057,6 +8249,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -8077,6 +8270,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -8088,7 +8282,8 @@ "node_modules/svgo/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -8119,6 +8314,7 @@ "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8135,7 +8331,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -8147,6 +8344,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", + "dev": true, "engines": { "node": ">=4" } @@ -8207,6 +8405,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -8227,6 +8426,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, "engines": { "node": ">=6" } @@ -8312,7 +8512,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8343,6 +8543,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8380,7 +8581,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/vary": { "version": "1.1.2", @@ -8655,6 +8857,7 @@ "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, "engines": { "node": ">=8.3.0" }, diff --git a/frontend/package.json b/frontend/package.json index d5dc29f1..822e3406 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@babel/runtime": "^7.28.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-go": "^6.0.1", @@ -30,11 +29,6 @@ "@codemirror/view": "^6.39.15", "@lucide/svelte": "^0.575.0", "@mateothegreat/svelte5-router": "^2.16.19", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-replace": "^6.0.1", - "@rollup/plugin-terser": "^0.4.4", "@uiw/codemirror-theme-bbedit": "^4.21.25", "@uiw/codemirror-theme-dracula": "^4.24.2", "@uiw/codemirror-theme-github": "^4.23.13", @@ -43,16 +37,7 @@ "ansi-to-html": "^0.7.2", "codemirror": "^6.0.1", "dompurify": "^3.2.0", - "dotenv": "^17.3.1", - "postcss": "^8.4.47", - "rollup": "^4.59.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-svelte": "^7.2.2", - "sirv-cli": "^3.0.1", "svelte": "^5.50.0", - "svelte-preprocess": "^6.0.3", "svelte-sonner": "^1.0.7" }, "devDependencies": { @@ -61,6 +46,11 @@ "@hey-api/openapi-ts": "^0.92.4", "@playwright/test": "^1.52.0", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", @@ -71,6 +61,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.17", + "dotenv": "^17.3.1", "eslint": "^10.0.1", "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", @@ -78,10 +69,18 @@ "http-proxy": "^1.18.1", "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", + "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "rollup": "^4.59.0", + "rollup-plugin-css-only": "^4.3.0", + "rollup-plugin-livereload": "^2.0.0", + "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-serve": "^3.0.0", + "rollup-plugin-svelte": "^7.2.2", + "sirv-cli": "^3.0.1", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^1.4.1", + "svelte-preprocess": "^6.0.3", "tailwindcss": "^4.1.13", "tslib": "^2.8.1", "typescript": "^5.7.2", diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index c995cacc..cd128779 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -131,7 +131,7 @@ function startServer() { export default { input: 'src/main.ts', output: { - sourcemap: true, + sourcemap: !production, format: 'es', name: 'app', dir: 'public/build', @@ -178,7 +178,7 @@ export default { minimize: false, }), typescript({ - sourceMap: true, + sourceMap: !production, inlineSources: !production }), json(), From d2e6b1ad5fe90618a3db2100e61f3b9b179b14d3 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Wed, 25 Feb 2026 20:56:36 +0100 Subject: [PATCH 02/20] fix: fixture for user in frontend/unit tests --- frontend/src/__tests__/test-utils.ts | 6 ++ .../components/__tests__/ErrorDisplay.test.ts | 4 +- .../src/components/__tests__/Header.test.ts | 42 +++++------ .../__tests__/NotificationCenter.test.ts | 53 +++++++------ .../__tests__/EventDetailsModal.test.ts | 6 +- .../events/__tests__/EventFilters.test.ts | 5 +- .../events/__tests__/EventsTable.test.ts | 6 +- .../__tests__/ReplayPreviewModal.test.ts | 4 +- .../__tests__/ReplayProgressBanner.test.ts | 3 +- .../editor/__tests__/LanguageSelect.test.ts | 24 +++--- .../editor/__tests__/OutputPanel.test.ts | 4 +- .../editor/__tests__/ResourceLimits.test.ts | 6 +- .../editor/__tests__/SavedScripts.test.ts | 17 ++--- frontend/src/routes/__tests__/Editor.test.ts | 4 +- frontend/src/routes/__tests__/Login.test.ts | 4 +- .../routes/__tests__/Notifications.test.ts | 5 +- .../src/routes/__tests__/Register.test.ts | 4 +- .../src/routes/__tests__/Settings.test.ts | 4 +- .../admin/__tests__/AdminEvents.test.ts | 75 +------------------ .../admin/__tests__/AdminExecutions.test.ts | 9 +-- .../routes/admin/__tests__/AdminSagas.test.ts | 16 +--- .../admin/__tests__/AdminSettings.test.ts | 4 +- .../routes/admin/__tests__/AdminUsers.test.ts | 33 +------- 23 files changed, 84 insertions(+), 254 deletions(-) diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index a14785ea..ec95d283 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -7,6 +7,7 @@ */ import { vi, type Mock } from 'vitest'; +import userEvent from '@testing-library/user-event'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { ExecutionCompletedEvent, @@ -19,6 +20,11 @@ import type { EventType, } from '$lib/api'; +export type UserEventInstance = ReturnType; + +export const user: UserEventInstance = userEvent.setup(); +export const userWithTimers: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + // ============================================================================ // Mock Svelte Component Factory // ============================================================================ diff --git a/frontend/src/components/__tests__/ErrorDisplay.test.ts b/frontend/src/components/__tests__/ErrorDisplay.test.ts index b9592528..c2ae5253 100644 --- a/frontend/src/components/__tests__/ErrorDisplay.test.ts +++ b/frontend/src/components/__tests__/ErrorDisplay.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import ErrorDisplay from '$components/ErrorDisplay.svelte'; describe('ErrorDisplay', () => { @@ -102,7 +102,6 @@ describe('ErrorDisplay', () => { }); it('reloads page when Reload button clicked', async () => { - const user = userEvent.setup(); render(ErrorDisplay, { props: { error: 'Error' } }); const reloadButton = screen.getByRole('button', { name: /Reload Page/i }); @@ -112,7 +111,6 @@ describe('ErrorDisplay', () => { }); it('navigates to home when Go to Home clicked', async () => { - const user = userEvent.setup(); render(ErrorDisplay, { props: { error: 'Error' } }); const homeButton = screen.getByRole('button', { name: /Go to Home/i }); diff --git a/frontend/src/components/__tests__/Header.test.ts b/frontend/src/components/__tests__/Header.test.ts index 322d3450..2c882754 100644 --- a/frontend/src/components/__tests__/Header.test.ts +++ b/frontend/src/components/__tests__/Header.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { suppressConsoleError } from '$test/test-utils'; +import { user, suppressConsoleError } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ mockAuthStore: { @@ -45,21 +44,17 @@ const setAuth = (isAuth: boolean, username: string | null = null, role: string | mocks.mockAuthStore.userEmail = email; }; -const openUserDropdown = async (_username: string) => { - const user = userEvent.setup(); - render(Header); - await user.click(screen.getByRole('button', { name: 'User menu' })); - return user; -}; +describe('Header', () => { + const openUserDropdown = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'User menu' })); + }; -const openMobileMenu = async () => { - const user = userEvent.setup(); - render(Header); - await user.click(screen.getByRole('button', { name: 'Open menu' })); - return { user }; -}; + const openMobileMenu = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'Open menu' })); + }; -describe('Header', () => { let originalInnerWidth: number; beforeEach(() => { @@ -96,7 +91,6 @@ describe('Header', () => { describe('theme toggle', () => { it('renders and calls toggleTheme when clicked', async () => { - const user = userEvent.setup(); render(Header); const themeButton = screen.getByTitle('Toggle theme'); expect(themeButton).toBeInTheDocument(); @@ -131,7 +125,7 @@ describe('Header', () => { beforeEach(() => { setAuth(true, 'testuser', 'user', 'test@example.com'); }); it('shows username and opens dropdown with user info', async () => { - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getAllByText(/testuser/i).length).toBeGreaterThan(0); expect(screen.getByText('test@example.com')).toBeInTheDocument(); @@ -143,12 +137,12 @@ describe('Header', () => { it('shows "No email set" when email is null', async () => { mocks.mockAuthStore.userEmail = null; - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByText('No email set')).toBeInTheDocument(); }); }); it('logout calls logout and redirects', async () => { - const user = await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Logout/i })); expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); @@ -160,7 +154,7 @@ describe('Header', () => { beforeEach(() => { setAuth(true, 'admin', 'admin', 'admin@example.com'); }); it('shows Admin indicator and button in dropdown', async () => { - await openUserDropdown('admin'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByText('(Admin)')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); @@ -168,7 +162,7 @@ describe('Header', () => { }); it('Admin button navigates to admin panel', async () => { - const user = await openUserDropdown('admin'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^Admin$/ })); await waitFor(() => { expect(mocks.mockGoto).toHaveBeenCalledWith('/admin/events'); }); @@ -215,7 +209,7 @@ describe('Header', () => { it('closes dropdown when clicking a menu item', async () => { const restoreConsole = suppressConsoleError(); setAuth(true, 'testuser', 'user'); - const user = await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('link', { name: /Settings/i })); await waitFor(() => { expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); }); @@ -224,7 +218,7 @@ describe('Header', () => { it('closes dropdown when clicking outside', async () => { setAuth(true, 'testuser', 'user'); - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); // Click outside the dropdown @@ -267,7 +261,7 @@ describe('Header', () => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); setAuth(true, 'mobileuser', 'user'); - const { user } = await openMobileMenu(); + await openMobileMenu(); await waitFor(() => { const mobileMenu = document.body.querySelector('.lg\\:hidden.absolute'); expect(mobileMenu?.textContent).toContain('Logout'); diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index 4e1c0578..beb88424 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user, userWithTimers, type UserEventInstance } from '$test/test-utils'; // Types for mock notification state interface MockNotification { notification_id: string; @@ -77,20 +77,6 @@ const setNotifications = (notifications: MockNotification[]) => { mocks.mockNotificationStore.unreadCount = notifications.filter(n => n.status !== 'read').length; }; -const openDropdown = async () => { - const user = userEvent.setup(); - render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - return user; -}; - -const openDropdownWithContainer = async () => { - const user = userEvent.setup(); - const { container } = render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - return { user, container }; -}; - /** Mocks window.location.href for external URL testing */ const withMockedLocation = async (testFn: (mockHref: ReturnType) => Promise) => { const originalLocation = window.location; @@ -103,7 +89,7 @@ const withMockedLocation = async (testFn: (mockHref: ReturnType) = /** Interacts with a notification button via click or keyboard */ const interactWithButton = async ( - user: ReturnType, + user: UserEventInstance, button: HTMLElement, method: 'click' | 'keyboard' ) => { @@ -152,6 +138,17 @@ const interactionTestCases = [ // Tests describe('NotificationCenter', () => { + const openDropdown = async () => { + render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + }; + + const openDropdownWithContainer = async () => { + const { container } = render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + return { container }; + }; + beforeEach(() => { mocks.mockAuthStore.isAuthenticated = true; mocks.mockAuthStore.username = 'testuser'; @@ -206,7 +203,7 @@ describe('NotificationCenter', () => { }); it('navigates to /notifications when View all clicked', async () => { - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('View all notifications')); expect(mocks.mockGoto).toHaveBeenCalledWith('/notifications'); }); @@ -241,14 +238,14 @@ describe('NotificationCenter', () => { it('calls markAllAsRead when button clicked', async () => { setNotifications([createNotification()]); - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('Mark all as read')); expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); }); it('skips markAsRead for already-read notifications', async () => { setNotifications([createNotification({ subject: 'Read', status: 'read' })]); - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByRole('button', { name: /View notification: Read/i })); expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); }); @@ -293,7 +290,7 @@ describe('NotificationCenter', () => { it.each(interactionTestCases)('$method: navigates=$hasUrl', async ({ method, hasUrl, url }) => { const subject = `${method}-${hasUrl}`; setNotifications([createNotification({ subject, action_url: url })]); - const user = await openDropdown(); + await openDropdown(); const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${subject}`, 'i') }); await interactWithButton(user, button, method); @@ -304,11 +301,11 @@ describe('NotificationCenter', () => { it('ignores non-Enter keydown', async () => { vi.useFakeTimers(); setNotifications([createNotification({ subject: 'Test', action_url: '/test' })]); - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const timerUser = userWithTimers; render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); + await timerUser.click(screen.getByRole('button', { name: /Notifications/i })); screen.getByRole('button', { name: /View notification: Test/i }).focus(); - await user.keyboard('{Tab}'); + await timerUser.keyboard('{Tab}'); expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); vi.useRealTimers(); }); @@ -319,7 +316,7 @@ describe('NotificationCenter', () => { await withMockedLocation(async (mockHref) => { const url = `https://example.com/${method}`; setNotifications([createNotification({ subject: method, action_url: url })]); - const user = await openDropdown(); + await openDropdown(); const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${method}`, 'i') }); await interactWithButton(user, button, method); expect(mockHref).toHaveBeenCalledWith(url); @@ -332,7 +329,7 @@ describe('NotificationCenter', () => { setNotifications([createNotification({ subject: 'Test' })]); render(NotificationCenter); expect(screen.getByRole('button', { name: /Notifications/i })).toHaveAttribute('aria-label', 'Notifications'); - await userEvent.click(screen.getByRole('button', { name: /Notifications/i })); + await user.click(screen.getByRole('button', { name: /Notifications/i })); await waitFor(() => { expect(screen.getByRole('button', { name: /View notification: Test/i })).toHaveAttribute('tabindex', '0'); }); @@ -343,9 +340,9 @@ describe('NotificationCenter', () => { it('marks notifications after 2s delay', async () => { vi.useFakeTimers(); setNotifications([createNotification({ notification_id: '1' }), createNotification({ notification_id: '2' })]); - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const timerUser = userWithTimers; render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); + await timerUser.click(screen.getByRole('button', { name: /Notifications/i })); await vi.advanceTimersByTimeAsync(2500); expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalled(); vi.useRealTimers(); @@ -390,7 +387,7 @@ describe('NotificationCenter', () => { it('calls requestPermission when enable button clicked', async () => { mockNotificationPermission = 'default'; - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('Enable desktop notifications')); expect(mockRequestPermission).toHaveBeenCalled(); }); diff --git a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts index 57189b08..347ce73c 100644 --- a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockEventDetail } from '$test/test-utils'; +import { createMockEventDetail, user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('X')); @@ -71,7 +70,6 @@ describe('EventDetailsModal', () => { }); it('calls onViewRelated with event_id when clicking a related event', async () => { - const user = userEvent.setup(); const { onViewRelated } = renderModal(); await user.click(screen.getByText('execution_started')); expect(onViewRelated).toHaveBeenCalledWith('rel-1'); @@ -85,14 +83,12 @@ describe('EventDetailsModal', () => { }); it('calls onReplay with event_id when Replay Event button is clicked', async () => { - const user = userEvent.setup(); const { onReplay } = renderModal(); await user.click(screen.getByRole('button', { name: 'Replay Event' })); expect(onReplay).toHaveBeenCalledWith('evt-1'); }); it('calls onClose when Close button in footer is clicked', async () => { - const user = userEvent.setup(); const { onClose } = renderModal(); await user.click(screen.getByRole('button', { name: 'Close' })); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts index 0e228210..32ad4838 100644 --- a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { EventFilter } from '$lib/api'; @@ -48,21 +48,18 @@ describe('EventFilters', () => { }); it('calls onApply when Apply button is clicked', async () => { - const user = userEvent.setup(); const { onApply } = renderFilters(); await user.click(screen.getByRole('button', { name: 'Apply' })); expect(onApply).toHaveBeenCalledOnce(); }); it('calls onClear when Clear All button is clicked', async () => { - const user = userEvent.setup(); const { onClear } = renderFilters(); await user.click(screen.getByRole('button', { name: 'Clear All' })); expect(onClear).toHaveBeenCalledOnce(); }); it('text inputs accept user input', async () => { - const user = userEvent.setup(); renderFilters(); const searchInput = screen.getByLabelText('Search') as HTMLInputElement; await user.type(searchInput, 'test query'); diff --git a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts index 64eb9d71..7e386c38 100644 --- a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockEvent, createMockEvents } from '$test/test-utils'; +import { createMockEvent, createMockEvents, user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('Eye', 'Play', 'Trash2')); @@ -87,7 +86,6 @@ describe('EventsTable', () => { describe('row click actions', () => { it('calls onViewDetails when clicking a table row', async () => { - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-click' })]; const { onViewDetails } = renderTable(events); const rows = screen.getAllByRole('button', { name: 'View event details' }); @@ -110,7 +108,6 @@ describe('EventsTable', () => { { title: 'Replay', callback: 'onReplay' as const }, { title: 'Delete', callback: 'onDelete' as const }, ])('$title button calls $callback with event_id without triggering row click', async ({ title, callback }) => { - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-action' })]; const handlers = renderTable(events); const buttons = screen.getAllByTitle(title); @@ -121,7 +118,6 @@ describe('EventsTable', () => { }); it('calls onViewUser with user_id when clicking user link (stopPropagation)', async () => { - const user = userEvent.setup(); const event = createMockEvent({ metadata: { user_id: 'user-linked' } }); const { onViewUser, onViewDetails } = renderTable([event]); const userButtons = screen.getAllByTitle('View user overview'); diff --git a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts index 7ec7af16..aa1bf07b 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('AlertTriangle', 'X')); @@ -101,7 +101,6 @@ describe('ReplayPreviewModal', () => { }); it('calls onConfirm with eventId and onClose when Proceed is clicked', async () => { - const user = userEvent.setup(); const { onConfirm, onClose } = renderModal(); await user.click(screen.getByRole('button', { name: 'Proceed with Replay' })); expect(onClose).toHaveBeenCalledOnce(); @@ -109,7 +108,6 @@ describe('ReplayPreviewModal', () => { }); it('calls onClose when Cancel button is clicked', async () => { - const user = userEvent.setup(); const { onClose, onConfirm } = renderModal(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts index 2e992a10..da846c95 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import type { EventReplayStatusResponse } from '$lib/api'; vi.mock('@lucide/svelte', async () => @@ -70,7 +70,6 @@ describe('ReplayProgressBanner', () => { }); it('calls onClose when close button is clicked', async () => { - const user = userEvent.setup(); const { onClose } = renderBanner(); await user.click(screen.getByTitle('Close')); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts index be9b50e8..0ff1ed0f 100644 --- a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, within, fireEvent, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('ChevronDown', 'ChevronRight')); @@ -20,14 +20,13 @@ function renderSelect(overrides: Partial = {}) { return { ...render(LanguageSelect, { props }), onselect: props.onselect }; } -async function openMenu() { - const user = userEvent.setup(); - const result = renderSelect(); - await user.click(screen.getByRole('button', { name: /Select language/i })); - return { user, ...result }; -} - describe('LanguageSelect', () => { + async function openMenu() { + const result = renderSelect(); + await user.click(screen.getByRole('button', { name: /Select language/i })); + return result; + } + describe('trigger button', () => { it('shows current language and version with aria-haspopup', () => { renderSelect(); @@ -54,7 +53,7 @@ describe('LanguageSelect', () => { }); it('closes menu on second click', async () => { - const { user } = await openMenu(); + await openMenu(); await user.click(screen.getByRole('button', { name: /Select language/i })); await waitFor(() => { expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); @@ -64,7 +63,7 @@ describe('LanguageSelect', () => { describe('version submenu', () => { it('shows all versions on language hover with correct aria-checked', async () => { - const { user } = await openMenu(); + await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /python/i })); const versionMenu = screen.getByRole('menu', { name: /python versions/i }); const versions = within(versionMenu).getAllByRole('menuitemradio'); @@ -74,7 +73,7 @@ describe('LanguageSelect', () => { }); it('calls onselect and closes menu on version click', async () => { - const { user, onselect } = await openMenu(); + const { onselect } = await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /node/i })); const nodeMenu = screen.getByRole('menu', { name: /node versions/i }); await user.click(within(nodeMenu).getByRole('menuitemradio', { name: '20' })); @@ -85,7 +84,7 @@ describe('LanguageSelect', () => { }); it('switches version submenu when hovering different language', async () => { - const { user } = await openMenu(); + await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /python/i })); expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); @@ -102,7 +101,6 @@ describe('LanguageSelect', () => { { key: '{ArrowDown}', label: 'ArrowDown' }, { key: '{Enter}', label: 'Enter' }, ])('opens menu with $label on trigger', async ({ key }) => { - const user = userEvent.setup(); renderSelect(); screen.getByRole('button', { name: /Select language/i }).focus(); await user.keyboard(key); diff --git a/frontend/src/components/editor/__tests__/OutputPanel.test.ts b/frontend/src/components/editor/__tests__/OutputPanel.test.ts index d3f7cdb1..20ec76aa 100644 --- a/frontend/src/components/editor/__tests__/OutputPanel.test.ts +++ b/frontend/src/components/editor/__tests__/OutputPanel.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import type { ExecutionResult } from '$lib/api'; import type { ExecutionPhase } from '$lib/editor'; @@ -155,7 +155,6 @@ describe('OutputPanel', () => { { target: 'stderr', ariaLabel: 'Copy error text to clipboard', text: 'some error', toastLabel: 'Error text' }, { target: 'execution_id', ariaLabel: 'Click to copy execution ID', text: 'uuid-abc', toastLabel: 'Execution ID' }, ])('copies $target and shows success toast', async ({ target, ariaLabel, text, toastLabel }) => { - const user = userEvent.setup(); mockClipboard(); renderIdle({ result: makeResult({ [target]: text }), @@ -166,7 +165,6 @@ describe('OutputPanel', () => { }); it('shows error toast when clipboard write fails', async () => { - const user = userEvent.setup(); mockClipboard(false); renderIdle({ result: makeResult({ stdout: 'x' }) }); await user.click(screen.getByLabelText('Copy output to clipboard')); diff --git a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts index 7bd4ef80..8412c9a3 100644 --- a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts +++ b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule( 'MessageSquare', 'ChevronUp', 'ChevronDown', 'Cpu', 'MemoryStick', 'Clock', @@ -18,7 +18,6 @@ const LIMITS = { }; describe('ResourceLimits', () => { - it('renders nothing when limits is null', () => { const { container } = render(ResourceLimits, { props: { limits: null } }); expect(container.textContent?.trim()).toBe(''); @@ -32,7 +31,6 @@ describe('ResourceLimits', () => { }); it('expands panel on click showing all limit values', async () => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); await user.click(screen.getByRole('button', { name: /Resource Limits/i })); @@ -44,7 +42,6 @@ describe('ResourceLimits', () => { { label: 'Memory Limit', value: '256Mi' }, { label: 'Timeout', value: '30s' }, ])('shows $label = $value when expanded', async ({ label, value }) => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); await user.click(screen.getByRole('button', { name: /Resource Limits/i })); expect(screen.getByText(label)).toBeInTheDocument(); @@ -52,7 +49,6 @@ describe('ResourceLimits', () => { }); it('collapses panel on second click', async () => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); const btn = screen.getByRole('button', { name: /Resource Limits/i }); diff --git a/frontend/src/components/editor/__tests__/SavedScripts.test.ts b/frontend/src/components/editor/__tests__/SavedScripts.test.ts index b3b18442..4ea704f4 100644 --- a/frontend/src/components/editor/__tests__/SavedScripts.test.ts +++ b/frontend/src/components/editor/__tests__/SavedScripts.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('List', 'Trash2')); @@ -28,14 +28,12 @@ function renderScripts(scripts: SavedScriptResponse[] = []) { return { ...result, onload, ondelete, onrefresh }; } -async function renderAndExpand(scripts: SavedScriptResponse[] = []) { - const user = userEvent.setup(); - const result = renderScripts(scripts); - await user.click(screen.getByRole('button', { name: /Show Saved Scripts/i })); - return { user, ...result }; -} - describe('SavedScripts', () => { + async function renderAndExpand(scripts: SavedScriptResponse[] = []) { + const result = renderScripts(scripts); + await user.click(screen.getByRole('button', { name: /Show Saved Scripts/i })); + return result; + } it('renders collapsed with heading, toggle button, and scripts hidden', () => { renderScripts(createScripts(2)); @@ -50,7 +48,6 @@ describe('SavedScripts', () => { expect(onrefresh).toHaveBeenCalledOnce(); onrefresh.mockClear(); - const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: /Hide Saved Scripts/i })); expect(onrefresh).not.toHaveBeenCalled(); }); @@ -79,7 +76,6 @@ describe('SavedScripts', () => { it('calls onload with full script object when clicking a script name', async () => { const scripts = createScripts(2); const { onload } = await renderAndExpand(scripts); - const user = userEvent.setup(); await user.click(screen.getByText('Script 2')); expect(onload).toHaveBeenCalledWith(scripts[1]); }); @@ -87,7 +83,6 @@ describe('SavedScripts', () => { it('calls ondelete with script id and does not trigger onload (stopPropagation)', async () => { const scripts = createScripts(1); const { onload, ondelete } = await renderAndExpand(scripts); - const user = userEvent.setup(); await user.click(screen.getByTitle('Delete Script 1')); expect(ondelete).toHaveBeenCalledWith('script-1'); expect(onload).not.toHaveBeenCalled(); diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index ddde5781..4bfa39ba 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; function createMockLimits() { return { @@ -114,8 +114,6 @@ vi.mock('$components/editor', async () => { }); describe('Editor', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); diff --git a/frontend/src/routes/__tests__/Login.test.ts b/frontend/src/routes/__tests__/Login.test.ts index cc9544bc..38182147 100644 --- a/frontend/src/routes/__tests__/Login.test.ts +++ b/frontend/src/routes/__tests__/Login.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ mockLogin: vi.fn(), mockGoto: vi.fn(), @@ -41,8 +41,6 @@ vi.mock('$components/Spinner.svelte', async () => (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); describe('Login', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.mockAuthStore.login = vi.fn().mockResolvedValue(true); diff --git a/frontend/src/routes/__tests__/Notifications.test.ts b/frontend/src/routes/__tests__/Notifications.test.ts index ee0b8bf9..50cfaeab 100644 --- a/frontend/src/routes/__tests__/Notifications.test.ts +++ b/frontend/src/routes/__tests__/Notifications.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockNotification, createMockNotifications } from '$test/test-utils'; +import { createMockNotification, createMockNotifications, user } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ addToast: vi.fn(), @@ -32,8 +31,6 @@ vi.mock('@lucide/svelte', async () => vi.mock('$lib/api', () => ({})); describe('Notifications', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.mockNotificationStore.notifications = []; diff --git a/frontend/src/routes/__tests__/Register.test.ts b/frontend/src/routes/__tests__/Register.test.ts index 500c14f5..006b0954 100644 --- a/frontend/src/routes/__tests__/Register.test.ts +++ b/frontend/src/routes/__tests__/Register.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ registerApiV1AuthRegisterPost: vi.fn(), mockGoto: vi.fn(), @@ -31,8 +31,6 @@ vi.mock('$components/Spinner.svelte', async () => (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); describe('Register', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.registerApiV1AuthRegisterPost.mockResolvedValue({ data: {}, error: undefined }); diff --git a/frontend/src/routes/__tests__/Settings.test.ts b/frontend/src/routes/__tests__/Settings.test.ts index c1501d79..f1618b81 100644 --- a/frontend/src/routes/__tests__/Settings.test.ts +++ b/frontend/src/routes/__tests__/Settings.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; function createMockSettings() { return { @@ -63,8 +63,6 @@ vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('ChevronDown')); describe('Settings', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('confirm', mocks.mockConfirm); diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index 81604060..e72b64f2 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; import { tick } from 'svelte'; import { mockWindowGlobals, @@ -9,6 +8,7 @@ import { createMockStats, createMockEventDetail, createMockUserOverview, + userWithTimers as user, } from '$test/test-utils'; // Hoisted mocks @@ -96,7 +96,6 @@ describe('AdminEvents', () => { describe('initial loading', () => { it('calls loadEvents and loadStats on mount', async () => { - vi.useRealTimers(); render(AdminEvents); await waitFor(() => { expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(1); @@ -118,7 +117,6 @@ describe('AdminEvents', () => { }); it('handles API error on load events and shows toast', async () => { - vi.useRealTimers(); const error = { message: 'Network error' }; mocks.browseEventsApiV1AdminEventsBrowsePost.mockImplementation(async () => { mocks.addToast('Failed to load events'); @@ -129,7 +127,6 @@ describe('AdminEvents', () => { }); it('displays empty state when no events', async () => { - vi.useRealTimers(); await renderWithEvents([], createMockStats({ total_events: 0 })); expect(screen.getByText(/No events found/i)).toBeInTheDocument(); }); @@ -137,7 +134,6 @@ describe('AdminEvents', () => { describe('stats display', () => { it('displays event statistics cards', async () => { - vi.useRealTimers(); await renderWithEvents(createMockEvents(5), createMockStats({ total_events: 150, error_rate: 2.5, avg_processing_time: 1.23 })); expect(screen.getByText(/Events \(Last 24h\)/i)).toBeInTheDocument(); expect(screen.getByText('150')).toBeInTheDocument(); @@ -146,14 +142,12 @@ describe('AdminEvents', () => { }); it('shows error rate in red when > 0', async () => { - vi.useRealTimers(); await renderWithEvents(createMockEvents(1), createMockStats({ error_rate: 5 })); const errorRateElement = screen.getByText('5%'); expect(errorRateElement).toHaveClass('text-red-600'); }); it('shows error rate in green when 0', async () => { - vi.useRealTimers(); await renderWithEvents(createMockEvents(1), createMockStats({ error_rate: 0 })); const errorRateElement = screen.getByText('0%'); expect(errorRateElement).toHaveClass('text-green-600'); @@ -162,7 +156,6 @@ describe('AdminEvents', () => { describe('event list rendering', () => { it('displays events in table', async () => { - vi.useRealTimers(); const events = [createMockEvent({ event_type: 'execution_completed', metadata: { user_id: 'user-1', service_name: 'test-service' } })]; await renderWithEvents(events); // Events are displayed (multiple due to mobile + desktop views) @@ -170,7 +163,6 @@ describe('AdminEvents', () => { }); it('displays multiple events', async () => { - vi.useRealTimers(); const events = createMockEvents(5); await renderWithEvents(events); // Should show events info in pagination @@ -178,7 +170,6 @@ describe('AdminEvents', () => { }); it('shows user ID as clickable link', async () => { - vi.useRealTimers(); const events = [createMockEvent({ metadata: { user_id: 'user-123' } })]; await renderWithEvents(events); const userLinks = screen.getAllByText('user-123'); @@ -191,8 +182,6 @@ describe('AdminEvents', () => { describe('refresh functionality', () => { it('refresh button reloads events', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); mocks.browseEventsApiV1AdminEventsBrowsePost.mockClear(); @@ -205,8 +194,6 @@ describe('AdminEvents', () => { describe('filters', () => { it('toggles filter panel', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); const filtersBtn = screen.getByRole('button', { name: /Filters/i }); @@ -219,8 +206,6 @@ describe('AdminEvents', () => { }); it('displays filter inputs when panel is open', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Filters/i })); @@ -236,8 +221,6 @@ describe('AdminEvents', () => { }); it('applies filters when Apply button is clicked', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Filters/i })); @@ -262,8 +245,6 @@ describe('AdminEvents', () => { }); it('clears all filters', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Filters/i })); @@ -276,8 +257,6 @@ describe('AdminEvents', () => { }); it('shows active filter count badge', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Filters/i })); @@ -298,7 +277,6 @@ describe('AdminEvents', () => { describe('pagination', () => { it('shows pagination info', async () => { - vi.useRealTimers(); const events = createMockEvents(25); mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: { events: events.slice(0, 10), total: 25 }, @@ -313,8 +291,6 @@ describe('AdminEvents', () => { }); it('changes page when next is clicked', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: { events: createMockEvents(10), total: 25 }, error: null, @@ -340,8 +316,6 @@ describe('AdminEvents', () => { }); it('changes page size', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: { events: createMockEvents(10), total: 50 }, error: null, @@ -369,8 +343,6 @@ describe('AdminEvents', () => { describe('event detail modal', () => { it('opens event detail modal when row is clicked', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-detail-1' })]; mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: createMockEventDetail(events[0]), @@ -391,8 +363,6 @@ describe('AdminEvents', () => { }); it('displays event information in modal', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const event = createMockEvent({ event_id: 'evt-123', event_type: 'execution_completed' }); mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: createMockEventDetail(event), @@ -411,8 +381,6 @@ describe('AdminEvents', () => { }); it('shows related events in modal', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const event = createMockEvent(); mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: createMockEventDetail(event), @@ -431,8 +399,6 @@ describe('AdminEvents', () => { }); it('has close button in modal', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const event = createMockEvent(); mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: createMockEventDetail(event), @@ -453,8 +419,6 @@ describe('AdminEvents', () => { describe('replay functionality', () => { it('shows replay preview on dry run', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-replay-1' })]; mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ data: { total_events: 1, events_preview: events, session_id: null }, @@ -474,8 +438,6 @@ describe('AdminEvents', () => { }); it('confirms before actual replay', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-replay-2' })]; mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ data: { total_events: 1, session_id: 'session-1' }, @@ -495,8 +457,6 @@ describe('AdminEvents', () => { }); it('does not replay if confirm is cancelled', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); mocks.windowConfirm.mockReturnValue(false); const events = [createMockEvent()]; await renderWithEvents(events); @@ -508,8 +468,6 @@ describe('AdminEvents', () => { }); it('shows replay progress when session is active', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-progress' })]; mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ data: { total_events: 5, session_id: 'session-progress' }, @@ -538,8 +496,6 @@ describe('AdminEvents', () => { describe('delete event', () => { it('confirms before deleting', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-delete-1' })]; mocks.deleteEventApiV1AdminEventsEventIdDelete.mockResolvedValue({ data: {}, error: null }); await renderWithEvents(events); @@ -556,8 +512,6 @@ describe('AdminEvents', () => { }); it('shows success toast after deletion', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent()]; mocks.deleteEventApiV1AdminEventsEventIdDelete.mockResolvedValue({ data: {}, error: null }); await renderWithEvents(events); @@ -571,8 +525,6 @@ describe('AdminEvents', () => { }); it('does not delete if confirm is cancelled', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); mocks.windowConfirm.mockReturnValue(false); const events = [createMockEvent()]; await renderWithEvents(events); @@ -584,8 +536,6 @@ describe('AdminEvents', () => { }); it('handles delete error and shows toast', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const error = { message: 'Cannot delete' }; mocks.deleteEventApiV1AdminEventsEventIdDelete.mockImplementation(async () => { mocks.addToast('Failed to delete event'); @@ -605,8 +555,6 @@ describe('AdminEvents', () => { describe('export', () => { it('opens export dropdown menu', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); const exportBtn = screen.getByRole('button', { name: /Export/i }); @@ -619,8 +567,6 @@ describe('AdminEvents', () => { }); it('exports as CSV', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Export/i })); @@ -636,8 +582,6 @@ describe('AdminEvents', () => { }); it('exports as JSON', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); await renderWithEvents(); await user.click(screen.getByRole('button', { name: /Export/i })); @@ -655,8 +599,6 @@ describe('AdminEvents', () => { describe('user overview modal', () => { it('opens user overview modal when user ID is clicked', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ metadata: { user_id: 'user-overview-1' } })]; mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockResolvedValue({ data: createMockUserOverview(), @@ -676,8 +618,6 @@ describe('AdminEvents', () => { }); it('displays user information in overview modal', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ metadata: { user_id: 'user-info' } })]; const overview = createMockUserOverview(); mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockResolvedValue({ @@ -696,8 +636,6 @@ describe('AdminEvents', () => { }); it('shows execution stats in overview modal', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const events = [createMockEvent({ metadata: { user_id: 'user-stats' } })]; mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockResolvedValue({ data: createMockUserOverview(), @@ -718,8 +656,6 @@ describe('AdminEvents', () => { }); it('handles user overview load error and shows toast', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const error = { message: 'Failed to load' }; const events = [createMockEvent({ metadata: { user_id: 'user-error' } })]; mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockImplementation(async () => { @@ -739,7 +675,6 @@ describe('AdminEvents', () => { describe('event type display', () => { it('shows correct color for completed events', async () => { - vi.useRealTimers(); const events = [createMockEvent({ event_type: 'execution_completed' })]; await renderWithEvents(events); const [icon] = screen.getAllByTestId('event-type-icon'); @@ -748,7 +683,6 @@ describe('AdminEvents', () => { }); it('shows correct color for failed events', async () => { - vi.useRealTimers(); const events = [createMockEvent({ event_type: 'execution_failed' })]; await renderWithEvents(events); const [icon] = screen.getAllByTestId('event-type-icon'); @@ -757,7 +691,6 @@ describe('AdminEvents', () => { }); it('shows correct color for started events', async () => { - vi.useRealTimers(); const events = [createMockEvent({ event_type: 'execution_started' })]; await renderWithEvents(events); const [icon] = screen.getAllByTestId('event-type-icon'); @@ -768,13 +701,11 @@ describe('AdminEvents', () => { describe('header and layout', () => { it('displays page title', async () => { - vi.useRealTimers(); await renderWithEvents(); expect(screen.getByText('Event Browser')).toBeInTheDocument(); }); it('has Filters, Export, and Refresh buttons', async () => { - vi.useRealTimers(); await renderWithEvents(); expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument(); @@ -784,8 +715,6 @@ describe('AdminEvents', () => { describe('error handling', () => { it('handles event detail load error and shows toast', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const error = { message: 'Detail not found' }; mocks.getEventDetailApiV1AdminEventsEventIdGet.mockImplementation(async () => { mocks.addToast('Failed to load event details'); @@ -803,8 +732,6 @@ describe('AdminEvents', () => { }); it('handles replay error and shows toast', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); const error = { message: 'Replay failed' }; mocks.replayEventsApiV1AdminEventsReplayPost.mockImplementation(async () => { mocks.addToast('Failed to replay event'); diff --git a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts index 01e62647..a9fcb9f2 100644 --- a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; import { tick } from 'svelte'; +import { userWithTimers as user } from '$test/test-utils'; interface MockExecutionOverrides { execution_id?: string; @@ -173,7 +173,6 @@ describe('AdminExecutions', () => { describe('filters', () => { it('filters by status', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithExecutions(); vi.clearAllMocks(); @@ -190,7 +189,6 @@ describe('AdminExecutions', () => { }); it('filters by priority', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithExecutions(); vi.clearAllMocks(); @@ -207,7 +205,6 @@ describe('AdminExecutions', () => { }); it('filters by user ID', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithExecutions(); vi.clearAllMocks(); @@ -224,7 +221,6 @@ describe('AdminExecutions', () => { }); it('reset button clears all filters', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithExecutions(); const statusSelect = screen.getByLabelText(/status/i); @@ -268,7 +264,6 @@ describe('AdminExecutions', () => { describe('priority update', () => { it('successful update shows success toast', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const execs = [createMockExecution({ execution_id: 'e1', priority: 'normal' })]; mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ data: { ...execs[0], priority: 'high' }, @@ -294,7 +289,6 @@ describe('AdminExecutions', () => { }); it('failed update calls API and does not show success toast', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const execs = [createMockExecution({ execution_id: 'e1', priority: 'normal' })]; mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ data: undefined, @@ -330,7 +324,6 @@ describe('AdminExecutions', () => { }); it('manual refresh button calls loadData', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithExecutions(); vi.clearAllMocks(); diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts index 65b4c271..4d650f90 100644 --- a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; import { tick } from 'svelte'; +import { userWithTimers as user } from '$test/test-utils'; interface MockSagaOverrides { saga_id?: string; @@ -174,7 +174,6 @@ describe('AdminSagas', () => { }); it('manual refresh button works', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithSagas(); vi.clearAllMocks(); @@ -185,7 +184,6 @@ describe('AdminSagas', () => { }); it('toggling auto-refresh off stops polling', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithSagas(); const checkbox = screen.getByRole('checkbox', { name: /auto-refresh/i }); @@ -200,7 +198,6 @@ describe('AdminSagas', () => { describe('filters', () => { it('filters by state', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithSagas(); vi.clearAllMocks(); @@ -217,7 +214,6 @@ describe('AdminSagas', () => { }); it('filters by search query', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const sagas = [ createMockSaga({ saga_id: 's1', saga_name: 'alpha_saga' }), createMockSaga({ saga_id: 's2', saga_name: 'beta_saga' }), @@ -233,7 +229,6 @@ describe('AdminSagas', () => { }); it('filters by execution ID', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const sagas = [ createMockSaga({ saga_id: 's1', execution_id: 'exec-abc' }), createMockSaga({ saga_id: 's2', execution_id: 'exec-xyz' }), @@ -249,7 +244,6 @@ describe('AdminSagas', () => { }); it('clears filters on clear button click', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithSagas(); const stateSelect = screen.getByLabelText(/state/i); @@ -271,7 +265,6 @@ describe('AdminSagas', () => { describe('saga details modal', () => { it('opens modal on View Details click', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const saga = createMockSaga({ saga_name: 'execution_saga', completed_steps: ['validate_execution', 'allocate_resources'], @@ -287,7 +280,6 @@ describe('AdminSagas', () => { }); it('displays saga information in modal', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const saga = createMockSaga({ saga_id: 'saga-detail-test', saga_name: 'execution_saga', @@ -307,7 +299,6 @@ describe('AdminSagas', () => { }); it('shows error message when saga has error', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const saga = createMockSaga({ state: 'failed', error_message: 'Pod creation failed: timeout', @@ -324,7 +315,6 @@ describe('AdminSagas', () => { }); it('closes modal on close button click', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const saga = createMockSaga(); mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); await renderWithSagas([saga]); @@ -341,7 +331,6 @@ describe('AdminSagas', () => { }); it('shows compensated steps', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const saga = createMockSaga({ saga_name: 'execution_saga', state: 'failed', @@ -361,7 +350,6 @@ describe('AdminSagas', () => { describe('view execution sagas', () => { it('loads sagas for specific execution', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const executionSagas = [createMockSaga({ execution_id: 'exec-target' })]; mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet.mockResolvedValue({ data: { sagas: executionSagas, total: 1 }, @@ -393,7 +381,6 @@ describe('AdminSagas', () => { }); it('changes page size', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const sagas = createMockSagas(5); mocks.listSagasApiV1SagasGet.mockResolvedValue({ data: { sagas, total: 25 }, @@ -420,7 +407,6 @@ describe('AdminSagas', () => { describe('refresh rate control', () => { it('changes refresh rate', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await renderWithSagas(); const rateSelect = screen.getByLabelText(/every/i); diff --git a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts index 45f91bce..45b38ba9 100644 --- a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; function createMockSystemSettings() { return { @@ -57,8 +57,6 @@ vi.mock('@lucide/svelte', async () => (await import('$test/test-utils')).createMockIconModule('ShieldCheck')); describe('AdminSettings', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('confirm', mocks.mockConfirm); diff --git a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts index 88489c66..91ce803b 100644 --- a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup, within } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; import { tick } from 'svelte'; +import { user } from '$test/test-utils'; interface MockUserOverrides { user_id?: string; @@ -168,7 +168,6 @@ describe('AdminUsers', () => { describe('refresh functionality', () => { it('refresh button calls loadUsers', async () => { - const user = userEvent.setup(); await renderWithUsers(); mocks.listUsersApiV1AdminUsersGet.mockClear(); const refreshBtn = screen.getByRole('button', { name: /Refresh/i }); @@ -179,7 +178,6 @@ describe('AdminUsers', () => { describe('search and filtering', () => { it('filters users by search query', async () => { - const user = userEvent.setup(); const allUsers = [ createMockUser({ username: 'alice', email: 'alice@test.com' }), createMockUser({ user_id: 'u2', username: 'bob', email: 'bob@test.com' }), @@ -202,7 +200,6 @@ describe('AdminUsers', () => { }); it('filters users by role', async () => { - const user = userEvent.setup(); const allUsers = [ createMockUser({ username: 'admin1', role: 'admin' }), createMockUser({ user_id: 'u2', username: 'user1', role: 'user' }), @@ -225,7 +222,6 @@ describe('AdminUsers', () => { }); it('filters users by status', async () => { - const user = userEvent.setup(); const users = [ createMockUser({ username: 'activeuser', is_active: true, is_disabled: false }), createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false, is_disabled: true }), @@ -240,7 +236,6 @@ describe('AdminUsers', () => { }); it('resets filters on Reset button click', async () => { - const user = userEvent.setup(); const users = createMockUsers(3); mocks.listUsersApiV1AdminUsersGet.mockImplementation(async ({ query } = {}) => { const search = (query as Record)?.search as string | null; @@ -262,7 +257,6 @@ describe('AdminUsers', () => { }); it('toggles advanced filters panel', async () => { - const user = userEvent.setup(); await renderWithUsers(); const advancedBtn = screen.getByRole('button', { name: /Advanced/i }); await user.click(advancedBtn); @@ -272,7 +266,6 @@ describe('AdminUsers', () => { }); it('filters by bypass rate limit', async () => { - const user = userEvent.setup(); const users = [ createMockUser({ username: 'bypassed', bypass_rate_limit: true }), createMockUser({ user_id: 'u2', username: 'limited', bypass_rate_limit: false }), @@ -290,7 +283,6 @@ describe('AdminUsers', () => { describe('create user modal', () => { it('opens create user modal on button click', async () => { - const user = userEvent.setup(); await renderWithUsers(); // Header has the Create User button const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); @@ -302,7 +294,6 @@ describe('AdminUsers', () => { it('submits new user data', async () => { mocks.createUserApiV1AdminUsersPost.mockResolvedValue({ data: {}, error: null }); - const user = userEvent.setup(); await renderWithUsers(); const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); await user.click(createButton!); @@ -333,7 +324,6 @@ describe('AdminUsers', () => { mocks.addToast('Validation error: username: Required'); return { data: null, error }; }); - const user = userEvent.setup(); await renderWithUsers(); const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); await user.click(createButton!); @@ -349,7 +339,6 @@ describe('AdminUsers', () => { }); it('closes modal on cancel', async () => { - const user = userEvent.setup(); await renderWithUsers(); const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); await user.click(createButton!); @@ -364,7 +353,6 @@ describe('AdminUsers', () => { describe('edit user modal', () => { it('opens edit modal with user data', async () => { - const user = userEvent.setup(); const users = [createMockUser({ username: 'editme', email: 'edit@test.com' })]; await renderWithUsers(users); @@ -381,7 +369,6 @@ describe('AdminUsers', () => { it('submits updated user data', async () => { mocks.updateUserApiV1AdminUsersUserIdPut.mockResolvedValue({ data: {}, error: null }); - const user = userEvent.setup(); const users = [createMockUser({ user_id: 'u1', username: 'editme' })]; await renderWithUsers(users); @@ -406,7 +393,6 @@ describe('AdminUsers', () => { describe('delete user modal', () => { it('opens delete confirmation modal', async () => { const users = [createMockUser({ username: 'deleteme' })]; - const user = userEvent.setup(); await renderWithUsers(users); // Multiple delete buttons exist (mobile + desktop views) @@ -421,7 +407,6 @@ describe('AdminUsers', () => { it('confirms deletion without cascade by default', async () => { mocks.deleteUserApiV1AdminUsersUserIdDelete.mockResolvedValue({ data: { message: 'Deleted' }, error: null }); const users = [createMockUser({ user_id: 'del1', username: 'deleteme' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [deleteButton] = screen.getAllByTitle('Delete User'); @@ -442,7 +427,6 @@ describe('AdminUsers', () => { it('confirms deletion with cascade option', async () => { mocks.deleteUserApiV1AdminUsersUserIdDelete.mockResolvedValue({ data: { message: 'Deleted' }, error: null }); const users = [createMockUser({ user_id: 'del1', username: 'deleteme' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [deleteButton] = screen.getAllByTitle('Delete User'); @@ -472,7 +456,6 @@ describe('AdminUsers', () => { return { data: null, error }; }); const users = [createMockUser({ username: 'deleteme' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [deleteButton] = screen.getAllByTitle('Delete User'); @@ -487,7 +470,6 @@ describe('AdminUsers', () => { it('cancels deletion', async () => { const users = [createMockUser({ username: 'keepme' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [deleteButton] = screen.getAllByTitle('Delete User'); @@ -504,7 +486,6 @@ describe('AdminUsers', () => { it('shows cascade warning when enabled', async () => { const users = [createMockUser({ username: 'deleteme' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [deleteButton] = screen.getAllByTitle('Delete User'); @@ -539,7 +520,6 @@ describe('AdminUsers', () => { error: null, }); const users = [createMockUser({ username: 'ratelimited' })]; - const user = userEvent.setup(); await renderWithUsers(users); // Multiple rate limit buttons exist (mobile + desktop views) @@ -558,7 +538,6 @@ describe('AdminUsers', () => { error: null, }); const users = [createMockUser({ username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -576,7 +555,6 @@ describe('AdminUsers', () => { }); mocks.updateUserRateLimitsApiV1AdminRateLimitsUserIdPut.mockResolvedValue({ data: {}, error: null }); const users = [createMockUser({ user_id: 'u1', username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -604,7 +582,6 @@ describe('AdminUsers', () => { return { data: null, error }; }); const users = [createMockUser({ username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -622,7 +599,6 @@ describe('AdminUsers', () => { error: null, }); const users = [createMockUser({ username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -643,7 +619,6 @@ describe('AdminUsers', () => { }); mocks.resetUserRateLimitsApiV1AdminRateLimitsUserIdResetPost.mockResolvedValue({ data: {}, error: null }); const users = [createMockUser({ user_id: 'u1', username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -668,7 +643,6 @@ describe('AdminUsers', () => { }); it('shows filtered count when filters applied', async () => { - const user = userEvent.setup(); const users = [ createMockUser({ username: 'activeuser', is_active: true, is_disabled: false }), createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false, is_disabled: true }), @@ -700,7 +674,6 @@ describe('AdminUsers', () => { describe('user form validation', () => { it('requires username for new user', async () => { mocks.createUserApiV1AdminUsersPost.mockResolvedValue({ data: {}, error: null }); - const user = userEvent.setup(); await renderWithUsers(); const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); @@ -718,7 +691,6 @@ describe('AdminUsers', () => { it('requires password for new user', async () => { mocks.createUserApiV1AdminUsersPost.mockResolvedValue({ data: {}, error: null }); - const user = userEvent.setup(); await renderWithUsers(); const [createButton] = screen.getAllByRole('button', { name: /Create User/i }); @@ -736,7 +708,6 @@ describe('AdminUsers', () => { it('password is optional for edit', async () => { mocks.updateUserApiV1AdminUsersUserIdPut.mockResolvedValue({ data: {}, error: null }); - const user = userEvent.setup(); const users = [createMockUser({ user_id: 'u1', username: 'editme' })]; await renderWithUsers(users); @@ -764,7 +735,6 @@ describe('AdminUsers', () => { return { data: null, error }; }); const users = [createMockUser({ username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); @@ -787,7 +757,6 @@ describe('AdminUsers', () => { return { data: null, error: resetError }; }); const users = [createMockUser({ user_id: 'u1', username: 'testuser' })]; - const user = userEvent.setup(); await renderWithUsers(users); const [rateLimitButton] = screen.getAllByTitle('Manage Rate Limits'); From 5ff60e7a2ac9afa857010f36892a3323c0a691fa Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Wed, 25 Feb 2026 21:03:54 +0100 Subject: [PATCH 03/20] fix: deploy issue --- .github/workflows/stack-tests.yml | 16 +++++----------- backend/pyproject.toml | 1 + backend/uv.lock | 12 ++++++++++++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/stack-tests.yml b/.github/workflows/stack-tests.yml index cba260c0..58729244 100644 --- a/.github/workflows/stack-tests.yml +++ b/.github/workflows/stack-tests.yml @@ -167,18 +167,12 @@ jobs: if: steps.base-cache.outputs.cache-hit != 'true' run: docker save integr8scode-base:latest | zstd -T0 -3 > /tmp/base-image.tar.zst - # ── Backend (depends on local base image, GHA-cached) ── + # ── Backend (depends on local base image) ─────────────── - name: Build backend image - uses: docker/build-push-action@v6 - with: - context: ./backend - file: ./backend/Dockerfile - build-contexts: | - base=docker-image://integr8scode-base:latest - load: true - tags: integr8scode-backend:latest - cache-from: type=gha,scope=backend - cache-to: type=gha,mode=max,scope=backend + run: | + docker build -t integr8scode-backend:latest \ + --build-context base=docker-image://integr8scode-base:latest \ + -f ./backend/Dockerfile ./backend # ── Utility images (GHA-cached, independent of base) ──────────── - name: Build cert-generator image diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4464759c..e08d5b0f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -131,6 +131,7 @@ packages = ["app", "workers"] [dependency-groups] dev = [ + "async-asgi-testclient==1.4.11", "coverage==7.13.0", "hypothesis==6.151.6", "iniconfig==2.3.0", diff --git a/backend/uv.lock b/backend/uv.lock index 32fd2be1..9d6b845d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -210,6 +210,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] +[[package]] +name = "async-asgi-testclient" +version = "1.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "multidict" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/9a/0eb3fd37d4f9ad1e9b2b6d6b91357d3ebf7534271c32e343185a5d204903/async-asgi-testclient-1.4.11.tar.gz", hash = "sha256:4449ac85d512d661998ec61f91c9ae01851639611d748d81ae7f816736551792", size = 11716, upload-time = "2022-06-13T09:30:07.279Z" } + [[package]] name = "async-timeout" version = "5.0.1" @@ -1150,6 +1160,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "async-asgi-testclient" }, { name = "coverage" }, { name = "hypothesis" }, { name = "iniconfig" }, @@ -1290,6 +1301,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "async-asgi-testclient", specifier = "==1.4.11" }, { name = "coverage", specifier = "==7.13.0" }, { name = "hypothesis", specifier = "==6.151.6" }, { name = "iniconfig", specifier = "==2.3.0" }, From b562d73d1085a864483e7350a10b5b24ba01967f Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Wed, 25 Feb 2026 22:17:24 +0100 Subject: [PATCH 04/20] fix: quicker frontend/unit tests --- frontend/package-lock.json | 105 ++++++++++++++++++ frontend/src/routes/__tests__/Editor.test.ts | 4 +- frontend/src/routes/__tests__/Privacy.test.ts | 4 +- .../src/routes/__tests__/Settings.test.ts | 4 +- .../admin/__tests__/AdminEvents.test.ts | 26 +++-- .../routes/admin/__tests__/AdminSagas.test.ts | 16 ++- 6 files changed, 136 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6544b54b..4b529853 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2335,6 +2335,17 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2347,6 +2358,25 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -4812,6 +4842,73 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/happy-dom": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", + "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/happy-dom/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8530,6 +8627,14 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index 4bfa39ba..e9a40327 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import Editor from '$routes/Editor.svelte'; function createMockLimits() { return { @@ -141,8 +142,7 @@ describe('Editor', () => { afterEach(() => vi.unstubAllGlobals()); - async function renderEditor() { - const { default: Editor } = await import('$routes/Editor.svelte'); + function renderEditor() { return render(Editor); } diff --git a/frontend/src/routes/__tests__/Privacy.test.ts b/frontend/src/routes/__tests__/Privacy.test.ts index 2e84fa0d..6c3e07b9 100644 --- a/frontend/src/routes/__tests__/Privacy.test.ts +++ b/frontend/src/routes/__tests__/Privacy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import Privacy from '$routes/Privacy.svelte'; const mocks = vi.hoisted(() => ({ scrollTo: vi.fn(), @@ -14,8 +15,7 @@ describe('Privacy', () => { vi.clearAllMocks(); }); - async function renderPrivacy() { - const { default: Privacy } = await import('$routes/Privacy.svelte'); + function renderPrivacy() { return render(Privacy); } diff --git a/frontend/src/routes/__tests__/Settings.test.ts b/frontend/src/routes/__tests__/Settings.test.ts index f1618b81..1795d214 100644 --- a/frontend/src/routes/__tests__/Settings.test.ts +++ b/frontend/src/routes/__tests__/Settings.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import Settings from '$routes/Settings.svelte'; function createMockSettings() { return { @@ -79,8 +80,7 @@ describe('Settings', () => { afterEach(() => vi.unstubAllGlobals()); - async function renderSettings() { - const { default: Settings } = await import('$routes/Settings.svelte'); + function renderSettings() { return render(Settings); } diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index e72b64f2..b09c0ae2 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -8,7 +8,8 @@ import { createMockStats, createMockEventDetail, createMockUserOverview, - userWithTimers as user, + user, + userWithTimers, } from '$test/test-utils'; // Hoisted mocks @@ -76,7 +77,6 @@ async function renderWithEvents(events = createMockEvents(5), stats = createMock describe('AdminEvents', () => { beforeEach(() => { - vi.useFakeTimers(); mockWindowGlobals(mocks.windowOpen, mocks.windowConfirm); vi.clearAllMocks(); mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: { events: [], total: 0 }, error: null }); @@ -89,7 +89,6 @@ describe('AdminEvents', () => { }); afterEach(() => { - vi.useRealTimers(); cleanup(); vi.unstubAllGlobals(); }); @@ -104,16 +103,21 @@ describe('AdminEvents', () => { }); it('sets up auto-refresh interval', async () => { - render(AdminEvents); - await tick(); + vi.useFakeTimers(); + try { + render(AdminEvents); + await tick(); - // Fast-forward 30 seconds - await vi.advanceTimersByTimeAsync(30000); + // Fast-forward 30 seconds + await vi.advanceTimersByTimeAsync(30000); - await waitFor(() => { - expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(2); - expect(mocks.getEventStatsApiV1AdminEventsStatsGet).toHaveBeenCalledTimes(2); - }); + await waitFor(() => { + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(2); + expect(mocks.getEventStatsApiV1AdminEventsStatsGet).toHaveBeenCalledTimes(2); + }); + } finally { + vi.useRealTimers(); + } }); it('handles API error on load events and shows toast', async () => { diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts index 4d650f90..db482b93 100644 --- a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; import { tick } from 'svelte'; -import { userWithTimers as user } from '$test/test-utils'; +import { user, userWithTimers } from '$test/test-utils'; interface MockSagaOverrides { saga_id?: string; @@ -83,7 +83,6 @@ async function renderWithSagas(sagas = createMockSagas(5)) { describe('AdminSagas', () => { beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); mocks.listSagasApiV1SagasGet.mockResolvedValue({ data: { sagas: [], total: 0 }, error: null }); mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: null, error: null }); @@ -91,7 +90,6 @@ describe('AdminSagas', () => { }); afterEach(() => { - vi.useRealTimers(); cleanup(); }); @@ -162,6 +160,9 @@ describe('AdminSagas', () => { }); describe('auto-refresh', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + it('auto-refreshes at specified interval', async () => { await renderWithSagas(); expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(1); @@ -178,7 +179,7 @@ describe('AdminSagas', () => { vi.clearAllMocks(); const refreshButton = screen.getByRole('button', { name: /refresh now/i }); - await user.click(refreshButton); + await userWithTimers.click(refreshButton); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); }); @@ -187,7 +188,7 @@ describe('AdminSagas', () => { await renderWithSagas(); const checkbox = screen.getByRole('checkbox', { name: /auto-refresh/i }); - await user.click(checkbox); + await userWithTimers.click(checkbox); vi.clearAllMocks(); await vi.advanceTimersByTimeAsync(10000); @@ -406,11 +407,14 @@ describe('AdminSagas', () => { }); describe('refresh rate control', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + it('changes refresh rate', async () => { await renderWithSagas(); const rateSelect = screen.getByLabelText(/every/i); - await user.selectOptions(rateSelect, '10'); + await userWithTimers.selectOptions(rateSelect, '10'); vi.clearAllMocks(); await vi.advanceTimersByTimeAsync(10000); From 98cdac2b2788d4d17bbcd05dbf92842c1bc0d3ce Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 26 Feb 2026 00:56:22 +0100 Subject: [PATCH 05/20] fix: quicker frontend/unit tests --- frontend/src/__tests__/test-utils.ts | 80 ------------------- .../src/components/__tests__/Header.test.ts | 20 ++--- .../__tests__/ProtectedRoute.test.ts | 32 ++++---- .../__tests__/EventDetailsModal.test.ts | 4 - .../events/__tests__/EventsTable.test.ts | 4 - .../__tests__/ReplayPreviewModal.test.ts | 2 - .../__tests__/ReplayProgressBanner.test.ts | 2 - .../__tests__/UserOverviewModal.test.ts | 6 -- .../editor/__tests__/LanguageSelect.test.ts | 2 - .../editor/__tests__/OutputPanel.test.ts | 18 ++--- .../editor/__tests__/ResourceLimits.test.ts | 5 +- .../editor/__tests__/SavedScripts.test.ts | 3 +- frontend/src/routes/__tests__/Editor.test.ts | 37 +++------ frontend/src/routes/__tests__/Home.test.ts | 21 ++--- frontend/src/routes/__tests__/Login.test.ts | 41 +++++----- .../routes/__tests__/Notifications.test.ts | 26 +++--- .../src/routes/__tests__/Register.test.ts | 39 ++++----- .../src/routes/__tests__/Settings.test.ts | 21 ++--- .../admin/__tests__/AdminEvents.test.ts | 6 -- .../admin/__tests__/AdminExecutions.test.ts | 5 -- .../admin/__tests__/AdminLayout.test.ts | 29 +++---- .../routes/admin/__tests__/AdminSagas.test.ts | 1 - .../admin/__tests__/AdminSettings.test.ts | 28 +++---- .../routes/admin/__tests__/AdminUsers.test.ts | 5 -- frontend/vitest.config.ts | 14 ++++ frontend/vitest.setup.ts | 3 +- 26 files changed, 141 insertions(+), 313 deletions(-) diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index ec95d283..a0e8e74a 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -25,37 +25,6 @@ export type UserEventInstance = ReturnType; export const user: UserEventInstance = userEvent.setup(); export const userWithTimers: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); -// ============================================================================ -// Mock Svelte Component Factory -// ============================================================================ - -/** - * Creates a mock Svelte 5 component with proper $$ structure. - * Use this for mocking child components in parent component tests. - * - * @param html - The HTML to render for this mock component - * @param testId - Optional data-testid attribute - */ -export function createMockSvelteComponent(html: string, testId?: string): { - default: { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; -} { - const htmlWithTestId = testId - ? html.replace('>', ` data-testid="${testId}">`) - : html; - - const MockComponent = function () { - return {}; - } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; - - MockComponent.render = () => ({ - html: htmlWithTestId, - css: { code: '', map: null }, - head: '', - }); - - return { default: MockComponent }; -} - // ============================================================================ // Mock Store Type (for use with vi.hoisted) // ============================================================================ @@ -122,55 +91,6 @@ export function createMockNamedComponents(components: Record): R return module; } -/** - * Creates a mock @lucide/svelte module with given icon names. - * All icons render as ``. - */ -export function createMockIconModule(...iconNames: string[]): Record { - return createMockNamedComponents( - Object.fromEntries(iconNames.map(name => [name, ''])) - ); -} - -/** - * Creates a mock svelte-sonner module with toast methods that delegate to addToast. - * Usage: `vi.mock('svelte-sonner', async () => (await import('...')).createToastMock(mocks.addToast))` - */ -export function createToastMock(addToast: (...args: unknown[]) => void) { - return { - toast: { - success: (...args: unknown[]) => addToast('success', ...args), - error: (...args: unknown[]) => addToast('error', ...args), - warning: (...args: unknown[]) => addToast('warning', ...args), - info: (...args: unknown[]) => addToast('info', ...args), - }, - }; -} - -/** - * Creates a mock @mateothegreat/svelte5-router module. - * If gotoFn is provided, goto calls are delegated to it for assertion tracking. - */ -export function createMockRouterModule(gotoFn?: (...args: unknown[]) => void) { - return { - goto: gotoFn ? (...args: unknown[]) => gotoFn(...args) : vi.fn(), - route: () => {}, - }; -} - -/** - * Creates a mock $utils/meta module with updateMetaTags and pageMeta. - */ -export function createMetaMock( - updateMetaTagsFn: (...args: unknown[]) => void, - pageMeta: Record, -) { - return { - updateMetaTags: (...args: unknown[]) => updateMetaTagsFn(...args), - pageMeta, - }; -} - // ============================================================================ // Test Data Factories // ============================================================================ diff --git a/frontend/src/components/__tests__/Header.test.ts b/frontend/src/components/__tests__/Header.test.ts index 2c882754..bdf55d37 100644 --- a/frontend/src/components/__tests__/Header.test.ts +++ b/frontend/src/components/__tests__/Header.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user, suppressConsoleError } from '$test/test-utils'; +import * as router from '@mateothegreat/svelte5-router'; + const mocks = vi.hoisted(() => ({ mockAuthStore: { isAuthenticated: false as boolean | null, @@ -19,10 +21,8 @@ const mocks = vi.hoisted(() => ({ value: 'auto' as string, }, mockToggleTheme: vi.fn(), - mockGoto: vi.fn(), })); -vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: mocks.mockGoto })); vi.mock('../../stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); @@ -30,9 +30,11 @@ vi.mock('../../stores/theme.svelte', () => ({ get themeStore() { return mocks.mockThemeStore; }, get toggleTheme() { return mocks.mockToggleTheme; }, })); -vi.mock('../NotificationCenter.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent( - '
NotificationCenter
', 'notification-center')); +vi.mock('../NotificationCenter.svelte', () => { + const M = function() { return {}; } as any; + M.render = () => ({ html: '
NotificationCenter
', css: { code: '', map: null }, head: '' }); + return { default: M }; +}); import Header from '$components/Header.svelte'; @@ -62,7 +64,7 @@ describe('Header', () => { mocks.mockThemeStore.value = 'auto'; mocks.mockAuthStore.logout.mockReset(); mocks.mockToggleTheme.mockReset(); - mocks.mockGoto.mockReset(); + vi.spyOn(router, 'goto'); originalInnerWidth = window.innerWidth; Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); Object.defineProperty(window, 'matchMedia', { @@ -146,7 +148,7 @@ describe('Header', () => { await waitFor(() => { expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Logout/i })); expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -165,7 +167,7 @@ describe('Header', () => { await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^Admin$/ })); - await waitFor(() => { expect(mocks.mockGoto).toHaveBeenCalledWith('/admin/events'); }); + await waitFor(() => { expect(router.goto).toHaveBeenCalledWith('/admin/events'); }); }); }); @@ -271,7 +273,7 @@ describe('Header', () => { await user.click(logoutButton); expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); }); diff --git a/frontend/src/components/__tests__/ProtectedRoute.test.ts b/frontend/src/components/__tests__/ProtectedRoute.test.ts index 81f4c7e6..7f6441ef 100644 --- a/frontend/src/components/__tests__/ProtectedRoute.test.ts +++ b/frontend/src/components/__tests__/ProtectedRoute.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; +import * as router from '@mateothegreat/svelte5-router'; + const mocks = vi.hoisted(() => ({ mockAuthStore: { isAuthenticated: null as boolean | null, @@ -11,18 +13,12 @@ const mocks = vi.hoisted(() => ({ csrfToken: null as string | null, waitForInit: vi.fn().mockResolvedValue(true), }, - mockGoto: vi.fn(), })); -vi.mock('@mateothegreat/svelte5-router', () => ({ goto: mocks.mockGoto })); vi.mock('../../stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); -vi.mock('../Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent( - '
Loading...
', 'spinner')); - import ProtectedRoute from '$components/ProtectedRoute.svelte'; describe('ProtectedRoute', () => { @@ -37,7 +33,7 @@ describe('ProtectedRoute', () => { mocks.mockAuthStore.userEmail = null; mocks.mockAuthStore.csrfToken = null; - mocks.mockGoto.mockReset(); + vi.spyOn(router, 'goto'); mocks.mockAuthStore.waitForInit.mockReset().mockResolvedValue(true); Object.defineProperty(window, 'location', { @@ -131,7 +127,7 @@ describe('ProtectedRoute', () => { // Give time for any potential redirect await new Promise(resolve => setTimeout(resolve, 50)); - expect(mocks.mockGoto).not.toHaveBeenCalled(); + expect(router.goto).not.toHaveBeenCalled(); }); it('does not save redirect path when authenticated', async () => { @@ -157,7 +153,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -165,7 +161,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { redirectTo: '/custom-login' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/custom-login'); + expect(router.goto).toHaveBeenCalledWith('/custom-login'); }); }); @@ -173,7 +169,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['redirectAfterLogin']).toBe('/protected-page?foo=bar#section'); @@ -193,7 +189,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['redirectAfterLogin']).toBeUndefined(); @@ -203,7 +199,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: 'Custom auth message' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('Custom auth message'); @@ -213,7 +209,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('Please log in to access this page'); @@ -227,7 +223,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { redirectTo: '/signin' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/signin'); + expect(router.goto).toHaveBeenCalledWith('/signin'); }); }); @@ -237,7 +233,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: 'You need to sign in first' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('You need to sign in first'); @@ -282,7 +278,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -292,7 +288,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: '' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); // Empty message should not be saved diff --git a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts index 347ce73c..3374b9e1 100644 --- a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts @@ -2,10 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { createMockEventDetail, user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import EventDetailsModal from '../EventDetailsModal.svelte'; diff --git a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts index 7e386c38..fed85a22 100644 --- a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts @@ -2,10 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { createMockEvent, createMockEvents, user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('Eye', 'Play', 'Trash2')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import EventsTable from '../EventsTable.svelte'; diff --git a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts index aa1bf07b..4efe2b53 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('AlertTriangle', 'X')); import ReplayPreviewModal from '../ReplayPreviewModal.svelte'; diff --git a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts index da846c95..3e1dd0c9 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts @@ -3,8 +3,6 @@ import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; import type { EventReplayStatusResponse } from '$lib/api'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); import ReplayProgressBanner from '../ReplayProgressBanner.svelte'; diff --git a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts index e7bbed6e..32a7315c 100644 --- a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts @@ -2,12 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { createMockUserOverview } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('
Loading...
', 'spinner')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import UserOverviewModal from '../UserOverviewModal.svelte'; diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts index 0ff1ed0f..c96b122f 100644 --- a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, within, fireEvent, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ChevronDown', 'ChevronRight')); import LanguageSelect from '../LanguageSelect.svelte'; diff --git a/frontend/src/components/editor/__tests__/OutputPanel.test.ts b/frontend/src/components/editor/__tests__/OutputPanel.test.ts index 20ec76aa..8e7d7077 100644 --- a/frontend/src/components/editor/__tests__/OutputPanel.test.ts +++ b/frontend/src/components/editor/__tests__/OutputPanel.test.ts @@ -1,20 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; import type { ExecutionResult } from '$lib/api'; import type { ExecutionPhase } from '$lib/editor'; -const mocks = vi.hoisted(() => ({ - addToast: vi.fn(), -})); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('AlertTriangle', 'FileText', 'Copy')); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('
Loading...
', 'spinner')); - import OutputPanel from '../OutputPanel.svelte'; function makeResult(overrides: Partial = {}): ExecutionResult { @@ -44,6 +34,8 @@ function renderIdle(overrides: Partial = {}) { describe('OutputPanel', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); }); it('shows heading and prompt text when idle with no result or error', () => { @@ -161,14 +153,14 @@ describe('OutputPanel', () => { }); await user.click(screen.getByLabelText(ariaLabel)); expect(writeTextMock).toHaveBeenCalledWith(text); - expect(mocks.addToast).toHaveBeenCalledWith('success', `${toastLabel} copied to clipboard`); + expect(toast.success).toHaveBeenCalledWith(`${toastLabel} copied to clipboard`); }); it('shows error toast when clipboard write fails', async () => { mockClipboard(false); renderIdle({ result: makeResult({ stdout: 'x' }) }); await user.click(screen.getByLabelText('Copy output to clipboard')); - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to copy output'); + expect(toast.error).toHaveBeenCalledWith('Failed to copy output'); }); }); diff --git a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts index 8412c9a3..753cc3a0 100644 --- a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts +++ b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'MessageSquare', 'ChevronUp', 'ChevronDown', 'Cpu', 'MemoryStick', 'Clock', - )); + import ResourceLimits from '../ResourceLimits.svelte'; diff --git a/frontend/src/components/editor/__tests__/SavedScripts.test.ts b/frontend/src/components/editor/__tests__/SavedScripts.test.ts index 4ea704f4..21869006 100644 --- a/frontend/src/components/editor/__tests__/SavedScripts.test.ts +++ b/frontend/src/components/editor/__tests__/SavedScripts.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('List', 'Trash2')); + import SavedScripts from '../SavedScripts.svelte'; import type { SavedScriptResponse } from '$lib/api'; diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index e9a40327..5475d12e 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as meta from '$utils/meta'; import Editor from '$routes/Editor.svelte'; function createMockLimits() { @@ -21,7 +23,6 @@ const mocks = vi.hoisted(() => ({ createSavedScriptApiV1ScriptsPost: vi.fn(), updateSavedScriptApiV1ScriptsScriptIdPut: vi.fn(), deleteSavedScriptApiV1ScriptsScriptIdDelete: vi.fn(), - addToast: vi.fn(), mockConfirm: vi.fn(), mockExecutionState: { phase: 'idle' as string, @@ -45,7 +46,6 @@ const mocks = vi.hoisted(() => ({ mockUnwrapOr: vi.fn((result: { data?: unknown; error?: unknown }, fallback: unknown) => { return result.error ? fallback : result.data ?? fallback; }), - mockUpdateMetaTags: vi.fn(), })); vi.mock('$lib/api', () => ({ @@ -66,29 +66,13 @@ vi.mock('$stores/userSettings.svelte', () => ({ }, })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/api-interceptors', () => ({ unwrap: (...args: unknown[]) => (mocks.mockUnwrap as (...a: unknown[]) => unknown)(...args), unwrapOr: (...args: unknown[]) => (mocks.mockUnwrapOr as (...a: unknown[]) => unknown)(...args), })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { editor: { title: 'Code Editor', description: 'Editor desc' } })); - vi.mock('$lib/editor', () => ({ createExecutionState: () => mocks.mockExecutionState })); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'CirclePlay', 'Settings', 'Lightbulb', - 'FilePlus', 'Upload', 'Download', 'Save', - 'List', 'Trash2')); - vi.mock('$components/editor', async () => { const utils = await import('$test/test-utils'); const components = utils.createMockNamedComponents({ @@ -118,6 +102,11 @@ describe('Editor', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.mockAuthStore.isAuthenticated = true; mocks.mockAuthStore.verifyAuth.mockResolvedValue(true); @@ -182,7 +171,7 @@ describe('Editor', () => { it('calls updateMetaTags with editor meta', async () => { await renderEditor(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Code Editor', 'Editor desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Code Editor', expect.stringContaining('Online code editor')); }); }); }); @@ -217,7 +206,7 @@ describe('Editor', () => { }), }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script saved successfully.'); }); it('falls back to create when update returns 404', async () => { @@ -247,7 +236,7 @@ describe('Editor', () => { expect(screen.getByTitle(/Load Existing Script/)).toBeInTheDocument(); }); await user.click(screen.getByTitle(/Load Existing Script/)); - expect(mocks.addToast).toHaveBeenCalledWith('info', 'Loaded script: Existing Script'); + expect(toast.info).toHaveBeenCalledWith('Loaded script: Existing Script'); // Options closed after loadScript, reopen and click Save await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); @@ -272,7 +261,7 @@ describe('Editor', () => { }), }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script saved successfully.'); }); }); @@ -303,7 +292,7 @@ describe('Editor', () => { path: { script_id: 'script-99' }, }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script deleted successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script deleted successfully.'); }); }); @@ -332,7 +321,7 @@ describe('Editor', () => { await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); await user.click(screen.getByRole('button', { name: 'New' })); expect(mocks.mockExecutionState.reset).toHaveBeenCalled(); - expect(mocks.addToast).toHaveBeenCalledWith('info', 'New script started.'); + expect(toast.info).toHaveBeenCalledWith('New script started.'); }); }); }); diff --git a/frontend/src/routes/__tests__/Home.test.ts b/frontend/src/routes/__tests__/Home.test.ts index d5aa13ac..3c21010e 100644 --- a/frontend/src/routes/__tests__/Home.test.ts +++ b/frontend/src/routes/__tests__/Home.test.ts @@ -1,26 +1,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -const mocks = vi.hoisted(() => ({ - mockUpdateMetaTags: vi.fn(), -})); +import * as meta from '$utils/meta'; +import Home from '$routes/Home.svelte'; -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule()); - -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { home: { title: 'Home', description: 'Home desc' } })); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('Zap', 'ShieldCheck', 'Clock')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Home', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderHome() { - const { default: Home } = await import('$routes/Home.svelte'); + function renderHome() { return render(Home); } @@ -58,7 +49,7 @@ describe('Home', () => { it('calls updateMetaTags with home meta on mount', async () => { await renderHome(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Home', 'Home desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Home', expect.stringContaining('Integr8sCode')); }); }); }); diff --git a/frontend/src/routes/__tests__/Login.test.ts b/frontend/src/routes/__tests__/Login.test.ts index 38182147..e7e94342 100644 --- a/frontend/src/routes/__tests__/Login.test.ts +++ b/frontend/src/routes/__tests__/Login.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as router from '@mateothegreat/svelte5-router'; +import * as meta from '$utils/meta'; +import Login from '$routes/Login.svelte'; + const mocks = vi.hoisted(() => ({ mockLogin: vi.fn(), - mockGoto: vi.fn(), - addToast: vi.fn(), mockLoadUserSettings: vi.fn(), mockGetErrorMessage: vi.fn((err: unknown, fallback?: string) => fallback || String(err)), - mockUpdateMetaTags: vi.fn(), mockAuthStore: { login: vi.fn(), isAuthenticated: false, @@ -19,12 +21,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('$stores/auth.svelte', () => ({ authStore: mocks.mockAuthStore })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule(mocks.mockGoto)); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/user-settings', () => ({ loadUserSettings: mocks.mockLoadUserSettings, })); @@ -33,22 +29,21 @@ vi.mock('$lib/api-interceptors', () => ({ getErrorMessage: mocks.mockGetErrorMessage, })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { login: { title: 'Login', description: 'Login desc' } })); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Login', () => { beforeEach(() => { vi.clearAllMocks(); mocks.mockAuthStore.login = vi.fn().mockResolvedValue(true); mocks.mockLoadUserSettings.mockResolvedValue(undefined); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderLogin() { - const { default: Login } = await import('$routes/Login.svelte'); + function renderLogin() { return render(Login); } @@ -65,7 +60,7 @@ describe('Login', () => { sessionStorage.setItem('authMessage', 'Please log in to continue'); await renderLogin(); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('info', 'Please log in to continue'); + expect(toast.info).toHaveBeenCalledWith('Please log in to continue'); }); expect(sessionStorage.getItem('authMessage')).toBeNull(); }); @@ -80,8 +75,8 @@ describe('Login', () => { expect(mocks.mockAuthStore.login).toHaveBeenCalledWith('testuser', 'pass1234'); }); expect(mocks.mockLoadUserSettings).toHaveBeenCalled(); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Login successful! Welcome back.'); - expect(mocks.mockGoto).toHaveBeenCalledWith('/editor'); + expect(toast.success).toHaveBeenCalledWith('Login successful! Welcome back.'); + expect(router.goto).toHaveBeenCalledWith('/editor'); }); it.each([ @@ -99,7 +94,7 @@ describe('Login', () => { await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith(expectedNav); + expect(router.goto).toHaveBeenCalledWith(expectedNav); }); expect(sessionStorage.getItem('redirectAfterLogin')).toBeNull(); }); @@ -116,7 +111,7 @@ describe('Login', () => { await waitFor(() => { expect(screen.getByText('Login failed. Please check your credentials.')).toBeInTheDocument(); }); - expect(mocks.addToast).not.toHaveBeenCalledWith('error', expect.anything()); + expect(toast.error).not.toHaveBeenCalled(); }); it('disables button and shows "Logging in..." during loading', async () => { @@ -144,7 +139,7 @@ describe('Login', () => { it('calls updateMetaTags on mount', async () => { await renderLogin(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Login', 'Login desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Login', expect.stringContaining('Sign in to Integr8sCode')); }); }); }); diff --git a/frontend/src/routes/__tests__/Notifications.test.ts b/frontend/src/routes/__tests__/Notifications.test.ts index 50cfaeab..8058dd92 100644 --- a/frontend/src/routes/__tests__/Notifications.test.ts +++ b/frontend/src/routes/__tests__/Notifications.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { createMockNotification, createMockNotifications, user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import Notifications from '$routes/Notifications.svelte'; const mocks = vi.hoisted(() => ({ - addToast: vi.fn(), mockNotificationStore: { notifications: [] as ReturnType[], unreadCount: 0, @@ -18,16 +19,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('$stores/notificationStore.svelte', () => ({ notificationStore: mocks.mockNotificationStore })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'Bell', 'Trash2', 'Clock', 'CircleCheck', 'AlertCircle', 'Info')); - vi.mock('$lib/api', () => ({})); describe('Notifications', () => { @@ -37,13 +28,14 @@ describe('Notifications', () => { mocks.mockNotificationStore.unreadCount = 0; mocks.mockNotificationStore.loading = false; mocks.mockNotificationStore.load.mockResolvedValue([]); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); mocks.mockNotificationStore.markAsRead.mockResolvedValue(true); mocks.mockNotificationStore.markAllAsRead.mockResolvedValue(true); mocks.mockNotificationStore.delete.mockResolvedValue(true); }); - async function renderNotifications() { - const { default: Notifications } = await import('$routes/Notifications.svelte'); + function renderNotifications() { return render(Notifications); } @@ -173,7 +165,7 @@ describe('Notifications', () => { await user.click(btn); expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('success', 'All notifications marked as read'); + expect(toast.success).toHaveBeenCalledWith('All notifications marked as read'); }); }); @@ -188,7 +180,7 @@ describe('Notifications', () => { ); await user.click(btn); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to mark all as read'); + expect(toast.error).toHaveBeenCalledWith('Failed to mark all as read'); }); }); }); @@ -204,7 +196,7 @@ describe('Notifications', () => { await user.click(deleteBtn); await waitFor(() => { expect(mocks.mockNotificationStore.delete).toHaveBeenCalledWith('del-1'); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Notification deleted'); + expect(toast.success).toHaveBeenCalledWith('Notification deleted'); }); }); @@ -218,7 +210,7 @@ describe('Notifications', () => { const deleteBtn = await screen.findByRole('button', { name: 'Delete notification' }); await user.click(deleteBtn); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to delete notification'); + expect(toast.error).toHaveBeenCalledWith('Failed to delete notification'); }); }); diff --git a/frontend/src/routes/__tests__/Register.test.ts b/frontend/src/routes/__tests__/Register.test.ts index 006b0954..f7229545 100644 --- a/frontend/src/routes/__tests__/Register.test.ts +++ b/frontend/src/routes/__tests__/Register.test.ts @@ -1,43 +1,38 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as router from '@mateothegreat/svelte5-router'; +import * as meta from '$utils/meta'; +import Register from '$routes/Register.svelte'; + const mocks = vi.hoisted(() => ({ registerApiV1AuthRegisterPost: vi.fn(), - mockGoto: vi.fn(), - addToast: vi.fn(), mockGetErrorMessage: vi.fn((_err: unknown, fallback?: string) => fallback || 'Unknown error'), - mockUpdateMetaTags: vi.fn(), })); vi.mock('$lib/api', () => ({ registerApiV1AuthRegisterPost: mocks.registerApiV1AuthRegisterPost, })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule(mocks.mockGoto)); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/api-interceptors', () => ({ getErrorMessage: mocks.mockGetErrorMessage, })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { register: { title: 'Register', description: 'Register desc' } })); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Register', () => { beforeEach(() => { vi.clearAllMocks(); mocks.registerApiV1AuthRegisterPost.mockResolvedValue({ data: {}, error: undefined }); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderRegister() { - const { default: Register } = await import('$routes/Register.svelte'); + function renderRegister() { return render(Register); } @@ -78,7 +73,7 @@ describe('Register', () => { await waitFor(() => { expect(screen.getByText(expectedError)).toBeInTheDocument(); }); - expect(mocks.addToast).toHaveBeenCalledWith(toastType, expectedError); + expect(toast[toastType as keyof typeof toast]).toHaveBeenCalledWith(expectedError); expect(mocks.registerApiV1AuthRegisterPost).not.toHaveBeenCalled(); }); @@ -95,8 +90,8 @@ describe('Register', () => { body: { username: 'newuser', email: 'new@email.com', password: 'securepass' }, }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Registration successful! Please log in.'); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(toast.success).toHaveBeenCalledWith('Registration successful! Please log in.'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); it('shows error in DOM on API error (no duplicate toast)', async () => { @@ -116,7 +111,7 @@ describe('Register', () => { await waitFor(() => { expect(screen.getByText('Registration failed. Please try again.')).toBeInTheDocument(); }); - expect(mocks.addToast).not.toHaveBeenCalledWith('error', expect.anything()); + expect(toast.error).not.toHaveBeenCalled(); }); it('disables button and shows "Registering..." during loading', async () => { @@ -146,7 +141,7 @@ describe('Register', () => { it('calls updateMetaTags on mount', async () => { await renderRegister(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Register', 'Register desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Register', expect.stringContaining('Create a free Integr8sCode account')); }); }); }); diff --git a/frontend/src/routes/__tests__/Settings.test.ts b/frontend/src/routes/__tests__/Settings.test.ts index 1795d214..7024ecc7 100644 --- a/frontend/src/routes/__tests__/Settings.test.ts +++ b/frontend/src/routes/__tests__/Settings.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; import Settings from '$routes/Settings.svelte'; function createMockSettings() { @@ -28,7 +29,6 @@ const mocks = vi.hoisted(() => ({ updateUserSettingsApiV1UserSettingsPut: vi.fn(), restoreSettingsApiV1UserSettingsRestorePost: vi.fn(), getSettingsHistoryApiV1UserSettingsHistoryGet: vi.fn(), - addToast: vi.fn(), mockSetTheme: vi.fn(), mockSetUserSettings: vi.fn(), mockConfirm: vi.fn(), @@ -54,18 +54,13 @@ vi.mock('$stores/userSettings.svelte', () => ({ userSettingsStore: { settings: null, editorSettings: {} }, })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ChevronDown')); - describe('Settings', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.mockAuthStore.isAuthenticated = true; mocks.getUserSettingsApiV1UserSettingsGet.mockResolvedValue({ @@ -150,7 +145,7 @@ describe('Settings', () => { }); await user.click(screen.getByRole('button', { name: /save settings/i })); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('info', 'No changes to save'); + expect(toast.info).toHaveBeenCalledWith('No changes to save'); }); expect(mocks.updateUserSettingsApiV1UserSettingsPut).not.toHaveBeenCalled(); }); @@ -184,7 +179,7 @@ describe('Settings', () => { const callArgs = mocks.updateUserSettingsApiV1UserSettingsPut.mock.calls[0]![0]; expect(callArgs.body).toHaveProperty('editor'); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings saved successfully'); + expect(toast.success).toHaveBeenCalledWith('Settings saved successfully'); }); expect(mocks.mockSetUserSettings).toHaveBeenCalled(); }); @@ -272,7 +267,7 @@ describe('Settings', () => { }); }); expect(mocks.mockSetTheme).toHaveBeenCalledWith('light'); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings restored successfully'); + expect(toast.success).toHaveBeenCalledWith('Settings restored successfully'); }); it('does not call API when confirm is cancelled', async () => { diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index b09c0ae2..53e8c0af 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -48,12 +48,6 @@ vi.mock('svelte-sonner', () => ({ vi.mock('../../../lib/api-interceptors'); -// Mock @mateothegreat/svelte5-router -vi.mock('@mateothegreat/svelte5-router', () => ({ - route: () => {}, - goto: vi.fn(), -})); - // Simple mock for AdminLayout vi.mock('../AdminLayout.svelte', async () => { const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte'); diff --git a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts index a9fcb9f2..9055bd52 100644 --- a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts @@ -81,11 +81,6 @@ vi.mock('../../../lib/api-interceptors', async (importOriginal) => { return { ...actual }; }); -vi.mock('@mateothegreat/svelte5-router', () => ({ - route: () => {}, - goto: vi.fn(), -})); - vi.mock('../AdminLayout.svelte', async () => { const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte'); return { default: MockLayout }; diff --git a/frontend/src/routes/admin/__tests__/AdminLayout.test.ts b/frontend/src/routes/admin/__tests__/AdminLayout.test.ts index 2fe48006..53c7ee1d 100644 --- a/frontend/src/routes/admin/__tests__/AdminLayout.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminLayout.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; +import { toast } from 'svelte-sonner'; +import * as router from '@mateothegreat/svelte5-router'; +import AdminLayout from '$routes/admin/AdminLayout.svelte'; + const mocks = vi.hoisted(() => ({ - addToast: vi.fn(), - mockGoto: vi.fn(), mockAuthStore: { isAuthenticated: true, username: 'adminuser', @@ -16,27 +18,20 @@ const mocks = vi.hoisted(() => ({ vi.mock('$stores/auth.svelte', () => ({ authStore: mocks.mockAuthStore })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule(mocks.mockGoto)); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ShieldCheck')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('AdminLayout', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); mocks.mockAuthStore.userRole = 'admin'; mocks.mockAuthStore.username = 'adminuser'; }); - async function renderLayout(path = '/admin/events') { - const { default: AdminLayout } = await import('$routes/admin/AdminLayout.svelte'); + function renderLayout(path = '/admin/events') { return render(AdminLayout, { props: { path } }); } @@ -44,9 +39,9 @@ describe('AdminLayout', () => { mocks.mockAuthStore.userRole = 'user'; await renderLayout(); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Admin access required'); + expect(toast.error).toHaveBeenCalledWith('Admin access required'); }); - expect(mocks.mockGoto).toHaveBeenCalledWith('/'); + expect(router.goto).toHaveBeenCalledWith('/'); }); it('renders sidebar with "Admin Panel" heading for admin users', async () => { diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts index db482b93..ddc5ac94 100644 --- a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -61,7 +61,6 @@ vi.mock('../../../lib/api', () => ({ })); vi.mock('../../../lib/api-interceptors'); -vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); vi.mock('../AdminLayout.svelte', async () => { const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte'); return { default: MockLayout }; diff --git a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts index 45b38ba9..853e76d7 100644 --- a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import AdminSettings from '$routes/admin/AdminSettings.svelte'; function createMockSystemSettings() { return { @@ -23,7 +25,6 @@ const mocks = vi.hoisted(() => ({ getSystemSettingsApiV1AdminSettingsGet: vi.fn(), updateSystemSettingsApiV1AdminSettingsPut: vi.fn(), resetSystemSettingsApiV1AdminSettingsResetPost: vi.fn(), - addToast: vi.fn(), mockConfirm: vi.fn(), mockAuthStore: { isAuthenticated: true, @@ -41,24 +42,16 @@ vi.mock('../../../lib/api', () => ({ vi.mock('$stores/auth.svelte', () => ({ authStore: mocks.mockAuthStore })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule()); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$routes/admin/AdminLayout.svelte', () => import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte')); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ShieldCheck')); - describe('AdminSettings', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.getSystemSettingsApiV1AdminSettingsGet.mockResolvedValue({ data: createMockSystemSettings(), @@ -76,8 +69,7 @@ describe('AdminSettings', () => { afterEach(() => vi.unstubAllGlobals()); - async function renderAdminSettings() { - const { default: AdminSettings } = await import('$routes/admin/AdminSettings.svelte'); + function renderAdminSettings() { return render(AdminSettings); } @@ -134,7 +126,7 @@ describe('AdminSettings', () => { expect(callArgs.body).toHaveProperty('max_timeout_seconds'); expect(callArgs.body).toHaveProperty('password_min_length'); expect(callArgs.body).toHaveProperty('metrics_retention_days'); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings saved successfully'); + expect(toast.success).toHaveBeenCalledWith('Settings saved successfully'); }); it('handles save error without crashing', async () => { @@ -150,7 +142,7 @@ describe('AdminSettings', () => { await waitFor(() => { expect(mocks.updateSystemSettingsApiV1AdminSettingsPut).toHaveBeenCalled(); }); - expect(mocks.addToast).not.toHaveBeenCalledWith('success', expect.anything()); + expect(toast.success).not.toHaveBeenCalledWith('Settings saved successfully'); }); }); @@ -186,7 +178,7 @@ describe('AdminSettings', () => { await waitFor(() => { expect(mocks.resetSystemSettingsApiV1AdminSettingsResetPost).toHaveBeenCalled(); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings reset to defaults'); + expect(toast.success).toHaveBeenCalledWith('Settings reset to defaults'); await waitFor(() => { const timeoutInput = document.getElementById('max-timeout') as HTMLInputElement; diff --git a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts index 91ce803b..4810ab82 100644 --- a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts @@ -82,11 +82,6 @@ vi.mock('../../../lib/formatters', () => ({ }, })); -vi.mock('@mateothegreat/svelte5-router', () => ({ - route: () => {}, - goto: vi.fn(), -})); - // Simple mock for AdminLayout that just renders children vi.mock('../AdminLayout.svelte', async () => { const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte'); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5ad10983..fd41404f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,9 +10,23 @@ export default defineConfig({ ], test: { environment: 'jsdom', + pool: 'threads', + css: false, setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.{test,spec}.{js,ts}'], globals: true, + environmentMatchGlobs: [ + ['src/lib/**/*.test.ts', 'node'], + ['src/stores/**/*.test.ts', 'node'], + ['src/utils/**/*.test.ts', 'node'], + ], + deps: { + optimizer: { + web: { + include: ['@lucide/svelte', 'svelte-sonner', '@mateothegreat/svelte5-router'], + }, + }, + }, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 6badbd70..33453a83 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -79,7 +79,8 @@ export function resetStorageMocks() { } // Mock Element.prototype.animate for Svelte transitions (canonical global stub) -Element.prototype.animate = vi.fn().mockImplementation(() => { +// Guarded for node environment where Element is not available +if (typeof Element !== 'undefined') Element.prototype.animate = vi.fn().mockImplementation(() => { const mock = { _onfinish: null as (() => void) | null, get onfinish() { return this._onfinish; }, From 274bf84a5d2acbdf21fad82be18d2d9dec39791e Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 26 Feb 2026 01:38:37 +0100 Subject: [PATCH 06/20] fix: quicker frontend/unit tests - independent unit tests --- .../src/routes/admin/__tests__/AdminEvents.test.ts | 10 +++------- frontend/vitest.config.ts | 6 +++++- frontend/vitest.setup.ts | 12 ++++++++++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index 53e8c0af..2e19edae 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -283,9 +283,7 @@ describe('AdminEvents', () => { mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: createMockStats(), error: null }); render(AdminEvents); - await waitFor(() => expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled()); - - expect(screen.getByText(/Showing 1 to 10 of 25 events/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/Showing 1 to 10 of 25 events/i)).toBeInTheDocument()); }); it('changes page when next is clicked', async () => { @@ -296,10 +294,9 @@ describe('AdminEvents', () => { mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: createMockStats(), error: null }); render(AdminEvents); - await waitFor(() => expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled()); + const nextBtn = await waitFor(() => screen.getByTitle('Next page')); mocks.browseEventsApiV1AdminEventsBrowsePost.mockClear(); - const nextBtn = screen.getByTitle('Next page'); await user.click(nextBtn); await waitFor(() => { @@ -321,10 +318,9 @@ describe('AdminEvents', () => { mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: createMockStats(), error: null }); render(AdminEvents); - await waitFor(() => expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled()); + const pageSizeSelect = await waitFor(() => screen.getByLabelText(/Show:/i)); mocks.browseEventsApiV1AdminEventsBrowsePost.mockClear(); - const pageSizeSelect = screen.getByLabelText(/Show:/i); await user.selectOptions(pageSizeSelect, '25'); await waitFor(() => { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fd41404f..d2f647fe 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vitest/config'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelteTesting } from '@testing-library/svelte/vite'; +import pkg from './package.json' with { type: 'json' }; // --8<-- [start:config] export default defineConfig({ @@ -11,6 +12,9 @@ export default defineConfig({ test: { environment: 'jsdom', pool: 'threads', + maxWorkers: 8, + minWorkers: 2, + isolate: false, css: false, setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.{test,spec}.{js,ts}'], @@ -23,7 +27,7 @@ export default defineConfig({ deps: { optimizer: { web: { - include: ['@lucide/svelte', 'svelte-sonner', '@mateothegreat/svelte5-router'], + include: Object.keys(pkg.dependencies), }, }, }, diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 33453a83..16002cef 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -1,5 +1,6 @@ import '@testing-library/jest-dom/vitest'; -import { vi } from 'vitest'; +import { vi, beforeEach } from 'vitest'; +import { cleanup } from '@testing-library/svelte'; // Global handler for promise rejections (mirrors main.ts behavior) // API errors are handled by interceptor - just silence the rejection in tests @@ -71,7 +72,14 @@ vi.stubGlobal('IntersectionObserver', vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), }))); -// Helper to reset mocks between tests +// Reset DOM and storage between every test (required for isolate: false) +beforeEach(() => { + cleanup(); + Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); + Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); +}); + +// Helper to reset mocks between tests (legacy export, kept for compatibility) export function resetStorageMocks() { Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); From 9cf05ae27a6af1a004dd520304c45cb7a1f2a1dc Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 26 Feb 2026 02:51:57 +0100 Subject: [PATCH 07/20] feat: pulling store refactor for specific components --- frontend/package-lock.json | 122 ++++-- frontend/package.json | 2 +- frontend/src/__tests__/test-utils.ts | 3 +- .../__tests__/NotificationCenter.test.ts | 14 +- frontend/src/lib/__tests__/formatters.test.ts | 4 +- .../lib/admin/__tests__/autoRefresh.test.ts | 11 +- frontend/src/lib/admin/autoRefresh.svelte.ts | 9 - .../stores/__tests__/eventsStore.test.ts | 367 ++++++++++++++++++ .../stores/__tests__/executionsStore.test.ts | 254 ++++++++++++ .../admin/stores/__tests__/sagasStore.test.ts | 231 +++++++++++ .../lib/admin/stores/eventsStore.svelte.ts | 186 +++++++++ .../admin/stores/executionsStore.svelte.ts | 81 ++++ .../src/lib/admin/stores/sagasStore.svelte.ts | 86 ++++ frontend/src/routes/admin/AdminEvents.svelte | 276 +++---------- .../src/routes/admin/AdminExecutions.svelte | 141 ++----- frontend/src/routes/admin/AdminSagas.svelte | 167 ++------ .../admin/__tests__/AdminEvents.test.ts | 32 +- .../admin/__tests__/AdminExecutions.test.ts | 11 +- .../routes/admin/__tests__/AdminSagas.test.ts | 21 +- .../routes/admin/__tests__/AdminUsers.test.ts | 8 +- frontend/vitest.config.ts | 4 + frontend/vitest.setup.ts | 3 + 22 files changed, 1465 insertions(+), 568 deletions(-) create mode 100644 frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts create mode 100644 frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts create mode 100644 frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts create mode 100644 frontend/src/lib/admin/stores/eventsStore.svelte.ts create mode 100644 frontend/src/lib/admin/stores/executionsStore.svelte.ts create mode 100644 frontend/src/lib/admin/stores/sagasStore.svelte.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4b529853..6e87f440 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,8 +57,8 @@ "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.7.0", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", @@ -82,7 +82,9 @@ "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@adobe/css-tools": { "version": "4.4.4", @@ -107,6 +109,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", @@ -120,6 +124,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", @@ -132,7 +138,9 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -217,6 +225,8 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "css-tree": "^3.0.0" }, @@ -370,6 +380,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -389,6 +401,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -412,6 +426,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" @@ -439,6 +455,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -460,7 +478,9 @@ "type": "opencollective", "url": "https://opencollective.com/csstools" } - ] + ], + "optional": true, + "peer": true }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -477,6 +497,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1045,6 +1067,8 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -2340,8 +2364,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2362,17 +2384,13 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@types/node": "*" } @@ -2877,6 +2895,8 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -3046,6 +3066,8 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -3565,6 +3587,8 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" @@ -3724,6 +3748,8 @@ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^4.1.2", "@csstools/css-syntax-patches-for-csstree": "^1.0.26", @@ -3739,6 +3765,8 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -3768,7 +3796,9 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/deep-equal": { "version": "1.0.1", @@ -4847,8 +4877,6 @@ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -4866,8 +4894,6 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -4880,8 +4906,6 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -4891,8 +4915,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -4947,6 +4969,8 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -5046,6 +5070,8 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -5059,6 +5085,8 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -5279,7 +5307,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/is-promise": { "version": "4.0.0", @@ -5385,6 +5415,8 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -5936,6 +5968,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -6002,7 +6036,9 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/media-typer": { "version": "1.1.0", @@ -6462,6 +6498,8 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -6474,6 +6512,8 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -7467,6 +7507,8 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7772,6 +7814,8 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -8386,7 +8430,9 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/tailwindcss": { "version": "4.1.18", @@ -8485,6 +8531,8 @@ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.0.19" }, @@ -8496,7 +8544,9 @@ "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -8533,6 +8583,8 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -8545,6 +8597,8 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -8623,6 +8677,8 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20.18.1" } @@ -8631,9 +8687,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/unpipe": { "version": "1.0.0", @@ -8873,6 +8927,8 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8885,6 +8941,8 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -8894,6 +8952,8 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -8903,6 +8963,8 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -9000,6 +9062,8 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -9008,7 +9072,9 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/yocto-queue": { "version": "0.1.0", diff --git a/frontend/package.json b/frontend/package.json index 822e3406..a99f69cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,8 +66,8 @@ "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.7.0", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index a0e8e74a..3392826d 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -22,8 +22,7 @@ import type { export type UserEventInstance = ReturnType; -export const user: UserEventInstance = userEvent.setup(); -export const userWithTimers: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); +export const user: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); // ============================================================================ // Mock Store Type (for use with vi.hoisted) diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index beb88424..330ec43e 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import { user, userWithTimers, type UserEventInstance } from '$test/test-utils'; +import { user, type UserEventInstance } from '$test/test-utils'; // Types for mock notification state interface MockNotification { notification_id: string; @@ -299,15 +299,12 @@ describe('NotificationCenter', () => { }); it('ignores non-Enter keydown', async () => { - vi.useFakeTimers(); setNotifications([createNotification({ subject: 'Test', action_url: '/test' })]); - const timerUser = userWithTimers; render(NotificationCenter); - await timerUser.click(screen.getByRole('button', { name: /Notifications/i })); + await user.click(screen.getByRole('button', { name: /Notifications/i })); screen.getByRole('button', { name: /View notification: Test/i }).focus(); - await timerUser.keyboard('{Tab}'); + await user.keyboard('{Tab}'); expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); - vi.useRealTimers(); }); }); @@ -338,14 +335,11 @@ describe('NotificationCenter', () => { describe('auto-mark as read', () => { it('marks notifications after 2s delay', async () => { - vi.useFakeTimers(); setNotifications([createNotification({ notification_id: '1' }), createNotification({ notification_id: '2' })]); - const timerUser = userWithTimers; render(NotificationCenter); - await timerUser.click(screen.getByRole('button', { name: /Notifications/i })); + await user.click(screen.getByRole('button', { name: /Notifications/i })); await vi.advanceTimersByTimeAsync(2500); expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalled(); - vi.useRealTimers(); }); }); diff --git a/frontend/src/lib/__tests__/formatters.test.ts b/frontend/src/lib/__tests__/formatters.test.ts index 6f289317..6f4a25d3 100644 --- a/frontend/src/lib/__tests__/formatters.test.ts +++ b/frontend/src/lib/__tests__/formatters.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { formatDate, formatTimestamp, @@ -98,10 +98,8 @@ describe('formatDurationBetween', () => { describe('formatRelativeTime', () => { beforeEach(() => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2025, 6, 15, 12, 0, 0)); }); - afterEach(() => vi.useRealTimers()); it.each([ [new Date(2025, 6, 15, 11, 59, 30), 'just now'], // 30s ago diff --git a/frontend/src/lib/admin/__tests__/autoRefresh.test.ts b/frontend/src/lib/admin/__tests__/autoRefresh.test.ts index a2e608c0..5e1b148b 100644 --- a/frontend/src/lib/admin/__tests__/autoRefresh.test.ts +++ b/frontend/src/lib/admin/__tests__/autoRefresh.test.ts @@ -1,17 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { effect_root } from 'svelte/internal/client'; -// Mock onDestroy — no component lifecycle in unit tests -vi.mock('svelte', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, onDestroy: vi.fn() }; -}); - const { createAutoRefresh } = await import('../autoRefresh.svelte'); describe('createAutoRefresh', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); function make(overrides: Record = {}) { const onRefresh = vi.fn(); @@ -19,7 +11,6 @@ describe('createAutoRefresh', () => { const teardown = effect_root(() => { ar = createAutoRefresh({ onRefresh, - autoCleanup: false, initialEnabled: false, ...overrides, }); diff --git a/frontend/src/lib/admin/autoRefresh.svelte.ts b/frontend/src/lib/admin/autoRefresh.svelte.ts index 1306a22e..1550dd16 100644 --- a/frontend/src/lib/admin/autoRefresh.svelte.ts +++ b/frontend/src/lib/admin/autoRefresh.svelte.ts @@ -3,8 +3,6 @@ * Manages interval-based data reloading with configurable rates */ -import { onDestroy } from 'svelte'; - export interface AutoRefreshState { enabled: boolean; rate: number; @@ -25,7 +23,6 @@ export interface AutoRefreshOptions { initialRate?: number; rateOptions?: RefreshRateOption[]; onRefresh: () => void | Promise; - autoCleanup?: boolean; } const DEFAULT_RATE_OPTIONS: RefreshRateOption[] = [ @@ -39,7 +36,6 @@ const DEFAULT_OPTIONS = { initialEnabled: true, initialRate: 5, rateOptions: DEFAULT_RATE_OPTIONS, - autoCleanup: true }; /** @@ -79,11 +75,6 @@ export function createAutoRefresh(options: AutoRefreshOptions): AutoRefreshState stop(); } - // Auto-cleanup on component destroy if enabled - if (opts.autoCleanup) { - onDestroy(cleanup); - } - // Watch for changes to enabled/rate and restart $effect(() => { void enabled; diff --git a/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts new file mode 100644 index 00000000..6ba4e11d --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + browseEventsApiV1AdminEventsBrowsePost: vi.fn(), + getEventStatsApiV1AdminEventsStatsGet: vi.fn(), + getEventDetailApiV1AdminEventsEventIdGet: vi.fn(), + getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet: vi.fn(), + replayEventsApiV1AdminEventsReplayPost: vi.fn(), + deleteEventApiV1AdminEventsEventIdDelete: vi.fn(), + getUserOverviewApiV1AdminUsersUserIdOverviewGet: vi.fn(), + unwrap: vi.fn((result: { data: unknown }) => result?.data), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), + toastSuccess: vi.fn(), + toastError: vi.fn(), + toastInfo: vi.fn(), + windowOpen: vi.fn(), + windowConfirm: vi.fn(), +})); + +vi.mock('$lib/api', () => ({ + browseEventsApiV1AdminEventsBrowsePost: (...args: unknown[]) => mocks.browseEventsApiV1AdminEventsBrowsePost(...args), + getEventStatsApiV1AdminEventsStatsGet: (...args: unknown[]) => mocks.getEventStatsApiV1AdminEventsStatsGet(...args), + getEventDetailApiV1AdminEventsEventIdGet: (...args: unknown[]) => mocks.getEventDetailApiV1AdminEventsEventIdGet(...args), + getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet: (...args: unknown[]) => mocks.getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet(...args), + replayEventsApiV1AdminEventsReplayPost: (...args: unknown[]) => mocks.replayEventsApiV1AdminEventsReplayPost(...args), + deleteEventApiV1AdminEventsEventIdDelete: (...args: unknown[]) => mocks.deleteEventApiV1AdminEventsEventIdDelete(...args), + getUserOverviewApiV1AdminUsersUserIdOverviewGet: (...args: unknown[]) => mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrap: (result: { data: unknown }) => mocks.unwrap(result), + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +vi.mock('svelte-sonner', () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + error: (...args: unknown[]) => mocks.toastError(...args), + info: (...args: unknown[]) => mocks.toastInfo(...args), + warning: vi.fn(), + }, +})); + +const { createEventsStore } = await import('../eventsStore.svelte'); + +const createMockEvent = (overrides: Record = {}) => ({ + event_id: 'evt-1', + event_type: 'execution_completed', + event_version: '1', + timestamp: '2024-01-15T10:30:00Z', + aggregate_id: 'exec-456', + metadata: { service_name: 'test-service', service_version: '1.0.0', user_id: 'user-1' }, + execution_id: 'exec-456', + exit_code: 0, + stdout: 'hello', + ...overrides, +}); + +const createMockStats = () => ({ + total_events: 150, + error_rate: 2.5, + avg_processing_time: 1.23, + top_users: [], + events_by_type: [], + events_by_hour: [], +}); + +describe('EventsStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('open', mocks.windowOpen); + vi.stubGlobal('confirm', mocks.windowConfirm); + mocks.windowConfirm.mockReturnValue(true); + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ + data: { events: [], total: 0 }, + }); + mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ + data: null, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createEventsStore(); + }); + } + + afterEach(() => { + vi.unstubAllGlobals(); + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data', () => { + createStore(); + expect(store.events).toEqual([]); + expect(store.totalEvents).toBe(0); + expect(store.loading).toBe(false); + expect(store.stats).toBeNull(); + expect(store.filters).toEqual({}); + }); + }); + + describe('loadAll', () => { + it('loads events and stats', async () => { + const events = [createMockEvent()]; + const stats = createMockStats(); + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ + data: { events, total: 1 }, + }); + mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ + data: stats, + }); + + createStore(); + await store.loadAll(); + + expect(store.events).toEqual(events); + expect(store.totalEvents).toBe(1); + expect(store.stats).toEqual(stats); + }); + }); + + describe('loadEvents', () => { + it('passes pagination to API', async () => { + createStore(); + store.pagination.currentPage = 2; + await store.loadEvents(); + + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ skip: 10, limit: 10 }), + }), + ); + }); + + it('passes filters to API', async () => { + createStore(); + store.filters = { user_id: 'user-1', aggregate_id: 'agg-1' }; + await store.loadEvents(); + + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + filters: expect.objectContaining({ + user_id: 'user-1', + aggregate_id: 'agg-1', + }), + }), + }), + ); + }); + + it('handles empty response', async () => { + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: null }); + + createStore(); + await store.loadEvents(); + + expect(store.events).toEqual([]); + expect(store.totalEvents).toBe(0); + }); + }); + + describe('loadEventDetail', () => { + it('returns event detail', async () => { + const detail = { event: createMockEvent(), related_events: [], timeline: [] }; + mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: detail }); + + createStore(); + const result = await store.loadEventDetail('evt-1'); + + expect(result).toEqual(detail); + expect(mocks.getEventDetailApiV1AdminEventsEventIdGet).toHaveBeenCalledWith({ + path: { event_id: 'evt-1' }, + }); + }); + }); + + describe('replayEvent', () => { + it('performs dry run and sets replayPreview', async () => { + const preview = [createMockEvent()]; + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, events_preview: preview }, + }); + + createStore(); + await store.replayEvent('evt-1', true); + + expect(store.replayPreview).toEqual({ + eventId: 'evt-1', + total_events: 1, + events_preview: preview, + }); + }); + + it('confirms before actual replay', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, session_id: 'session-1', replay_id: 'replay-1' }, + }); + mocks.getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet.mockResolvedValue({ + data: { session_id: 'session-1', status: 'in_progress', total_events: 1, replayed_events: 0, progress_percentage: 0 }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + expect(mocks.windowConfirm).toHaveBeenCalled(); + expect(mocks.toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Replay scheduled')); + expect(store.activeReplaySession).toBeTruthy(); + }); + + it('does not replay if confirm is cancelled', async () => { + mocks.windowConfirm.mockReturnValue(false); + + createStore(); + await store.replayEvent('evt-1', false); + + expect(mocks.replayEventsApiV1AdminEventsReplayPost).not.toHaveBeenCalled(); + }); + }); + + describe('deleteEvent', () => { + it('confirms and deletes event', async () => { + mocks.deleteEventApiV1AdminEventsEventIdDelete.mockResolvedValue({ data: {} }); + + createStore(); + await store.deleteEvent('evt-1'); + + expect(mocks.windowConfirm).toHaveBeenCalled(); + expect(mocks.deleteEventApiV1AdminEventsEventIdDelete).toHaveBeenCalledWith({ + path: { event_id: 'evt-1' }, + }); + expect(mocks.toastSuccess).toHaveBeenCalledWith('Event deleted successfully'); + }); + + it('does not delete if confirm is cancelled', async () => { + mocks.windowConfirm.mockReturnValue(false); + + createStore(); + await store.deleteEvent('evt-1'); + + expect(mocks.deleteEventApiV1AdminEventsEventIdDelete).not.toHaveBeenCalled(); + }); + }); + + describe('exportEvents', () => { + it('opens export URL for CSV', () => { + createStore(); + store.exportEvents('csv'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/admin/events/export/csv'), + '_blank', + ); + expect(mocks.toastInfo).toHaveBeenCalledWith(expect.stringContaining('CSV')); + }); + + it('opens export URL for JSON', () => { + createStore(); + store.exportEvents('json'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/admin/events/export/json'), + '_blank', + ); + }); + + it('includes filter params in export URL', () => { + createStore(); + store.filters = { user_id: 'user-1', aggregate_id: 'agg-1' }; + store.exportEvents('csv'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringMatching(/user_id=user-1/), + '_blank', + ); + }); + }); + + describe('openUserOverview', () => { + it('loads user overview', async () => { + const overview = { user: { user_id: 'user-1' }, stats: {}, derived_counts: {} }; + mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockResolvedValue({ + data: overview, + }); + + createStore(); + await store.openUserOverview('user-1'); + + expect(store.userOverview).toEqual(overview); + expect(store.userOverviewLoading).toBe(false); + }); + + it('skips empty userId', async () => { + createStore(); + await store.openUserOverview(''); + + expect(mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet).not.toHaveBeenCalled(); + }); + }); + + describe('clearFilters', () => { + it('resets filters and reloads', async () => { + createStore(); + store.filters = { user_id: 'test' }; + store.pagination.currentPage = 3; + + store.clearFilters(); + + expect(store.filters).toEqual({}); + expect(store.pagination.currentPage).toBe(1); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled(); + }); + }); + + describe('auto-refresh', () => { + it('fires loadAll on 30s interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(2); + }); + + it('stops on cleanup', async () => { + createStore(); + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled(); + + const callsBefore = mocks.browseEventsApiV1AdminEventsBrowsePost.mock.calls.length; + store.mainRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(60000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost.mock.calls.length).toBe(callsBefore); + }); + }); + + describe('cleanup', () => { + it('cleans up replay interval', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, session_id: 'session-1', replay_id: 'replay-1' }, + }); + mocks.getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet.mockResolvedValue({ + data: { session_id: 'session-1', status: 'in_progress', total_events: 1, replayed_events: 0, progress_percentage: 0 }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + store.cleanup(); + + mocks.getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet.mockClear(); + vi.advanceTimersByTime(10000); + expect(mocks.getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts new file mode 100644 index 00000000..dde36a3f --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + listExecutionsApiV1AdminExecutionsGet: vi.fn(), + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut: vi.fn(), + getQueueStatusApiV1AdminExecutionsQueueGet: vi.fn(), + unwrap: vi.fn((result: { data: unknown }) => result?.data), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), + toastSuccess: vi.fn(), +})); + +vi.mock('$lib/api', () => ({ + listExecutionsApiV1AdminExecutionsGet: (...args: unknown[]) => mocks.listExecutionsApiV1AdminExecutionsGet(...args), + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut: (...args: unknown[]) => mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut(...args), + getQueueStatusApiV1AdminExecutionsQueueGet: (...args: unknown[]) => mocks.getQueueStatusApiV1AdminExecutionsQueueGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrap: (result: { data: unknown }) => mocks.unwrap(result), + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +vi.mock('svelte-sonner', () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }, +})); + +const { createExecutionsStore } = await import('../executionsStore.svelte'); + +const createMockExecution = (overrides: Record = {}) => ({ + execution_id: 'exec-1', + script: 'print("hi")', + status: 'queued', + lang: 'python', + lang_version: '3.11', + priority: 'normal', + user_id: 'user-1', + created_at: '2024-01-15T10:30:00Z', + updated_at: null, + ...overrides, +}); + +const createMockQueueStatus = (overrides: Record = {}) => ({ + queue_depth: 5, + active_count: 2, + max_concurrent: 10, + by_priority: { normal: 3, high: 2 }, + ...overrides, +}); + +describe('ExecutionsStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ + data: { executions: [], total: 0, limit: 20, skip: 0, has_more: false }, + }); + mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ + data: createMockQueueStatus(), + }); + mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ + data: null, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createExecutionsStore(); + }); + } + + afterEach(() => { + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data', () => { + createStore(); + expect(store.executions).toEqual([]); + expect(store.total).toBe(0); + expect(store.loading).toBe(false); + expect(store.queueStatus).toBeNull(); + }); + + it('starts with default filters', () => { + createStore(); + expect(store.statusFilter).toBe('all'); + expect(store.priorityFilter).toBe('all'); + expect(store.userSearch).toBe(''); + }); + }); + + describe('loadData', () => { + it('loads executions and queue status', async () => { + const execs = [createMockExecution()]; + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ + data: { executions: execs, total: 1 }, + }); + mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ + data: createMockQueueStatus(), + }); + + createStore(); + await store.loadData(); + + expect(store.executions).toEqual(execs); + expect(store.total).toBe(1); + expect(store.queueStatus).toEqual(createMockQueueStatus()); + }); + + it('handles empty API response', async () => { + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ data: null }); + + createStore(); + await store.loadExecutions(); + + expect(store.executions).toEqual([]); + expect(store.total).toBe(0); + }); + }); + + describe('loadExecutions with filters', () => { + it('passes status filter to API', async () => { + createStore(); + store.statusFilter = 'running'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ status: 'running' }), + }), + ); + }); + + it('passes priority filter to API', async () => { + createStore(); + store.priorityFilter = 'high'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ priority: 'high' }), + }), + ); + }); + + it('passes user search to API', async () => { + createStore(); + store.userSearch = 'user-42'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ user_id: 'user-42' }), + }), + ); + }); + + it('omits undefined filter values when "all"', async () => { + createStore(); + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + status: undefined, + priority: undefined, + user_id: undefined, + }), + }), + ); + }); + }); + + describe('updatePriority', () => { + it('calls API and reloads data', async () => { + createStore(); + await store.updatePriority('exec-1', 'high'); + + expect(mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut).toHaveBeenCalledWith({ + path: { execution_id: 'exec-1' }, + body: { priority: 'high' }, + }); + expect(mocks.toastSuccess).toHaveBeenCalledWith('Priority updated to high'); + }); + }); + + describe('resetFilters', () => { + it('resets all filters to defaults', () => { + createStore(); + store.statusFilter = 'running'; + store.priorityFilter = 'high'; + store.userSearch = 'test'; + + store.resetFilters(); + + expect(store.statusFilter).toBe('all'); + expect(store.priorityFilter).toBe('all'); + expect(store.userSearch).toBe(''); + }); + }); + + describe('auto-refresh', () => { + it('fires loadData on interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledTimes(2); + }); + + it('stops on cleanup', async () => { + createStore(); + // Verify interval is running + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled(); + + const callsBefore = mocks.listExecutionsApiV1AdminExecutionsGet.mock.calls.length; + store.autoRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(10000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet.mock.calls.length).toBe(callsBefore); + }); + }); + + describe('pagination', () => { + it('passes pagination params to API', async () => { + createStore(); + store.pagination.currentPage = 2; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + skip: 20, + limit: 20, + }), + }), + ); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts new file mode 100644 index 00000000..8d764b90 --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + listSagasApiV1SagasGet: vi.fn(), + getExecutionSagasApiV1SagasExecutionExecutionIdGet: vi.fn(), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), +})); + +vi.mock('$lib/api', () => ({ + listSagasApiV1SagasGet: (...args: unknown[]) => mocks.listSagasApiV1SagasGet(...args), + getExecutionSagasApiV1SagasExecutionExecutionIdGet: (...args: unknown[]) => mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +const { createSagasStore } = await import('../sagasStore.svelte'); + +const createMockSaga = (overrides: Record = {}) => ({ + saga_id: 'saga-1', + saga_name: 'execution_saga', + execution_id: 'exec-123', + state: 'running', + current_step: 'create_pod', + completed_steps: ['validate_execution'], + compensated_steps: [], + retry_count: 0, + error_message: null, + context_data: {}, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:31:00Z', + completed_at: null, + ...overrides, +}); + +describe('SagasStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas: [], total: 0 }, + }); + mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet.mockResolvedValue({ + data: { sagas: [], total: 0 }, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createSagasStore(); + }); + } + + afterEach(() => { + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data and loading true', () => { + createStore(); + expect(store.sagas).toEqual([]); + expect(store.loading).toBe(true); + expect(store.totalItems).toBe(0); + }); + + it('starts with default filters', () => { + createStore(); + expect(store.stateFilter).toBe(''); + expect(store.executionIdFilter).toBe(''); + expect(store.searchQuery).toBe(''); + }); + }); + + describe('loadSagas', () => { + it('loads sagas from API', async () => { + const sagas = [createMockSaga()]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + await store.loadSagas(); + + expect(store.sagas).toEqual(sagas); + expect(store.totalItems).toBe(1); + expect(store.loading).toBe(false); + }); + + it('handles empty API response', async () => { + mocks.listSagasApiV1SagasGet.mockResolvedValue({ data: null }); + + createStore(); + await store.loadSagas(); + + expect(store.sagas).toEqual([]); + expect(store.totalItems).toBe(0); + }); + + it('passes state filter to API', async () => { + createStore(); + store.stateFilter = 'running'; + await store.loadSagas(); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ state: 'running' }), + }), + ); + }); + + it('passes pagination to API', async () => { + createStore(); + store.pagination.currentPage = 3; + await store.loadSagas(); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ skip: 20, limit: 10 }), + }), + ); + }); + }); + + describe('client-side filtering', () => { + it('filters by execution ID', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', execution_id: 'exec-abc' }), + createMockSaga({ saga_id: 's2', execution_id: 'exec-xyz' }), + ]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 2 }, + }); + + createStore(); + store.executionIdFilter = 'abc'; + await store.loadSagas(); + + expect(store.sagas).toHaveLength(1); + expect(store.sagas[0]!.execution_id).toBe('exec-abc'); + }); + + it('filters by search query', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', saga_name: 'alpha_saga' }), + createMockSaga({ saga_id: 's2', saga_name: 'beta_saga' }), + ]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 2 }, + }); + + createStore(); + store.searchQuery = 'alpha'; + await store.loadSagas(); + + expect(store.sagas).toHaveLength(1); + expect(store.sagas[0]!.saga_name).toBe('alpha_saga'); + }); + + it('hasClientFilters is true when filters active', () => { + createStore(); + expect(store.hasClientFilters).toBe(false); + + store.executionIdFilter = 'test'; + expect(store.hasClientFilters).toBe(true); + }); + }); + + describe('loadExecutionSagas', () => { + it('loads sagas for specific execution', async () => { + const sagas = [createMockSaga({ execution_id: 'exec-target' })]; + mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + await store.loadExecutionSagas('exec-target'); + + expect(store.sagas).toEqual(sagas); + expect(store.executionIdFilter).toBe('exec-target'); + }); + }); + + describe('clearFilters', () => { + it('resets all filters and reloads', async () => { + createStore(); + store.stateFilter = 'failed'; + store.executionIdFilter = 'test'; + store.searchQuery = 'query'; + store.pagination.currentPage = 3; + + await store.clearFilters(); + + expect(store.stateFilter).toBe(''); + expect(store.executionIdFilter).toBe(''); + expect(store.searchQuery).toBe(''); + expect(store.pagination.currentPage).toBe(1); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled(); + }); + }); + + describe('auto-refresh', () => { + it('fires loadSagas on interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(2); + }); + + it('stops on cleanup', async () => { + createStore(); + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled(); + + const callsBefore = mocks.listSagasApiV1SagasGet.mock.calls.length; + store.autoRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(10000); + expect(mocks.listSagasApiV1SagasGet.mock.calls.length).toBe(callsBefore); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/eventsStore.svelte.ts b/frontend/src/lib/admin/stores/eventsStore.svelte.ts new file mode 100644 index 00000000..cafc755a --- /dev/null +++ b/frontend/src/lib/admin/stores/eventsStore.svelte.ts @@ -0,0 +1,186 @@ +import { + browseEventsApiV1AdminEventsBrowsePost, + getEventStatsApiV1AdminEventsStatsGet, + getEventDetailApiV1AdminEventsEventIdGet, + getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet, + replayEventsApiV1AdminEventsReplayPost, + deleteEventApiV1AdminEventsEventIdDelete, + getUserOverviewApiV1AdminUsersUserIdOverviewGet, + type EventBrowseResponse, + type EventFilter, + type EventStatsResponse, + type EventDetailResponse, + type EventReplayStatusResponse, + type EventSummary, + type AdminUserOverview, +} from '$lib/api'; +import { unwrap, unwrapOr } from '$lib/api-interceptors'; +import { toast } from 'svelte-sonner'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; + +export type BrowsedEvent = EventBrowseResponse['events'][number]; + +class EventsStore { + events = $state([]); + loading = $state(false); + totalEvents = $state(0); + stats = $state(null); + filters = $state({}); + + activeReplaySession = $state(null); + replayPreview = $state<{ eventId: string; total_events: number; events_preview?: EventSummary[] } | null>(null); + private replayCheckInterval: ReturnType | null = null; + + userOverview = $state(null); + userOverviewLoading = $state(false); + + pagination = createPaginationState({ initialPageSize: 10 }); + + mainRefresh = createAutoRefresh({ + onRefresh: () => this.loadAll(), + initialRate: 30, + initialEnabled: true, + }); + + async loadAll(): Promise { + await Promise.all([this.loadEvents(), this.loadStats()]); + } + + async loadEvents(): Promise { + this.loading = true; + const data = unwrapOr(await browseEventsApiV1AdminEventsBrowsePost({ + body: { + filters: { + ...this.filters, + start_time: this.filters.start_time ? new Date(this.filters.start_time).toISOString() : null, + end_time: this.filters.end_time ? new Date(this.filters.end_time).toISOString() : null + }, + skip: this.pagination.skip, + limit: this.pagination.pageSize + } + }), null); + this.loading = false; + this.events = data?.events ?? []; + this.totalEvents = data?.total || 0; + } + + async loadStats(): Promise { + this.stats = unwrapOr(await getEventStatsApiV1AdminEventsStatsGet({ query: { hours: 24 } }), null); + } + + async loadEventDetail(eventId: string): Promise { + return unwrapOr(await getEventDetailApiV1AdminEventsEventIdGet({ path: { event_id: eventId } }), null); + } + + async replayEvent(eventId: string, dryRun: boolean = true): Promise { + if (!dryRun && !confirm('Are you sure you want to replay this event? This will re-process the event through the system.')) { + return; + } + + const response = unwrap(await replayEventsApiV1AdminEventsReplayPost({ + body: { event_ids: [eventId], dry_run: dryRun } + })); + + if (dryRun) { + if (response.events_preview && response.events_preview.length > 0) { + this.replayPreview = { eventId, total_events: response.total_events, events_preview: response.events_preview }; + } else { + toast.info(`Dry run: ${response.total_events} events would be replayed`); + } + } else { + toast.success(`Replay scheduled! Tracking progress...`); + const sessionId = response.session_id; + if (sessionId) { + this.activeReplaySession = { + session_id: sessionId, + status: 'scheduled', + total_events: response.total_events, + replayed_events: 0, + progress_percentage: 0, + failed_events: 0, + skipped_events: 0, + replay_id: response.replay_id, + created_at: new Date().toISOString(), + started_at: null, + completed_at: null, + errors: null, + estimated_completion: null, + execution_results: null, + }; + void this.checkReplayStatus(sessionId); + this.replayCheckInterval = setInterval(() => { void this.checkReplayStatus(sessionId); }, 2000); + } + } + } + + private async checkReplayStatus(sessionId: string): Promise { + const status = unwrapOr(await getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet({ + path: { session_id: sessionId } + }), null); + if (!status) { + if (this.replayCheckInterval) { clearInterval(this.replayCheckInterval); this.replayCheckInterval = null; } + return; + } + this.activeReplaySession = status; + + if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') { + if (this.replayCheckInterval) { clearInterval(this.replayCheckInterval); this.replayCheckInterval = null; } + if (status.status === 'completed') { + toast.success(`Replay completed! Processed ${status.replayed_events} events successfully.`); + } else if (status.status === 'failed') { + toast.error(`Replay failed: ${status.errors?.[0]?.error || 'Unknown error'}`); + } + } + } + + async deleteEvent(eventId: string): Promise { + if (!confirm('Are you sure you want to delete this event? This action cannot be undone.')) return; + unwrap(await deleteEventApiV1AdminEventsEventIdDelete({ path: { event_id: eventId } })); + toast.success('Event deleted successfully'); + await Promise.all([this.loadEvents(), this.loadStats()]); + } + + exportEvents(format: 'csv' | 'json' = 'csv'): void { + const params = new URLSearchParams(); + if (this.filters.event_types?.length) params.append('event_types', this.filters.event_types.join(',')); + if (this.filters.start_time) params.append('start_time', new Date(this.filters.start_time).toISOString()); + if (this.filters.end_time) params.append('end_time', new Date(this.filters.end_time).toISOString()); + if (this.filters.aggregate_id) params.append('aggregate_id', this.filters.aggregate_id); + if (this.filters.user_id) params.append('user_id', this.filters.user_id); + if (this.filters.service_name) params.append('service_name', this.filters.service_name); + + window.open(`/api/v1/admin/events/export/${format}?${params.toString()}`, '_blank'); + toast.info(`Starting ${format.toUpperCase()} export...`); + } + + async openUserOverview(userId: string): Promise { + if (!userId) return; + this.userOverview = null; + this.userOverviewLoading = true; + const data = unwrapOr(await getUserOverviewApiV1AdminUsersUserIdOverviewGet({ path: { user_id: userId } }), null); + this.userOverviewLoading = false; + if (!data) return; + this.userOverview = data; + } + + clearFilters(): void { + this.filters = {}; + this.pagination.currentPage = 1; + void this.loadEvents(); + } + + applyFilters(): void { + this.pagination.currentPage = 1; + void this.loadEvents(); + } + + cleanup(): void { + this.mainRefresh.cleanup(); + if (this.replayCheckInterval) { clearInterval(this.replayCheckInterval); this.replayCheckInterval = null; } + } +} + +export function createEventsStore(): EventsStore { + return new EventsStore(); +} diff --git a/frontend/src/lib/admin/stores/executionsStore.svelte.ts b/frontend/src/lib/admin/stores/executionsStore.svelte.ts new file mode 100644 index 00000000..1a9a7b42 --- /dev/null +++ b/frontend/src/lib/admin/stores/executionsStore.svelte.ts @@ -0,0 +1,81 @@ +import { + listExecutionsApiV1AdminExecutionsGet, + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut, + getQueueStatusApiV1AdminExecutionsQueueGet, + type AdminExecutionResponse, + type QueueStatusResponse, + type QueuePriority, + type ExecutionStatus, +} from '$lib/api'; +import { unwrap, unwrapOr } from '$lib/api-interceptors'; +import { toast } from 'svelte-sonner'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; + +class ExecutionsStore { + executions = $state([]); + total = $state(0); + loading = $state(false); + queueStatus = $state(null); + + statusFilter = $state<'all' | ExecutionStatus>('all'); + priorityFilter = $state<'all' | QueuePriority>('all'); + userSearch = $state(''); + + pagination = createPaginationState({ initialPageSize: 20 }); + autoRefresh = createAutoRefresh({ + onRefresh: () => this.loadData(), + initialRate: 5, + initialEnabled: true, + }); + + async loadData(): Promise { + await Promise.all([this.loadExecutions(), this.loadQueueStatus()]); + } + + async loadExecutions(): Promise { + this.loading = true; + const data = unwrapOr(await listExecutionsApiV1AdminExecutionsGet({ + query: { + limit: this.pagination.pageSize, + skip: this.pagination.skip, + status: this.statusFilter !== 'all' ? this.statusFilter : undefined, + priority: this.priorityFilter !== 'all' ? this.priorityFilter : undefined, + user_id: this.userSearch || undefined, + }, + }), null); + this.executions = data?.executions || []; + this.total = data?.total || 0; + this.loading = false; + } + + async loadQueueStatus(): Promise { + const data = unwrapOr(await getQueueStatusApiV1AdminExecutionsQueueGet({}), null); + if (data) { + this.queueStatus = data; + } + } + + async updatePriority(executionId: string, newPriority: QueuePriority): Promise { + unwrap(await updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut({ + path: { execution_id: executionId }, + body: { priority: newPriority }, + })); + toast.success(`Priority updated to ${newPriority}`); + await this.loadData(); + } + + resetFilters(): void { + this.statusFilter = 'all'; + this.priorityFilter = 'all'; + this.userSearch = ''; + } + + cleanup(): void { + this.autoRefresh.cleanup(); + } +} + +export function createExecutionsStore(): ExecutionsStore { + return new ExecutionsStore(); +} diff --git a/frontend/src/lib/admin/stores/sagasStore.svelte.ts b/frontend/src/lib/admin/stores/sagasStore.svelte.ts new file mode 100644 index 00000000..8cdb3ec9 --- /dev/null +++ b/frontend/src/lib/admin/stores/sagasStore.svelte.ts @@ -0,0 +1,86 @@ +import { + listSagasApiV1SagasGet, + getExecutionSagasApiV1SagasExecutionExecutionIdGet, + type SagaStatusResponse, +} from '$lib/api'; +import { unwrapOr } from '$lib/api-interceptors'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; +import { type SagaStateFilter } from '$lib/admin/sagas'; + +class SagasStore { + sagas = $state([]); + loading = $state(true); + totalItems = $state(0); + serverReturnedCount = $state(0); + + stateFilter = $state(''); + executionIdFilter = $state(''); + searchQuery = $state(''); + + hasClientFilters = $derived(Boolean(this.executionIdFilter || this.searchQuery)); + + pagination = createPaginationState({ initialPageSize: 10 }); + autoRefresh = createAutoRefresh({ + onRefresh: () => this.loadSagas(), + initialRate: 5, + initialEnabled: true, + }); + + async loadSagas(): Promise { + this.loading = true; + const data = unwrapOr(await listSagasApiV1SagasGet({ + query: { + state: this.stateFilter || undefined, + limit: this.pagination.pageSize, + skip: this.pagination.skip, + } + }), null); + this.loading = false; + + let result = data?.sagas || []; + this.totalItems = data?.total || 0; + this.serverReturnedCount = result.length; + + if (this.executionIdFilter) { + result = result.filter(s => s.execution_id.includes(this.executionIdFilter)); + } + if (this.searchQuery) { + const q = this.searchQuery.toLowerCase(); + result = result.filter(s => + s.saga_id.toLowerCase().includes(q) || + s.saga_name.toLowerCase().includes(q) || + s.execution_id.toLowerCase().includes(q) || + s.error_message?.toLowerCase().includes(q) + ); + } + this.sagas = result; + } + + async loadExecutionSagas(executionId: string): Promise { + this.loading = true; + const data = unwrapOr(await getExecutionSagasApiV1SagasExecutionExecutionIdGet({ + path: { execution_id: executionId } + }), null); + this.loading = false; + this.sagas = data?.sagas || []; + this.totalItems = data?.total || 0; + this.executionIdFilter = executionId; + } + + clearFilters(): void { + this.stateFilter = ''; + this.executionIdFilter = ''; + this.searchQuery = ''; + this.pagination.currentPage = 1; + void this.loadSagas(); + } + + cleanup(): void { + this.autoRefresh.cleanup(); + } +} + +export function createSagasStore(): SagasStore { + return new SagasStore(); +} diff --git a/frontend/src/routes/admin/AdminEvents.svelte b/frontend/src/routes/admin/AdminEvents.svelte index 3f46c2f4..502f92f6 100644 --- a/frontend/src/routes/admin/AdminEvents.svelte +++ b/frontend/src/routes/admin/AdminEvents.svelte @@ -1,25 +1,6 @@ @@ -263,9 +93,9 @@ - {#if hasActiveFilters(filters)} + {#if hasActiveFilters(store.filters)} - {getActiveFilterCount(filters)} + {getActiveFilterCount(store.filters)} {/if} @@ -285,14 +115,14 @@ {#if showExportMenu}
- - activeReplaySession = null} /> + store.activeReplaySession = null} /> - + - {#if !showFilters && hasActiveFilters(filters)} + {#if !showFilters && hasActiveFilters(store.filters)}
Active filters: - {#each getActiveFilterSummary(filters) as filter} + {#each getActiveFilterSummary(store.filters) as filter} {filter} {/each}
store.deleteEvent(id)} + onViewUser={handleUserOverview} /> - {#if totalEvents > 0} + {#if store.totalEvents > 0}
@@ -361,8 +191,8 @@ + + +
- +
@@ -188,12 +121,12 @@

- Executions ({total}) + Executions ({store.total})

- {#if loading && executions.length === 0} + {#if store.loading && store.executions.length === 0}
- {:else if executions.length === 0} + {:else if store.executions.length === 0}

No executions found

{:else}
@@ -209,7 +142,7 @@ - {#each executions as exec} + {#each store.executions as exec} {truncate(exec.execution_id, 12)} {exec.user_id ? truncate(exec.user_id, 12) : '-'} @@ -220,7 +153,7 @@