diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000000000000000000000000000000..b4ca01f9fa198dc322d5a40a26825ed0a8f6e029 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "Dermatolog AI Scanner Dev", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.black-formatter", + "charliermarsh.ruff", + "tamasfe.even-better-toml" + ] + } + }, + "forwardPorts": [ + 8000 + ], + "postCreateCommand": "pip install -r requirements-dev.txt && playwright install --with-deps chromium && npm install", + "runArgs": [ + "--env-file", + ".env" + ] +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..380e307d0fdbaa54431417fe0b1c6b16eebf0c29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesize + +# Data and Cache +tmp/ +db/ +cache/ +model_cache/ + +# Local Config +.env +.DS_Store diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..499da7c542796fcecb16366d4bda08258746669f --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# GCP Project Configuration +PROJECT_ID=your-project-id +LOCATION=europe-central2 + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..117c066ded2db08358d6bfdaa3b716e1fb01b4e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class +server.log +debug*.log +image_stats.duckdb +venv/ +db/ +tmp/ +coverage/coverage-final.json +node_modules/* +coverage/* +app/static/js/client/git_push.sh +app/static/js/client/mocha.opts +*.DS_Store +gcp_key.json +verify*.py +test_*.log +*.out +.env +*.db +yolov8n.pt diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000000000000000000000000000000000..3c032078a4a21c5c51d3c93d91717c1dabbb8cd0 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000000000000000000000000000000000000..c6369191c30c70fb8d44264541ab67168395d538 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,144 @@ +# πŸ“¦ Deployment Guide + +This application is fully containerized and can be deployed to Google Cloud Run, AWS, or any Kubernetes cluster. + +## Minimum Requirements + +- **RAM**: 4 GB (8 GB Recommended for MedSigLIP model) +- **CPU**: 2 vCPU +- **Dependencies**: Docker (for building the image) + +--- + +## πŸš€ Google Cloud Run + +We provide a helper script to deploy with the correct hardware configuration. + +1. **Authenticate**: + ```bash + gcloud auth login + gcloud config set project YOUR_PROJECT_ID + ``` + + 2. **Export HF_TOKEN (Crucial)**: + For the build to succeed (downloading gated model), you must export your token: + ```bash + export HF_TOKEN=your_hf_token + ``` + + 3. **Run Deployment Script**: + ```bash + chmod +x bin/deploy.sh + ./bin/deploy.sh + ``` + + This script will: + - Build the container image. + - Deploy to Cloud Run with **8GB RAM** and **2 vCPUs**. + - Configure the fallback to public models if no gated token is provided. + +4. **Access**: + The script will output the public URL of your application. + +### πŸ”§ Cloud Build Configuration (`cloudbuild.yaml`) + +The project includes a `cloudbuild.yaml` file, which is used by Google Cloud Build to execute the container build process. + +**Why is it needed?** +The standard `gcloud builds submit` command does not support passing build arguments (like `HF_TOKEN`) directly to the Dockerfile easily. The `cloudbuild.yaml` file explicitly defines the build steps to include the `--build-arg` flag, ensuring the gated MedSigLIP model can be downloaded securely during the build. + +**Manual Usage:** +If you need to trigger a build manually without `bin/deploy.sh`: +```bash +gcloud builds submit --config cloudbuild.yaml \ + --substitutions=_HF_TOKEN="$HF_TOKEN",_SERVICE_NAME="dermatolog-ai-scan" . +``` + + +## AWS (Amazon Web Services) + +You can deploy using **AWS App Runner** (easiest) or **Amazon ECS**. + +1. **Build and Push Image**: + Creating an ECR repository and pushing your image: + ```bash + aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com + + docker build -t dermatolog-ai-scan . + docker tag dermatolog-ai-scan:latest YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/dermatolog-ai-scan:latest + docker push YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/dermatolog-ai-scan:latest + ``` + +2. **Deploy via App Runner**: + - Select **Container Registry** in App Runner. + - Choose the pushed image. + - **Configuration**: + - **CPU**: 2 vCPU + - **Memory**: 4 GB (Minimum) or higher. + - **Port**: 8000 + - **Environment Variables**: Add `HF_TOKEN` if you have one. + +--- + +## ☸️ Kubernetes (K8s) + +Deploy to any Kubernetes formatted cluster (EKS, GKE, K3s, Minikube). + +**1. Create Deployment (`k8s-deployment.yaml`)**: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dermatolog-ai +spec: + replicas: 1 + selector: + matchLabels: + app: dermatolog-ai + template: + metadata: + labels: + app: dermatolog-ai + spec: + containers: + - name: dermatolog-ai + image: your-registry/dermatolog-ai-scan:latest + resources: + requests: + memory: "4Gi" + cpu: "1000m" + limits: + memory: "8Gi" + cpu: "2000m" + ports: + - containerPort: 8000 + env: + # Optional: Add HF_TOKEN secret if using gated models + # - name: HF_TOKEN + # valueFrom: + # secretKeyRef: + # name: hf-secret + # key: token +``` + +**2. Expose Service (`k8s-service.yaml`)**: +```yaml +apiVersion: v1 +kind: Service +metadata: + name: dermatolog-ai-service +spec: + type: LoadBalancer + selector: + app: dermatolog-ai + ports: + - protocol: TCP + port: 80 + targetPort: 8000 +``` + +**3. Apply Configuration**: +```bash +kubectl apply -f k8s-deployment.yaml +kubectl apply -f k8s-service.yaml +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8ebdce159343e330da372c8bcb5f3cb5ddf5111e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + build-essential \ + libgl1 \ + libglib2.0-0 \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Install python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Pre-download the model to bake it into the image +# This prevents downloading 4GB+ on every container start +ARG HF_TOKEN +ENV HF_TOKEN=${HF_TOKEN} + +RUN python -c "from transformers import AutoProcessor, AutoModel; \ + import os; \ + token = os.environ.get('HF_TOKEN'); \ + print(f'Downloading MedSigLIP model with token present: {bool(token)}...'); \ + AutoProcessor.from_pretrained('google/medsiglip-448', token=token); \ + AutoModel.from_pretrained('google/medsiglip-448', token=token)" + +# Pre-download YOLO model +RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')" + +# Copy application code +COPY . . + +# Expose port (Cloud Run defaults to 8080, providing a fallback) +ENV PORT=8080 +EXPOSE $PORT + +# Command to run (Using Shell form so it evaluates $PORT) +CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..276234f31af5194a8ec6700c5a33e9c60ad548ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marcin Stepien + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0bb0ee100f1393a3317c270f903e61016f0ba868 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Dermatolog AI Scan + +A privacy-first, free, and easy-to-use dermatology scan app powered by latest AI models. + +## Features + +- **Local Models**: Direct interface with MedSigLIP model locally or on Cloud Run. +- **Lesion Detection**: Uses **YOLOv8-Nano** to automatically identify and localise skin lesions for optimized preprocessing. +- **Session-based Photo Management**: + - **Local-Only Storage**: Images are processed and stored entirely within your browser's memory using DataURLs. No image files are ever written to the server's disk, ensuring maximum patient privacy. + - **Drag & Drop Upload**: Upload multiple images easily. + - **Clipboard Paste Support**: Paste images directly from your clipboard (Ctrl+V) to preview them instantly. + - **Smart Timeline**: Photos are automatically grouped into "Virtual Directories" based on their creation date (extracted from EXIF). + - **Privacy**: All data is scoped to your browser session. +- **Zero-Shot Dermatology Analysis**: + - Uses **Google Health's MedSigLIP** (`google/medsiglip-448`) model for localized analysis. + - Classifies images against a comprehensive set of **25+ dermatological conditions** relevant to EU medical practices. + - **Rationale**: The label set focuses on high-mortality cancers (Melanoma), high-prevalence conditions (Eczema, Acne), and common differential diagnoses to aid in effective triage. + +### πŸ“Š Confidence & Interpretation Logic + +The application uses specialized logic to convert raw model scores into clinical insights: + +- **Cancerous Tumor Consolidation**: If the top-ranked results are malignant tumor diseases + ( + Melanoma, + Basal Cell Carcinoma, + Squamous Cell Carcinoma, + Bowen's Disease + ) + the confidence margin is calculated as the **difference between the sum of these top tumor scores and the first non-tumor result**. This ensures high confidence is reported when the AI is certain of malignancy, even if it is debating the specific tumor subtype. +- **Predictive Entropy**: The system calculates Shannon Entropy across all predictions. If entropy is high (e.g., above 2.0 bits), the result is flagged as unreliable regardless of the top score. +- **Interpretation Margin**: For mixed cases (Tumor vs. Non-Tumor), if the margin is below the configurable threshold (default 5%), the application flags the result as "Not clear" to prompt manual review. + +### 🩺 Supported Dermatological Conditions + +The system is tuned to detect the following conditions based on EU referral guidelines and prevalence statistics: + +| Category | Conditions | Rationale | +| :--- | :--- | :--- | +| **Malignant / Pre-malignant** | Melanoma, Basal Cell Carcinoma (BCC), Squamous Cell Carcinoma (SCC), Actinic Keratosis, Bowen's Disease, Dysplastic Nevus | Priority for early detection due to mortality risk (Melanoma) or high prevalence impacting healthcare resources (BCC/SCC). | +| **Inflammatory** | Psoriasis, Atopic Dermatitis (Eczema), Acne Vulgaris, Rosacea, Urticaria, Lichen Planus, Hidradenitis Suppurativa | Represents the highest burden of disease on quality of life in the EU population. | +| **Infectious** | Fungal Infections (Tinea), Herpes Zoster (Shingles), Impetigo, Warts, Molluscum Contagiosum | Frequent reasons for primary care visits; contagious nature requires accurate identification. | +| **Benign / Differential** | Melanocytic Nevus, Seborrheic Keratosis, Dermatofibroma, Haemangioma, Epidermoid Cyst, Lipoma | Crucial for distinguishing from malignant lesions to reduce unnecessary anxiety and referrals. | +| **Other** | Vitiligo, Alopecia Areata, Melasma | Common pigmentary and hair disorders affecting psychological well-being. | + +## πŸ”’ Privacy & Security + +Dermatolog AI Scan is built with a **Privacy-First** architecture: + +1. **Browser-Side Image Handling**: When you select an image, it is read by the `FileReader` API and converted to a Base64 DataURL. +2. **No Server-Side Persistence**: The backend receives the image data only for the duration of the analysis request. It process the image in-memory and returns the results. No temporary or permanent image files are created on the server's filesystem. +3. **Local Memory State**: Image data is pinned to the JavaScript state of your current browser tab. Refreshing the page or closing the tab clears the local image memory. +4. **Session Isolation**: Each user is assigned a unique, random session ID to isolate their requests and analysis cache. + + +## πŸš€ Getting Started + +### Prerequisites + +- **Docker** and **Docker Compose** installed. +- **VS Code** with the **Dev Containers** extension. +- **Node.js** (v18+) and **npm** (for frontend tests). + +### πŸ› οΈ Development Setup + +The project is designed to be developed inside a **Dev Container**. This ensures a consistent environment with all dependencies pre-installed. + +1. **Clone the Repository**: + ```bash + git clone + cd dermatolog-ai-scan + ``` + +3. **HuggingFace Configuration**: + Access to the MedSigLIP model is gated. You must provide a token in your `.env` file to download/load the model. + + +4. **Environment Variables (`.env`)**: + + Create a `.env` file in the root directory to store configuration variables. This file is automatically loaded by: + - **Docker Compose**: Used to populate `environment:` variables in `docker-compose.yml`. + - **Development Container**: To set workspace environment variables. + - **Deployment Script**: `bin/deploy.sh` reads `PROJECT_ID` from this file. + + + **Template `.env`:** + ```ini + # GCP Project Configuration (for deployment) + PROJECT_ID=your-gcp-project-id + LOCATION=us-central1 + + # Optional: Temporary File Cleanup (seconds) - Default 86400 (24h) + TMP_MAX_AGE_SECONDS=86400 + + # Optional: HuggingFace Token for Gated Models (Local MedSigLIP) + HF_TOKEN=your_hf_token + ``` + + **To obtain `HF_TOKEN` for `google/medsiglip-448`:** + 1. Create a [Hugging Face account](https://huggingface.co/join). + 2. Visit the [google/medsiglip-448 model page](https://huggingface.co/google/medsiglip-448) and check if you need to accept a license agreement (gated access). + 3. Go to your [Settings > Access Tokens](https://huggingface.co/settings/tokens) page. + 4. Create a new token with **Read** permissions. + 5. Copy the token and paste it into your `.env` file as `HF_TOKEN`. + +5. **Start Dev Container**: + - Open the folder in VS Code. + - When prompted, click **"Reopen in Container"** (or run standard command `Dev Containers: Reopen in Container`). + - VS Code will build the container and install all dependencies defined in `requirements-dev.txt` and `package.json`. + + Inside the integrated terminal of VS Code (running in the container): + ```bash + npm install # If not run automatically + uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + ``` + - The API will be available at: http://localhost:8000 (docs at http://localhost:8000/docs/) + - Frontend: http://localhost:8000/ + - **Debug Mode**: Append `?debug` to the URL (e.g., http://localhost:8000/?debug) to reveal detailed model logs, execution timers, saliency maps, and preprocessing calibration settings. + +### 🐳 Running with Docker (Manual) + +If you prefer to run the container manually (outside VS Code): + +**1. Build the Image:** +You MUST pass your `HF_TOKEN` as a build argument to download the gated model. +```bash +# Load token from .env or export it matches your environment +export HF_TOKEN=your_token_here +docker build --build-arg HF_TOKEN=$HF_TOKEN -t dermatolog-ai-scan . +``` + +**2. Run the Container:** +Pass the token as an environment variable for runtime checks (optional if baked in, but recommended). +```bash +docker run -p 8000:8000 -e HF_TOKEN=$HF_TOKEN dermatolog-ai-scan +``` + +### πŸ§ͺ Running Tests + +We use `pytest` for unit tests and `playwright` for end-to-end tests. + +- **Unit Tests**: + ```bash + pytest tests/unit + ``` + +- **Integration/E2E Tests**: + ```bash + pytest tests/e2e + ``` + +- **JavaScript Unit Tests**: + ```bash + npm test + ``` + +### Deployment + +The application is containerized and can be deployed to Google Cloud Run, AWS, or Kubernetes. + +πŸ‘‰ **See [DEPLOY.md](DEPLOY.md) for full deployment instructions.** \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..248a7fd7cc7f222cca3fe6ace56fabde5abe57af --- /dev/null +++ b/app/config.py @@ -0,0 +1,44 @@ +""" +Configuration settings for the Dermatolog AI Scan application. +Contains model parameters, clinical thresholds, and system constants. +""" + + +# --- Stage 2: Result Interpretation Parameters --- + +# Shannon Entropy threshold (in bits) for determining prediction reliability. +# Entropy measures the model's "confusion" across all classes. +# For a 10-class distribution: +# - Max entropy (complete guessing) is ~3.32 bits. +# - High confidence (90% in one class) approaches 0 bits. +# Threshold of 2.5 allows for relative clarity but flags high-chaos distributions. +INTERPRETER_ENTROPY_THRESHOLD = 2.5 + +# Margin threshold specifically for Mixed (Tumor vs Non-Tumor) cases. +# If the top prediction is a tumor but the second is non-tumor (or vice versa), +# and the absolute difference in their scores is less than this value, +# the result is annotated as "Not clear". +INTERPRETER_MARGIN_THRESHOLD = 0.05 + +# --- Confidence Classification (Margin Based) --- + +# Mapping of confidence levels based on the margin between Top-1 and Top-2 results. +# Used to provide qualitative feedback to the end user. +CONFIDENCE_CLASSES = [ + {"min": 0.40, "label": "Confident", "color_hint": "green"}, + {"min": 0.20, "label": "Plausible", "color_hint": "gray"}, + {"min": 0.10, "label": "Low confidence", "color_hint": "yellow"}, + {"min": 0.00, "label": "Results unclear", "color_hint": "red"}, +] + + +# --- Model Configuration --- + +# The target image resolution for MedSigLIP. +# Changing this requires a compatible model checkpoint. +MODEL_IMAGE_SIZE = (448, 448) + +# The default HuggingFace model path for MedSigLIP. +MEDSIGLIP_MODEL_NAME = "google/medsiglip-448" + + diff --git a/app/dal/__init__.py b/app/dal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/dal/database.py b/app/dal/database.py new file mode 100644 index 0000000000000000000000000000000000000000..43f52d4f15f63ce2aa419221cfdb79edecefbb75 --- /dev/null +++ b/app/dal/database.py @@ -0,0 +1,68 @@ +import duckdb +import os +import logging +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + +class DuckDBManager: + def __init__(self, db_path: str = "data/app.duckdb"): + self.db_path = db_path + # Initialize or migrate schema + self._init_schema() + + def _init_schema(self): + """Initializes the database schema.""" + try: + with self.get_connection() as con: + con.execute(""" + CREATE TABLE IF NOT EXISTS interaction_logs ( + id INTEGER PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + prompt TEXT, + response TEXT, + latency_ms INTEGER + ); + CREATE SEQUENCE IF NOT EXISTS seq_interaction_id START 1; + + CREATE TABLE IF NOT EXISTS photos ( + id UUID PRIMARY KEY, + session_id VARCHAR, + filename VARCHAR, + content BLOB, + creation_date DATE, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + md5_hash VARCHAR, + analysis_results VARCHAR, + analysis_date VARCHAR + ); + -- Migration for existing tables + ALTER TABLE photos ADD COLUMN IF NOT EXISTS md5_hash VARCHAR; + ALTER TABLE photos ADD COLUMN IF NOT EXISTS analysis_results VARCHAR; + ALTER TABLE photos ADD COLUMN IF NOT EXISTS analysis_date VARCHAR; + """) + logger.info("Database schema initialized.") + except Exception as e: + logger.error(f"Failed to init schema: {e}") + + @contextmanager + def get_connection(self): + """Yields a DuckDB connection.""" + # DuckDB handles concurrency well, but creating a connection per request is safe for persistence + con = duckdb.connect(self.db_path) + try: + yield con + finally: + con.close() + + def log_interaction(self, prompt: str, response: str, latency_ms: int): + try: + with self.get_connection() as con: + con.execute(""" + INSERT INTO interaction_logs (id, prompt, response, latency_ms) + VALUES (nextval('seq_interaction_id'), ?, ?, ?) + """, [prompt, response, latency_ms]) + except Exception as e: + logger.error(f"Failed to log interaction: {e}") + +db_manager = DuckDBManager(db_path=os.getenv("DUCKDB_PATH", "data/app.duckdb")) diff --git a/app/dal/photo_repo.py b/app/dal/photo_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..def75fdd2a0f124e6094ebacd92e5e24cd106602 --- /dev/null +++ b/app/dal/photo_repo.py @@ -0,0 +1,89 @@ + +import logging +from typing import List, Optional, Tuple, Dict + +logger = logging.getLogger(__name__) + +class PhotoRepository: + def __init__(self): + # In-memory storage instead of DuckDB + # key: session_id, value: { photo_id: metadata_dict } + self._storage: Dict[str, Dict[str, dict]] = {} + + def _get_session_store(self, session_id: str) -> Dict[str, dict]: + if session_id not in self._storage: + self._storage[session_id] = {} + return self._storage[session_id] + + def find_duplicate(self, session_id: str, file_hash: str) -> Optional[str]: + store = self._get_session_store(session_id) + for photo_id, metadata in store.items(): + if metadata.get("md5_hash") == file_hash: + return photo_id + return None + + def create_photo(self, photo_id: str, session_id: str, filename: str, ext: str, creation_date: str, file_hash: str, content: bytes): + store = self._get_session_store(session_id) + store[photo_id] = { + "id": photo_id, + "filename": filename, + "content": content, + "creation_date": creation_date, + "uploaded_at": str(logging.Formatter().formatTime(logging.LogRecord(None, None, None, None, None, None, None), "%Y-%m-%d %H:%M:%S")), + "md5_hash": file_hash, + "analysis_results": None, + "analysis_date": None + } + + def get_timeline_photos(self, session_id: str) -> List[Tuple]: + store = self._get_session_store(session_id) + results = [] + # Convert to the tuple format expected by router + # (id, filename, creation_date, uploaded_at, analysis_results, analysis_date) + for p in store.values(): + results.append(( + p["id"], + p["filename"], + p["creation_date"], + p["uploaded_at"], + p["analysis_results"], + p["analysis_date"] + )) + # Sort by creation_date DESC, then uploaded_at DESC + return sorted(results, key=lambda x: (x[2], x[3]), reverse=True) + + def save_analysis_results(self, photo_id: str, session_id: str, results_json: str): + store = self._get_session_store(session_id) + if photo_id in store: + store[photo_id]["analysis_results"] = results_json + store[photo_id]["analysis_date"] = str(logging.Formatter().formatTime(logging.LogRecord(None, None, None, None, None, None, None), "%H:%M:%S")) + + def get_analysis_results(self, photo_id: str, session_id: str) -> Optional[Tuple[str, str]]: + store = self._get_session_store(session_id) + p = store.get(photo_id) + if p and p["analysis_results"]: + return (p["analysis_results"], p["analysis_date"]) + return None + + def update_date(self, photo_id: str, session_id: str, new_date: str): + store = self._get_session_store(session_id) + if photo_id in store: + store[photo_id]["creation_date"] = new_date + + def get_photo_metadata(self, photo_id: str, session_id: str) -> Optional[Tuple[str, bytes]]: + store = self._get_session_store(session_id) + p = store.get(photo_id) + if p: + return (p["filename"], p["content"]) + return None + + def delete_photo(self, photo_id: str, session_id: str): + store = self._get_session_store(session_id) + if photo_id in store: + del store[photo_id] + + def clear_session(self, session_id: str): + if session_id in self._storage: + del self._storage[session_id] + +photo_repo = PhotoRepository() diff --git a/app/dermatology_data.py b/app/dermatology_data.py new file mode 100644 index 0000000000000000000000000000000000000000..5ee85bf1f1fb992507f01b499a4a944dc1dde05a --- /dev/null +++ b/app/dermatology_data.py @@ -0,0 +1,104 @@ +# Comprehensive dermatology labels based on EU prevalence and referral guidelines +# Rationale: +# 1. Malignant/Pre-malignant: Detecting high-mortality (Melanoma) and high-prevalence (BCC/SCC) cancers is the priority. +# 2. Inflammatory: Eczema, Psoriasis, and Acne are the most common burdens on quality of life in EU. +# 3. Infectious: Fungal and viral infections are frequent reasons for primary care visits. +# 4. Benign: Essential for differential diagnosis to reduce unnecessary anxiety or referrals. + +MEDSIGLIP_DERMATOLOGY_LABELS = { + # Malignant & Pre-malignant + "Melanoma": "malignant melanoma, asymmetric pigmented lesion with irregular borders and color variegation", + "Basal Cell Carcinoma": "basal cell carcinoma, pearly translucent papule with arborizing telangiectasia", + "Squamous Cell Carcinoma": "squamous cell carcinoma, indurated hyperkeratotic erythematous nodule or ulcerated plaque", + "Actinic Keratosis": "actinic keratosis, rough scaly erythematous macule on sun-damaged skin", + "Bowen's Disease": "Bowen's disease, well-demarcated erythematous scaly plaque", + "Dysplastic Nevus": "dysplastic nevus, atypical melanocytic lesion with irregular borders and variable pigmentation", + + # Benign Tumors (Differential Diagnosis) + "Melanocytic Nevus": "benign melanocytic nevus, well-circumscribed symmetrical pigmented macule", + "Seborrheic Keratosis": "seborrheic keratosis, sharply demarcated verrucous plaque with stuck-on appearance", + "Dermatofibroma": "dermatofibroma, firm hyperpigmented dermal nodule with positive dimple sign", + "Haemangioma": "hemangioma, benign vascular anomaly, bright red or violaceous nodule", + "Epidermoid Cyst": "epidermoid cyst, subcutaneous skin-colored nodule with central punctum", + + # Inflammatory Conditions + "Psoriasis": "psoriasis vulgaris, well-demarcated erythematous plaques with thick silvery-white scale", + "Atopic Dermatitis": "atopic dermatitis, pruritic erythematous scaling patches with lichenification", + "Acne Vulgaris": "acne vulgaris, inflammatory eruption with comedones, papules, and pustules", + "Rosacea": "rosacea, facial erythema and telangiectasia with inflammatory papules", + "Urticaria": "urticaria, transient circumscribed erythematous and edematous wheals", + "Lichen Planus": "lichen planus, pruritic purple polygonal planar papules with Wickham striae", + "Hidradenitis Suppurativa": "hidradenitis suppurativa, painful deep-seated inflammatory nodules and abscesses", + + # Infectious + "Fungal Infection": "tinea fungal infection, an annular, scaling, erythematous patch with raised borders and central clearing", + "Herpes Zoster": "herpes zoster, a unilateral, dermatomal eruption of grouped, painful vesicles on an erythematous base", + "Impetigo": "impetigo, superficial bacterial infection with erosions and classic honey-colored crusting", + "Warts": "verruca vulgaris, a viral infection presenting as a hyperkeratotic, exophytic papule", + "Molluscum Contagiosum": "molluscum contagiosum, presenting as firm, dome-shaped, umbilicated, pearly papules", + + # Pigmentary & Hair + "Vitiligo": "vitiligo, depigmented white macules and patches devoid of melanocytes", + "Alopecia Areata": "alopecia areata, localized patches of non-scarring hair loss on the scalp or body", + "Melasma": "melasma, symmetric, hyperpigmented brown macules primarily on sun-exposed facial areas", + + # Miscellaneous + "Insect Bites": "arthropod bite reaction, intensely pruritic, erythematous papules with a central punctum", + "Folliculitis": "folliculitis, inflammation of hair follicles with multiple erythematous papules and pustules", + "Drug Rash": "morbilliform drug eruption, a generalized, symmetric, maculopapular erythematous exanthem", + + # Baseline + "Normal Skin": "normal, healthy skin with intact epidermis, uniform texture, and no visible lesions" +} +#Inflammatory vs. Neoplastic Differentiation: The model can effectively distinguish +# between inflammatory skin conditions and neoplastic (cancerous) +## Used for triage analysis +MEDSIGLIP_DERMATOLOGY_FIRST_CLASSES = { + # 1. Inflammatory + "Inflammatory skin disease": "showing inflammatory lesion, or a rash or redness, or scaling", + # 2. Neoplastic + #"Neoplastic skin tumor": "neoplastic skin tumor or suspect growth or abnormal mole", + "Melanoma": MEDSIGLIP_DERMATOLOGY_LABELS["Melanoma"], + # 3. Zero-Shot Baseline + "Healthly skin": "melanocytic naevus, pigmented naevus" +} + +CANCEROUS_TUMOR_CLASSES = { + "Melanoma", + "Basal Cell Carcinoma", + "Squamous Cell Carcinoma", + "Bowen's Disease" +} + +BENIGN_TUMOR_CLASSES = { + "Melanocytic Nevus", + "Seborrheic Keratosis", + "Dermatofibroma", + "Haemangioma", + "Epidermoid Cyst" +} + +# Narrow set of labels focusing on MedSigLIP's highest performance tiers +MEDSIGLIP_DERMATOLOGY_NARROW_LABELS = { + # 1. High-Precision Vascular & Pigmented Lesions + "Melanoma": MEDSIGLIP_DERMATOLOGY_LABELS["Melanoma"], + "Basal Cell Carcinoma": MEDSIGLIP_DERMATOLOGY_LABELS["Basal Cell Carcinoma"], + "Melanocytic Nevus": MEDSIGLIP_DERMATOLOGY_LABELS["Melanocytic Nevus"], + "Seborrheic Keratosis": MEDSIGLIP_DERMATOLOGY_LABELS["Seborrheic Keratosis"], + + # 2. Texture-Heavy Inflammatory Conditions + "Psoriasis": MEDSIGLIP_DERMATOLOGY_LABELS["Psoriasis"], + "Atopic Dermatitis": MEDSIGLIP_DERMATOLOGY_LABELS["Atopic Dermatitis"], + "Acne Vulgaris": MEDSIGLIP_DERMATOLOGY_LABELS["Acne Vulgaris"], + "Rosacea": MEDSIGLIP_DERMATOLOGY_LABELS["Rosacea"], + + # 3. Morphologically Distinct Infections + "Herpes Zoster": MEDSIGLIP_DERMATOLOGY_LABELS["Herpes Zoster"], + "Warts": MEDSIGLIP_DERMATOLOGY_LABELS["Warts"], + "Molluscum Contagiosum": MEDSIGLIP_DERMATOLOGY_LABELS["Molluscum Contagiosum"], + + # 4. Zero-Shot Baseline + "Normal Skin": MEDSIGLIP_DERMATOLOGY_LABELS["Normal Skin"] +} + + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..1361ea05ac4b9a7610f5e5c72d28924038efdff8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,68 @@ +import time +import logging +import uuid +import os +from dotenv import load_dotenv + +load_dotenv() + +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse +from starlette.middleware.base import BaseHTTPMiddleware + + +from app.models import HealthCheckResponse +from app.routers.photos import router as photos_router +from app.routers.api import router as api_router + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Dermatolog AI Scan", + description="FastAPI application for dermatology analysis", + version="1.0.0" +) + +# Simple Session Middleware +class SessionMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + session_id = request.cookies.get("session_id") + created_new = False + if not session_id: + session_id = str(uuid.uuid4()) + created_new = True + # Hack: Inject into request scope so endpoints can see it if they looked there, + # but usually they look at cookies. We rely on the client sending it back, + # but for the *first* request, we need to handle it. + # Ideally endpoints assume cookie exists. + # Let's set the cookie on the response. + + # Pass session_id in request state if needed? + # request.state.session_id = session_id + + response = await call_next(request) + + if created_new: + # Set cookie for 1 day + response.set_cookie(key="session_id", value=session_id, max_age=86400) + + return response + +app.add_middleware(SessionMiddleware) + +app.include_router(photos_router) +app.include_router(api_router) + +# Mount static files +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) + +@app.get("/", response_class=HTMLResponse) +async def read_root(request: Request): + """Serve the main frontend page.""" + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..e1513e7635b73b33b002510807a2ebd486c7e4cb --- /dev/null +++ b/app/models.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel +from typing import List, Optional +class HealthCheckResponse(BaseModel): + status: str + yolo_available: bool + +class Photo(BaseModel): + id: str + filename: str + creation_date: str # ISO date string YYYY-MM-DD + uploaded_at: str + analysis: Optional[object] = None # Can be List[dict] (legacy) or dict (new with comparison) + analysis_date: Optional[str] = None + local_content: Optional[str] = None # Base64 data for client-side storage + + +# Response model for the timeline: a list of either Photo (single) or VirtualDirectory (group) +# In Pydantic V2 we might use Union, but for simplicity/JSON serialization, +# we can return a list of objects that have a 'type' field. + +class TimelineItem(BaseModel): + type: str # 'photo' or 'directory' + date: str + data: Optional[Photo] = None # If type is photo + items: Optional[List[Photo]] = None # If type is directory + + + +from app.config import INTERPRETER_MARGIN_THRESHOLD + +class SinglePhotoAnalysisRequest(BaseModel): + # Default labels for zero-shot classification from centralized config + candidate_labels: Optional[List[str]] = None + model: Optional[str] = "medsiglip" # "medsiglip" only now + base64_image: Optional[str] = None # Client-side image data + margin_threshold: Optional[float] = INTERPRETER_MARGIN_THRESHOLD + +class SinglePhotoAnalysisResponse(BaseModel): + photo_id: str + predictions: List[dict] + primary_model_name: Optional[str] = None + analysis_date: Optional[str] = None + prepared_image_base64: Optional[str] = None + saliency_base64: Optional[str] = None # Returning saliency as base64 + interpretation: Optional[dict] = None + preprocess_strategy: Optional[dict] = None + execution_times: Optional[dict] = None + +class SaliencyRequest(BaseModel): + base64_image: str + target_label: str + +class SaliencyResponse(BaseModel): + photo_id: str + saliency_base64: str diff --git a/app/photos.py b/app/photos.py new file mode 100644 index 0000000000000000000000000000000000000000..ebe8705521c1392d98f0265fdc4d308c69581e48 --- /dev/null +++ b/app/photos.py @@ -0,0 +1,334 @@ +import uuid +import base64 +import logging +import io +import json +import os +from datetime import datetime, date +from typing import List, Optional +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Cookie, Response, Request +from fastapi.responses import JSONResponse +from PIL import Image, ExifTags + + +from app.models import TimelineItem, Photo, VirtualDirectory, SinglePhotoAnalysisRequest, SinglePhotoAnalysisResponse +from app.services.medsiglip_service import medsiglip_service +from app.dal.photo_repo import photo_repo +from app.dermatology_data import EU_DERMATOLOGY_LABELS + +router = APIRouter(prefix="/api/photos", tags=["photos"]) + +logger = logging.getLogger(__name__) + +def get_date_from_image(image_bytes: bytes) -> str: + """Heuristic to find creation date from EXIF or return today.""" + try: + image = Image.open(io.BytesIO(image_bytes)) + exif = image._getexif() + if exif: + # 36867 is DateTimeOriginal, 306 is DateTime + for tag_id in [36867, 306]: + if tag_id in exif: + date_str = exif[tag_id] + # Format is usually "YYYY:MM:DD HH:MM:SS" + try: + dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") + return dt.date().isoformat() + except ValueError: + continue + except Exception as e: + logger.warning(f"Failed to extract EXIF: {e}") + + # Fallback to today + return date.today().isoformat() + +import hashlib + +@router.post("/upload") +async def upload_photos( + request: Request, + files: List[UploadFile] = File(...), +): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found - reload page") + + processed_ids = [] + skipped_count = 0 + + try: + for file in files: + content = await file.read() + + # Calculate MD5 hash + file_hash = hashlib.md5(content).hexdigest() + + # Check for duplicate in this session + existing_id = photo_repo.find_duplicate(session_id, file_hash) + + if existing_id: + skipped_count += 1 + continue + + # Heuristic Date Extraction + creation_date = get_date_from_image(content) + + photo_id = str(uuid.uuid4()) + + # Save to filesystem + session_dir = os.path.join("img", session_id) + os.makedirs(session_dir, exist_ok=True) + + # Use original extension or default to .jpg + ext = os.path.splitext(file.filename)[1] + if not ext: + ext = ".jpg" + + file_path = os.path.join(session_dir, f"{photo_id}{ext}") + with open(file_path, "wb") as f: + f.write(content) + + # Save metadata to DB via Repo + photo_repo.create_photo(photo_id, session_id, file.filename, ext, creation_date, file_hash) + + processed_ids.append(photo_id) + + return { + "uploaded": len(processed_ids), + "skipped": skipped_count, + "ids": processed_ids, + "message": f"Uploaded {len(processed_ids)} photos, skipped {skipped_count} duplicates." + } + + except Exception as e: + logger.error(f"Upload failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("", response_model=List[TimelineItem]) +async def get_timeline(request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + return [] + + try: + # Fetch from Repo + rows = photo_repo.get_timeline_photos(session_id) + + photos = [] + for r in rows: + analysis_data = None + if len(r) > 4 and r[4]: + try: + analysis_data = json.loads(r[4]) + except: + pass + + analysis_date = None + if len(r) > 5 and r[5]: + analysis_date = r[5] + + photos.append(Photo( + id=str(r[0]), + filename=r[1], + creation_date=r[2], + uploaded_at=r[3], + analysis=analysis_data, + analysis_date=analysis_date + )) + + # Grouping Logic: ALWAYS group by date (directory mode) + timeline = [] + if not photos: + return timeline + + current_group = [] + current_date = None + + for p in photos: + if p.creation_date != current_date: + # Flush previous group + if current_group: + timeline.append(TimelineItem( + type="directory", + date=current_date, + items=current_group + )) + # Start new group + current_group = [p] + current_date = p.creation_date + else: + current_group.append(p) + + # Flush last group + if current_group: + timeline.append(TimelineItem( + type="directory", + date=current_date, + items=current_group + )) + + logger.info(f"Timeline fetched: {len(timeline)} groups for session {session_id}") + return timeline + + except Exception as e: + logger.error(f"Timeline fetch failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +def _append_group(timeline: List[TimelineItem], group: List[Photo], date_str: str): + if len(group) == 1: + # Single photo item + timeline.append(TimelineItem( + type="photo", + date=date_str, + data=group[0] + )) + else: + # Virtual Directory + timeline.append(TimelineItem( + type="directory", + date=date_str, + items=group + )) + +@router.patch("/{photo_id}/date") +async def patch_photo_date(photo_id: str, request: Request, payload: dict): + # payload: {"date": "2023-01-01"} + session_id = request.cookies.get("session_id") + new_date = payload.get("date") + + if not new_date: + raise HTTPException(status_code=400, detail="Date required") + + try: + photo_repo.update_date(photo_id, session_id, new_date) + return {"status": "updated"} + except Exception as e: + logger.error(f"Update failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{photo_id}/content") +async def get_photo_content(photo_id: str, request: Request): + session_id = request.cookies.get("session_id") + try: + result = photo_repo.get_photo_metadata(photo_id, session_id) + if not result: + raise HTTPException(status_code=404, detail="Photo not found") + + original_filename = result[0] + stored_content = result[1] + + try: + local_filename = stored_content.decode('utf-8') + file_path = os.path.join("img", session_id, local_filename) + + if os.path.exists(file_path): + with open(file_path, "rb") as f: + content = f.read() + else: + content = stored_content + except: + content = stored_content + + # Simple mimetype detection or default + media_type = "image/jpeg" + if original_filename.lower().endswith(".png"): + media_type = "image/png" + + return Response(content=content, media_type=media_type) + + except Exception as e: + logger.error(f"Content fetch failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/{photo_id}/analyze", response_model=SinglePhotoAnalysisResponse) +async def analyze_photo(photo_id: str, request: Request, payload: SinglePhotoAnalysisRequest): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + # Check cache first + cached = photo_repo.get_analysis_results(photo_id, session_id) + if cached: + try: + # cached is (json_str, date_str) + preds = json.loads(cached[0]) + return SinglePhotoAnalysisResponse( + photo_id=photo_id, + predictions=preds, + analysis_date=cached[1] + ) + except Exception: + pass + + # 1. Fetch Photo Content via Repo + result = photo_repo.get_photo_metadata(photo_id, session_id) + if not result: + raise HTTPException(status_code=404, detail="Photo not found") + + stored_content = result[1] + + try: + local_filename = stored_content.decode('utf-8') + file_path = os.path.join("img", session_id, local_filename) + if os.path.exists(file_path): + with open(file_path, "rb") as f: + content = f.read() + else: + content = stored_content + except: + content = stored_content + + # 2. Run Inference + labels = payload.candidate_labels + if not labels: + labels = EU_DERMATOLOGY_LABELS + + predictions = medsiglip_service.get_embeddings(content, texts=labels) + + # Save results for future use + try: + photo_repo.save_analysis_results(photo_id, session_id, json.dumps(predictions)) + except Exception as e: + logger.error(f"Failed to save analysis results: {e}") + + return SinglePhotoAnalysisResponse( + photo_id=photo_id, + predictions=predictions, + analysis_date=datetime.now().isoformat() + ) + + except Exception as e: + logger.error(f"Analysis failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/{photo_id}") +async def delete_photo(photo_id: str, request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + photo_repo.delete_photo(photo_id, session_id) + # Ideally delete file too, but keeping it simple for now + return {"status": "deleted", "id": photo_id} + + except Exception as e: + logger.error(f"Delete failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("") +async def clear_session_photos(request: Request): + """Deletes all photos associated with the current session ID.""" + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + photo_repo.clear_session(session_id) + # Ideally clean up directory + return {"status": "cleared", "message": "All session photos deleted"} + + except Exception as e: + logger.error(f"Clear session failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000000000000000000000000000000000000..81aa606659a584f5aa2443d6f950edf11e3ecb6c --- /dev/null +++ b/app/routers/api.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from app.models import HealthCheckResponse +import os + +router = APIRouter(prefix="/api") + +from app.services.medsiglip_service import medsiglip_service +from app.services.yolo_service import yolo_service + +@router.get("/health", response_model=HealthCheckResponse) +async def health_check(): + """Health check endpoint.""" + yolo_available = yolo_service.load_model() is not None + return HealthCheckResponse( + status="OK", + yolo_available=yolo_available + ) diff --git a/app/routers/photos.py b/app/routers/photos.py new file mode 100644 index 0000000000000000000000000000000000000000..d2f15bddc02ebd9232f182d902c77adda7e01e6b --- /dev/null +++ b/app/routers/photos.py @@ -0,0 +1,394 @@ +import uuid +import base64 +import logging +import io +import json +import os +import time +from datetime import datetime, date +from typing import List, Optional +from fastapi import APIRouter, UploadFile, File, HTTPException, Response, Request +from PIL import Image + + +from app.models import TimelineItem, Photo, SinglePhotoAnalysisRequest, SinglePhotoAnalysisResponse, SaliencyRequest, SaliencyResponse +from app.services.medsiglip_service import medsiglip_service +from app.services.medsiglip_modality_wrapper import ( + medsiglip_wrapped_service, +) +from app.services.image_preprocess_service import image_preprocess_service, PreprocessStrategy +from app.services.result_interpreter import result_interpreter +from app.dal.photo_repo import photo_repo + +router = APIRouter(prefix="/api/photos", tags=["photos"]) + +logger = logging.getLogger(__name__) + +def get_date_from_image(image_bytes: bytes) -> str: + """Heuristic to find creation date from EXIF or return today.""" + try: + image = Image.open(io.BytesIO(image_bytes)) + exif = image._getexif() + if exif: + # 36867 is DateTimeOriginal, 306 is DateTime + for tag_id in [36867, 306]: + if tag_id in exif: + date_str = exif[tag_id] + # Format is usually "YYYY:MM:DD HH:MM:SS" + try: + dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") + return dt.date().isoformat() + except ValueError: + continue + except Exception as e: + logger.warning(f"Failed to extract EXIF: {e}") + + # Fallback to today + return date.today().isoformat() + +import hashlib + +@router.post("/upload") +async def upload_photos( + request: Request, + files: List[UploadFile] = File(...), +): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found - reload page") + + processed_ids = [] + skipped_count = 0 + + try: + for file in files: + content = await file.read() + + # Calculate MD5 hash + file_hash = hashlib.md5(content).hexdigest() + + # Check for duplicate in this session + existing_id = photo_repo.find_duplicate(session_id, file_hash) + + if existing_id: + skipped_count += 1 + continue + + # Heuristic Date Extraction + creation_date = get_date_from_image(content) + + photo_id = str(uuid.uuid4()) + + # Use original extension or default to .jpg + ext = os.path.splitext(file.filename)[1] + if not ext: + ext = ".jpg" + + # Save metadata and binary content to Repo + photo_repo.create_photo(photo_id, session_id, file.filename, ext, creation_date, file_hash, content) + + processed_ids.append(photo_id) + + return { + "uploaded": len(processed_ids), + "skipped": skipped_count, + "ids": processed_ids, + "message": f"Uploaded {len(processed_ids)} photos, skipped {skipped_count} duplicates." + } + + except Exception as e: + logger.error(f"Upload failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("", response_model=List[TimelineItem]) +async def get_timeline(request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + return [] + + try: + # Fetch from Repo + rows = photo_repo.get_timeline_photos(session_id) + + photos = [] + for r in rows: + analysis_data = None + if len(r) > 4 and r[4]: + try: + analysis_data = json.loads(r[4]) + except: + pass + + analysis_date = None + if len(r) > 5 and r[5]: + analysis_date = r[5] + + photos.append(Photo( + id=str(r[0]), + filename=r[1], + creation_date=r[2], + uploaded_at=r[3], + analysis=analysis_data, + analysis_date=analysis_date + )) + + # Grouping Logic: ALWAYS group by date (directory mode) + timeline = [] + if not photos: + return timeline + + current_group = [] + current_date = None + + for p in photos: + if p.creation_date != current_date: + # Flush previous group + if current_group: + timeline.append(TimelineItem( + type="directory", + date=current_date, + items=current_group + )) + # Start new group + current_group = [p] + current_date = p.creation_date + else: + current_group.append(p) + + # Flush last group + if current_group: + timeline.append(TimelineItem( + type="directory", + date=current_date, + items=current_group + )) + + logger.info(f"Timeline fetched: {len(timeline)} groups for session {session_id}") + return timeline + + except Exception as e: + logger.error(f"Timeline fetch failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +def _append_group(timeline: List[TimelineItem], group: List[Photo], date_str: str): + if len(group) == 1: + # Single photo item + timeline.append(TimelineItem( + type="photo", + date=date_str, + data=group[0] + )) + else: + # Virtual Directory + timeline.append(TimelineItem( + type="directory", + date=date_str, + items=group + )) + +@router.patch("/{photo_id}/date") +async def patch_photo_date(photo_id: str, request: Request, payload: dict): + # payload: {"date": "2023-01-01"} + session_id = request.cookies.get("session_id") + new_date = payload.get("date") + + if not new_date: + raise HTTPException(status_code=400, detail="Date required") + + try: + photo_repo.update_date(photo_id, session_id, new_date) + return {"status": "updated"} + except Exception as e: + logger.error(f"Update failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{photo_id}/content") +async def get_photo_content(photo_id: str, request: Request): + session_id = request.cookies.get("session_id") + try: + result = photo_repo.get_photo_metadata(photo_id, session_id) + if not result: + raise HTTPException(status_code=404, detail="Photo not found") + + original_filename = result[0] + stored_content = result[1] + + content = stored_content + + # Simple mimetype detection or default + media_type = "image/jpeg" + if original_filename.lower().endswith(".png"): + media_type = "image/png" + + return Response(content=content, media_type=media_type) + + except Exception as e: + logger.error(f"Content fetch failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/{photo_id}/analyze", response_model=SinglePhotoAnalysisResponse) +async def analyze_photo(photo_id: str, request: Request, payload: SinglePhotoAnalysisRequest): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + # 1. Get Photo Content (Prioritize payload for local-only storage) + if payload.base64_image: + # Decode base64 image + if "," in payload.base64_image: + _, encoded = payload.base64_image.split(",", 1) + else: + encoded = payload.base64_image + content = base64.b64decode(encoded) + else: + # Fallback to fetching from Repo (Database/Filesystem) + result = photo_repo.get_photo_metadata(photo_id, session_id) + if not result: + raise HTTPException(status_code=404, detail="Photo not found") + + stored_content = result[1] + content = stored_content + + # 2. Run Inference + custom_labels = payload.candidate_labels + + execution_times = {} + + # Determine Preprocessing Strategy and prepare image + start_time = time.perf_counter() + prep_strategy = image_preprocess_service.recommend_prep_strategy(content) + prepared_base64 = image_preprocess_service.prepare_image_base64(content) + execution_times["image_preprocess"] = f"{(time.perf_counter() - start_time):.3f}s" + + primary_results = [] + primary_name = None + + # Run Primary (MedSigLIP) + interpretation = None + try: + start_time = time.perf_counter() + primary_results = medsiglip_wrapped_service.analyze_image(content, custom_labels=custom_labels) + execution_times["primary_medsiglip"] = f"{(time.perf_counter() - start_time):.3f}s" + primary_name = medsiglip_wrapped_service.service.model_name + + # Interpret results with configurable threshold + interpretation = result_interpreter.interpret( + primary_results, + margin_threshold=payload.margin_threshold + ) + except Exception as e: + logger.error(f"Primary inference failed: {e}") + raise HTTPException(status_code=500, detail="Primary model failed") + + if primary_results: + logger.info(f"Primary ({primary_name}) top result: {primary_results[0]['label']} ({primary_results[0]['score']:.2f})") + + results_dict = { + "primary": primary_results, + "interpretation": interpretation, + "primary_model_name": primary_name, + "preprocess_strategy": prep_strategy, + "prepared_image_base64": prepared_base64, + "execution_times": execution_times + } + + # Merge with existing cache + try: + current_cache = photo_repo.get_analysis_results(photo_id, session_id) + if current_cache: + start_data = json.loads(current_cache[0]) + if isinstance(start_data, dict): + if not primary_results and "primary" in start_data: + results_dict["primary"] = start_data["primary"] + results_dict["primary_model_name"] = start_data.get("primary_model_name") + except: + pass + + # Save results + if not payload.base64_image: + try: + photo_repo.save_analysis_results(photo_id, session_id, json.dumps(results_dict)) + except Exception as e: + logger.error(f"Failed to save analysis results: {e}") + + return SinglePhotoAnalysisResponse( + photo_id=photo_id, + predictions=results_dict.get("primary") or [], + interpretation=results_dict.get("interpretation"), + primary_model_name=results_dict.get("primary_model_name"), + analysis_date=datetime.now().isoformat(), + prepared_image_base64=results_dict.get("prepared_image_base64"), + preprocess_strategy=results_dict.get("preprocess_strategy"), + execution_times=results_dict.get("execution_times") + ) + + except Exception as e: + logger.error(f"Analysis failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/{photo_id}") +async def delete_photo(photo_id: str, request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + photo_repo.delete_photo(photo_id, session_id) + # Ideally delete file too, but keeping it simple for now + return {"status": "deleted", "id": photo_id} + + except Exception as e: + logger.error(f"Delete failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +from app.services.gradcam_service import gradcam_service + +@router.post("/{photo_id}/saliency", response_model=SaliencyResponse) +async def generate_saliency_map( + photo_id: str, + payload: SaliencyRequest, + request: Request +): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + # Decode base64 image + if "," in payload.base64_image: + _, encoded = payload.base64_image.split(",", 1) + else: + encoded = payload.base64_image + content = base64.b64decode(encoded) + + # Generate Saliency (Grad-CAM) + heatmap_bytes = gradcam_service.get_heatmap(content, payload.target_label) + saliency_base64 = base64.b64encode(heatmap_bytes).decode('utf-8') + + return SaliencyResponse( + photo_id=photo_id, + saliency_base64=saliency_base64 + ) + + except Exception as e: + logger.error(f"Saliency generation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("") +async def clear_session_photos(request: Request): + """Deletes all photos associated with the current session ID.""" + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="No session found") + + try: + photo_repo.clear_session(session_id) + + + return {"status": "cleared", "message": "All session photos deleted"} + + except Exception as e: + logger.error(f"Clear session failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/detection_visualizer_service.py b/app/services/detection_visualizer_service.py new file mode 100644 index 0000000000000000000000000000000000000000..bbe65003ab1bea27d79278988e5c538e96069a9d --- /dev/null +++ b/app/services/detection_visualizer_service.py @@ -0,0 +1,56 @@ +import logging +from PIL import Image, ImageDraw +import io +import numpy as np + +from app.services.yolo_service import yolo_service + +logger = logging.getLogger(__name__) + +class DetectionVisualizerService: + def __init__(self): + pass + + def get_detection_visual(self, image_content: bytes, target_label: str = None) -> bytes: + """ + Detects lesions using YOLOv8-Nano and draws a bounding box. + Returns the image with box as bytes (JPEG). + """ + try: + # Prepare Inputs + image = Image.open(io.BytesIO(image_content)).convert("RGB") + + model = yolo_service.load_model() + results = model.predict(image, conf=0.25, verbose=False) + + # Draw on image + draw = ImageDraw.Draw(image) + + found = False + if results and len(results[0].boxes) > 0: + for box in results[0].boxes: + b = box.xyxy[0].cpu().numpy() + conf = float(box.conf[0]) + + # Draw red box for lesion + draw.rectangle([b[0], b[1], b[2], b[3]], outline="red", width=5) + # Draw label background + label = f"Lesion {conf:.2f}" + draw.text((b[0] + 5, b[1] + 5), label, fill="red") + found = True + + if not found: + # Optional: draw some indicator that nothing was found? + # Or just return original image. + pass + + # Return + buf = io.BytesIO() + image.save(buf, format="JPEG") + return buf.getvalue() + + except Exception as e: + logger.error(f"YOLO visualizer error: {e}") + return image_content + +detection_visualizer_service = DetectionVisualizerService() diff --git a/app/services/gradcam_service.py b/app/services/gradcam_service.py new file mode 100644 index 0000000000000000000000000000000000000000..60231e385e8674e9603e6747214a733ae28d79ac --- /dev/null +++ b/app/services/gradcam_service.py @@ -0,0 +1,113 @@ +import logging +import torch +import torch.nn.functional as F +import numpy as np +import cv2 +from PIL import Image +import io +from app.services.medsiglip_service import medsiglip_service + +logger = logging.getLogger(__name__) + +class GradCAMService: + def __init__(self): + self.gradients = None + self.activations = None + self.hooks = [] + + def _save_gradient(self, _module, _grad_input, grad_output): + self.gradients = grad_output[0] + + def _save_activation(self, _module, _input, output): + if isinstance(output, tuple): + self.activations = output[0] + else: + self.activations = output + + def get_heatmap(self, image_content: bytes, target_label: str) -> bytes: + """ + Generates a Grad-CAM heatmap for the given image and target label. + Returns the overlay image as bytes (JPEG). + """ + # Ensure model is ready + medsiglip_service._load_model() + model = medsiglip_service.model + processor = medsiglip_service.processor + device = medsiglip_service.device + + # Clean state + self.gradients = None + self.activations = None + for h in self.hooks: h.remove() + self.hooks = [] + + try: + # Prepare Inputs + image = Image.open(io.BytesIO(image_content)).convert("RGB") + inputs = processor(text=[target_label], images=image, return_tensors="pt", padding="max_length").to(device) + + # Hook Target Layer: Last Encoder Layer of Vision Model + target_layer = model.vision_model.encoder.layers[-1] + + h1 = target_layer.register_forward_hook(self._save_activation) + h2 = target_layer.register_full_backward_hook(self._save_gradient) + self.hooks.extend([h1, h2]) + + # Forward Pass + model.zero_grad() + outputs = model(**inputs) + + # Calculate Score + score = outputs.logits_per_image[0, 0] + + # Backward Pass + score.backward() + + if self.gradients is None or self.activations is None: + logger.error("Failed to capture gradients or activations.") + return image_content + + # CPU processing + gradients = self.gradients[0].detach().cpu() + activations = self.activations[0].detach().cpu() + + weights = torch.mean(gradients, dim=0) + cam = torch.matmul(activations, weights) + + seq_len = cam.shape[0] + grid_size = int(seq_len**0.5) + + if grid_size * grid_size != seq_len: + logger.warning(f"Non-square sequence length: {seq_len}") + return image_content + + cam_map = cam.view(grid_size, grid_size) + cam_map = F.relu(cam_map) + + if cam_map.max() > 0: + cam_map = cam_map - cam_map.min() + cam_map = cam_map / cam_map.max() + + cam_map_np = cam_map.numpy() + + img_np = np.array(image) + heatmap = cv2.resize(cam_map_np, (img_np.shape[1], img_np.shape[0])) + + heatmap = np.uint8(255 * heatmap) + heatmap_color = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) + + overlay = cv2.addWeighted(img_np, 0.6, heatmap_color, 0.4, 0) + + out_img = Image.fromarray(overlay) + buf = io.BytesIO() + out_img.save(buf, format="JPEG") + return buf.getvalue() + + except Exception as e: + logger.error(f"Grad-CAM error: {e}") + return image_content + finally: + for h in self.hooks: h.remove() + self.hooks = [] + +gradcam_service = GradCAMService() diff --git a/app/services/image_preprocess_service.py b/app/services/image_preprocess_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3ad8fa41ae2f47cac137896e60ca45d30352ad8f --- /dev/null +++ b/app/services/image_preprocess_service.py @@ -0,0 +1,192 @@ +import logging +import functools +import time +import numpy as np +from PIL import Image +import io +from app.services.yolo_service import yolo_service + +logger = logging.getLogger(__name__) + +from enum import Enum + +class PreprocessStrategy(str, Enum): + CROP = "crop" + PAD = "pad" + NONE = "none" + +class ImagePreprocessService: + def __init__(self): + pass + + + def get_lesion_bbox(self, image_content: bytes, threshold: float = 0.25) -> tuple: + """ + Detects the lesion bounding box using YOLOv8-Nano. + """ + try: + with Image.open(io.BytesIO(image_content)) as img: + if img.mode != "RGB": + img = img.convert("RGB") + width, height = img.size + + model = yolo_service.load_model() + if model is None: + return (0, 0, width, height) + + # Run inference + results = model.predict(img, conf=threshold, verbose=False) + + if not results or len(results[0].boxes) == 0: + logger.debug("YOLO detection found no boxes, falling back to full image") + return (0, 0, width, height) + + # Take the highest confidence box (YOLO sorts by confidence by default) + box = results[0].boxes[0].xyxy[0].cpu().numpy() + return (float(box[0]), float(box[1]), float(box[2]), float(box[3])) + except Exception as e: + logger.error(f"YOLO detection failed: {e}") + return None + + + @functools.lru_cache(maxsize=32) + def recommend_prep_strategy(self, image_bytes: bytes) -> dict: + """ + Decides whether to 'crop' or 'pad' based on object detection. + """ + start_time = time.perf_counter() + image = Image.open(io.BytesIO(image_bytes)) + width, height = image.size + + if width == height: + return { + "strategy": PreprocessStrategy.NONE, + "reason": "Already square", + "execution_time": f"{(time.perf_counter() - start_time):.3f}s" + } + + if width <= 448 and height <= 448: + return { + "strategy": PreprocessStrategy.PAD, + "reason": "Image is 448x448 or smaller; padding to square to avoid any data loss or scale-down", + "execution_time": f"{(time.perf_counter() - start_time):.3f}s" + } + + bbox = self.get_lesion_bbox(image_bytes) + if not bbox: + return { + "strategy": PreprocessStrategy.CROP, + "reason": "Detection failed, defaulting to center crop", + "execution_time": f"{(time.perf_counter() - start_time):.3f}s" + } + + x1, y1, x2, y2 = bbox + + # Center square boundaries + new_dim = min(width, height) + if width > height: + # Landscape + crop_x1 = (width - new_dim) / 2 + crop_x2 = (width + new_dim) / 2 + + # Check if bbox is outside the horizontal center crop + is_cut = (x1 < crop_x1) or (x2 > crop_x2) + else: + # Portrait + crop_y1 = (height - new_dim) / 2 + crop_y2 = (height + new_dim) / 2 + + # Check if bbox is outside the vertical center crop + is_cut = (y1 < crop_y1) or (y2 > crop_y2) + + if is_cut: + return { + "strategy": PreprocessStrategy.PAD, + "reason": "Object extends beyond center crop area", + "bbox": bbox, + "execution_time": f"{(time.perf_counter() - start_time):.3f}s" + } + else: + return { + "strategy": PreprocessStrategy.CROP, + "reason": "Object fully contained in center crop area", + "bbox": bbox, + "execution_time": f"{(time.perf_counter() - start_time):.3f}s" + } + + def prepare_image(self, image: Image.Image, target_size: tuple = (448, 448)) -> Image.Image: + """ + Intelligently prepares an image by either cropping or padding to a square, + then resizing to target_size. + """ + # Convert to bytes for strategy detection + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format='JPEG') + image_bytes = img_byte_arr.getvalue() + + strategy_res = self.recommend_prep_strategy(image_bytes) + strategy = strategy_res["strategy"] + + width, height = image.size + + if strategy == PreprocessStrategy.CROP or strategy == PreprocessStrategy.NONE: + # Traditional center crop (or already square) + new_dim = min(width, height) + left = (width - new_dim) / 2 + top = (height - new_dim) / 2 + right = (width + new_dim) / 2 + bottom = (height + new_dim) / 2 + image = image.crop((left, top, right, bottom)) + elif strategy == PreprocessStrategy.PAD: + # Pad to square + new_dim = max(width, height) + # Use black background for padding as it is common for clinical vision models + new_image = Image.new("RGB", (new_dim, new_dim), (0, 0, 0)) + if width > height: + # Landscape -> Pad Top/Bottom + new_image.paste(image, (0, (new_dim - height) // 2)) + else: + # Portrait -> Pad Left/Right + new_image.paste(image, ((new_dim - width) // 2, 0)) + image = new_image + + # Finally resize + if image.size != target_size: + logger.debug(f"Resizing image to {target_size}") + image = image.resize(target_size, Image.Resampling.LANCZOS) + + return image + + def prepare_image_base64(self, image_bytes: bytes, target_size: tuple = (448, 448)) -> str: + """ + Prepares image and returns as base64 data URI for UI debugging/display. + """ + import base64 + try: + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + prepared_image = self.prepare_image(image, target_size) + + buf = io.BytesIO() + prepared_image.save(buf, format="JPEG") + img_b64 = base64.b64encode(buf.getvalue()).decode('utf-8') + return f"data:image/jpeg;base64,{img_b64}" + except Exception as e: + logger.error(f"Failed to prepare image base64: {e}") + return None + + def prepare_image_bytes(self, image_bytes: bytes, target_size: tuple = (448, 448)) -> bytes: + """ + Helper to prepare image directly from bytes and return bytes. + """ + try: + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + prepared_image = self.prepare_image(image, target_size) + + buf = io.BytesIO() + prepared_image.save(buf, format="JPEG") + return buf.getvalue() + except Exception as e: + logger.error(f"Failed to prepare image bytes: {e}") + raise e + +image_preprocess_service = ImagePreprocessService() diff --git a/app/services/medsiglip_modality_wrapper.py b/app/services/medsiglip_modality_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..02eaad366bef9f80e22ff1494300210994dc0010 --- /dev/null +++ b/app/services/medsiglip_modality_wrapper.py @@ -0,0 +1,80 @@ +import logging +from typing import List, Dict, Optional, Any +from app.services.medsiglip_service import medsiglip_service +from app.dermatology_data import MEDSIGLIP_DERMATOLOGY_NARROW_LABELS + +logger = logging.getLogger(__name__) + +class ClinicalModalityWrapper: + """ + Generic wrapper for vision-language models that implements clinical modality templating + using the MEDSIGLIP_DERMATOLOGY_NARROW_LABELS map (Keys and Values). + """ + def __init__(self, service: Any, modality: str = "macroscopic"): + self.service = service + # "macroscopic" -> "Clinical photograph showing {desc}." + # "dermoscopy" -> "Dermoscopy image revealing {desc}." + self.modality = modality + self.labels_map = MEDSIGLIP_DERMATOLOGY_NARROW_LABELS + + def _get_template(self) -> str: + if self.modality == "dermoscopy": + return "Dermoscopy image revealing {}." + #return "Clinical photograph showing {}." + return "A patient-submitted smartphone photograph showing {}." + + def analyze_image(self, image_bytes: bytes, custom_labels: Optional[List[str]] = None) -> List[Dict]: + """ + Analyzes an image using clinical descriptions (values) wrapped in modality templates. + Returns mapped results with original short labels (keys). + """ + # 1. Prepare labels and descriptions from MEDSIGLIP_DERMATOLOGY_NARROW_LABELS + if custom_labels: + descriptions = [] + valid_labels = [] + for label in custom_labels: + if label in self.labels_map: + descriptions.append(self.labels_map[label]) + valid_labels.append(label) + else: + # If not in our clinical map, use original label as description + descriptions.append(label) + valid_labels.append(label) + else: + # Use all predefined clinical labels (Keys and Values) + valid_labels = list(self.labels_map.keys()) + descriptions = list(self.labels_map.values()) + + # 2. Apply modality template to descriptions (Values) + template = self._get_template() + prompts = [template.format(desc) for desc in descriptions] + + print(f"\n[DEBUG] Prompts for {self.service.model_name}:") + for p in prompts: + print(f" - {p}") + + # 3. Call the underlying service + # Handle different method names between MedSigLIP and SigLIP services + if hasattr(self.service, "get_embeddings"): + raw_results = self.service.get_embeddings(image_bytes, texts=prompts) + elif hasattr(self.service, "get_predictions"): + raw_results = self.service.get_predictions(image_bytes, texts=prompts) + else: + raise AttributeError(f"Service {type(self.service)} has no supported inference method.") + + # 4. Map prompts back to original short labels (Keys) + prompt_to_label = dict(zip(prompts, valid_labels)) + + mapped_results = [] + for res in raw_results: + original_label = prompt_to_label.get(res["label"], res["label"]) + mapped_results.append({ + "label": original_label, + "description": res["label"], # The full prompt used + "score": res["score"] + }) + + return mapped_results + +# Global instances for easy access +medsiglip_wrapped_service = ClinicalModalityWrapper(medsiglip_service) diff --git a/app/services/medsiglip_service.py b/app/services/medsiglip_service.py new file mode 100644 index 0000000000000000000000000000000000000000..32896b6b917f7de38630678d89608f9a3287f881 --- /dev/null +++ b/app/services/medsiglip_service.py @@ -0,0 +1,95 @@ +import logging +import torch +import os +from PIL import Image +from transformers import AutoProcessor, AutoModel +import io +from typing import List, Optional +from app.services.image_preprocess_service import image_preprocess_service +from app.config import MEDSIGLIP_MODEL_NAME, MODEL_IMAGE_SIZE + +logger = logging.getLogger(__name__) + +class MedSigLIPService: + def __init__(self, model_name=MEDSIGLIP_MODEL_NAME): + # We'll lazy load the model to avoid startup costs and potential auth issues crashing the app immediately + self.model_name = model_name + + self.processor = None + self.model = None + if torch.cuda.is_available(): + self.device = "cuda" + elif torch.backends.mps.is_available(): + self.device = "mps" + else: + self.device = "cpu" + + def _load_model(self): + if self.model is None: + logger.info(f"Loading MedSigLIP model: {self.model_name} on {self.device}...") + try: + token = os.getenv("HF_TOKEN") + self.processor = AutoProcessor.from_pretrained(self.model_name, token=token) + self.model = AutoModel.from_pretrained(self.model_name, token=token).to(self.device) + logger.info("MedSigLIP model loaded successfully.") + except Exception as e: + logger.error(f"Failed to load MedSigLIP model: {e}") + raise e + + def get_embeddings(self, image_bytes: bytes, texts: Optional[List[str]] = None): + """ + Run inference to get embeddings or probabilities for zero-shot classification. + If texts is provided, performs zero-shot classification via similarity. + """ + self._load_model() + try: + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + image = image_preprocess_service.prepare_image(image, MODEL_IMAGE_SIZE) + + if texts: + # 64-token limit check as per requirements + inputs = self.processor( + text=texts, + images=image, + padding="max_length", + max_length=64, + truncation=True, + return_tensors="pt" + ).to(self.device) + + # Optional: Log warning if truncation occurred (check input_ids shape vs max_length) + # Note: with truncation=True, the shape will be (num_texts, 64) + # To detect if it *would* have exceeded, we could tokenize without truncation first, + # but that's expensive. Instead, we can just ensure we stay within the limit. + + with torch.no_grad(): + outputs = self.model(**inputs) + + # Retrieve logits + logits_per_image = outputs.logits_per_image + probs = logits_per_image.softmax(dim=1) + + # Format results + results = [] + prob_values = probs[0].tolist() + for i, text in enumerate(texts): + results.append({"label": text, "score": prob_values[i]}) + + # Sort by score descending + results.sort(key=lambda x: x["score"], reverse=True) + return results + else: + # Just image embedding + # MedSigLIP is a CLIP-like model, so we can get features + inputs = self.processor(images=image, return_tensors="pt").to(self.device) # Only image + with torch.no_grad(): + image_features = self.model.get_image_features(**inputs) + + return {"embedding": image_features[0].tolist()} + + except Exception as e: + logger.error(f"MedSigLIP inference failed: {e}") + raise e + +# Global instance +medsiglip_service = MedSigLIPService() diff --git a/app/services/result_interpreter.py b/app/services/result_interpreter.py new file mode 100644 index 0000000000000000000000000000000000000000..396b14496fdc9752bac2936710cb0b5677598f32 --- /dev/null +++ b/app/services/result_interpreter.py @@ -0,0 +1,159 @@ +import math +import logging +from typing import List, Dict, Any +from app.dermatology_data import CANCEROUS_TUMOR_CLASSES +from app.config import INTERPRETER_ENTROPY_THRESHOLD, INTERPRETER_MARGIN_THRESHOLD, CONFIDENCE_CLASSES + +logger = logging.getLogger(__name__) + +class ResultInterpreter: + """ + Analyzes classification results from MedSigLIP models to provide clinical insights. + + Responsibilities: + 1. Detect if top predictions indicate tumor-related diseases based on CANCEROUS_TUMOR_CLASSES. + 2. Handle mixed cases (Tumor vs Non-Tumor) with confidence margins. + 3. Calculate Predictive Entropy (Shannon Entropy) as a measure of model uncertainty. + 4. Provide descriptive annotations and color hints for UI. + 5. Classify confidence based on Top-1 vs Top-2 margin. + """ + + def interpret(self, results: List[Dict[str, Any]], + entropy_threshold: float = INTERPRETER_ENTROPY_THRESHOLD, + margin_threshold: float = INTERPRETER_MARGIN_THRESHOLD) -> Dict[str, Any]: + """Interprets a list of classification results.""" + if not results: + return self._empty_result() + + scores = [r["score"] for r in results] + entropy = self.calculate_entropy(scores) + is_reliable = entropy < entropy_threshold + + # Rule 1: Margin Calculation (including Tumor Consolidation) + margin = self._calculate_margin(results) + conf_info = self.get_confidence_level(margin) + + # Rule 2: Status and Annotation Logic + analysis = self._determine_status_and_annotation(results, margin, margin_threshold) + + # Rule 3: Format computation process for tech logs + comp_process = self._format_computation_process( + results, margin, margin_threshold, conf_info, entropy, entropy_threshold, is_reliable + ) + + return { + "is_high_risk": analysis["is_high_risk"], + "entropy": entropy, + "is_reliable": is_reliable, + "annotation": analysis["annotation"], + "color_hint": analysis["color_hint"], + "confidence_label": conf_info["label"], + "confidence_color": conf_info["color_hint"], + "status": analysis["status"], + "margin": margin, + "margin_threshold": margin_threshold, + "computation_process": comp_process, + "top_2_labels": [results[0]["label"], results[1]["label"]] if len(results) > 1 else [results[0]["label"], "None"] + } + + def _empty_result(self) -> Dict[str, Any]: + return { + "is_high_risk": False, "entropy": 0.0, "is_reliable": False, + "annotation": "No results available to interpret.", "color_hint": "gray", + "confidence_label": "Unknown", "confidence_color": "gray", + "computation_process": ["No results provided."] + } + + def _calculate_margin(self, results: List[Dict[str, Any]]) -> float: + """ + Calculates margin. + Tumor rule: sum(contiguous tumors) - first_non_tumor + Default rule: top_1 - top_2 + """ + top_1 = results[0] + if top_1["label"] in CANCEROUS_TUMOR_CLASSES: + tumor_sum = 0.0 + next_non_tumor_score = 0.0 + found_non_tumor = False + for r in results: + if not found_non_tumor and r["label"] in CANCEROUS_TUMOR_CLASSES: + tumor_sum += r["score"] + elif not found_non_tumor: + next_non_tumor_score = r["score"] + found_non_tumor = True + return round(tumor_sum - next_non_tumor_score, 4) + + top_2_score = results[1]["score"] if len(results) > 1 else 0.0 + return round(top_1["score"] - top_2_score, 4) + + def _determine_status_and_annotation(self, results: List[Dict[str, Any]], margin: float, margin_threshold: float) -> Dict[str, Any]: + """Provides status, annotation, and color hint based on top results.""" + t1 = results[0] + t2 = results[1] if len(results) > 1 else {"label": "None", "score": 0.0} + + is_t1_tumor = t1["label"] in CANCEROUS_TUMOR_CLASSES + is_t2_tumor = t2["label"] in CANCEROUS_TUMOR_CLASSES + + if is_t1_tumor and is_t2_tumor: + return {"is_high_risk": True, "annotation": "High likeness of tumor disease", "color_hint": "red", "status": "tumor_detected"} + + if is_t1_tumor != is_t2_tumor: + if margin < margin_threshold: + return {"is_high_risk": False, "annotation": "Not clear", "color_hint": "yellow", "status": "uncertain_mixed"} + + if is_t1_tumor: + return {"is_high_risk": False, "annotation": f"Potential cancerous condition: {t1['label']}", "color_hint": "red", "status": "potential_tumor"} + return {"is_high_risk": False, "annotation": f"Likely benign: {t1['label']}", "color_hint": "green", "status": "likely_benign"} + + return {"is_high_risk": False, "annotation": "No immediate tumor likeness detected in top results", "color_hint": "green", "status": "benign"} + + def _format_computation_process(self, results, margin, margin_threshold, conf_info, entropy, entropy_threshold, is_reliable) -> List[str]: + """Formats the detailed steps of interpretation for UI display.""" + t1 = results[0] + t2 = results[1] if len(results) > 1 else {"label": "None", "score": 0.0} + + process = [ + f"Top 1: {t1['label']} ({t1['score']:.2f}) - Tumor: {t1['label'] in CANCEROUS_TUMOR_CLASSES}", + f"Top 2: {t2['label']} ({t2['score']:.2f}) - Tumor: {t2['label'] in CANCEROUS_TUMOR_CLASSES}", + f"Margin: {margin:.4f} (Threshold: {margin_threshold})", + f"Confidence: {conf_info['label']}", + f"Mixed Case Detect: {'Yes' if (t1['label'] in CANCEROUS_TUMOR_CLASSES) != (t2['label'] in CANCEROUS_TUMOR_CLASSES) else 'No'}", + f"Entropy: {entropy:.2f} bits (Limit: {entropy_threshold})" + ] + process.append("Status: Prediction within reliability limits" if is_reliable else f"Status: Low confidence - High uncertainty detected (Entropy: {entropy:.2f})") + return process + + def get_confidence_level(self, margin: float) -> Dict[str, str]: + """ + Maps margin to a qualitative confidence level using thresholds from config. + """ + # Round to avoid floating point precision issues (e.g. 0.4 - 0.1 = 0.30000000000000004) + m = round(margin, 4) + + for cls in CONFIDENCE_CLASSES: + if m > cls["min"]: + return { + "label": cls["label"], + "color_hint": cls.get("color_hint", "") + } + + # Fallback to the last class (usually 0.0) if no match found + last_cls = CONFIDENCE_CLASSES[-1] + return { + "label": last_cls["label"], + "color_hint": last_cls.get("color_hint", "") + } + + def calculate_entropy(self, probabilities: List[float]) -> float: + """ + Calculates Shannon entropy in bits. + H = -sum(pi * log2(pi)) + """ + entropy = 0.0 + for p in probabilities: + if p > 1e-9: # Avoid log(0) + entropy -= p * math.log2(p) + return entropy + +# Global singleton instance +result_interpreter = ResultInterpreter() diff --git a/app/services/vertex_client.py b/app/services/vertex_client.py new file mode 100644 index 0000000000000000000000000000000000000000..3bd0d0217922cf89a1231af54b9921863dc15a4c --- /dev/null +++ b/app/services/vertex_client.py @@ -0,0 +1,77 @@ +import os +import logging +try: + from google.cloud import aiplatform + from google.oauth2 import service_account + import google.auth + GOOGLE_CLOUD_AVAILABLE = True +except ImportError: + GOOGLE_CLOUD_AVAILABLE = False + aiplatform = None + service_account = None + google = None + +logger = logging.getLogger(__name__) + +class VertexClient: + def __init__(self): + self.project_id = os.environ.get("PROJECT_ID") + self.location = os.environ.get("LOCATION", "us-central1") + self.endpoint_id = os.environ.get("ENDPOINT_ID") # ID of the deployed MedGemma endpoint + self.credentials_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + + self.setup_complete = False + + if not GOOGLE_CLOUD_AVAILABLE: + logger.warning("google-cloud-aiplatform not installed. Vertex AI client will be mocked.") + return + + if self.project_id: + try: + # If credentials path is set, explicit load (dev), else default (cloud run) + if self.credentials_path and os.path.exists(self.credentials_path): + creds = service_account.Credentials.from_service_account_file(self.credentials_path) + else: + creds, _ = google.auth.default() + + aiplatform.init( + project=self.project_id, + location=self.location, + credentials=creds + ) + self.setup_complete = True + logger.info(f"Vertex AI initialized for project {self.project_id}") + except Exception as e: + logger.error(f"Failed to initialize Vertex AI: {e}") + + async def predict(self, prompt: str, max_tokens: int = 256, temperature: float = 0.2) -> str: + if not self.setup_complete: + logger.info("Returning mock prediction because Vertex AI is not configured.") + return "Mock Response: Vertex AI is not configured. This is a dummy prediction." + + if not self.endpoint_id: + return "Endpoint ID not configured." + + try: + # Get Endpoint + endpoint = aiplatform.Endpoint(self.endpoint_id) + + # Predict + # Structure depends on the model serving container. + # MedGemma usually expects instances=[{"prompt": ...}] + instances = [{"prompt": prompt, "max_tokens": max_tokens, "temperature": temperature}] + + response = endpoint.predict(instances=instances) + + # Parse prediction (assuming standard format, adjust based on actual model output) + # Typically response.predictions is a list + if response.predictions: + return str(response.predictions[0]) + else: + return "No prediction returned." + + except Exception as e: + logger.error(f"Prediction failed: {e}") + raise e + +vertex_client = VertexClient() diff --git a/app/services/yolo_service.py b/app/services/yolo_service.py new file mode 100644 index 0000000000000000000000000000000000000000..bed05c771a3b0bfc028c6073c511adaf184c6d70 --- /dev/null +++ b/app/services/yolo_service.py @@ -0,0 +1,22 @@ +import logging +import os + +logger = logging.getLogger(__name__) + +class YOLOService: + def __init__(self): + self.model = None + + def load_model(self): + if self.model is None: + try: + from ultralytics import YOLO + # Use YOLOv8-Nano + logger.info("Loading YOLOv8-Nano model...") + self.model = YOLO('yolov8n.pt') + except ImportError: + logger.warning("ultralytics not installed. YOLO detection will be skipped.") + return None + return self.model + +yolo_service = YOLOService() diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..4ea8f2d29cdce7422ee67b1590d5990682154392 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,512 @@ +function dermatologApp() { + return { + // App State + activeTab: 'photos', + analysisResults: {}, + showTechnicalDetails: {}, // Map of photo_id -> boolean + + // Chat State + prompt: '', + temperature: 0.2, + loading: false, + response: null, + latency: null, + sessionId: null, + + // Photo State + timeline: [], + dragover: false, + editingPhoto: null, + editingDate: '', + + // Common + error: null, + modelName: 'Loading...', + yoloAvailable: false, + marginThreshold: 0.05, + currentAnalysisId: null, + clearPromise: null, + debugMode: false, + + init() { + this.sessionId = this.getCookie('session_id'); + const urlParams = new URLSearchParams(window.location.search); + this.debugMode = urlParams.has('debug'); + + this.loadTimeline(); + this.fetchModelInfo(); + + // Global Paste Handler + window.addEventListener('paste', (e) => { + const items = (e.clipboardData || e.originalEvent.clipboardData).items; + const files = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + this.handleFiles(files); + } + }); + + // Prevent accidental refresh + window.addEventListener('beforeunload', (e) => { + if (this.timeline && this.timeline.length > 0) { + const msg = "On refresh the content would be cleared. Are you sure you want to leave?"; + e.preventDefault(); + e.returnValue = msg; + return msg; + } + }); + }, + + async fetchModelInfo() { + try { + const res = await fetch('/api/health'); + if (res.ok) { + const data = await res.json(); + this.yoloAvailable = data.yolo_available; + if (data.status === "OK") { + this.modelName = "MedSigLIP (Local)"; + } else if (data.status === "suspended") { + this.modelName = "Service Suspended"; + } else { + this.modelName = data.status || "Unknown Status"; + } + } + } catch (e) { + console.error("Failed to fetch model info", e); + this.modelName = "Error fetching health"; + } + }, + + getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; + }, + + async loadTimeline() { + try { + const res = await fetch('/api/photos?t=' + new Date().getTime()); + if (res.ok) { + this.timeline = await res.json(); + + this.timeline.forEach(item => { + const processPhoto = (p) => { + // Status is handled at runtime in this.analysisResults, not restored from DB + // satisfy "store the state whether image was processed in the html not db" + }; + + if (item.type === 'directory') { + item.items.forEach(processPhoto); + } else if (item.type === 'photo') { + processPhoto(item.data); + } + }); + } + } catch (e) { + console.error("Timeline load failed", e); + } + }, + + async handleDrop(event) { + this.dragover = false; + const files = event.dataTransfer.files; + if (files.length > 0) { + this.handleFiles(files); + } + }, + + async handleFiles(files) { + if (files.length === 0) return; + + // Ensure we wait for any ongoing session clearing to finish + if (this.clearPromise) { + await this.clearPromise; + } + + this.loading = true; + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const dataUrl = await this.readAsDataURL(file); + + // Basic duplicate check (by name and size for local) + const isDuplicate = this.getAllPhotos().some(p => p.filename === file.name && p.size === file.size); + if (isDuplicate) { + this.showToast("Upload Notice", `Skipped ${file.name} (already in timeline)`, "warning"); + continue; + } + + const photoId = crypto.randomUUID(); + const photo = { + id: photoId, + filename: file.name, + size: file.size, + creation_date: new Date(file.lastModified || Date.now()).toISOString().split('T')[0], + uploaded_at: new Date().toISOString(), + local_content: dataUrl, + analysis: null + }; + + this.addPhotoToTimeline(photo); + } + + // Brief delay to let UI render the new cards + setTimeout(() => { + this.analyzeAllPhotos(); + }, 300); + + } catch (e) { + console.error("Local processing error:", e); + this.error = "Failed to process images: " + e.message; + } finally { + this.loading = false; + } + }, + + readAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }, + + addPhotoToTimeline(photo) { + // Check if day exists + let dir = this.timeline.find(item => item.type === 'directory' && item.date === photo.creation_date); + if (!dir) { + dir = { + type: 'directory', + date: photo.creation_date, + items: [], + count: 0 + }; + this.timeline.push(dir); + // Sort timeline by date descending + this.timeline.sort((a, b) => b.date.localeCompare(a.date)); + } + + // Avoid duplicates in items list + if (!dir.items.some(p => p.id === photo.id)) { + dir.items.push(photo); + dir.items.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at)); + dir.count = dir.items.length; + } + }, + + async deletePhoto(photoId) { + if (!confirm("Are you sure you want to delete this photo locally?")) return; + + // Remove from timeline state (purely local) + this.timeline.forEach(dir => { + if (dir.type === 'directory') { + dir.items = dir.items.filter(p => p.id !== photoId); + dir.count = dir.items.length; + } + }); + // Clean up empty directories + this.timeline = this.timeline.filter(dir => dir.type !== 'directory' || dir.count > 0); + + delete this.analysisResults[photoId]; + return true; + }, + + async deletePhotoFromModal() { + if (!this.editingPhoto) return; + const success = await this.deletePhoto(this.editingPhoto.id); + if (success) { + document.querySelector('.edit-dialog').hide(); + this.editingPhoto = null; + } + }, + + async clearSession() { + if (!confirm("Clear all local photos?")) return; + // reset all frontend reactive state variables needed for a clean run + this.analysisResults = {}; + this.timeline = []; + this.showTechnicalDetails = {}; + this.prompt = ''; + this.loading = false; + this.response = null; + this.latency = null; + this.currentAnalysisId = null; + this.editingPhoto = null; + this.editingDate = ''; + + // Reset file inputs so identical files can trigger @change again + if (this.$refs.fileInput) this.$refs.fileInput.value = ''; + if (this.$refs.cameraInput) this.$refs.cameraInput.value = ''; + + // Optional: Tell backend to clear its session context if needed + fetch('/api/photos', { method: 'DELETE' }).catch(console.error); + }, + + + openEditModal(photo) { + this.editingPhoto = photo; + this.editingDate = photo.creation_date; + document.querySelector('.edit-dialog').show(); + }, + + async saveDate() { + if (!this.editingPhoto) return; + + // Update locally + const oldDate = this.editingPhoto.creation_date; + const newDate = this.editingDate; + + if (oldDate !== newDate) { + // Remove from old location + this.timeline.forEach(dir => { + if (dir.date === oldDate) { + dir.items = dir.items.filter(p => p.id !== this.editingPhoto.id); + dir.count = dir.items.length; + } + }); + + // Add to new + this.editingPhoto.creation_date = newDate; + this.addPhotoToTimeline(this.editingPhoto); + + // Cleanup empty + this.timeline = this.timeline.filter(dir => dir.count > 0); + } + + document.querySelector('.edit-dialog').hide(); + this.editingPhoto = null; + }, + + async analyzeAllPhotos() { + this.loading = true; + this.error = null; + this.latency = 0; + + const photos = this.getAllPhotos(); + if (photos.length === 0) { + this.loading = false; + return; + } + + const photosToAnalyze = photos.filter(p => !this.analysisResults[p.id]); + if (photosToAnalyze.length === 0) { + this.loading = false; + return; + } + + let report = (this.response || ""); + if (report && !report.endsWith("\n\n")) report += "\n\n"; + report += `--- Starting Local Analysis Batch [${new Date().toLocaleTimeString()}] ---\n`; + this.response = report; + + let startTime = performance.now(); + + try { + for (const photo of photosToAnalyze) { + console.log(`Starting analysis for ${photo.filename} (${photo.id})`); + report += `Analyzing ${photo.filename} (Local Transfer)...\n`; + this.response = report; + this.currentAnalysisId = photo.id; + + try { + const res = await fetch(`/api/photos/${photo.id}/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'medsiglip', + margin_threshold: parseFloat(this.marginThreshold), + base64_image: photo.local_content + }) + }); + + if (res.ok) { + const data = await res.json(); + if (data.predictions && data.predictions.length > 0) { + // Populate results for UI + this.analysisResults[photo.id] = { + id: photo.id, + date: new Date().toISOString(), + prediction: data.predictions[0], + predictions: data.predictions, // For legacy if any + primary: data.predictions, + initial_classification: data.initial_classification, + primary_name: data.primary_model_name, + interpretation: data.interpretation, + preprocess_strategy: data.preprocess_strategy, + prepared_image_base64: data.prepared_image_base64, + execution_times: data.execution_times, + saliency_base64: data.saliency_base64 + }; + report += ` βž” Primary Results (${data.primary_model_name}):\n`; + data.predictions.forEach(p => { + report += ` - ${p.label}: ${(p.score * 100).toFixed(1)}%\n`; + }); + } + } else { + const err = await res.text(); + report += ` βž” Request Failed: ${res.status} ${err}\n`; + } + } catch (e) { + console.error(`Analysis error for ${photo.id}:`, e); + report += ` βž” Error: ${e.message}\n`; + } + + this.currentAnalysisId = null; + report += "\n"; + this.response = report; + } + + this.latency = Math.round(performance.now() - startTime); + report += "Batch Completion Success."; + this.response = report; + + } catch (e) { + console.error(e); + this.error = "Analysis process encountered a critical error."; + } finally { + this.loading = false; + } + }, + + async fetchSaliency(photo) { + console.log("fetchSaliency triggered for", photo.id); + if (!this.analysisResults[photo.id]) { + console.warn("No analysis results for photo", photo.id); + return; + } + if (this.analysisResults[photo.id].saliency_base64) { + console.log("Saliency already exists for", photo.id); + return; + } + if (!this.analysisResults[photo.id].primary || this.analysisResults[photo.id].primary.length === 0) { + console.warn("No primary assessment predictions for", photo.id); + return; + } + + const topLabel = this.analysisResults[photo.id].primary[0].label; + console.log("Fetching saliency for label:", topLabel); + + try { + const res = await fetch(`/api/photos/${photo.id}/saliency`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + base64_image: photo.local_content, + target_label: topLabel + }) + }); + + if (res.ok) { + const data = await res.json(); + console.log("Saliency data received for", photo.id, "len:", data.saliency_base64 ? data.saliency_base64.length : 0); + this.analysisResults[photo.id].saliency_base64 = data.saliency_base64; + console.log("Updated analysisResults with saliency for", photo.id); + } else { + console.error("Saliency fetch failed with status:", res.status); + } + } catch (e) { + console.error("Saliency fetch error:", e); + } + }, + + getAllPhotos() { + let photos = []; + this.timeline.forEach(item => { + if (item.type === 'photo') photos.push(item.data); + else if (item.type === 'directory') photos.push(...item.items); + }); + return photos; + }, + + getInterpretationColor(hint) { + const colors = { + 'red': 'var(--sl-color-danger-600)', + 'yellow': 'var(--sl-color-warning-600)', + 'green': 'var(--sl-color-success-600)', + 'gray': 'var(--sl-color-neutral-600)' + }; + return colors[hint] || colors['gray']; + }, + + getBadgeVariant(hint) { + const variants = { + 'green': 'success', + 'gray': 'neutral', + 'yellow': 'warning', + 'red': 'danger' + }; + return variants[hint] || 'neutral'; + }, + + getInterpretationIcon(hint) { + if (hint === 'green') return 'shield-check'; + if (hint === 'red') return 'exclamation-triangle'; + return 'activity'; + }, + + copyReport(photoId) { + const result = this.analysisResults[photoId]; + if (!result) return; + + const annotation = result.interpretation ? result.interpretation.annotation : result.prediction.label; + const confidence = result.interpretation ? result.interpretation.confidence_label : 'N/A'; + const score = Math.round(result.prediction.score * 100) + '%'; + + const text = `Clinical Summary\n----------------\nResult: ${annotation}\nConfidence: ${confidence} (${score})\nDate: ${new Date(result.date).toLocaleString()}\n\nNote: This is an AI-assisted analysis and should be reviewed by a professional.`; + + navigator.clipboard.writeText(text).then(() => { + this.showToast('Copied', 'Clinical summary copied to clipboard', 'success', 'clipboard-check'); + }); + }, + + toggleDebug() { + this.debugMode = !this.debugMode; + const url = new URL(window.location.href); + if (this.debugMode) { + url.searchParams.set('debug', '1'); + } else { + url.searchParams.delete('debug'); + } + window.history.replaceState({}, '', url.toString()); + }, + + showToast(title, message, variant = 'primary', icon = 'info-circle') { + const alert = Object.assign(document.createElement('sl-alert'), { + variant: variant, + closable: true, + duration: 5000, + innerHTML: ` + + ${title}
+ ${message} + ` + }); + document.body.append(alert); + + // Ensure shoelace components are defined before calling methods + if (typeof customElements !== 'undefined' && customElements.whenDefined) { + customElements.whenDefined('sl-alert').then(() => { + if (typeof alert.toast === 'function') { + alert.toast(); + } + }); + } else { + // Fallback for environments where customElements/Shoelace might not be fully loaded + setTimeout(() => { + if (typeof alert.toast === 'function') alert.toast(); + }, 100); + } + } + } +} + +if (typeof window !== 'undefined') { + window.dermatologApp = dermatologApp; +} diff --git a/app/static/img/body_outline.svg b/app/static/img/body_outline.svg new file mode 100644 index 0000000000000000000000000000000000000000..d37cc1f6769da0b0f232fe11e672f03b06f72bb7 --- /dev/null +++ b/app/static/img/body_outline.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js new file mode 100644 index 0000000000000000000000000000000000000000..7fb17a4e908c796e0a154a57327143a047efacf5 --- /dev/null +++ b/app/static/js/modules/api.js @@ -0,0 +1,59 @@ +/** + * API client for backend communication + */ +export class ApiClient { + /** + * @param {string} baseUrl - Base URL for API (default: current origin) + */ + constructor(baseUrl = '') { + this.baseUrl = baseUrl; + } + + /** + * Uploads an image for analysis + * @param {FormData} formData - Form data containing image file + * @returns {Promise} { task_id: string } + * @throws {Error} If upload fails + */ + async uploadImage(formData) { + const response = await fetch(`${this.baseUrl}/upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Gets progress for a task + * @param {string} taskId - Task ID + * @returns {Promise} Task data or null if not found + */ + async getProgress(taskId) { + try { + const response = await fetch(`${this.baseUrl}/progress/${taskId}`); + if (!response.ok) return null; + return response.json(); + } catch (error) { + console.error('Failed to fetch progress:', error); + return null; + } + } + + /** + * Gets aggregate statistics + * @returns {Promise} Statistics data + * @throws {Error} If fetch fails + */ + async getStats() { + const response = await fetch(`${this.baseUrl}/stats`); + if (!response.ok) { + throw new Error(`Failed to fetch stats: ${response.statusText}`); + } + return response.json(); + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..7a401746eff36cdf83aec6897ed5e1ca05f166e7 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,1149 @@ + + + + + + + Dermatolog AI Scan + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+ +
+ +
+

Secure Image Analysis +

+

+ Drag photos here or click to browse +

+
+ +
+ Press Ctrl+V to paste images +
+ + +
+ + + Capture Image + + + + Library + +
+
+ +
+ + + + + + +
+ +
+

Analysis + History

+ +
+ + + +
+ +
+
+
+ + +
+ + Privacy Mode: Images are stored locally on your device +
+ + + + + + + + + + + +
+ + Delete + + Save +
+
+ + + + + +
+ + Debug Mode + +
+ + + +
+ + + \ No newline at end of file diff --git a/bin/app_restart.sh b/bin/app_restart.sh new file mode 100755 index 0000000000000000000000000000000000000000..b98ff1577934033c15fdf802f2c96cfe6422db74 --- /dev/null +++ b/bin/app_restart.sh @@ -0,0 +1,22 @@ +#!/bin/bash +echo "Stopping any existing uvicorn processes..." +pkill -f uvicorn || true + +echo "Starting application server..." +# Using nohup and python -m uvicorn to ensure correct python path and persistence +nohup /Users/mstepien/Documents/dev2/py/fasts/venv/bin/python3.10 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > server.log 2>&1 & + +echo "Waiting for server to be ready..." +# Simple loop to check if port 8000 is open (using curl or netcat logic via python) +for i in {1..30}; do + if curl -s http://localhost:8000/api/health >/dev/null; then + echo "Server is UP!" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 +done + +echo "Server failed to start. Check server.log:" +tail -n 20 server.log +exit 1 diff --git a/bin/app_stop.sh b/bin/app_stop.sh new file mode 100755 index 0000000000000000000000000000000000000000..7805c968bc8e8be3f2759e89d4820f9ccf38aad8 --- /dev/null +++ b/bin/app_stop.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +#kill && source venv/bin/activate && nohup uvicorn main:app --host 127.0.0.1 --port 8080 > server.log 2>&1 & + +# Find the PID of the running uvicorn process +PID=$(ps aux | grep "uvicorn app.main:app" | grep -v grep | awk '{print $2}') + +if [ -n "$PID" ]; then + echo "Stopping existing application(s) (PIDs: $PID)..." + echo "$PID" | xargs kill + sleep 2 # Wait for it to shut down +else + echo "No running application found." +fi diff --git a/bin/check_models.sh b/bin/check_models.sh new file mode 100755 index 0000000000000000000000000000000000000000..667efe94d99839818ab2a3931aa5b74453df1da9 --- /dev/null +++ b/bin/check_models.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Get the directory where the script is located +BIN_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +PROJECT_ROOT="$(dirname "$BIN_DIR")" + +echo "Checking and downloading models for Dermatolog AI..." + +# Use the same python executable as the app (or just python3) +# Based on previous turns, /usr/local/opt/python@3.8/bin/python3.8 was used +PYTHON_EXEC="/usr/local/opt/python@3.8/bin/python3.8" + +if [ ! -x "$PYTHON_EXEC" ]; then + PYTHON_EXEC="python3" +fi + +$PYTHON_EXEC "$BIN_DIR/download_models.py" diff --git a/bin/cleanup_chromium.sh b/bin/cleanup_chromium.sh new file mode 100755 index 0000000000000000000000000000000000000000..85e667c39b6266aaa5299dd87a694433d211a71c --- /dev/null +++ b/bin/cleanup_chromium.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Script to clear any hanging Chromium/Chrome/Playwright processes + +echo "🧹 Cleaning up hanging Chromium and test processes..." + +# List of process patterns to target (case-insensitive) +TARGETS=( + #"chromium" "chrome" + "playwright" "ms-playwright") + +for target in "${TARGETS[@]}"; do + # Check if any processes exist for this target (full command line match) + if pgrep -if "$target" > /dev/null; then + echo "Killing processes matching: $target" + pkill -9 -if "$target" + fi +done + +# Also handle specific Playwright driver if it's hanging +if pgrep -f "playwright-core" > /dev/null; then + echo "Killing playwright-core processes..." + pkill -9 -f "playwright-core" +fi + +echo "βœ… Cleanup complete." diff --git a/bin/deploy.sh b/bin/deploy.sh new file mode 100755 index 0000000000000000000000000000000000000000..cedb651667ad87cbb47d4fb870baaaf865d1e9ba --- /dev/null +++ b/bin/deploy.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + + +# Load .env file if it exists +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +# Check for PROJECT_ID +if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" == "your-project-id" ]; then + echo "Error: PROJECT_ID is not set. Please set it in .env or export it." + echo "Example: export PROJECT_ID=my-gcp-project-id" + exit 1 +fi + +GOOGLE_CLOUD_PROJECT=$PROJECT_ID +SERVICE_NAME="dermatolog-ai-scan" +REGION="us-central1" +# We need enough memory for the model (MedSigLIP) to load. +# 4GB is the absolute minimum, 8GB is safer. +MEMORY="8Gi" +CPU="2" + +echo "========================================================" +echo " Deploying $SERVICE_NAME to Cloud Run ($REGION)" +echo " Mode: Self-Contained (Local Inference)" +echo "========================================================" + +# 1. Build and Submit Container (Using Cloud Build to inject build args) +echo "[1/3] Building container image..." +gcloud builds submit --config cloudbuild.yaml --substitutions=_HF_TOKEN="$HF_TOKEN",_SERVICE_NAME="$SERVICE_NAME" . + +# 2. Deploy to Cloud Run +echo "[2/3] Deploying to Cloud Run..." +gcloud run deploy $SERVICE_NAME \ + --image gcr.io/$GOOGLE_CLOUD_PROJECT/$SERVICE_NAME \ + --region $REGION \ + --platform managed \ + --allow-unauthenticated \ + --memory $MEMORY \ + --cpu $CPU \ + --timeout 300 \ + --concurrency 10 \ + --set-env-vars="HF_TOKEN=$HF_TOKEN" + # Note: If HF_TOKEN is not set in your local shell, this will be empty. + # The app handles missing token by falling back to public model. + +echo "========================================================" +echo " Deployment Complete!" +echo "========================================================" diff --git a/bin/docker-test.sh b/bin/docker-test.sh new file mode 100755 index 0000000000000000000000000000000000000000..dcdf9b3894fb3b976af85467b91a5da497aafe1c --- /dev/null +++ b/bin/docker-test.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Helper script to run tests inside the Docker container + +echo "Running tests in the 'app' container..." +docker compose exec app pytest "$@" diff --git a/bin/download_models.py b/bin/download_models.py new file mode 100644 index 0000000000000000000000000000000000000000..8eae1c5ec8f64387ec94787ff6f50ca78b46d187 --- /dev/null +++ b/bin/download_models.py @@ -0,0 +1,58 @@ +import os +import sys +from huggingface_hub import snapshot_download +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +MODELS = [ + "google/medsiglip-448" +] + +YOLO_MODELS = [ + "yolov8n.pt" +] + +def check_and_download(): + token = os.environ.get("HF_TOKEN") + if not token: + print("Warning: HF_TOKEN not found in environment. Gated models like MedSigLIP may fail to download.") + + success = True + for model_id in MODELS: + print(f"\n--- Checking {model_id} ---") + try: + # snackshot_download checks if files are already present and only downloads missing pieces + path = snapshot_download( + repo_id=model_id, + token=token, + local_files_only=False # Set to True if we only wanted to check, but user wants to download too + ) + print(f"Model {model_id} is ready at: {path}") + except Exception as e: + print(f"Error handling {model_id}: {e}") + success = False + + # Download YOLO models + try: + from ultralytics import YOLO + for yolo_model in YOLO_MODELS: + print(f"\n--- Checking YOLO {yolo_model} ---") + try: + YOLO(yolo_model) + print(f"YOLO Model {yolo_model} is ready.") + except Exception as e: + print(f"Error handling YOLO {yolo_model}: {e}") + success = False + except ImportError: + print("\nWarning: ultralytics not installed. Skipping YOLO model download.") + + if success: + print("\nAll models are downloaded and verified.") + else: + print("\nSome models failed to download. Please check your HF_TOKEN and internet connection.") + sys.exit(1) + +if __name__ == "__main__": + check_and_download() diff --git a/bin/generate-api.sh b/bin/generate-api.sh new file mode 100755 index 0000000000000000000000000000000000000000..519246f3c152ea6cd78e8f7594eb8661c1392d6b --- /dev/null +++ b/bin/generate-api.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Generating Python models..." + +# Find datamodel-codegen in path or venv +if command -v datamodel-codegen >/dev/null 2>&1; then + CODEGEN_BIN="datamodel-codegen" +elif [ -f "./venv/bin/datamodel-codegen" ]; then + CODEGEN_BIN="./venv/bin/datamodel-codegen" +else + echo "datamodel-codegen not found. Attempting to install..." + pip install datamodel-code-generator || ./venv/bin/pip install datamodel-code-generator + CODEGEN_BIN="datamodel-codegen" + if ! command -v "$CODEGEN_BIN" >/dev/null 2>&1 && [ -f "./venv/bin/datamodel-codegen" ]; then + CODEGEN_BIN="./venv/bin/datamodel-codegen" + fi +fi + +if ! "$CODEGEN_BIN" --input openapi.yaml --output app/models.py; then + echo "Error: Python model generation failed. Check openapi.yaml for syntax errors." + exit 1 +fi + +echo "API Generation for Python Complete!" diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3cabdea3be4968e5e5ade5a0506a272de6369162 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,11 @@ +steps: +- name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '--build-arg' + - 'HF_TOKEN=$_HF_TOKEN' + - '-t' + - 'europe-west1-docker.pkg.dev/$PROJECT_ID/dermatolog-scan/medgemma-app' + - '.' +images: +- 'europe-west1-docker.pkg.dev/$PROJECT_ID/dermatolog-scan/medgemma-app' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..c4e0cba123ab481f3ab350e6c8961603aec44e03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + dermatolog-ai-scan: + build: + context: . + dockerfile: Dockerfile + command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + volumes: + - .:/app + ports: + - "8000:8000" + environment: + + - PROJECT_ID=${PROJECT_ID} + - LOCATION=${LOCATION} diff --git a/docker-test.sh b/docker-test.sh new file mode 100755 index 0000000000000000000000000000000000000000..dcdf9b3894fb3b976af85467b91a5da497aafe1c --- /dev/null +++ b/docker-test.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Helper script to run tests inside the Docker container + +echo "Running tests in the 'app' container..." +docker compose exec app pytest "$@" diff --git a/generate-api.sh b/generate-api.sh new file mode 100644 index 0000000000000000000000000000000000000000..519246f3c152ea6cd78e8f7594eb8661c1392d6b --- /dev/null +++ b/generate-api.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Generating Python models..." + +# Find datamodel-codegen in path or venv +if command -v datamodel-codegen >/dev/null 2>&1; then + CODEGEN_BIN="datamodel-codegen" +elif [ -f "./venv/bin/datamodel-codegen" ]; then + CODEGEN_BIN="./venv/bin/datamodel-codegen" +else + echo "datamodel-codegen not found. Attempting to install..." + pip install datamodel-code-generator || ./venv/bin/pip install datamodel-code-generator + CODEGEN_BIN="datamodel-codegen" + if ! command -v "$CODEGEN_BIN" >/dev/null 2>&1 && [ -f "./venv/bin/datamodel-codegen" ]; then + CODEGEN_BIN="./venv/bin/datamodel-codegen" + fi +fi + +if ! "$CODEGEN_BIN" --input openapi.yaml --output app/models.py; then + echo "Error: Python model generation failed. Check openapi.yaml for syntax errors." + exit 1 +fi + +echo "API Generation for Python Complete!" diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000000000000000000000000000000000000..029835bf2cf35898a8fa334139c853f93ce60481 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.3 +info: + title: Dermatolog AI Scan + description: AI application for dermatology analysis + version: 1.0.0 +paths: + /api/health: + get: + summary: Health check endpoint + operationId: health_check + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/HealthCheckResponse' +components: + schemas: + HealthCheckResponse: + properties: + status: + type: string + title: Status + database: + type: string + title: Database + gcp_project: + type: string + title: Gcp Project + type: object + required: + - status + - database + title: HealthCheckResponse diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..b28f1b0c432d9684fe8b658f6b38367cd1cfa77d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4161 @@ +{ + "name": "dermatolog-ai-scan-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dermatolog-ai-scan-frontend", + "version": "1.0.0", + "devDependencies": { + "@jest/globals": "^29.7.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@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, + "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==", + "dev": true + }, + "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, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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 + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "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, + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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 + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.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, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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 + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "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, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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, + "engines": { + "node": ">=4.0" + } + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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 + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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 + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "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, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "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 + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "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, + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "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, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-changed-files/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest/node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "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 + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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 + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/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, + "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", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "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, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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 + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "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, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/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, + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "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 + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6acf33ff89c44a2aaf7c60d056bdac99f6f1cdda --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "dermatolog-ai-scan-frontend", + "version": "1.0.0", + "engines": { + "node": ">=16.0.0" + }, + "description": "Frontend JavaScript modules for Dermatolog AI Scan", + "type": "module", + "scripts": { + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "generate-api": "bash bin/generate-api.sh" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0" + }, + "jest": { + "testEnvironment": "jsdom", + "transform": {}, + "testMatch": [ + "**/tests/javascript/**/*.test.js" + ], + "collectCoverageFrom": [ + "app/static/js/modules/**/*.js" + ], + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } + } +} \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..644d6b22c6a41dcd682cac99c6f3b48f18bba5a5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +# Playwright configuration +# Browser tests run in headless mode by default +# Use --headed flag to run with visible browser when pytest-playwright is installed + +# Asyncio configuration +# Using strict mode to avoid loop interference with non-async tests (like Playwright) +asyncio_mode = strict +asyncio_default_fixture_loop_scope = function +asyncio_default_test_loop_scope = function + +# Disable anyio to avoid duplicate runner conflicts +addopts = -p no:anyio + +# Markers +markers = + browser: Browser integration tests using Playwright diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..0b82641d7a50f4fdb1d4788107fd06c6a7a61cdd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt +pytest +pytest-asyncio +playwright +httpx +pytest-playwright +ruff +black diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..79cfca04ad47ed462ee6d10c8487aa742ef32ce9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi +uvicorn +pillow +numpy +jinja2 +python-multipart + +transformers +torch +python-dotenv +sentencepiece +ultralytics +opencv-python +protobuf + diff --git a/restart_server.sh b/restart_server.sh new file mode 100755 index 0000000000000000000000000000000000000000..6f0133b3fce3934e4103758d86c84702d46ec07e --- /dev/null +++ b/restart_server.sh @@ -0,0 +1,22 @@ +#!/bin/bash +echo "Stopping any existing uvicorn processes..." +pkill -f uvicorn || true + +echo "Starting application server..." +# Using nohup and python -m uvicorn to ensure correct python path and persistence +nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > server.log 2>&1 & + +echo "Waiting for server to be ready..." +# Simple loop to check if port 8000 is open (using curl or netcat logic via python) +for i in {1..30}; do + if curl -s http://localhost:8000/api/health >/dev/null; then + echo "Server is UP!" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 +done + +echo "Server failed to start. Check server.log:" +tail -n 20 server.log +exit 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..3269f6573f5ebda50fc17eebb86b288100d1954c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +import os +import sys +import time +import subprocess +import requests +import socket +from contextlib import closing + +# Ensure project root is in path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, PROJECT_ROOT) + +import pytest +from fastapi.testclient import TestClient +from app.main import app + +def find_free_port(): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + +@pytest.fixture +def client(): + """ + Test client for the FastAPI app. + """ + return TestClient(app) + +@pytest.fixture(scope="session") +def test_server(): + """ + Starts a uvicorn server in a subprocess for E2E tests. + Yields the base URL (e.g., http://127.0.0.1:8001). + """ + port = find_free_port() + host = "127.0.0.1" + base_url = f"http://{host}:{port}" + env = os.environ.copy() + env["PYTHONPATH"] = PROJECT_ROOT # Ensure standard imports work + + log_path = f"/tmp/test_server_{port}.log" + log_file = open(log_path, "w") + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", "--host", host, "--port", str(port)], + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + cwd=PROJECT_ROOT # Start from project root + ) + + # Health check loop + start_time = time.time() + while time.time() - start_time < 10: + try: + resp = requests.get(f"{base_url}/api/health") + if resp.status_code == 200: + break + except requests.ConnectionError: + time.sleep(0.1) + else: + # Timeout + print(f"Server failed to start. Logs in {log_path}") + proc.kill() + log_file.close() + raise RuntimeError("Test server failed to start") + + yield base_url + + # Teardown + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + log_file.close() + + # Read and print logs if failed (or always for debugging now) + try: + with open(log_path, "r") as f: + print(f"\n--- TEST SERVER LOGS ({port}) ---\n") + print(f.read()) + print(f"\n--- END LOGS ---\n") + except: + pass + + # Clean up log file + + + # Clean up log file + if os.path.exists(log_path): + try: + os.remove(log_path) + except: + pass diff --git a/tests/javascript/drag_drop_logic.test.js b/tests/javascript/drag_drop_logic.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e392f3ce0fa7555c3a97e5a17f70af17d23b6faf --- /dev/null +++ b/tests/javascript/drag_drop_logic.test.js @@ -0,0 +1,140 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; + +// Polyfill crypto for JSDOM/Node environment +if (!global.crypto) { + global.crypto = { + randomUUID: () => 'test-uuid-123' + }; +} else if (!global.crypto.randomUUID) { + global.crypto.randomUUID = () => 'test-uuid-123'; +} + +// Manually load app.js +const appJsPath = path.resolve('app/static/app.js'); +const appJsContent = fs.readFileSync(appJsPath, 'utf8'); + +// Execute in global scope (JSDOM provides window, document etc.) +(0, eval)(appJsContent); +const { dermatologApp } = global.window; + +describe('Drag and Drop Logic', () => { + let app; + + beforeEach(() => { + // Reset mocks on globals + global.fetch = jest.fn(); + + // Mock properties on existing window/document if needed + global.document.cookie = ''; + const originalCreateElement = global.document.createElement; + global.document.createElement = jest.fn((tag) => { + if (tag === 'sl-alert') { + return { + toast: jest.fn(), + append: jest.fn() + }; + } + return originalCreateElement.call(global.document, tag); + }); + global.document.body.append = jest.fn(); + + // Mock customElements if missing + if (!global.customElements) { + global.customElements = { + whenDefined: jest.fn().mockResolvedValue() + }; + } + + // Initialize app instance + app = dermatologApp(); + }); + + test('handleDrop processes files and resets dragover', async () => { + // Mock handleFiles to isolate handleDrop logic + app.handleFiles = jest.fn(); + + const mockFile = new File([''], 'test.png', { type: 'image/png' }); + const mockEvent = { + dataTransfer: { + files: [mockFile] + } + }; + + app.dragover = true; + await app.handleDrop(mockEvent); + + expect(app.dragover).toBe(false); + expect(app.handleFiles).toHaveBeenCalledWith(mockEvent.dataTransfer.files); + }); + + test('handleDrop ignores empty file list', async () => { + app.handleFiles = jest.fn(); + const mockEvent = { + dataTransfer: { + files: [] + } + }; + + app.dragover = true; + await app.handleDrop(mockEvent); + + expect(app.dragover).toBe(false); + expect(app.handleFiles).not.toHaveBeenCalled(); + }); + + test('handleFiles processes files locally and schedules analysis', async () => { + jest.useFakeTimers(); + const mockFile = new File(['content'], 'test.png', { + type: 'image/png', + lastModified: Date.now() + }); + const mockFiles = [mockFile]; + + // Mock methods + app.addPhotoToTimeline = jest.fn(); + app.analyzeAllPhotos = jest.fn(); + // Mock readAsDataURL to return a dummy string + app.readAsDataURL = jest.fn().mockResolvedValue('data:image/png;base64,test'); + // Mock getAllPhotos to avoid empty check failure + app.getAllPhotos = jest.fn().mockReturnValue([]); + + await app.handleFiles(mockFiles); + + // Should NOT call fetch anymore for upload + expect(global.fetch).not.toHaveBeenCalled(); + + // Should add to timeline + expect(app.addPhotoToTimeline).toHaveBeenCalledWith(expect.objectContaining({ + id: 'test-uuid-123', + filename: 'test.png' + })); + + // Assert analysis NOT called yet (waiting for timeout) + expect(app.analyzeAllPhotos).not.toHaveBeenCalled(); + + // Fast-forward time + jest.advanceTimersByTime(300); + + // Now assert analysis called + expect(app.analyzeAllPhotos).toHaveBeenCalled(); + expect(app.loading).toBe(false); + + jest.useRealTimers(); + }); + + test('handleFiles handles local processing failure', async () => { + const mockFile = new File([''], 'test.png', { type: 'image/png' }); + + // Mock failure in readAsDataURL + app.readAsDataURL = jest.fn().mockRejectedValue(new Error("Local read failed")); + app.analyzeAllPhotos = jest.fn(); + + await app.handleFiles([mockFile]); + + expect(app.error).toBe("Failed to process images: Local read failed"); + expect(app.loading).toBe(false); + expect(app.analyzeAllPhotos).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/test_e2e_local.py b/tests/test_e2e_local.py new file mode 100644 index 0000000000000000000000000000000000000000..80db88121a14b805ad56645ce9b4c63c7368c525 --- /dev/null +++ b/tests/test_e2e_local.py @@ -0,0 +1,125 @@ +import os +import time +import pytest +from PIL import Image +import base64 + +@pytest.fixture(scope="function") +def dummy_image(): + """Creates a temporary dummy image for testing and cleans it up after.""" + filename = "test_e2e_local.jpg" + img = Image.new('RGB', (100, 100), color='blue') + img.save(filename) + yield filename + if os.path.exists(filename): + os.remove(filename) + +def test_local_upload_and_analysis_flow(page, dummy_image, test_server): + """ + Test the new local-only workflow: + 1. Image is processed into DataURL locally. + 2. No /upload request is sent. + 3. Image appears in timeline. + 4. /analyze request is sent with base64 data. + 5. Results are displayed in the UI. + """ + page.on("console", lambda msg: print(f"Browser Console: [{msg.type}] {msg.text}")) + page.goto(test_server) + + # 1. Clear any existing state + clear_btn = page.locator("sl-button", has_text="Clear All").first + if clear_btn.count() > 0: + try: + clear_btn.wait_for(state="visible", timeout=2000) + page.once("dialog", lambda dialog: dialog.accept()) + clear_btn.click() + page.locator("text=No photos yet").wait_for(state="visible", timeout=5000) + except: + print("Clear All button not visible or timed out, continuing...") + + # 2. Trigger "Upload" (Local load) + # We expect TO NOT see an /upload network call + print("Selecting local file...") + + # Listen for /analyze calls + # Note: analyzeAllPhotos is called after 300ms timeout in app.js + with page.expect_response("**/api/photos/*/analyze", timeout=300000) as response_info: + page.set_input_files("input[type='file']", dummy_image) + + # Verify image appears in DOM with a data: URI (within timeline) + print("Waiting for image to appear in timeline...") + img_locator = page.locator(".timeline-container img[src^='data:image/']") + img_locator.first.wait_for(state="visible", timeout=60000) + print("Local image confirmed in timeline.") + print("Waiting for analyze response (inference can be slow)...") + + # 3. Check Analysis Response + response = response_info.value + print(f"Response received. Status: {response.status}") + assert response.ok + data = response.json() + assert "predictions" in data + assert len(data["predictions"]) > 0 + print(f"Analysis successful: {data['predictions'][0]['label']}") + + # 4. Verify results display in UI + # The 'Clinical Assessment' text should appear + print("Waiting for Clinical Assessment UI element...") + page.locator("text=Clinical Assessment").first.wait_for(state="visible", timeout=300000) + + # Verify the label is visible + label_text = data["predictions"][0]["label"] + page.locator(f"text={label_text}").first.wait_for(state="visible", timeout=30000) + + # Verify Saliency Map toggle is present + saliency_toggle = page.locator("sl-details", has_text="View Grad-CAM Saliency Map").first + saliency_toggle.wait_for(state="visible", timeout=10000) + + # 5. Trigger Lazy Saliency Load + print("Triggering lazy saliency map load...") + saliency_toggle.click() + + # Wait for the spinner to appear first (optional but helps verify lazy state) + spinner = page.locator("sl-details[summary*='Saliency'] sl-spinner").first + try: + spinner.wait_for(state="visible", timeout=10000) + print("Spinner visible - computing saliency...") + except: + print("Spinner not seen, maybe it was too fast or already loaded.") + + # Wait for the image to load inside sl-details + # Saliency generation on CPU can be very slow (forward + backward pass) + saliency_img = page.locator("sl-details[summary*='Saliency'] img[src^='data:image/']").first + saliency_img.wait_for(state="visible", timeout=300000) + print("Lazy saliency map confirmed in UI.") + + print("E2E Local Workflow Test Passed.") + +def test_local_duplicate_handling(page, dummy_image, test_server): + """Verifies that selecting the same file twice doesn't create duplicate timeline items.""" + page.goto(test_server) + + # 1. Clear state + clear_btn = page.locator("sl-button", has_text="Clear All").first + if clear_btn.is_visible(): + page.once("dialog", lambda dialog: dialog.accept()) + clear_btn.click() + page.locator("text=No photos yet").wait_for(state="visible", timeout=5000) + + # 2. Load first time + page.set_input_files("input[type='file']", dummy_image) + page.locator(".timeline-container img[src^='data:image/']").first.wait_for(state="visible", timeout=5000) + count1 = page.locator(".timeline-container img[src^='data:image/']").count() + assert count1 == 1 + + # 3. Load same file again + # Clear input first to trigger change event + page.set_input_files("input[type='file']", []) + page.set_input_files("input[type='file']", dummy_image) + + # Wait a bit + page.wait_for_timeout(1000) + + count2 = page.locator(".timeline-container img[src^='data:image/']").count() + assert count2 == 1, "Duplicate item should not be added to timeline" + print("Local duplicate detection confirmed.") diff --git a/tests/test_inference_trigger.py b/tests/test_inference_trigger.py new file mode 100644 index 0000000000000000000000000000000000000000..3117ed7420a48d455bb7d0648f046e4c6bc33f5f --- /dev/null +++ b/tests/test_inference_trigger.py @@ -0,0 +1,49 @@ + +import os +import pytest +from PIL import Image + +@pytest.fixture(scope="function") +def dummy_image(): + """Creates a temporary dummy image for testing.""" + filename = "test_trigger.jpg" + img = Image.new('RGB', (100, 100), color='blue') + img.save(filename) + yield filename + if os.path.exists(filename): + try: + os.remove(filename) + except: + pass + +def test_inference_starts_on_upload(page, dummy_image, test_server): + """ + Verifies that after selecting a file, the /analyze endpoint is called. + """ + page.goto(test_server) + + # Clear session to ensure we are fresh + clear_btn = page.locator("sl-button", has_text="Clear All").first + if clear_btn.is_visible(): + page.once("dialog", lambda dialog: dialog.accept()) + clear_btn.click() + page.locator("text=No photos yet").wait_for(state="visible", timeout=5000) + + # We expect a POST to /api/photos/*/analyze + # The app.js calls it after a 300ms timeout + print("Uploading file and waiting for analyze request...") + with page.expect_response("**/api/photos/*/analyze", timeout=60000) as response_info: + page.set_input_files("input[type='file']", dummy_image) + + response = response_info.value + print(f"Intercepted analyze request: {response.url}") + assert response.request.method == "POST", "Expected a POST request for analysis" + assert response.ok, f"Analyze request failed with status {response.status}" + + # Also verify UI shows it's analyzing + photo_item = page.locator("[data-analyzed='false']").first + # It might be very fast, but usually it stays 'Pending' or shows a spinner + # If it's already done, it should have [data-analyzed='true'] + + page.wait_for_selector("[data-analyzed='true']", timeout=300000) + print("Inference completed successfully according to DOM state.") diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..81a478a18923bc1a452ce1e056ee33f5e6c0ab94 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,81 @@ +import os +import time +import pytest +from PIL import Image + +@pytest.fixture(scope="function") +def dummy_image(): + """Creates a temporary dummy image for testing and cleans it up after.""" + filename = "test_e2e_img.jpg" + img = Image.new('RGB', (100, 100), color='green') + img.save(filename) + yield filename + if os.path.exists(filename): + os.remove(filename) + +def test_duplicate_upload_shows_warning_context(page, dummy_image, test_server): + """ + Test flow: + 1. Clear session to start fresh. + 2. Upload an image. + 3. Upload the SAME image again. + 4. Verify 'Upload Notice' appears. + 5. Verify NO error alerts appear. + """ + # 1. Clear Session (Navigate and click Clear if exists) + page.on("console", lambda msg: print(f"Browser Console: {msg.text}")) + page.goto(test_server) + + # Check if we need to clear previous state + # We use a locator for the button + clear_btn = page.locator("sl-button", has_text="Clear All").first + + # Wait briefly to see if it appears (it depends on timeline length) + try: + clear_btn.wait_for(state="visible", timeout=2000) + page.once("dialog", lambda dialog: dialog.accept()) + clear_btn.click() + # Wait for timeline to empty + page.locator("text=No photos yet").wait_for(state="visible", timeout=5000) + page.reload() + except: + pass # Button not found, session likely empty + + # 2. Upload First Image + print("Uploading first image...") + page.set_input_files("input[type='file']", dummy_image) + + # Wait for image to appear in DOM with data: URI + try: + page.locator("img[src^='data:image/']").first.wait_for(state="visible", timeout=30000) + except Exception as e: + print(f"Timeline items found: {page.locator('.timeline-item').count()}") + print(f"Page content dump: {page.content()}") + raise e + + # 3. Upload Duplicate Image + # We need to trigger the change event again. + # To ensure the 'change' event fires even if we select the exact same file path, + # we first clear the input. + page.set_input_files("input[type='file']", []) + + # Now set the same file again + page.set_input_files("input[type='file']", dummy_image) + + # 4. Verify Warning Toast + # We look for an alert with variant='warning' + warning_alert = page.locator("sl-alert[variant='warning']") + warning_alert.wait_for(state="visible", timeout=20000) + + content = warning_alert.text_content() + assert "Upload Notice" in content + assert "Skipped" in content + + # 5. Verify NO Error Toast + # variant='danger' is used for errors + error_alert = page.locator("sl-alert[variant='danger']") + + # Wait a bit to ensure no error pops up + page.wait_for_timeout(1000) + + assert not error_alert.is_visible(), f"Found unexpected error alert: {error_alert.text_content() if error_alert.is_visible() else ''}" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..b9d610f7fa90ac7045a856340ea85bd7344ee264 --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,8 @@ +def test_health_check(client): + response = client.get("/api/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "OK" + assert "yolo_available" in data + + diff --git a/tests/unit/test_cleanup.py b/tests/unit/test_cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..b552be542175243c7b9ed56555a04610652ed237 --- /dev/null +++ b/tests/unit/test_cleanup.py @@ -0,0 +1,6 @@ +import pytest + +pytest.skip("Cleanup functionality removed as images are no longer stored on server.", allow_module_level=True) + +import os +# ... rest of the file content commented out or ignored by skip above diff --git a/tests/unit/test_image_preprocess_service.py b/tests/unit/test_image_preprocess_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1ec3db5d59928c03982e74639c2ce3a6cd63fb9a --- /dev/null +++ b/tests/unit/test_image_preprocess_service.py @@ -0,0 +1,173 @@ +import unittest +from unittest.mock import MagicMock, patch +import os +import io +from PIL import Image +import numpy as np +from app.services.image_preprocess_service import image_preprocess_service, PreprocessStrategy + +class TestImagePreprocessService(unittest.TestCase): + def setUp(self): + self.image_path = "tests/data/Melanoma1280x891.jpg" + self.small_image_path = "tests/data/Melanoma400x278.jpg" + self.melanoma_path = "tests/data/melanoma.jpg" + self.mole_path = "tests/data/mole.jpg" + + # Verify all files exist + for p in [self.image_path, self.small_image_path, self.melanoma_path, self.mole_path]: + if not os.path.exists(p): + print(f"DEBUG: Missing test file {p}") + # We skip instead of failing to avoid breaking CI if files are partially missing + # though they should be in the repo. + self.skipTest(f"Missing required test data: {p}") + + image_preprocess_service.recommend_prep_strategy.cache_clear() + + def test_melanoma_crop_vs_pad_logic(self): + """ + Test the logic that decides between cropping and padding based on detection. + Using Melanoma1280Γ—891.jpg (Landscape: 1280x891). + Center crop window would be [194.5, 0, 1085.5, 891]. + """ + with open(self.image_path, "rb") as f: + content = f.read() + + # Verify image dimensions first + img = Image.open(io.BytesIO(content)) + self.assertEqual(img.size, (1280, 891)) + + # Case 1: Lesion is centered -> Strategy: CROP + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(500, 300, 700, 500)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.CROP) + self.assertIn("fully contained", res["reason"]) + + # Case 2: Lesion is at the far left edge (x=50) -> Strategy: PAD + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 300, 200, 500)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + self.assertIn("extends beyond", res["reason"]) + + # Case 3: Lesion is at the far right edge (x=1200) -> Strategy: PAD + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(1100, 300, 1250, 500)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + self.assertIn("extends beyond", res["reason"]) + + def test_melanoma_real_image_strategy(self): + """ + Test that the real Melanoma1280Γ—891.jpg results in PAD strategy. + This image has the melanoma near the edge, so cropping would cut it. + We mock the detection bbox to represent this edge-positioning. + """ + with open(self.image_path, "rb") as f: + content = f.read() + + # Mocking the detection result for this specific file: + # For a 1280 wide image, center crop starts at 194.5. + # We mock a lesion at the far left edge (x=50) to verify PAD logic. + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 400, 250, 600)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + self.assertIn("extends beyond", res["reason"]) + + def test_mole_crop_vs_pad_strategy(self): + """ + Test logic for mole.jpg (670x442). + Center crop x-range is [114, 556]. + """ + path = "tests/data/mole.jpg" + if not os.path.exists(path): + self.skipTest("mole.jpg not found") + + with open(path, "rb") as f: + content = f.read() + + # Case 1: Centered mole -> CROP + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(200, 100, 400, 300)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.CROP) + + # Case 2: Mole at left edge (x=50) -> PAD (Cutoff is at x=114) + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 100, 150, 300)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + + def test_prepare_image_basic(self): + """Test basic crop/resize via prepare_image.""" + # Force a crop strategy by mocking get_lesion_bbox to return centered result + # Use existing melanoma.jpg (224x224) + with Image.open(self.melanoma_path) as img: + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(90, 90, 130, 130)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + prepared = image_preprocess_service.prepare_image(img, (50, 50)) + self.assertEqual(prepared.size, (50, 50)) + + def test_prepare_image_pad(self): + """Test padding via prepare_image.""" + # Force a pad strategy by using a LARGE image that exceeds 448x448 + # Use existing Melanoma1280x891.jpg + with Image.open(self.image_path) as img: + # Mock lesion at the very left (x=50) so it's outside center crop + with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 200, 150, 300)): + image_preprocess_service.recommend_prep_strategy.cache_clear() + prepared = image_preprocess_service.prepare_image(img, (50, 50)) + self.assertEqual(prepared.size, (50, 50)) + # Resize to 50x50 -> should have black bars if PAD was used + pixels = list(prepared.getdata()) + top_pixel = pixels[0] + self.assertEqual(top_pixel, (0, 0, 0)) # Should be black padding + + def test_small_image_bypass_logic(self): + """Test that images <= 448x448 return PAD strategy to avoid cropping/scaling down.""" + # Use Melanoma400x278.jpg as the small rectangular image + with open(self.small_image_path, "rb") as f: + content = f.read() + + image_preprocess_service.recommend_prep_strategy.cache_clear() + res = image_preprocess_service.recommend_prep_strategy(content) + + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + self.assertIn("padding to square to avoid any data loss", res["reason"]) + + def test_melanoma_small_padding_real_flow(self): + """ + Test that Melanoma400x278.jpg is padded to 400x400. + It should not be scaled down (kept at 400 max dim). + """ + with open(self.small_image_path, "rb") as f: + content = f.read() + + # 1. Check recommendation + res = image_preprocess_service.recommend_prep_strategy(content) + self.assertEqual(res["strategy"], PreprocessStrategy.PAD) + + # 2. Check preparation result + # We specify target_size=(400, 400) to verify it stays at that size + img = Image.open(io.BytesIO(content)) + prepared = image_preprocess_service.prepare_image(img, target_size=(400, 400)) + + self.assertEqual(prepared.size, (400, 400)) + + # Verify symmetric padding (top and bottom should be black) + # 400x278 -> 400x400 square. Padding = (400-278)/2 = 61 pixels top and bottom + pixels = list(prepared.getdata()) + + # Top-left pixel should be black padding (0,0,0) + self.assertEqual(pixels[0], (0, 0, 0)) + # Top-middle pixel should be black padding + self.assertEqual(pixels[200], (0, 0, 0)) + + # Center pixel (200, 200) should be the original image content (not black) + center_pixel = pixels[200 * 400 + 200] + self.assertNotEqual(center_pixel, (0, 0, 0)) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/test_medsiglip_inference.py b/tests/unit/test_medsiglip_inference.py new file mode 100644 index 0000000000000000000000000000000000000000..99484be28dc7f92c63b81e532ee811318795294e --- /dev/null +++ b/tests/unit/test_medsiglip_inference.py @@ -0,0 +1,65 @@ + +import pytest +import os +from dotenv import load_dotenv + +load_dotenv() + +from app.services.medsiglip_service import medsiglip_service +from PIL import Image +import io + +# We need to ensure we can run this test even if we are not in a full app context, +# but the service relies on having torch/transformers installed. + +# We need to ensure we can run this test even if we are not in a full app context, +# but the service relies on having torch/transformers installed. + +def test_medsiglip_inference_on_melanoma(): + """ + Test MedSigLIP inference on a melanoma image. + This test verifies that the model loads and runs prediction. + """ + image_path = "tests/data/melanoma.jpg" + + # Ensure image exists (created by create_test_image.py) + if not os.path.exists(image_path): + pytest.skip("Melanoma test image not found. Run create_test_image.py first.") + + with open(image_path, "rb") as f: + image_bytes = f.read() + + # Define candidate labels + labels = ["Melanoma", "Nevus", "Healthy Skin"] + + # Use MedSigLIP model for testing. + from app.services.medsiglip_service import MedSigLIPService + test_service = MedSigLIPService(model_name="google/medsiglip-448") + + # Run inference + # Ensure no fallback occurred + assert test_service.model_name == "google/medsiglip-448", "Test failing: Falling back to public model. Check HF_TOKEN." + + # Note: The first run might take time to download the model + try: + results = test_service.get_embeddings(image_bytes, texts=labels) + + print("\nInference Results:") + for res in results: + print(f" {res['label']}: {res['score']:.4f}") + + # Basic assertions + assert isinstance(results, list) + assert len(results) == len(labels) + assert "label" in results[0] + assert "score" in results[0] + + # Verify scores sum to roughly 1 (softmax) + total_score = sum(r["score"] for r in results) + assert abs(total_score - 1.0) < 0.05 + + # Since we use a dummy image, we can't assert it's classified as Melanoma effectively, + # but we can assert the *structure* of the response is correct. + + except Exception as e: + pytest.fail(f"Inference failed with error: {e}") diff --git a/tests/unit/test_medsiglip_wrapper.py b/tests/unit/test_medsiglip_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..c9155caadfdf488bd5288ae6b3d565c3083091ce --- /dev/null +++ b/tests/unit/test_medsiglip_wrapper.py @@ -0,0 +1,65 @@ +import pytest +import os +from dotenv import load_dotenv + +load_dotenv() + +from app.services.medsiglip_modality_wrapper import ClinicalModalityWrapper +from app.services.medsiglip_service import MedSigLIPService + +def test_medsiglip_wrapper_templating(): + """ + Test that the wrapper correctly templates and maps labels. + """ + image_path = "tests/data/melanoma.jpg" + if not os.path.exists(image_path): + pytest.skip("Melanoma test image not found.") + + with open(image_path, "rb") as f: + image_bytes = f.read() + + # Use a real service if possible, or mock it. + # For now, we'll try to use the real one if HF_TOKEN is present. + if not os.environ.get("HF_TOKEN"): + pytest.skip("HF_TOKEN missing, cannot run MedSigLIP tests.") + + service = MedSigLIPService() + wrapper = ClinicalModalityWrapper(service=service, modality="macroscopic") + + # Test with custom labels + custom_labels = ["Melanoma", "Normal Skin"] + results = wrapper.analyze_image(image_bytes, custom_labels=custom_labels) + + assert len(results) == len(custom_labels) + # Check that labels are mapped back to short names + labels_received = [r["label"] for r in results] + assert "Melanoma" in labels_received + assert "Normal Skin" in labels_received + + # Check that descriptions (prompts) were used + assert "A patient-submitted smartphone photograph showing" in results[0]["description"] + +def test_medsiglip_wrapper_default_labels(): + """ + Test that the wrapper works with default clinical labels. + """ + image_path = "tests/data/melanoma.jpg" + if not os.path.exists(image_path): + pytest.skip("Melanoma test image not found.") + + with open(image_path, "rb") as f: + image_bytes = f.read() + + if not os.environ.get("HF_TOKEN"): + pytest.skip("HF_TOKEN missing, cannot run MedSigLIP tests.") + + service = MedSigLIPService() + wrapper = ClinicalModalityWrapper(service=service, modality="dermoscopy") + + # Run subset for speed if we were mocking, but here we run full inference + # Just check return structure + results = wrapper.analyze_image(image_bytes) + + assert len(results) == 12 # Matches MEDSIGLIP_DERMATOLOGY_NARROW_LABELS + assert "Dermoscopy image revealing" in results[0]["description"] + assert "score" in results[0] diff --git a/tests/unit/test_result_interpreter.py b/tests/unit/test_result_interpreter.py new file mode 100644 index 0000000000000000000000000000000000000000..e9d03d454550f3628ee081f5ef0ef8aec13addbd --- /dev/null +++ b/tests/unit/test_result_interpreter.py @@ -0,0 +1,122 @@ +import pytest +import math +from app.services.result_interpreter import result_interpreter + +def test_interpret_high_risk_detected(): + # Case where Melanoma and BCC are the top 2 + # Margin: (0.45 + 0.40) - 0.15 = 0.70 -> Confidence Class: Confident (> 40%) + results = [ + {"label": "Melanoma", "score": 0.45}, + {"label": "Basal Cell Carcinoma", "score": 0.40}, + {"label": "Normal Skin", "score": 0.15} + ] + interpretation = result_interpreter.interpret(results) + assert interpretation["is_high_risk"] is True + assert interpretation["annotation"] == "High likeness of tumor disease" + assert interpretation["color_hint"] == "red" + # New rule: Margin is 0.70, so it's Confident + assert interpretation["confidence_label"] == "Confident" + assert interpretation["margin"] == 0.70 + +def test_tumor_consolidation_rule(): + # Multiple tumor classes at the top + results = [ + {"label": "Melanoma", "score": 0.30}, + {"label": "Basal Cell Carcinoma", "score": 0.25}, + {"label": "Squamous Cell Carcinoma", "score": 0.20}, + {"label": "Atopic Dermatitis", "score": 0.15}, + {"label": "Normal Skin", "score": 0.10} + ] + # Sum of tumors = 0.30 + 0.25 + 0.20 = 0.75 + # Next non-tumor = 0.15 + # Margin = 0.75 - 0.15 = 0.60 + interpretation = result_interpreter.interpret(results) + assert interpretation["margin"] == 0.60 + assert interpretation["confidence_label"] == "Confident" + assert interpretation["is_high_risk"] is True + +def test_margin_confidence_tiers(): + # 1. Confident (Margin > 40%) + # 40.1% -> Confident + res_401 = [{"label": "A", "score": 0.501}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_401)["confidence_label"] == "Confident" + + # 2. Plausible (40% >= Margin > 25%) + # 40.0% -> Plausible + res_400 = [{"label": "A", "score": 0.50}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_400)["confidence_label"] == "Plausible" + # 25.1% -> Plausible + res_251 = [{"label": "A", "score": 0.351}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_251)["confidence_label"] == "Plausible" + + # 3. Low confidence (20% >= Margin > 10%) + # 20.0% -> Low confidence + res_200 = [{"label": "A", "score": 0.30}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_200)["confidence_label"] == "Low confidence" + # 10.1% -> Low confidence + res_101 = [{"label": "A", "score": 0.201}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_101)["confidence_label"] == "Low confidence" + + # 4. Results unclear (10% >= Margin) + # 10.0% -> Results unclear + res_100 = [{"label": "A", "score": 0.20}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_100)["confidence_label"] == "Results unclear" + # 5.0% -> Results unclear + res_050 = [{"label": "A", "score": 0.15}, {"label": "B", "score": 0.10}] + assert result_interpreter.interpret(res_050)["confidence_label"] == "Results unclear" + +def test_interpret_not_clear_mixed(): + # Case: Top 1 is Tumor, Top 2 is Non-Tumor, Margin is small (0.01) + results = [ + {"label": "Melanoma", "score": 0.34}, + {"label": "Melanocytic Nevus", "score": 0.33}, + {"label": "Normal Skin", "score": 0.33} + ] + # Margin is 0.01 < margin_threshold (default 0.05) + interpretation = result_interpreter.interpret(results) + assert interpretation["annotation"] == "Not clear" + assert interpretation["color_hint"] == "yellow" + assert interpretation["status"] == "uncertain_mixed" + assert interpretation["confidence_label"] == "Results unclear" + +def test_interpret_clear_tumor(): + # Top 1 is Tumor, Top 2 is Non-Tumor, Margin is large (0.60) + results = [ + {"label": "Melanoma", "score": 0.80}, + {"label": "Melanocytic Nevus", "score": 0.20} + ] + interpretation = result_interpreter.interpret(results) + assert "Potential cancerous condition" in interpretation["annotation"] + assert interpretation["confidence_label"] == "Confident" + +def test_interpret_low_risk(): + # Both top 2 are non-tumor, Margin: 0.20 + results = [ + {"label": "Psoriasis", "score": 0.50}, + {"label": "Atopic Dermatitis", "score": 0.30}, + {"label": "Melanoma", "score": 0.20} + ] + interpretation = result_interpreter.interpret(results) + assert interpretation["is_high_risk"] is False + assert "No immediate tumor likeness detected" in interpretation["annotation"] + assert interpretation["confidence_label"] == "Low confidence" + +def test_entropy_values(): + results_uniform = [{"label": f"L{i}", "score": 0.125} for i in range(8)] + interpretation = result_interpreter.interpret(results_uniform) + assert pytest.approx(interpretation["entropy"], 0.01) == 3.0 + assert interpretation["is_reliable"] is False + assert "No immediate tumor likeness detected" in interpretation["annotation"] + assert any("Low confidence" in line for line in interpretation["computation_process"]) + assert any("3.00" in line for line in interpretation["computation_process"]) + +def test_empty_results(): + interpretation = result_interpreter.interpret([]) + assert interpretation["confidence_label"] == "Unknown" + assert interpretation["color_hint"] == "gray" + +def test_boundary_entropy(): + results = [{"label": "A", "score": 0.5}, {"label": "B", "score": 0.5}] + interpretation = result_interpreter.interpret(results) + assert interpretation["entropy"] == 1.0 + assert interpretation["is_reliable"] is True diff --git a/tests/unit/test_session_cleanup.py b/tests/unit/test_session_cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..b15feb92ed89b0ecfd2e4b86366ed102ea5435fc --- /dev/null +++ b/tests/unit/test_session_cleanup.py @@ -0,0 +1,85 @@ +import pytest +from unittest.mock import patch +from app.dal.photo_repo import photo_repo + +@patch("app.routers.photos.image_preprocess_service") +def test_session_cleanup_flow(mock_prep, client): + """ + Test the full lifecycle: + 1. Upload image + 2. Analyze image (mocked) and verify persistence + 3. Clear session + 4. Verify data and images are gone + """ + mock_prep.recommend_prep_strategy.return_value = {"strategy": "crop", "reason": "mocked"} + mock_prep.prepare_image_bytes.return_value = b"mocked-prepared-bytes" + mock_prep.prepare_image_base64.return_value = "mocked-base64" + + session_id = "test-cleanup-session-001" + client.cookies.set("session_id", session_id) + + # 1. Add Image + img_content = b"fake-image-content-for-test" + files = {"files": ("test_cleanup.jpg", img_content, "image/jpeg")} + + resp = client.post("/api/photos/upload", files=files) + assert resp.status_code == 200 + + # Verify it exists in timeline + resp = client.get("/api/photos") + assert resp.status_code == 200 + timeline = resp.json() + + # Find the photo + photos = [] + for item in timeline: + if item["type"] == "photo": photos.append(item["data"]) + elif item["type"] == "directory": photos.extend(item["items"]) + + assert len(photos) >= 1 + # Filter for our specific file in case DB is shared + my_photo = next((p for p in photos if p["filename"] == "test_cleanup.jpg"), None) + assert my_photo is not None + photo_id = my_photo["id"] + + # Verify via Repo directly + meta = photo_repo.get_photo_metadata(photo_id, session_id) + assert meta is not None + assert meta[0] == "test_cleanup.jpg" + + # 2. Mock Analysis & Verify Persistence + with patch("app.services.medsiglip_service.medsiglip_service.get_embeddings") as mock_embed: + mock_embed.return_value = [{"label": "TestCondition", "score": 0.95}] + + # Call analyze + resp = client.post(f"/api/photos/{photo_id}/analyze", json={}) + assert resp.status_code == 200 + + # Check Response + data = resp.json() + assert data["predictions"][0]["label"] == "TestCondition" + assert "analysis_date" in data + + # Check DB Persistence + cached = photo_repo.get_analysis_results(photo_id, session_id) + assert cached is not None + assert "TestCondition" in cached[0] + assert cached[1] is not None # Date exists + + # 3. Delete Session (Reset) + resp = client.delete("/api/photos") + assert resp.status_code == 200 + + # 4. Verify Cleanup + # Check Repo - Row should be gone + meta = photo_repo.get_photo_metadata(photo_id, session_id) + assert meta is None + + # Check Analysis - Should be gone + cached = photo_repo.get_analysis_results(photo_id, session_id) + assert cached is None + + # Check Timeline - Should be empty for this session + resp = client.get("/api/photos") + timeline = resp.json() + assert len(timeline) == 0 diff --git a/tests/unit/test_yolo_integration.py b/tests/unit/test_yolo_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..9d2caeb52a3f8a99283bfd1108af77a295e61cff --- /dev/null +++ b/tests/unit/test_yolo_integration.py @@ -0,0 +1,49 @@ + +import pytest +from unittest.mock import MagicMock, patch +import sys +from app.services.yolo_service import YOLOService + +def test_yolo_load_model_success(): + """Test that YOLO model loads when ultralytics is present.""" + service = YOLOService() + + mock_yolo = MagicMock() + with patch.dict(sys.modules, {'ultralytics': MagicMock()}): + from ultralytics import YOLO + with patch('ultralytics.YOLO', return_value=mock_yolo): + model = service.load_model() + assert model == mock_yolo + assert service.model == mock_yolo + +def test_yolo_load_model_missing_dependency(): + """Test that YOLO model returns None when ultralytics is missing.""" + service = YOLOService() + + with patch.dict(sys.modules, {'ultralytics': None}): + # In newer python/pytest patching sys.modules to None might behave differently or raise differently + # But our code catches ImportError specifically. + with patch('builtins.__import__', side_effect=ImportError("No module named 'ultralytics'")): + model = service.load_model() + assert model is None + assert service.model is None + +def test_preprocess_graceful_fallback(): + """Test that ImagePreprocessService handles YOLO failure gracefully.""" + from app.services.image_preprocess_service import ImagePreprocessService + from PIL import Image + import io + + service = ImagePreprocessService() + + # Create a dummy image + img = Image.new('RGB', (1000, 500), color='blue') + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format='JPEG') + image_bytes = img_byte_arr.getvalue() + + # Mock get_lesion_bbox to return None (simulating YOLO failure) + with patch.object(service, 'get_lesion_bbox', return_value=None): + strategy = service.recommend_prep_strategy(image_bytes) + assert strategy['strategy'] == "crop" + assert "Detection failed" in strategy['reason'] diff --git a/tests/yolo_tests/test_melanoma_yolo_crop.py b/tests/yolo_tests/test_melanoma_yolo_crop.py new file mode 100644 index 0000000000000000000000000000000000000000..26f2765cacfe195e4c0647e049d25da4cbc69a1c --- /dev/null +++ b/tests/yolo_tests/test_melanoma_yolo_crop.py @@ -0,0 +1,55 @@ + +import pytest +import io +import os +from PIL import Image +from app.services.image_preprocess_service import image_preprocess_service, PreprocessStrategy + +from unittest.mock import MagicMock, patch + +def test_melanoma_wiki_a_crop_strategy(): + """ + Test that melanoma_wiki_A.jpg (centered/rectangular) results in a CROP strategy. + We mock the YOLO output to simulate a centered lesion detection. + """ + image_path = "tests/data/melanoma_wiki_A.jpg" + + # Ensure image exists + assert os.path.exists(image_path), f"Test image missing: {image_path}" + + with open(image_path, "rb") as f: + image_bytes = f.read() + + img = Image.open(io.BytesIO(image_bytes)) + w, h = img.size + + # Simulate a centered lesion (roughly in the middle of the image) + # BBox format is [x1, y1, x2, y2] + mock_bbox = [w*0.3, h*0.3, w*0.7, h*0.7] + + # Create mock YOLO results object + mock_results = MagicMock() + mock_box = MagicMock() + mock_box.xyxy = [MagicMock()] + mock_box.xyxy[0].cpu().numpy.return_value = mock_bbox + mock_results.boxes = [mock_box] + + # Patch yolo_service to return our mock + with patch("app.services.image_preprocess_service.yolo_service.load_model") as mock_load: + mock_model = MagicMock() + mock_model.predict.return_value = [mock_results] + mock_load.return_value = mock_model + + # Clear cache to ensure fresh run + image_preprocess_service.recommend_prep_strategy.cache_clear() + + # Run recommendation + result = image_preprocess_service.recommend_prep_strategy(image_bytes) + + # Assert CROP strategy + assert result['strategy'] == PreprocessStrategy.CROP, \ + f"Expected CROP for centered lesion in {image_path}, but got {result['strategy']}. Reason: {result.get('reason')}" + + print(f"\nβœ… SUCCESS: Preprocessing correctly chose CROP for {image_path}") + print(f"Reason: {result.get('reason')}") + print(f"Simulated BBox: {result.get('bbox')}")