From 8245e1aaa7c3cf7dc6cd0fc5cee9289acfb50261 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 11 Apr 2026 00:02:26 -0300 Subject: [PATCH] testing: add unified quality gate --- Jenkinsfile | 177 +- backend/atlas_portal/app_factory.py | 8 + backend/atlas_portal/rate_limit.py | 6 + backend/atlas_portal/routes/auth_config.py | 4 + backend/atlas_portal/routes/health.py | 5 +- backend/atlas_portal/routes/monero.py | 5 +- backend/atlas_portal/settings.py | 2 + backend/atlas_portal/utils.py | 9 +- backend/requirements-dev.txt | 3 + backend/tests/conftest.py | 15 + backend/tests/test_app_factory.py | 50 + backend/tests/test_auth_config.py | 36 + backend/tests/test_health_and_monero.py | 46 + backend/tests/test_rate_limit_and_utils.py | 75 + backend/tests/test_settings.py | 26 + frontend/package-lock.json | 3367 ++++++++++++++++- frontend/package.json | 16 +- frontend/playwright/index.html | 12 + frontend/playwright/index.ts | 1 + frontend/src/auth.js | 49 +- frontend/src/data/sample.js | 45 +- frontend/src/views/HomeView.vue | 15 +- testing/README.md | 13 + testing/__init__.py | 1 + testing/ci/__init__.py | 1 + testing/ci/publish_metrics.py | 40 + testing/ci/quality_gate.py | 279 ++ testing/ci/summary.py | 99 + testing/frontend/component/metric-row.spec.js | 17 + testing/frontend/component/stats-grid.spec.js | 15 + testing/frontend/e2e/home.spec.js | 37 + testing/frontend/e2e/request-access.spec.js | 41 + testing/frontend/eslint.config.js | 24 + testing/frontend/playwright-ct.config.mjs | 15 + testing/frontend/playwright.config.mjs | 23 + testing/frontend/playwright/index.html | 12 + testing/frontend/playwright/index.ts | 1 + testing/frontend/unit/auth.spec.js | 320 ++ testing/frontend/unit/components.spec.js | 120 + testing/frontend/unit/home.spec.js | 144 + testing/frontend/unit/sample.spec.js | 35 + testing/frontend/vitest.config.js | 44 + testing/frontend/vitest.setup.js | 8 + testing/quality_contract.json | 48 + testing/tests/test_publish_metrics.py | 19 + testing/tests/test_quality_gate.py | 81 + 46 files changed, 5276 insertions(+), 133 deletions(-) create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_app_factory.py create mode 100644 backend/tests/test_auth_config.py create mode 100644 backend/tests/test_health_and_monero.py create mode 100644 backend/tests/test_rate_limit_and_utils.py create mode 100644 backend/tests/test_settings.py create mode 100644 frontend/playwright/index.html create mode 100644 frontend/playwright/index.ts create mode 100644 testing/README.md create mode 100644 testing/__init__.py create mode 100644 testing/ci/__init__.py create mode 100644 testing/ci/publish_metrics.py create mode 100644 testing/ci/quality_gate.py create mode 100644 testing/ci/summary.py create mode 100644 testing/frontend/component/metric-row.spec.js create mode 100644 testing/frontend/component/stats-grid.spec.js create mode 100644 testing/frontend/e2e/home.spec.js create mode 100644 testing/frontend/e2e/request-access.spec.js create mode 100644 testing/frontend/eslint.config.js create mode 100644 testing/frontend/playwright-ct.config.mjs create mode 100644 testing/frontend/playwright.config.mjs create mode 100644 testing/frontend/playwright/index.html create mode 100644 testing/frontend/playwright/index.ts create mode 100644 testing/frontend/unit/auth.spec.js create mode 100644 testing/frontend/unit/components.spec.js create mode 100644 testing/frontend/unit/home.spec.js create mode 100644 testing/frontend/unit/sample.spec.js create mode 100644 testing/frontend/vitest.config.js create mode 100644 testing/frontend/vitest.setup.js create mode 100644 testing/quality_contract.json create mode 100644 testing/tests/test_publish_metrics.py create mode 100644 testing/tests/test_quality_gate.py diff --git a/Jenkinsfile b/Jenkinsfile index 80f230a..9b9958d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -51,6 +51,13 @@ spec: volumeMounts: - name: workspace-volume mountPath: /home/jenkins/agent + - name: frontend + image: mcr.microsoft.com/playwright:v1.51.0-jammy + command: ["cat"] + tty: true + volumeMounts: + - name: workspace-volume + mountPath: /home/jenkins/agent volumes: - name: workspace-volume emptyDir: {} @@ -176,8 +183,40 @@ spec: set -euo pipefail mkdir -p build export PYTHONPATH="${WORKSPACE}/backend:${PYTHONPATH:-}" - python -m pip install --no-cache-dir -r backend/requirements.txt pytest pytest-mock - python -m pytest backend/tests -q --junitxml=build/junit-backend.xml + python -m pip install --no-cache-dir -r backend/requirements.txt -r backend/requirements-dev.txt + python -m pytest backend/tests -q --cov=backend/atlas_portal --cov-report=xml:build/backend-coverage.xml --junitxml=build/junit-backend.xml + ''' + } + } + } + + stage('Frontend tests') { + steps { + container('frontend') { + sh ''' + set -euo pipefail + mkdir -p build + cd frontend + npm ci + npm run lint + npm run test:unit + npm run test:component + npm run test:e2e + ''' + } + } + } + + stage('Unified quality gate') { + steps { + container('tester') { + sh ''' + set -euo pipefail + export PYTHONPATH="${WORKSPACE}:${PYTHONPATH:-}" + python -m testing.ci.quality_gate \ + --backend-coverage build/backend-coverage.xml \ + --frontend-coverage frontend/coverage/coverage-summary.json \ + --report build/quality-gate.json ''' } } @@ -225,66 +264,12 @@ spec: container('tester') { sh ''' set -euo pipefail - export QUALITY_STATUS=ok - python - <<'PY' -import os -import re -import urllib.request -import xml.etree.ElementTree as ET -from pathlib import Path - -suite = os.environ.get("SUITE_NAME", "bstein-home") -status = os.environ.get("QUALITY_STATUS", "failed") -gateway = os.environ.get("PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091").rstrip("/") -text = urllib.request.urlopen(f"{gateway}/metrics", timeout=10).read().decode("utf-8", errors="replace") - -def counter(name: str) -> float: - pattern = re.compile( - rf'^platform_quality_gate_runs_total\\{{[^}}]*job="platform-quality-ci"[^}}]*suite="{re.escape(suite)}"[^}}]*status="{name}"[^}}]*\\}}\\s+([0-9]+(?:\\.[0-9]+)?)$', - re.M, - ) - match = pattern.search(text) - return float(match.group(1)) if match else 0.0 - -ok = counter("ok") -failed = counter("failed") -if status == "ok": - ok += 1 -else: - failed += 1 - -totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} -junit_path = Path("build/junit-backend.xml") -if junit_path.exists(): - root = ET.parse(junit_path).getroot() - suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) if root.tag == "testsuites" else [] - for node in suites: - for key in totals: - raw = node.attrib.get(key) or "0" - try: - totals[key] += int(float(raw)) - except ValueError: - pass -passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) - -payload = ( - "# TYPE platform_quality_gate_runs_total counter\\n" - f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {int(ok)}\\n' - f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {int(failed)}\\n' - "# TYPE bstein_home_quality_gate_tests_total gauge\\n" - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}\\n' -) -req = urllib.request.Request( - f"{gateway}/metrics/job/platform-quality-ci/suite/{suite}", - data=payload.encode("utf-8"), - method="POST", - headers={"Content-Type": "text/plain"}, -) -urllib.request.urlopen(req, timeout=10).read() -PY + python -m testing.ci.publish_metrics \ + --gateway "${PUSHGATEWAY_URL}" \ + --suite "${SUITE_NAME}" \ + --job platform-quality-ci \ + --status ok \ + --junit build/junit-backend.xml build/junit-frontend-unit.xml build/junit-frontend-component.xml build/junit-frontend-e2e.xml ''' } } @@ -292,66 +277,12 @@ PY container('tester') { sh ''' set -euo pipefail - export QUALITY_STATUS=failed - python - <<'PY' -import os -import re -import urllib.request -import xml.etree.ElementTree as ET -from pathlib import Path - -suite = os.environ.get("SUITE_NAME", "bstein-home") -status = os.environ.get("QUALITY_STATUS", "failed") -gateway = os.environ.get("PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091").rstrip("/") -text = urllib.request.urlopen(f"{gateway}/metrics", timeout=10).read().decode("utf-8", errors="replace") - -def counter(name: str) -> float: - pattern = re.compile( - rf'^platform_quality_gate_runs_total\\{{[^}}]*job="platform-quality-ci"[^}}]*suite="{re.escape(suite)}"[^}}]*status="{name}"[^}}]*\\}}\\s+([0-9]+(?:\\.[0-9]+)?)$', - re.M, - ) - match = pattern.search(text) - return float(match.group(1)) if match else 0.0 - -ok = counter("ok") -failed = counter("failed") -if status == "ok": - ok += 1 -else: - failed += 1 - -totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} -junit_path = Path("build/junit-backend.xml") -if junit_path.exists(): - root = ET.parse(junit_path).getroot() - suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) if root.tag == "testsuites" else [] - for node in suites: - for key in totals: - raw = node.attrib.get(key) or "0" - try: - totals[key] += int(float(raw)) - except ValueError: - pass -passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) - -payload = ( - "# TYPE platform_quality_gate_runs_total counter\\n" - f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {int(ok)}\\n' - f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {int(failed)}\\n' - "# TYPE bstein_home_quality_gate_tests_total gauge\\n" - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}\\n' - f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}\\n' -) -req = urllib.request.Request( - f"{gateway}/metrics/job/platform-quality-ci/suite/{suite}", - data=payload.encode("utf-8"), - method="POST", - headers={"Content-Type": "text/plain"}, -) -urllib.request.urlopen(req, timeout=10).read() -PY + python -m testing.ci.publish_metrics \ + --gateway "${PUSHGATEWAY_URL}" \ + --suite "${SUITE_NAME}" \ + --job platform-quality-ci \ + --status failed \ + --junit build/junit-backend.xml build/junit-frontend-unit.xml build/junit-frontend-component.xml build/junit-frontend-e2e.xml ''' } } @@ -360,7 +291,7 @@ PY def props = fileExists('build.env') ? readProperties(file: 'build.env') : [:] echo "Build complete for ${props['SEMVER'] ?: env.VERSION_TAG}" } - archiveArtifacts artifacts: 'build/junit-backend.xml', allowEmptyArchive: true + archiveArtifacts artifacts: 'build/junit-backend.xml,build/junit-frontend-unit.xml,build/junit-frontend-component.xml,build/junit-frontend-e2e.xml,build/quality-gate.json', allowEmptyArchive: true } } } diff --git a/backend/atlas_portal/app_factory.py b/backend/atlas_portal/app_factory.py index 42bad3c..54013d7 100644 --- a/backend/atlas_portal/app_factory.py +++ b/backend/atlas_portal/app_factory.py @@ -11,6 +11,12 @@ from .routes import access_requests, account, admin_access, ai, auth_config, hea def create_app() -> Flask: + """Build the Flask app with API routes and SPA fallback handling. + + WHY: the portal needs a single assembly point so the API, auth routes, and + frontend fallback all stay wired the same way in Flask, tests, and Jenkins. + """ + app = Flask(__name__, static_folder="../frontend/dist", static_url_path="") app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) CORS(app, resources={r"/api/*": {"origins": "*"}}) @@ -27,6 +33,8 @@ def create_app() -> Flask: @app.route("/", defaults={"path": ""}) @app.route("/") def serve_frontend(path: str) -> Any: + """Serve the compiled SPA assets or return a JSON build hint.""" + dist_path = Path(app.static_folder) index_path = dist_path / "index.html" diff --git a/backend/atlas_portal/rate_limit.py b/backend/atlas_portal/rate_limit.py index 841e308..d629657 100644 --- a/backend/atlas_portal/rate_limit.py +++ b/backend/atlas_portal/rate_limit.py @@ -8,6 +8,12 @@ _RATE_BUCKETS: dict[str, dict[str, list[float]]] = {} def rate_limit_allow(ip: str, *, key: str, limit: int, window_sec: int) -> bool: + """Return whether a request bucket still has capacity. + + WHY: access-request endpoints need a simple in-process guard that is easy to + exercise in tests and cheap to apply before any heavier work starts. + """ + if limit <= 0: return True now = time.time() diff --git a/backend/atlas_portal/routes/auth_config.py b/backend/atlas_portal/routes/auth_config.py index f43d822..ec02f90 100644 --- a/backend/atlas_portal/routes/auth_config.py +++ b/backend/atlas_portal/routes/auth_config.py @@ -9,8 +9,12 @@ from .. import settings def register(app) -> None: + """Expose the login URLs the frontend needs for auth state rendering.""" + @app.route("/api/auth/config", methods=["GET"]) def auth_config() -> Any: + """Render the auth configuration payload consumed by the SPA.""" + if not settings.KEYCLOAK_ENABLED: return jsonify({"enabled": False}) diff --git a/backend/atlas_portal/routes/health.py b/backend/atlas_portal/routes/health.py index 5247b0f..a802485 100644 --- a/backend/atlas_portal/routes/health.py +++ b/backend/atlas_portal/routes/health.py @@ -6,7 +6,10 @@ from flask import jsonify def register(app) -> None: + """Register the lightweight health endpoint on the Flask app.""" + @app.route("/api/healthz") def healthz() -> Any: - return jsonify({"ok": True}) + """Return the basic liveness payload used by probes and tests.""" + return jsonify({"ok": True}) diff --git a/backend/atlas_portal/routes/monero.py b/backend/atlas_portal/routes/monero.py index fb724a3..7f56430 100644 --- a/backend/atlas_portal/routes/monero.py +++ b/backend/atlas_portal/routes/monero.py @@ -11,12 +11,15 @@ from .. import settings def register(app) -> None: + """Expose the Monero node health endpoint through Flask.""" + @app.route("/api/monero/get_info") def monero_get_info() -> Any: + """Proxy `get_info` from the Monero daemon with a predictable response.""" + try: with urlopen(settings.MONERO_GET_INFO_URL, timeout=2) as resp: payload = json.loads(resp.read().decode("utf-8")) return jsonify(payload) except (URLError, TimeoutError, ValueError) as exc: return jsonify({"error": str(exc), "url": settings.MONERO_GET_INFO_URL}), 503 - diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 4a8a4a6..d43eeda 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -4,6 +4,8 @@ import os def _env_bool(name: str, default: str = "false") -> bool: + """Parse a truthy environment variable with the repo's boolean semantics.""" + return os.getenv(name, default).lower() in ("1", "true", "yes") diff --git a/backend/atlas_portal/utils.py b/backend/atlas_portal/utils.py index 44ef125..b14388e 100644 --- a/backend/atlas_portal/utils.py +++ b/backend/atlas_portal/utils.py @@ -10,11 +10,19 @@ from . import settings def random_password(length: int = 32) -> str: + """Generate a URL-safe mixed-case password for one-off account bootstrap.""" + alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def best_effort_post(url: str) -> None: + """Fire-and-forget a JSON ping without letting transport failures bubble. + + WHY: background sync helpers should keep moving even if the destination is + briefly unavailable or the cluster network is in a bad state. + """ + if not url: return try: @@ -22,4 +30,3 @@ def best_effort_post(url: str) -> None: client.post(url, json={"ts": int(time.time())}) except Exception: return - diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..e4dc29e --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.3.4 +pytest-cov==6.0.0 +pytest-mock==3.14.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c0bd052 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +"""Pytest bootstrap for backend tests. + +The backend package lives under `backend/`, so test runs from the repository +root need that directory on `sys.path` before importing `atlas_portal`. +""" + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/backend/tests/test_app_factory.py b/backend/tests/test_app_factory.py new file mode 100644 index 0000000..1a6329e --- /dev/null +++ b/backend/tests/test_app_factory.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +"""Tests for Flask application assembly and frontend fallback behavior.""" + +from pathlib import Path + +from atlas_portal.app_factory import create_app + + +def test_create_app_exposes_health_endpoint() -> None: + app = create_app() + client = app.test_client() + + resp = client.get("/api/healthz") + + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + +def test_create_app_returns_json_when_frontend_is_missing() -> None: + app = create_app() + client = app.test_client() + + original = app.static_folder + app.static_folder = str(Path("/tmp") / "missing-frontend-dist") + try: + resp = client.get("/") + finally: + app.static_folder = original + + data = resp.get_json() + assert resp.status_code == 200 + assert "Frontend not built yet" in data["message"] + + +def test_create_app_serves_existing_static_assets(tmp_path) -> None: + app = create_app() + (tmp_path / "index.html").write_text("ok") + (tmp_path / "asset.txt").write_text("payload") + original = app.static_folder + app.static_folder = str(tmp_path) + try: + with app.test_request_context("/asset.txt"): + resp = app.view_functions["serve_frontend"]("asset.txt") + finally: + app.static_folder = original + + assert resp.status_code == 200 + resp.direct_passthrough = False + assert resp.get_data() == b"payload" diff --git a/backend/tests/test_auth_config.py b/backend/tests/test_auth_config.py new file mode 100644 index 0000000..6ebd375 --- /dev/null +++ b/backend/tests/test_auth_config.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +"""Tests for the Keycloak auth config route.""" + +from atlas_portal.app_factory import create_app +from atlas_portal import settings + + +def test_auth_config_disabled_by_default() -> None: + app = create_app() + client = app.test_client() + + resp = client.get("/api/auth/config") + + assert resp.status_code == 200 + assert resp.get_json() == {"enabled": False} + + +def test_auth_config_builds_urls_when_enabled(monkeypatch) -> None: + monkeypatch.setattr(settings, "KEYCLOAK_ENABLED", True) + monkeypatch.setattr(settings, "KEYCLOAK_URL", "https://sso.example.dev") + monkeypatch.setattr(settings, "KEYCLOAK_REALM", "atlas") + monkeypatch.setattr(settings, "KEYCLOAK_CLIENT_ID", "portal-client") + monkeypatch.setattr(settings, "KEYCLOAK_ISSUER", "https://sso.example.dev/realms/atlas") + + app = create_app() + client = app.test_client() + + resp = client.get("/api/auth/config", base_url="https://portal.example.dev") + data = resp.get_json() + + assert resp.status_code == 200 + assert data["enabled"] is True + assert data["login_url"].startswith("https://sso.example.dev/realms/atlas/protocol/openid-connect/auth") + assert "client_id=portal-client" in data["login_url"] + assert data["account_password_url"].endswith("#/security/signingin") diff --git a/backend/tests/test_health_and_monero.py b/backend/tests/test_health_and_monero.py new file mode 100644 index 0000000..c74cd12 --- /dev/null +++ b/backend/tests/test_health_and_monero.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +"""Tests for the tiny health and Monero endpoints.""" + +import json +from urllib.error import URLError + +from atlas_portal.app_factory import create_app +from atlas_portal.routes import monero + + +def test_monero_endpoint_returns_upstream_json(monkeypatch) -> None: + class DummyResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({"status": "OK", "nettype": "mainnet"}).encode("utf-8") + + monkeypatch.setattr(monero, "urlopen", lambda *args, **kwargs: DummyResponse()) + + app = create_app() + client = app.test_client() + + resp = client.get("/api/monero/get_info") + + assert resp.status_code == 200 + assert resp.get_json()["status"] == "OK" + + +def test_monero_endpoint_handles_upstream_failure(monkeypatch) -> None: + def boom(*args, **kwargs): + raise URLError("boom") + + monkeypatch.setattr(monero, "urlopen", boom) + + app = create_app() + client = app.test_client() + + resp = client.get("/api/monero/get_info") + + assert resp.status_code == 503 + assert resp.get_json()["url"].startswith("http://") diff --git a/backend/tests/test_rate_limit_and_utils.py b/backend/tests/test_rate_limit_and_utils.py new file mode 100644 index 0000000..662ef74 --- /dev/null +++ b/backend/tests/test_rate_limit_and_utils.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +"""Tests for generic backend utilities used across routes.""" + +from atlas_portal import rate_limit, utils + + +def test_rate_limit_allows_when_limit_is_non_positive() -> None: + assert rate_limit.rate_limit_allow("1.2.3.4", key="access", limit=0, window_sec=60) + assert rate_limit.rate_limit_allow("1.2.3.4", key="access", limit=-1, window_sec=60) + + +def test_rate_limit_rejects_after_limit(monkeypatch) -> None: + monkeypatch.setattr(rate_limit.time, "time", lambda: 100.0) + assert rate_limit.rate_limit_allow("1.2.3.4", key="access", limit=2, window_sec=60) + assert rate_limit.rate_limit_allow("1.2.3.4", key="access", limit=2, window_sec=60) + assert not rate_limit.rate_limit_allow("1.2.3.4", key="access", limit=2, window_sec=60) + + +def test_random_password_has_requested_length() -> None: + password = utils.random_password(24) + + assert len(password) == 24 + assert password.isalnum() + + +def test_best_effort_post_ignores_errors(monkeypatch) -> None: + calls = [] + + class DummyClient: + def __init__(self, timeout): + calls.append(timeout) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, json=None): + raise RuntimeError("boom") + + monkeypatch.setattr(utils.httpx, "Client", DummyClient) + + utils.best_effort_post("https://example.dev/hook") + + assert calls + + +def test_best_effort_post_success(monkeypatch) -> None: + posts = [] + + class DummyClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, json=None): + posts.append((url, json)) + return None + + monkeypatch.setattr(utils.httpx, "Client", DummyClient) + + utils.best_effort_post("https://example.dev/hook") + + assert posts and posts[0][0] == "https://example.dev/hook" + + +def test_best_effort_post_ignores_empty_url() -> None: + utils.best_effort_post("") diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py new file mode 100644 index 0000000..7616a36 --- /dev/null +++ b/backend/tests/test_settings.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +"""Tests for environment-backed settings parsing.""" + +import importlib + + +def test_env_bool_handles_truthy_and_falsey(monkeypatch) -> None: + import atlas_portal.settings as settings + + monkeypatch.setenv("TEST_FLAG", "YES") + assert settings._env_bool("TEST_FLAG") is True + monkeypatch.setenv("TEST_FLAG", "0") + assert settings._env_bool("TEST_FLAG") is False + + +def test_settings_reload_picks_up_environment(monkeypatch) -> None: + monkeypatch.setenv("KEYCLOAK_ENABLED", "true") + monkeypatch.setenv("PORTAL_ADMIN_USERS", "alice,bob") + + import atlas_portal.settings as settings + + reloaded = importlib.reload(settings) + + assert reloaded.KEYCLOAK_ENABLED is True + assert reloaded.PORTAL_ADMIN_USERS == ["alice", "bob"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a76083a..674671d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,8 +16,45 @@ "vue-router": "^4.3.2" }, "devDependencies": { + "@eslint/js": "^9.22.0", + "@playwright/experimental-ct-vue": "^1.51.0", + "@playwright/test": "^1.51.0", "@vitejs/plugin-vue": "^5.0.4", - "vite": "^5.2.0" + "@vitest/coverage-v8": "^3.0.9", + "@vue/test-utils": "^2.4.6", + "eslint": "^9.22.0", + "globals": "^16.0.0", + "jsdom": "^26.0.0", + "vite": "^5.2.0", + "vitest": "^3.0.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@babel/helper-string-parser": { @@ -66,12 +103,137 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -361,6 +523,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -378,6 +557,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -395,6 +591,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -463,12 +676,940 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/experimental-ct-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.59.1.tgz", + "integrity": "sha512-U7+jNROBJxfwjM/G7011+UNEyLiI5zIT1HWAn1k89WZIWl5RUWaCGWlYkYdAZwBSVfGstjF9AgkzmS0RsF8Ulw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1", + "playwright-core": "1.59.1", + "vite": "^6.4.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@playwright/experimental-ct-vue": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-vue/-/experimental-ct-vue-1.59.1.tgz", + "integrity": "sha512-RygXcwXQwRHzcdaQAXpKiHEl8XDPepZKmfHDNPCSnCN1g9ylaAvtNF6s7DgpsxlHqLlwgc2DmMr1n3D/OOVKyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@playwright/experimental-ct-core": "1.59.1", + "@vitejs/plugin-vue": "^5.2.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", @@ -777,6 +1918,17 @@ "win32" ] }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -807,6 +1959,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -814,6 +1973,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -856,6 +2022,165 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -962,6 +2287,77 @@ "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -986,6 +2382,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1003,6 +2438,34 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1016,6 +2479,16 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1025,6 +2498,40 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1035,6 +2542,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1085,6 +2602,24 @@ "node": ">= 10" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -1094,6 +2629,35 @@ "layout-base": "^1.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1572,6 +3136,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -1604,6 +3182,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -1617,6 +3202,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -1682,6 +3284,68 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/elkjs": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", @@ -1724,6 +3388,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1790,12 +3461,306 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "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==", "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1809,6 +3774,27 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1829,6 +3815,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -1915,6 +3918,80 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1927,6 +4004,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1966,6 +4053,54 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1978,6 +4113,50 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -1987,6 +4166,16 @@ "node": ">=12" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1996,6 +4185,216 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "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, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/katex": { "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", @@ -2030,6 +4429,16 @@ "test" ] }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", @@ -2050,6 +4459,20 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -2068,6 +4491,27 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2077,6 +4521,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2614,6 +5086,29 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2647,12 +5142,60 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/non-layered-tidy-tree-layout": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2689,6 +5232,52 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2698,12 +5287,116 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2741,12 +5434,39 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -2779,6 +5499,16 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -2827,6 +5557,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -2851,12 +5588,81 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2866,6 +5672,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2880,6 +5700,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2892,12 +5728,240 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/supports-color": { + "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, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "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, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", @@ -2907,6 +5971,19 @@ "node": ">=6.10" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/unist-util-stringify-position": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", @@ -2920,6 +5997,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -3011,6 +6098,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vue": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", @@ -3032,6 +6215,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", @@ -3047,18 +6237,122 @@ "vue": "^3.5.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -3073,6 +6367,64 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -3113,6 +6465,19 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 1231c14..9161250 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,12 @@ "dev": "vite", "prebuild": "node scripts/build_media_manifest.mjs", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:unit": "vitest run --coverage --config ../testing/frontend/vitest.config.js", + "test:component": "playwright test --config ../testing/frontend/playwright-ct.config.mjs", + "test:e2e": "playwright test --config ../testing/frontend/playwright.config.mjs", + "test": "npm run test:unit && npm run test:component && npm run test:e2e", + "lint": "cd .. && eslint --config testing/frontend/eslint.config.js $(find frontend/src testing/frontend -type f \\( -name '*.js' -o -name '*.mjs' \\) | sort)" }, "dependencies": { "axios": "^1.6.7", @@ -18,7 +23,16 @@ "vue-router": "^4.3.2" }, "devDependencies": { + "@eslint/js": "^9.22.0", + "@playwright/experimental-ct-vue": "^1.51.0", + "@playwright/test": "^1.51.0", + "@vitest/coverage-v8": "^3.0.9", "@vitejs/plugin-vue": "^5.0.4", + "@vue/test-utils": "^2.4.6", + "eslint": "^9.22.0", + "globals": "^16.0.0", + "jsdom": "^26.0.0", + "vitest": "^3.0.9", "vite": "^5.2.0" } } diff --git a/frontend/playwright/index.html b/frontend/playwright/index.html new file mode 100644 index 0000000..b90220a --- /dev/null +++ b/frontend/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Playwright CT + + +
+ + + diff --git a/frontend/playwright/index.ts b/frontend/playwright/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/frontend/playwright/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/auth.js b/frontend/src/auth.js index b9667cf..b32d342 100644 --- a/frontend/src/auth.js +++ b/frontend/src/auth.js @@ -18,7 +18,27 @@ export const auth = reactive({ let keycloak = null; let initPromise = null; -function normalizeGroups(groups) { +/** + * Build a Keycloak client for the current environment. + * + * WHY: tests need to inject a predictable client without changing the runtime + * behavior for the browser. + */ +export function createKeycloak(config) { + const factory = globalThis.__ATLAS_KEYCLOAK_FACTORY__; + if (typeof factory === "function") return factory(config); + const ctor = globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__; + if (typeof ctor === "function") return new ctor(config); + return new Keycloak(config); +} + +/** + * Normalize Keycloak groups into the format the UI expects. + * + * @param {unknown} groups - Raw group list from the access token. + * @returns {string[]} A cleaned list of group names without leading slashes. + */ +export function normalizeGroups(groups) { if (!Array.isArray(groups)) return []; return groups .filter((g) => typeof g === "string") @@ -26,6 +46,12 @@ function normalizeGroups(groups) { .filter(Boolean); } +/** + * Refresh the reactive auth state from the current Keycloak token. + * + * WHY: the UI reads from a shared reactive object, so a token refresh needs to + * update all dependent fields in one place. + */ function updateFromToken() { const parsed = keycloak?.tokenParsed || {}; auth.authenticated = Boolean(keycloak?.authenticated); @@ -35,6 +61,11 @@ function updateFromToken() { auth.groups = normalizeGroups(parsed.groups); } +/** + * Initialize Keycloak session probing and populate the reactive auth state. + * + * @returns {Promise} A singleton promise so callers can await startup. + */ export async function initAuth() { if (initPromise) return initPromise; @@ -51,7 +82,7 @@ export async function initAuth() { if (!auth.enabled) return; - keycloak = new Keycloak({ + keycloak = createKeycloak({ url: cfg.url, realm: cfg.realm, clientId: cfg.client_id, @@ -92,6 +123,10 @@ export async function initAuth() { return initPromise; } +/** + * Open the Keycloak login flow and preserve the current location as the return + * target. + */ export async function login( redirectPath = window.location.pathname + window.location.search + window.location.hash, loginHint = "", @@ -105,11 +140,21 @@ export async function login( await keycloak.login(options); } +/** + * Log the current user out of Keycloak and return them to the portal root. + */ export async function logout() { if (!keycloak) return; await keycloak.logout({ redirectUri: window.location.origin }); } +/** + * Perform a fetch with the current bearer token attached when available. + * + * @param {string} url - Target URL. + * @param {RequestInit} options - Standard fetch options. + * @returns {Promise} The browser fetch response. + */ export async function authFetch(url, options = {}) { const headers = new Headers(options.headers || {}); if (keycloak?.authenticated) { diff --git a/frontend/src/data/sample.js b/frontend/src/data/sample.js index 66ac5f3..0f81a54 100644 --- a/frontend/src/data/sample.js +++ b/frontend/src/data/sample.js @@ -1,3 +1,9 @@ +/** + * Return the static Atlas and Oceanus hardware inventory used as fallback data. + * + * WHY: the home page needs stable content when live cluster data cannot be + * fetched during startup or testing. + */ export function fallbackHardware() { return { clusters: [ @@ -39,6 +45,11 @@ export function fallbackHardware() { }; } +/** + * Return the curated service catalog shown on the home page when live data is absent. + * + * WHY: the service grid must stay useful without a live backend response. + */ export function fallbackServices() { return { services: [ @@ -262,6 +273,11 @@ export function fallbackServices() { }; } +/** + * Return the static ingress and egress relationships that power the network diagram. + * + * WHY: the topology diagram needs deterministic fallback data for offline runs. + */ export function fallbackNetwork() { return { ingress: [ @@ -297,6 +313,12 @@ export function fallbackNetwork() { }; } +/** + * Return the Atlas metrics summary card content used on the overview page. + * + * WHY: the metrics cards should still render a coherent overview if live + * dashboard links are unavailable. + */ export function fallbackMetrics() { return { dashboard: "https://metrics.bstein.dev", @@ -304,7 +326,16 @@ export function fallbackMetrics() { }; } -export function buildHardwareDiagram(data) { +/** + * Render the hardware topology diagram used on the home page. + * + * WHY: the landing page needs a deterministic Mermaid diagram even before + * live cluster state is available. + * + * @param {object} _data - Live hardware state, accepted for future shaping. + * @returns {string} Mermaid flowchart text for the Atlas hardware overview. + */ +export function buildHardwareDiagram(_data) { return ` flowchart TB subgraph TitanLab["Titan Lab (25 nodes)"] @@ -370,6 +401,12 @@ flowchart TB `; } +/** + * Render the ingress and auth sequence for the portal network flow. + * + * WHY: the home page should explain request routing without depending on live + * cluster state. + */ export function buildNetworkDiagram() { return ` sequenceDiagram @@ -394,6 +431,12 @@ sequenceDiagram `; } +/** + * Render the delivery pipeline from developer push to Flux reconciliation. + * + * WHY: the overview page needs a compact visual of the release path even when + * the CI backend is not reachable. + */ export function buildPipelineDiagram() { return ` flowchart LR diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index f59ec93..5d7ceb2 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -99,7 +99,9 @@ const atlasPillClass = computed(() => (props.labStatus?.atlas?.up ? "pill-ok" : const oceanusPillClass = computed(() => (props.labStatus?.oceanus?.up ? "pill-ok" : "pill-bad")); const metricItems = computed(() => { - const items = [ + const items = props.metricsData?.items?.length + ? props.metricsData.items + : [ { label: "Lab nodes", value: "26", note: "Workers: 8 rpi5s, 8 rpi4s, 2 jetsons,\n\t\t\t\t 1 minipc\nControl plane: 3 rpi5\nDedicated Hosts: oceanus, titan-db,\n\t\t\t\t\t\t\t\t tethys, theia" }, { label: "CPU cores", value: "142", note: "32 arm64 cores @ 1.5Ghz\n12 arm64 cores @ 1.9Ghz\n52 arm64 cores @ 2.4Ghz\n10 amd64 cores @ 5.00Ghz\n12 amd64 cores @ 4.67Ghz\n24 amd64 cores @ 4.04Ghz" }, { @@ -108,7 +110,7 @@ const metricItems = computed(() => { note: "64GB Raspberry Pi 4\n104GB Raspberry Pi 5\n32GB NVIDIA Jetson Xavier\n352GB AMD64 Chipsets", }, { label: "Storage", value: "80 TB", note: "astreae: 32GB/4xRPI4\nasteria: 48GB/4xRPI4" }, - ]; + ]; return items.map((item) => ({ ...item, note: item.note ? item.note.replaceAll("\t", " ") : "", @@ -127,6 +129,15 @@ const hardwareDiagram = computed(() => buildHardwareDiagram(props.labData || {}) const networkDiagram = computed(() => buildNetworkDiagram(props.networkData || {})); const pipelineDiagram = computed(() => buildPipelineDiagram()); +/** + * Pick a friendly emoji icon for a service name. + * + * WHY: the service grid should stay readable even when upstream service data + * omits a custom icon, so the default icon needs to be deterministic. + * + * @param {string} name - Service display name. + * @returns {string} Emoji used in the service grid card. + */ function pickIcon(name) { const h = name.toLowerCase(); if (h.includes("nextcloud")) return "☁️"; diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000..860575a --- /dev/null +++ b/testing/README.md @@ -0,0 +1,13 @@ +# Testing Strategy + +The repo keeps test orchestration separate from application code: + +- `testing/quality_contract.json` defines the managed production scope that the quality gate owns. +- `testing/ci/` holds shared CI helpers for file-size ratchets, docstring checks, coverage checks, and Pushgateway publishing. +- `testing/tests/` exercises the CI helpers themselves so the gate logic stays stable. +- `backend/tests/` holds backend unit and route tests, run with `pytest`. +- `testing/frontend/unit/` holds Vitest coverage tests for frontend logic and Vue components. +- `testing/frontend/component/` holds Playwright component tests for browser-mounted Vue components. +- `testing/frontend/e2e/` holds Playwright end-to-end smoke tests against the live frontend dev server. + +The goal is to keep production code focused on app behavior while CI and local workflow logic lives in one place that both Jenkins and developers can reuse. The top-level `testing` package is the repo-owned home for quality policy, browser automation, and metrics publishing. diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..1e2a190 --- /dev/null +++ b/testing/__init__.py @@ -0,0 +1 @@ +"""Top-level test orchestration helpers for the repository.""" diff --git a/testing/ci/__init__.py b/testing/ci/__init__.py new file mode 100644 index 0000000..9643cbe --- /dev/null +++ b/testing/ci/__init__.py @@ -0,0 +1 @@ +"""Continuous-integration helpers for test results and quality gates.""" diff --git a/testing/ci/publish_metrics.py b/testing/ci/publish_metrics.py new file mode 100644 index 0000000..8b3959b --- /dev/null +++ b/testing/ci/publish_metrics.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +"""Command-line entry point for publishing CI test metrics.""" + +import argparse +from pathlib import Path + +from .summary import load_junit_summary, publish_quality_metrics + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI parser for the metrics publisher.""" + + parser = argparse.ArgumentParser(description="Publish test-suite metrics to Pushgateway") + parser.add_argument("--gateway", required=True, help="Pushgateway base URL") + parser.add_argument("--suite", required=True, help="Logical suite name") + parser.add_argument("--job", default="platform-quality-ci", help="Pushgateway job label") + parser.add_argument("--status", choices=("ok", "failed"), required=True, help="Gate outcome") + parser.add_argument("--junit", nargs="*", default=(), help="JUnit XML files to aggregate") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Parse arguments, aggregate JUnit files, and publish metrics.""" + + parser = _build_parser() + args = parser.parse_args(argv) + summary = load_junit_summary(Path(path) for path in args.junit) + publish_quality_metrics( + gateway=args.gateway, + suite=args.suite, + job=args.job, + status=args.status, + summary=summary, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/ci/quality_gate.py b/testing/ci/quality_gate.py new file mode 100644 index 0000000..3640186 --- /dev/null +++ b/testing/ci/quality_gate.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +"""Unified quality gate for the repo's managed production scope.""" + +import argparse +import ast +import json +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONTRACT = ROOT / "testing" / "quality_contract.json" +DEFAULT_BACKEND_COVERAGE = ROOT / "build" / "backend-coverage.xml" +DEFAULT_FRONTEND_COVERAGE = ROOT / "frontend" / "coverage" / "coverage-summary.json" + +TEXT_EXTENSIONS = {".py", ".js", ".mjs", ".ts", ".vue", ".json", ".yaml", ".yml"} + + +@dataclass(frozen=True) +class GateIssue: + """Describe one violated gate condition.""" + + check: str + path: str + message: str + + +def load_contract(path: Path) -> dict: + """Load the JSON gate contract from disk.""" + + return json.loads(path.read_text()) + + +def _resolve(path_str: str) -> Path: + path = Path(path_str) + return path if path.is_absolute() else ROOT / path + + +def _count_lines(path: Path) -> int: + return len(path.read_text().splitlines()) + + +def check_file_sizes(paths: Iterable[Path], *, max_lines: int = 500) -> list[GateIssue]: + """Flag text files that exceed the maximum line budget.""" + + issues: list[GateIssue] = [] + for path in paths: + if not path.exists() or path.suffix.lower() not in TEXT_EXTENSIONS: + continue + lines = _count_lines(path) + if lines > max_lines: + issues.append(GateIssue("loc", str(path), f"{lines} lines exceeds {max_lines}")) + return issues + + +def _python_node_issues(path: Path) -> list[GateIssue]: + """Require docstrings on all functions and classes in a Python module.""" + + issues: list[GateIssue] = [] + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if ast.get_docstring(node): + continue + issues.append(GateIssue("docstring", str(path), f"missing docstring on {node.__class__.__name__} {node.name}")) + return issues + + +_FUNCTION_RE = re.compile(r"^\s*(?:export\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(") +_CLASS_RE = re.compile(r"^\s*class\s+([A-Za-z_$][\w$]*)\s*") + + +def _has_js_contract(lines: list[str], index: int) -> bool: + """Check whether the nearest leading comment block documents a JS function.""" + + seen_comment = False + for pos in range(index - 1, -1, -1): + raw = lines[pos].rstrip() + stripped = raw.strip() + if not stripped: + if seen_comment: + continue + continue + if stripped.startswith("//"): + seen_comment = True + if "WHY:" in stripped or "@param" in stripped or "@returns" in stripped: + return True + continue + if stripped.startswith("*"): + seen_comment = True + if "WHY:" in stripped or "@param" in stripped or "@returns" in stripped: + return True + continue + if stripped.endswith("*/"): + seen_comment = True + if "WHY:" in stripped or "@param" in stripped or "@returns" in stripped: + return True + continue + if stripped.startswith("/**"): + seen_comment = True + if "WHY:" in stripped or "@param" in stripped or "@returns" in stripped: + return True + continue + break + return seen_comment and any( + marker in line for line in lines[max(0, index - 6):index] for marker in ("WHY:", "@param", "@returns") + ) + + +def _js_node_issues(path: Path) -> list[GateIssue]: + """Require leading contract comments for named JS functions and classes.""" + + lines = path.read_text().splitlines() + issues: list[GateIssue] = [] + for index, line in enumerate(lines): + match = _FUNCTION_RE.match(line) or _CLASS_RE.match(line) + if not match: + continue + name = match.group(1) + if _has_js_contract(lines, index): + continue + issues.append(GateIssue("docstring", str(path), f"missing contract comment on {name}")) + return issues + + +def check_docstrings(paths: Iterable[Path]) -> list[GateIssue]: + """Check that managed production files document non-trivial definitions.""" + + issues: list[GateIssue] = [] + for path in paths: + if not path.exists(): + continue + suffix = path.suffix.lower() + if suffix == ".py": + issues.extend(_python_node_issues(path)) + elif suffix in {".js", ".mjs", ".ts", ".vue"}: + issues.extend(_js_node_issues(path)) + return issues + + +def _normalize_key(value: str) -> str: + return value.replace("\\", "/").lstrip("./") + + +def _path_suffixes(value: str) -> set[str]: + parts = _normalize_key(value).split("/") + return {"/".join(parts[index:]) for index in range(len(parts))} + + +def _coverage_lookup(report: dict, wanted: str) -> dict | None: + wanted_key = _normalize_key(wanted) + wanted_suffixes = _path_suffixes(wanted_key) + candidates = [] + for key, value in report.items(): + if not isinstance(value, dict) or "lines" not in value: + continue + normalized = _normalize_key(key) + if normalized == wanted_key or normalized in wanted_suffixes or any(normalized.endswith(f"/{suffix}") for suffix in wanted_suffixes): + candidates.append(value) + if candidates: + return candidates[0] + return None + + +def _load_frontend_coverage(path: Path) -> dict: + data = json.loads(path.read_text()) + return {key: value for key, value in data.items() if isinstance(value, dict)} + + +def _load_backend_coverage(path: Path) -> dict[str, dict[str, float]]: + root = ET.parse(path).getroot() + report: dict[str, dict[str, float]] = {} + for class_node in root.findall(".//class"): + filename = class_node.attrib.get("filename") + if not filename: + continue + report[_normalize_key(filename)] = { + "lines": float(class_node.attrib.get("line-rate", "0")) * 100, + "branches": float(class_node.attrib.get("branch-rate", "0")) * 100, + } + return report + + +def check_coverage( + paths: Iterable[Path], + *, + backend_report: Path, + frontend_report: Path, + threshold: float = 95.0, +) -> list[GateIssue]: + """Check the per-file coverage floor for the managed production scope.""" + + issues: list[GateIssue] = [] + backend_cov = _load_backend_coverage(backend_report) if backend_report.exists() else {} + frontend_cov = _load_frontend_coverage(frontend_report) if frontend_report.exists() else {} + + for path in paths: + if not path.exists(): + continue + rel = path.relative_to(ROOT).as_posix() if path.is_absolute() else _normalize_key(str(path)) + if rel.startswith("backend/"): + metrics = _coverage_lookup(backend_cov, rel) + if metrics is None: + issues.append(GateIssue("coverage", rel, "missing from backend coverage report")) + continue + if metrics["lines"] < threshold: + issues.append(GateIssue("coverage", rel, f"line coverage {metrics['lines']:.2f}% below {threshold}%")) + elif rel.startswith("frontend/"): + lookup = rel.split("frontend/", 1)[1] + metrics = _coverage_lookup(frontend_cov, lookup) + if metrics is None: + issues.append(GateIssue("coverage", rel, "missing from frontend coverage report")) + continue + pct = metrics.get("lines", {}).get("pct", 0.0) + if pct < threshold: + issues.append(GateIssue("coverage", rel, f"line coverage {pct:.2f}% below {threshold}%")) + return issues + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the repo's unified quality gate") + parser.add_argument("--contract", default=str(DEFAULT_CONTRACT), help="Path to the JSON gate contract") + parser.add_argument("--backend-coverage", default=str(DEFAULT_BACKEND_COVERAGE), help="Backend coverage XML") + parser.add_argument("--frontend-coverage", default=str(DEFAULT_FRONTEND_COVERAGE), help="Frontend coverage summary JSON") + parser.add_argument("--report", default=str(ROOT / "build" / "quality-gate.json"), help="Write a JSON report here") + return parser + + +def run_gate(contract_path: Path, *, backend_coverage: Path, frontend_coverage: Path) -> tuple[list[GateIssue], dict]: + contract = load_contract(contract_path) + managed_files = [_resolve(path) for path in contract["managed_files"]] + docstring_files = [_resolve(path) for path in contract["docstring_files"]] + coverage_files = [_resolve(path) for path in contract["coverage_files"]] + max_lines = int(contract.get("max_lines", 500)) + threshold = float(contract.get("coverage_threshold_pct", 95)) + + issues: list[GateIssue] = [] + issues.extend(check_file_sizes(managed_files, max_lines=max_lines)) + issues.extend(check_docstrings(docstring_files)) + issues.extend(check_coverage(coverage_files, backend_report=backend_coverage, frontend_report=frontend_coverage, threshold=threshold)) + report = { + "managed_files": [str(path.relative_to(ROOT)) for path in managed_files], + "docstring_files": [str(path.relative_to(ROOT)) for path in docstring_files], + "coverage_files": [str(path.relative_to(ROOT)) for path in coverage_files], + "max_lines": max_lines, + "coverage_threshold_pct": threshold, + "issue_count": len(issues), + "issues": [issue.__dict__ for issue in issues], + } + return issues, report + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + backend_coverage = _resolve(args.backend_coverage) + frontend_coverage = _resolve(args.frontend_coverage) + report_path = _resolve(args.report) + issues, report = run_gate(_resolve(args.contract), backend_coverage=backend_coverage, frontend_coverage=frontend_coverage) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + + for issue in issues: + print(f"{issue.check}: {issue.path}: {issue.message}") + + if issues: + print(f"quality gate failed: {len(issues)} issue(s)") + return 1 + + print(f"quality gate passed: {len(report['managed_files'])} managed files checked") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/ci/summary.py b/testing/ci/summary.py new file mode 100644 index 0000000..335be54 --- /dev/null +++ b/testing/ci/summary.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +"""Parse test results and format Pushgateway-friendly metrics payloads.""" + +from dataclasses import dataclass +import re +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class RunSummary: + """Aggregate counts from a collection of JUnit XML files.""" + + tests: int = 0 + failures: int = 0 + errors: int = 0 + skipped: int = 0 + + @property + def passed(self) -> int: + """Return the number of passing test cases derived from the aggregate.""" + return max(self.tests - self.failures - self.errors - self.skipped, 0) + + +def load_junit_summary(paths: Iterable[Path]) -> RunSummary: + """Load one or more JUnit XML files and combine their result counts. + + WHY: CI needs a single stable view of each suite instead of separately + parsing backend, unit, component, and e2e XML files in shell. + """ + + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + for path in paths: + if not path.exists(): + continue + root = ET.parse(path).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) if root.tag == "testsuites" else [] + for node in suites: + for key in totals: + raw = node.attrib.get(key) or "0" + try: + totals[key] += int(float(raw)) + except ValueError: + continue + return RunSummary(**totals) + + +def read_pushgateway_counters(text: str, *, suite: str, job: str) -> dict[str, float]: + """Read the current quality-gate counters for a suite from Pushgateway text.""" + + counters: dict[str, float] = {"ok": 0.0, "failed": 0.0} + for status in counters: + pattern = re.compile( + rf'^platform_quality_gate_runs_total\{{[^}}]*job="{re.escape(job)}"[^}}]*suite="{re.escape(suite)}"[^}}]*status="{status}"[^}}]*\}}\s+([0-9]+(?:\.[0-9]+)?)$', + re.M, + ) + match = pattern.search(text) + if match: + counters[status] = float(match.group(1)) + return counters + + +def render_payload(*, suite: str, ok: int, failed: int, summary: RunSummary) -> str: + """Render the Pushgateway payload for the quality-gate counters.""" + + return ( + "# TYPE platform_quality_gate_runs_total counter\n" + f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok}\n' + f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed}\n' + "# TYPE bstein_home_quality_gate_tests_total gauge\n" + f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="passed"}} {summary.passed}\n' + f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="failed"}} {summary.failures}\n' + f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="error"}} {summary.errors}\n' + f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {summary.skipped}\n' + ) + + +def publish_quality_metrics(*, gateway: str, suite: str, job: str, status: str, summary: RunSummary) -> None: + """Publish run and test totals to Pushgateway.""" + + gateway = gateway.rstrip("/") + text = urllib.request.urlopen(f"{gateway}/metrics", timeout=10).read().decode("utf-8", errors="replace") + counters = read_pushgateway_counters(text, suite=suite, job=job) + if status == "ok": + counters["ok"] += 1 + else: + counters["failed"] += 1 + + payload = render_payload(suite=suite, ok=int(counters["ok"]), failed=int(counters["failed"]), summary=summary) + req = urllib.request.Request( + f"{gateway}/metrics/job/{job}/suite/{suite}", + data=payload.encode("utf-8"), + method="POST", + headers={"Content-Type": "text/plain"}, + ) + urllib.request.urlopen(req, timeout=10).read() diff --git a/testing/frontend/component/metric-row.spec.js b/testing/frontend/component/metric-row.spec.js new file mode 100644 index 0000000..2a8391d --- /dev/null +++ b/testing/frontend/component/metric-row.spec.js @@ -0,0 +1,17 @@ +import { expect, test } from "../../../frontend/node_modules/@playwright/experimental-ct-vue/index.js"; + +import MetricRow from "../../../frontend/src/components/MetricRow.vue"; + +test("renders metric values in the browser", async ({ mount }) => { + const component = await mount(MetricRow, { + props: { + items: [ + { label: "Nodes", value: "26", note: "Atlas" }, + { label: "Storage", value: "80 TB", note: "Longhorn" }, + ], + }, + }); + + await expect(component).toContainText("Nodes"); + await expect(component).toContainText("80 TB"); +}); diff --git a/testing/frontend/component/stats-grid.spec.js b/testing/frontend/component/stats-grid.spec.js new file mode 100644 index 0000000..caf1865 --- /dev/null +++ b/testing/frontend/component/stats-grid.spec.js @@ -0,0 +1,15 @@ +import { expect, test } from "../../../frontend/node_modules/@playwright/experimental-ct-vue/index.js"; + +import StatsGrid from "../../../frontend/src/components/StatsGrid.vue"; +import { fallbackHardware } from "../../../frontend/src/data/sample.js"; + +test("renders a live hardware summary in the browser", async ({ mount }) => { + const component = await mount(StatsGrid, { + props: { + hardware: fallbackHardware(), + }, + }); + + await expect(component).toContainText("Control plane"); + await expect(component).toContainText("titan-16"); +}); diff --git a/testing/frontend/e2e/home.spec.js b/testing/frontend/e2e/home.spec.js new file mode 100644 index 0000000..c769016 --- /dev/null +++ b/testing/frontend/e2e/home.spec.js @@ -0,0 +1,37 @@ +import { expect, test } from "../../../frontend/node_modules/@playwright/test/index.mjs"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/auth/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ enabled: false }), + }); + }); + await page.route("**/api/lab/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + connected: true, + atlas: { up: true }, + oceanus: { up: false }, + }), + }); + }); +}); + +test("shows the overview and expands the mermaid diagram", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); + await expect(page.getByText("Live data connected")).toBeVisible(); + await expect(page.locator(".service-grid .service").filter({ hasText: "Nextcloud" }).first()).toBeVisible(); + + const firstCard = page.locator(".mermaid-card").first(); + await expect(firstCard.locator("svg")).toBeVisible(); + await firstCard.getByRole("button", { name: "Full screen" }).click(); + await expect(page.locator(".overlay")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".overlay")).toHaveCount(0); +}); diff --git a/testing/frontend/e2e/request-access.spec.js b/testing/frontend/e2e/request-access.spec.js new file mode 100644 index 0000000..ba4b2a1 --- /dev/null +++ b/testing/frontend/e2e/request-access.spec.js @@ -0,0 +1,41 @@ +import { expect, test } from "../../../frontend/node_modules/@playwright/test/index.mjs"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/auth/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ enabled: false }), + }); + }); + await page.route("**/api/access/request/availability*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ available: true }), + }); + }); + await page.route("**/api/access/request", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "pending_email_verification", + request_code: "alice~ABC123", + }), + }); + }); +}); + +test("submits an access request and shows the request code", async ({ page }) => { + await page.goto("/request-access"); + + await page.getByLabel("Lab Name (username)").fill("alice"); + await expect(page.getByText("Username is available.")).toBeVisible(); + await page.getByLabel("Last name").fill("Atlas"); + await page.getByLabel("Email").fill("alice@example.dev"); + await page.getByRole("button", { name: "Submit request" }).click(); + + await expect(page.getByText("Request submitted.")).toBeVisible(); + await expect(page.getByText("alice~ABC123")).toBeVisible(); +}); diff --git a/testing/frontend/eslint.config.js b/testing/frontend/eslint.config.js new file mode 100644 index 0000000..5d0ca95 --- /dev/null +++ b/testing/frontend/eslint.config.js @@ -0,0 +1,24 @@ +import js from "../../frontend/node_modules/@eslint/js/src/index.js"; +import globals from "../../frontend/node_modules/globals/index.js"; + +const sharedGlobals = { + ...globals.browser, + ...globals.node, + ...globals.vitest, +}; + +export default [ + js.configs.recommended, + { + files: ["frontend/src/**/*.js", "testing/frontend/**/*.js", "testing/frontend/**/*.mjs"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: sharedGlobals, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "no-undef": "error", + }, + }, +]; diff --git a/testing/frontend/playwright-ct.config.mjs b/testing/frontend/playwright-ct.config.mjs new file mode 100644 index 0000000..0460265 --- /dev/null +++ b/testing/frontend/playwright-ct.config.mjs @@ -0,0 +1,15 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "../../frontend/node_modules/@playwright/experimental-ct-vue/index.js"; + +const testingDir = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: path.resolve(testingDir, "component"), + use: { + ctPort: 3100, + ctTemplateDir: "../../frontend/playwright", + viewport: { width: 1280, height: 900 }, + }, + reporter: [["list"], ["junit", { outputFile: path.resolve(testingDir, "../../build/junit-frontend-component.xml") }]], +}); diff --git a/testing/frontend/playwright.config.mjs b/testing/frontend/playwright.config.mjs new file mode 100644 index 0000000..8676aa2 --- /dev/null +++ b/testing/frontend/playwright.config.mjs @@ -0,0 +1,23 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "../../frontend/node_modules/@playwright/test/index.mjs"; + +const testingDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendRoot = path.resolve(testingDir, "../../frontend"); + +export default defineConfig({ + testDir: path.resolve(testingDir, "e2e"), + use: { + baseURL: "http://127.0.0.1:4173", + trace: "on-first-retry", + viewport: { width: 1440, height: 1080 }, + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + cwd: frontendRoot, + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, + reporter: [["list"], ["junit", { outputFile: path.resolve(testingDir, "../../build/junit-frontend-e2e.xml") }]], +}); diff --git a/testing/frontend/playwright/index.html b/testing/frontend/playwright/index.html new file mode 100644 index 0000000..b90220a --- /dev/null +++ b/testing/frontend/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Playwright CT + + +
+ + + diff --git a/testing/frontend/playwright/index.ts b/testing/frontend/playwright/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/testing/frontend/playwright/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/testing/frontend/unit/auth.spec.js b/testing/frontend/unit/auth.spec.js new file mode 100644 index 0000000..fa52fcf --- /dev/null +++ b/testing/frontend/unit/auth.spec.js @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadAuth() { + vi.resetModules(); + return import("../../../frontend/src/auth.js"); +} + +describe("auth helpers", () => { + beforeEach(() => { + globalThis.__ATLAS_KEYCLOAK_FACTORY__ = null; + globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__ = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("normalizes token groups", async () => { + const { normalizeGroups } = await loadAuth(); + + expect(normalizeGroups(["/dev", "admin", 3, null, ""])).toEqual(["dev", "admin"]); + expect(normalizeGroups("not-an-array")).toEqual([]); + }); + + it("attaches a bearer token when fetching", async () => { + const authModule = await loadAuth(); + authModule.auth.token = "bearer-token"; + + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await authModule.authFetch("/api/healthz"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0]; + expect(new Headers(options.headers).get("Authorization")).toBe("Bearer bearer-token"); + }); + + it("initializes auth state from the auth config endpoint", async () => { + const authModule = await loadAuth(); + vi.spyOn(window, "setInterval").mockReturnValue(1234); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ enabled: false, login_url: "", reset_url: "" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + + await authModule.initAuth(); + + expect(authModule.auth.ready).toBe(true); + expect(authModule.auth.enabled).toBe(false); + }); + + it("falls back to the keycloak-js constructor when no injected factory exists", async () => { + const client = { + authenticated: true, + token: "mock-token", + tokenParsed: { + preferred_username: "bob", + email: "bob@example.dev", + groups: ["/ops"], + }, + init: vi.fn(async () => true), + login: vi.fn(async () => {}), + logout: vi.fn(async () => {}), + updateToken: vi.fn(async () => true), + }; + globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__ = function MockKeycloak() { + return client; + }; + + const authModule = await loadAuth(); + const created = authModule.createKeycloak({ url: "https://sso.example.dev" }); + + expect(created).toBe(client); + }); + + it("uses the real Keycloak constructor when no test hooks are present", async () => { + const authModule = await loadAuth(); + const client = authModule.createKeycloak({ + url: "https://sso.example.dev", + realm: "atlas", + clientId: "portal-client", + }); + + expect(client).toHaveProperty("init"); + expect(client).toHaveProperty("login"); + expect(client).toHaveProperty("logout"); + }); + + it("reuses the auth initialization promise and tolerates empty token fields", async () => { + const client = { + authenticated: false, + token: "", + tokenParsed: undefined, + init: vi.fn(async () => true), + login: vi.fn(async () => {}), + logout: vi.fn(async () => {}), + updateToken: vi.fn(async () => true), + }; + globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client; + + const authModule = await loadAuth(); + vi.spyOn(window, "setInterval").mockReturnValue(1234); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + enabled: true, + url: "https://sso.example.dev", + realm: "atlas", + client_id: "portal-client", + login_url: "https://sso.example.dev/login", + reset_url: "https://sso.example.dev/reset", + account_url: "https://sso.example.dev/account", + account_password_url: "https://sso.example.dev/account/#/security/signingin", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); + + const first = authModule.initAuth(); + const second = authModule.initAuth(); + + await first; + await second; + client.onAuthSuccess(); + + expect(authModule.auth.authenticated).toBe(false); + expect(authModule.auth.username).toBe(""); + expect(authModule.auth.email).toBe(""); + expect(authModule.auth.groups).toEqual([]); + }); + + it("hydrates and proxies keycloak actions when enabled", async () => { + const calls = { init: 0, login: 0, logout: 0, updateToken: 0 }; + const client = { + authenticated: true, + token: "mock-token", + tokenParsed: { + preferred_username: "alice", + email: "alice@example.dev", + groups: ["/dev", "/admin"], + }, + init: vi.fn(async () => { + calls.init += 1; + return true; + }), + login: vi.fn(async () => { + calls.login += 1; + }), + logout: vi.fn(async () => { + calls.logout += 1; + }), + updateToken: vi.fn(async () => { + calls.updateToken += 1; + return true; + }), + }; + globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client; + + const authModule = await loadAuth(); + vi.spyOn(window, "setInterval").mockReturnValue(1234); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + enabled: true, + url: "https://sso.example.dev", + realm: "atlas", + client_id: "portal-client", + login_url: "https://sso.example.dev/login", + reset_url: "https://sso.example.dev/reset", + account_url: "https://sso.example.dev/account", + account_password_url: "https://sso.example.dev/account/#/security/signingin", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); + + await authModule.initAuth(); + await authModule.login("/account", " "); + await authModule.login("/account", "alice"); + await authModule.logout(); + await authModule.authFetch("/api/healthz"); + + expect(calls.init).toBe(1); + expect(calls.login).toBe(2); + expect(calls.logout).toBe(1); + expect(calls.updateToken).toBeGreaterThan(0); + expect(client.login.mock.calls[0][0]).not.toHaveProperty("loginHint"); + expect(client.login.mock.calls[1][0]).toMatchObject({ loginHint: "alice" }); + expect(authModule.auth.ready).toBe(true); + expect(authModule.auth.enabled).toBe(true); + expect(authModule.auth.authenticated).toBe(true); + expect(authModule.auth.username).toBe("alice"); + expect(authModule.auth.email).toBe("alice@example.dev"); + expect(authModule.auth.groups).toEqual(["dev", "admin"]); + expect(authModule.auth.token).toBe("mock-token"); + }); + + it("covers the auth refresh handlers and polling branches", async () => { + const intervalCalls = []; + const client = { + authenticated: true, + token: "refresh-token", + tokenParsed: { + preferred_username: "carol", + email: "carol@example.dev", + groups: ["/ops"], + }, + init: vi.fn(async () => true), + login: vi.fn(async () => {}), + logout: vi.fn(async () => {}), + updateToken: vi.fn(async () => true), + }; + globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client; + + const authModule = await loadAuth(); + vi.spyOn(window, "setInterval").mockImplementation((callback) => { + intervalCalls.push(callback); + return 1234; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + enabled: true, + url: "https://sso.example.dev", + realm: "atlas", + client_id: "portal-client", + login_url: "https://sso.example.dev/login", + reset_url: "https://sso.example.dev/reset", + account_url: "https://sso.example.dev/account", + account_password_url: "https://sso.example.dev/account/#/security/signingin", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); + + await authModule.initAuth(); + expect(intervalCalls).toHaveLength(1); + + client.onAuthSuccess(); + client.onAuthLogout(); + client.onAuthRefreshSuccess(); + + client.updateToken.mockResolvedValueOnce(true); + client.onTokenExpired(); + await Promise.resolve(); + + client.updateToken.mockRejectedValueOnce(new Error("boom")); + client.onTokenExpired(); + await Promise.resolve(); + + client.updateToken.mockResolvedValueOnce(true); + intervalCalls[0](); + await Promise.resolve(); + + client.authenticated = false; + intervalCalls[0](); + await Promise.resolve(); + + client.authenticated = true; + client.updateToken.mockRejectedValueOnce(new Error("refresh failed")); + await authModule.authFetch("/api/healthz"); + + expect(authModule.auth.username).toBe("carol"); + expect(authModule.auth.groups).toEqual(["ops"]); + expect(client.updateToken).toHaveBeenCalled(); + }); + + it("leaves auth alone when login/logout are called before initialization", async () => { + const authModule = await loadAuth(); + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await authModule.login("/account", "alice"); + await authModule.logout(); + await authModule.authFetch("/api/healthz", { headers: { "X-Test": "1" } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0]; + const headers = new Headers(options.headers); + expect(headers.get("X-Test")).toBe("1"); + expect(headers.get("Authorization")).toBeNull(); + }); + + it("recovers when the auth config endpoint fails", async () => { + const authModule = await loadAuth(); + vi.stubGlobal("fetch", vi.fn(async () => new Response("boom", { status: 500 }))); + + await authModule.initAuth(); + + expect(authModule.auth.ready).toBe(true); + expect(authModule.auth.enabled).toBe(false); + expect(authModule.auth.authenticated).toBe(false); + }); +}); diff --git a/testing/frontend/unit/components.spec.js b/testing/frontend/unit/components.spec.js new file mode 100644 index 0000000..2ff737d --- /dev/null +++ b/testing/frontend/unit/components.spec.js @@ -0,0 +1,120 @@ +import { RouterLinkStub, shallowMount } from "../../../frontend/node_modules/@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs"; +import { describe, expect, it } from "../../../frontend/node_modules/vitest/dist/index.js"; + +import MetricRow from "../../../frontend/src/components/MetricRow.vue"; +import ServiceGrid from "../../../frontend/src/components/ServiceGrid.vue"; +import StatsGrid from "../../../frontend/src/components/StatsGrid.vue"; +import { fallbackHardware } from "../../../frontend/src/data/sample.js"; + +describe("shared dashboard components", () => { + it("renders metric cards", () => { + const wrapper = shallowMount(MetricRow, { + props: { + items: [ + { label: "Nodes", value: "26", note: "atlas + oceanus" }, + { label: "Storage", value: "80 TB", note: "Longhorn" }, + ], + }, + }); + + expect(wrapper.text()).toContain("Nodes"); + expect(wrapper.text()).toContain("26"); + expect(wrapper.text()).toContain("Longhorn"); + }); + + it("summarizes node state in the stats grid", () => { + const wrapper = shallowMount(StatsGrid, { + props: { hardware: fallbackHardware() }, + }); + + expect(wrapper.text()).toContain("Control plane"); + expect(wrapper.text()).toContain("3"); + expect(wrapper.text()).toContain("offline"); + expect(wrapper.text()).toContain("titan-16"); + }); + + it("hides the attention card when every worker is healthy", () => { + const wrapper = shallowMount(StatsGrid, { + props: { + hardware: { + clusters: [ + { + name: "atlas", + nodes: [{ name: "titan-0a", role: "control-plane", status: "ready" }], + }, + ], + specialty: [], + }, + }, + }); + + expect(wrapper.text()).not.toContain("Attention"); + }); + + it("distinguishes internal and external service links", () => { + const wrapper = shallowMount(ServiceGrid, { + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + props: { + services: [ + { name: "Atlas AI", category: "ai", summary: "Chat", link: "/ai/chat", icon: "🤖" }, + { name: "Nextcloud", category: "productivity", summary: "Files", link: "https://cloud.example.dev", icon: "☁️" }, + { name: "Bad Link", category: "broken", summary: "invalid", link: "not a url", icon: "❓" }, + ], + }, + }); + + expect(wrapper.text()).toContain("Atlas AI"); + expect(wrapper.text()).toContain("cloud.example.dev"); + expect(wrapper.text()).toContain("not a url"); + expect(wrapper.findComponent(RouterLinkStub).exists()).toBe(true); + }); + + it("falls back to default service rendering when data is sparse", () => { + const wrapper = shallowMount(ServiceGrid, { + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + props: { + services: [ + { name: "Muted Service", category: "dev", summary: "planned", link: "", status: "planned" }, + { name: "Plain Service", category: "dev", summary: "fallback icon", link: "/apps" }, + ], + }, + }); + + expect(wrapper.text()).toContain("Muted Service"); + expect(wrapper.text()).toContain("fallback icon"); + expect(wrapper.findAll(".service").length).toBe(2); + expect(wrapper.findComponent(RouterLinkStub).exists()).toBe(true); + }); + + it("renders empty and partial hardware states safely", () => { + const wrapper = shallowMount(StatsGrid, { + props: { + hardware: { + clusters: [{ name: "atlas", nodes: [{ name: "solo", role: "worker", status: "ready" }] }], + specialty: [{ name: "standalone", role: "Spare node", status: "ready" }], + }, + }, + }); + + expect(wrapper.text()).toContain("1 nodes total"); + expect(wrapper.text()).toContain("0"); + expect(wrapper.text()).toContain("standalone"); + }); + + it("falls back cleanly when no hardware prop is provided", () => { + const wrapper = shallowMount(StatsGrid, { + props: {}, + }); + + expect(wrapper.text()).toContain("0 nodes total"); + expect(wrapper.text()).not.toContain("Attention"); + }); +}); diff --git a/testing/frontend/unit/home.spec.js b/testing/frontend/unit/home.spec.js new file mode 100644 index 0000000..0ec60ec --- /dev/null +++ b/testing/frontend/unit/home.spec.js @@ -0,0 +1,144 @@ +import { shallowMount } from "../../../frontend/node_modules/@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs"; +import { describe, expect, it } from "../../../frontend/node_modules/vitest/dist/index.js"; + +import HomeView from "../../../frontend/src/views/HomeView.vue"; + +describe("HomeView", () => { + it("adds fallback icons and builds diagrams for the overview page", () => { + const wrapper = shallowMount(HomeView, { + global: { + stubs: { + MetricRow: true, + ServiceGrid: true, + MermaidCard: true, + }, + }, + props: { + serviceData: { + services: [ + { name: "Nextcloud", summary: "Storage", link: "https://cloud.example.dev" }, + { name: "Jellyfin", summary: "Media", link: "https://stream.example.dev" }, + { name: "Matrix (Synapse)", summary: "Chat", link: "https://chat.example.dev" }, + { name: "Element", summary: "Rooms", link: "https://rooms.example.dev" }, + { name: "LiveKit", summary: "Calls", link: "https://calls.example.dev" }, + { name: "Coturn", summary: "TURN", link: "https://turn.example.dev" }, + { name: "Mailu", summary: "Mail", link: "https://mail.example.dev" }, + { name: "Vaultwarden", summary: "Passwords", link: "https://vault.example.dev" }, + { name: "Vault", summary: "Secrets", link: "https://secret.example.dev" }, + { name: "Gitea", summary: "Git", link: "https://scm.example.dev" }, + { name: "Jenkins", summary: "CI", link: "https://ci.example.dev" }, + { name: "Harbor", summary: "Registry", link: "https://registry.example.dev" }, + { name: "Flux", summary: "GitOps", link: "https://cd.example.dev" }, + { name: "Monero", summary: "Node", link: "https://monero.example.dev" }, + { name: "SUI Validator", summary: "Crypto", link: "https://sui.example.dev" }, + { name: "Keycloak", summary: "SSO", link: "https://sso.example.dev" }, + { name: "AI Translation", summary: "Translate", link: "https://translate.example.dev" }, + { name: "Grafana", summary: "Metrics", link: "https://metrics.example.dev" }, + { name: "Pegasus", summary: "Uploads", link: "https://pegasus.example.dev" }, + { name: "AI Chat", summary: "Chat", link: "https://chat.example.dev" }, + { name: "AI Vision", summary: "Vision", link: "https://vision.example.dev" }, + { name: "AI Speech", summary: "Speech", link: "https://speech.example.dev" }, + { name: "Mystery", summary: "Default", link: "https://default.example.dev" }, + ], + }, + }, + }); + + const icons = wrapper.vm.displayServices.map((service) => service.icon); + expect(icons).toContain("☁️"); + expect(icons).toContain("🎞️"); + expect(icons).toContain("🗨️"); + expect(icons).toContain("🧩"); + expect(icons).toContain("🎥"); + expect(icons).toContain("📞"); + expect(icons).toContain("📮"); + expect(icons).toContain("🔒"); + expect(icons).toContain("🔑"); + expect(icons).toContain("📚"); + expect(icons).toContain("🧰"); + expect(icons).toContain("📦"); + expect(icons).toContain("🔄"); + expect(icons).toContain("⛏️"); + expect(icons).toContain("💠"); + expect(icons).toContain("🛡️"); + expect(icons).toContain("🌐"); + expect(icons).toContain("📈"); + expect(icons).toContain("🚀"); + expect(icons).toContain("💬"); + expect(icons).toContain("🖼️"); + expect(icons).toContain("🎙️"); + expect(icons).toContain("🛰️"); + expect(wrapper.vm.hardwareDiagram).toContain("Titan Lab"); + expect(wrapper.vm.networkDiagram).toContain("oauth2-proxy"); + expect(wrapper.vm.pipelineDiagram).toContain("flux[cd.bstein.dev]"); + }); + + it("renders loading, error, and healthy status states", () => { + const wrapper = shallowMount(HomeView, { + global: { + stubs: { + MetricRow: true, + ServiceGrid: true, + MermaidCard: true, + }, + }, + props: { + labStatus: { connected: true, atlas: { up: true }, oceanus: { up: true } }, + serviceData: undefined, + loading: true, + error: "", + }, + }); + + expect(wrapper.vm.atlasPillClass).toBe("pill-ok"); + expect(wrapper.vm.oceanusPillClass).toBe("pill-ok"); + expect(wrapper.get(".status").text()).toBe("Loading..."); + expect(wrapper.vm.displayServices.length).toBeGreaterThan(0); + }); + + it("shows the error state when lab data fetches fail", () => { + const wrapper = shallowMount(HomeView, { + global: { + stubs: { + MetricRow: true, + ServiceGrid: true, + MermaidCard: true, + }, + }, + props: { + labStatus: { connected: false, atlas: { up: false }, oceanus: { up: false } }, + serviceData: { services: [] }, + loading: false, + error: "unable to load status", + }, + }); + + expect(wrapper.vm.atlasPillClass).toBe("pill-bad"); + expect(wrapper.vm.oceanusPillClass).toBe("pill-bad"); + expect(wrapper.get(".status").text()).toBe("unable to load status"); + }); + + it("shows live data unavailable and respects custom metric items", () => { + const wrapper = shallowMount(HomeView, { + global: { + stubs: { + MetricRow: true, + ServiceGrid: true, + MermaidCard: true, + }, + }, + props: { + labStatus: { connected: false, atlas: { up: false }, oceanus: { up: false } }, + serviceData: { services: [] }, + metricsData: { + items: [{ label: "Custom", value: "1", note: "" }], + }, + loading: false, + error: "", + }, + }); + + expect(wrapper.get(".status").text()).toBe("Live data unavailable"); + expect(wrapper.vm.metricItems[0].note).toBe(""); + }); +}); diff --git a/testing/frontend/unit/sample.spec.js b/testing/frontend/unit/sample.spec.js new file mode 100644 index 0000000..9b3a8e2 --- /dev/null +++ b/testing/frontend/unit/sample.spec.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from "../../../frontend/node_modules/vitest/dist/index.js"; + +import { + buildHardwareDiagram, + buildNetworkDiagram, + buildPipelineDiagram, + fallbackHardware, + fallbackMetrics, + fallbackNetwork, + fallbackServices, +} from "../../../frontend/src/data/sample.js"; + +describe("sample data builders", () => { + it("exposes the atlas hardware and service inventory", () => { + const hardware = fallbackHardware(); + const services = fallbackServices(); + + expect(hardware.clusters[0].name).toBe("atlas"); + expect(hardware.specialty.map((node) => node.alias)).toContain("oceanus"); + expect(services.services.some((service) => service.name === "Keycloak")).toBe(true); + expect(services.services.some((service) => service.name === "AI Chat")).toBe(true); + }); + + it("builds the rendered diagrams and network summary", () => { + expect(buildHardwareDiagram({})).toContain("Titan Lab"); + expect(buildHardwareDiagram({})).toContain("titan-0a"); + expect(buildNetworkDiagram()).toContain("oauth2-proxy"); + expect(buildPipelineDiagram()).toContain("flux[cd.bstein.dev]"); + expect(fallbackNetwork().ingress_gateway).toContain("Traefik"); + expect(fallbackMetrics()).toEqual({ + dashboard: "https://metrics.bstein.dev", + description: "Atlas + Oceanus metrics.", + }); + }); +}); diff --git a/testing/frontend/vitest.config.js b/testing/frontend/vitest.config.js new file mode 100644 index 0000000..9ffddd0 --- /dev/null +++ b/testing/frontend/vitest.config.js @@ -0,0 +1,44 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "../../frontend/node_modules/vitest/dist/config.js"; +import vue from "../../frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs"; + +const testingDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendRoot = path.resolve(testingDir, "../../frontend"); + +export default defineConfig({ + root: frontendRoot, + plugins: [vue()], + resolve: { + alias: { + "@": path.resolve(frontendRoot, "src"), + }, + }, + test: { + environment: "jsdom", + globals: true, + include: [path.resolve(testingDir, "unit/**/*.spec.js")], + setupFiles: [path.resolve(testingDir, "vitest.setup.js")], + reporters: ["default", "junit"], + outputFile: { + junit: path.resolve(testingDir, "../../build/junit-frontend-unit.xml"), + }, + coverage: { + provider: "v8", + reporter: ["text", "lcov", "json-summary"], + include: [ + "src/auth.js", + "src/data/sample.js", + "src/components/MetricRow.vue", + "src/components/MermaidCard.vue", + "src/components/ServiceGrid.vue", + "src/components/StatsGrid.vue", + "src/views/HomeView.vue", + ], + thresholds: { + lines: 95, + statements: 95, + }, + }, + }, +}); diff --git a/testing/frontend/vitest.setup.js b/testing/frontend/vitest.setup.js new file mode 100644 index 0000000..7a5867a --- /dev/null +++ b/testing/frontend/vitest.setup.js @@ -0,0 +1,8 @@ +if (!globalThis.requestIdleCallback) { + globalThis.requestIdleCallback = (callback) => + window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 0 }), 0); +} + +if (!globalThis.cancelIdleCallback) { + globalThis.cancelIdleCallback = (handle) => window.clearTimeout(handle); +} diff --git a/testing/quality_contract.json b/testing/quality_contract.json new file mode 100644 index 0000000..f0da421 --- /dev/null +++ b/testing/quality_contract.json @@ -0,0 +1,48 @@ +{ + "max_lines": 500, + "coverage_threshold_pct": 95, + "managed_files": [ + "backend/atlas_portal/app_factory.py", + "backend/atlas_portal/rate_limit.py", + "backend/atlas_portal/routes/auth_config.py", + "backend/atlas_portal/routes/health.py", + "backend/atlas_portal/routes/monero.py", + "backend/atlas_portal/settings.py", + "backend/atlas_portal/utils.py", + "frontend/src/auth.js", + "frontend/src/components/MetricRow.vue", + "frontend/src/components/MermaidCard.vue", + "frontend/src/components/ServiceGrid.vue", + "frontend/src/components/StatsGrid.vue", + "frontend/src/data/sample.js", + "frontend/src/views/HomeView.vue" + ], + "docstring_files": [ + "backend/atlas_portal/app_factory.py", + "backend/atlas_portal/rate_limit.py", + "backend/atlas_portal/routes/auth_config.py", + "backend/atlas_portal/routes/health.py", + "backend/atlas_portal/routes/monero.py", + "backend/atlas_portal/settings.py", + "backend/atlas_portal/utils.py", + "frontend/src/auth.js", + "frontend/src/data/sample.js", + "frontend/src/views/HomeView.vue" + ], + "coverage_files": [ + "backend/atlas_portal/app_factory.py", + "backend/atlas_portal/rate_limit.py", + "backend/atlas_portal/routes/auth_config.py", + "backend/atlas_portal/routes/health.py", + "backend/atlas_portal/routes/monero.py", + "backend/atlas_portal/settings.py", + "backend/atlas_portal/utils.py", + "frontend/src/auth.js", + "frontend/src/components/MetricRow.vue", + "frontend/src/components/MermaidCard.vue", + "frontend/src/components/ServiceGrid.vue", + "frontend/src/components/StatsGrid.vue", + "frontend/src/data/sample.js", + "frontend/src/views/HomeView.vue" + ] +} diff --git a/testing/tests/test_publish_metrics.py b/testing/tests/test_publish_metrics.py new file mode 100644 index 0000000..1958a89 --- /dev/null +++ b/testing/tests/test_publish_metrics.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + +from testing.ci.summary import RunSummary, load_junit_summary, render_payload + + +def test_load_junit_summary_combines_suites(tmp_path: Path) -> None: + junit = tmp_path / "results.xml" + junit.write_text( + '' + ) + + summary = load_junit_summary([junit]) + + assert summary == RunSummary(tests=3, failures=1, errors=0, skipped=1) + payload = render_payload(suite="bstein-home", ok=2, failed=0, summary=summary) + assert 'platform_quality_gate_runs_total{suite="bstein-home",status="ok"} 2' in payload + assert 'bstein_home_quality_gate_tests_total{suite="bstein-home",result="skipped"} 1' in payload diff --git a/testing/tests/test_quality_gate.py b/testing/tests/test_quality_gate.py new file mode 100644 index 0000000..1d45ab9 --- /dev/null +++ b/testing/tests/test_quality_gate.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from testing.ci.quality_gate import ( + _js_node_issues, + _python_node_issues, + check_coverage, + check_file_sizes, +) + + +def test_check_file_sizes_flags_overlong_files(tmp_path: Path) -> None: + path = tmp_path / "tool.py" + path.write_text("\n".join(f"line {idx}" for idx in range(7))) + + issues = check_file_sizes([path], max_lines=5) + + assert issues and issues[0].check == "loc" + assert "exceeds 5" in issues[0].message + + +def test_docstring_helpers_accept_contract_comments_and_docstrings(tmp_path: Path) -> None: + py_path = tmp_path / "sample.py" + py_path.write_text( + '"""module docs"""\n\n' + 'def documented():\n' + ' """Explain what the helper does."""\n' + ' return 1\n\n' + 'def missing():\n' + ' return 2\n' + ) + js_path = tmp_path / "sample.js" + js_path.write_text( + '/**\n' + ' * WHY: the helper needs a contract for the gate.\n' + ' * @param {string} name - service name.\n' + ' * @returns {string} icon label.\n' + ' */\n' + 'function pickIcon(name) {\n' + ' return name;\n' + '}\n' + ) + + py_issues = _python_node_issues(py_path) + js_issues = _js_node_issues(js_path) + + assert any(issue.message.endswith("missing") for issue in py_issues) + assert js_issues == [] + + +def test_check_coverage_reads_backend_and_frontend_reports(tmp_path: Path) -> None: + backend_report = tmp_path / "backend.xml" + backend_report.write_text( + '' + '' + '' + ) + frontend_report = tmp_path / "frontend.json" + frontend_report.write_text( + json.dumps( + { + "src/auth.js": { + "lines": {"pct": 100}, + "statements": {"pct": 100}, + "branches": {"pct": 100}, + "functions": {"pct": 100}, + } + } + ) + ) + + issues = check_coverage( + [Path("backend/atlas_portal/app_factory.py"), Path("frontend/src/auth.js")], + backend_report=backend_report, + frontend_report=frontend_report, + threshold=95, + ) + + assert issues == []