From 520be53901f793228180cf136bd1fdc0768e0bc2 Mon Sep 17 00:00:00 2001 From: Kimi Agent Date: Tue, 12 May 2026 11:45:56 +0000 Subject: [PATCH] fix: migrations, auth, providers health check, E2E tests, remove neko-sama - Add proper Alembic initial migration (0001_initial_schema.py) - Migrate refresh tokens from JSON file to SQLite (RefreshTokenTable) - Remove Neko-Sama provider entirely (redirects to Gupy, not a host) - Fix provider health check always showing UNKNOWN - Run check_all_health() on startup - Fix POST /providers/health/check background task bug - Add HTMX refresh after manual health check trigger - Fix anime search relevance scoring with MIN_RELEVANCE_THRESHOLD=0.5 - Replace bare 'except:' with 'except Exception:' across codebase - Add Playwright E2E test suite (12 tests, auth setup, helpers) - Fix toast container blocking clicks via pointer-events: none - Remove obsolete Jest/Vite test files and config - Clean up obsolete test_watchlist scripts - Update sonarr model comment for active providers --- .gitignore | 1 + CLAUDE.md | 8 +- alembic/versions/0001_initial_schema.py | 209 ++ .../e0273f326a15_initial_migration.py | 30 - ...e88271d11851_add_watchlistsettingstable.py | 30 - app/auth.py | 89 +- app/database.py | 2 +- app/downloaders/__init__.py | 1 - app/downloaders/anime_sites/__init__.py | 3 - app/downloaders/anime_sites/animesama.py | 16 +- app/downloaders/anime_sites/nekosama.py | 317 --- app/downloaders/video_players/doodstream.py | 2 +- app/downloaders/video_players/lpayer.py | 4 +- app/downloaders/video_players/oneupload.py | 2 +- app/downloaders/video_players/rapidfile.py | 2 +- app/downloaders/video_players/smoothpre.py | 2 +- app/downloaders/video_players/unfichier.py | 2 +- app/downloaders/video_players/vidmoly.py | 2 +- app/models/auth.py | 19 + app/models/settings.py | 2 +- app/models/sonarr.py | 2 +- app/providers.py | 7 - app/providers_manager.py | 19 +- app/routers/router_anime.py | 18 +- app/sonarr_handler.py | 3 +- config/refresh_tokens.json | 380 --- main.py | 8 + package-lock.json | 2189 +---------------- package.json | 4 +- playwright.config.ts | 35 +- static/js/__tests__/auth-api.test.js | 85 - static/js/__tests__/auth-utils.test.js | 80 - static/js/__tests__/smoke.test.js | 8 - templates/components/settings_section.html | 3 +- templates/components/toast_container.html | 4 + test_watchlist.py | 16 +- test_watchlist_simple.py | 4 +- tests/e2e/auth.setup.ts | 33 + tests/e2e/auth.spec.ts | 144 +- tests/e2e/downloads.spec.ts | 11 + tests/e2e/global-setup.ts | 29 + tests/e2e/helpers.ts | 81 + tests/e2e/user_journey.spec.ts | 154 +- tests/e2e/watchlist.spec.ts | 11 + tests/test_api.py | 4 +- tests/test_download_manager.py | 2 +- vite.config.js | 14 - 47 files changed, 654 insertions(+), 3437 deletions(-) create mode 100644 alembic/versions/0001_initial_schema.py delete mode 100644 alembic/versions/e0273f326a15_initial_migration.py delete mode 100644 alembic/versions/e88271d11851_add_watchlistsettingstable.py delete mode 100644 app/downloaders/anime_sites/nekosama.py delete mode 100644 config/refresh_tokens.json delete mode 100644 static/js/__tests__/auth-api.test.js delete mode 100644 static/js/__tests__/auth-utils.test.js delete mode 100644 static/js/__tests__/smoke.test.js create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/downloads.spec.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/watchlist.spec.ts delete mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore index 091fae8..99bca9a 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ test-results/ .opencode/ .mypy_cache/ .ruff_cache/ +playwright/.auth/ diff --git a/CLAUDE.md b/CLAUDE.md index de268c8..c834aae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,7 +335,7 @@ The downloaders are organized into three categories with separate base classes: - User authentication and last login tracking - **JWT Tokens** - Stateless authentication with refresh token support - Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`) - - Refresh tokens: 30-day expiration (stored in `config/refresh_tokens.json`) + - Refresh tokens: 30-day expiration (stored in SQLite `refresh_tokens` table) - HS256 algorithm with JWT_SECRET_KEY (change in production!) - Token verification and user extraction - **Password Security** @@ -348,7 +348,7 @@ The downloaders are organized into three categories with separate base classes: - **Configuration** - `JWT_SECRET_KEY` environment variable (MUST be changed from default) - Users stored in `config/users.json` - - Refresh tokens stored in `config/refresh_tokens.json` + - Refresh tokens stored in SQLite `refresh_tokens` table **Authentication Endpoints:** - `POST /api/auth/register` - User registration @@ -709,7 +709,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min **Configuration Files:** - `.env` - Environment configuration (create from .env.example) - `config/users.json` - User authentication database (created automatically) -- `config/refresh_tokens.json` - Refresh token storage (created automatically) +- `refresh_tokens` table - Refresh token storage (SQLite database) - `config/sonarr.json` - Sonarr webhook configuration (created automatically) - `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically) - `config/watchlist.json` - User watchlist items (created automatically) @@ -746,7 +746,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min - Passwords truncated to 72 bytes (bcrypt limitation) - JWT secret key validation (minimum 32 characters, default rejected) - Credentials stored in `config/users.json` -- Refresh tokens stored in `config/refresh_tokens.json` +- Refresh tokens stored in SQLite `refresh_tokens` table ## Key Implementation Details diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..00b5c47 --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -0,0 +1,209 @@ +"""Initial schema + +Revision ID: 0001_initial_schema +Revises: +Create Date: 2026-05-12 08:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0001_initial_schema' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'users', + sa.Column('username', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=True), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('last_login', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + op.create_table( + 'app_settings', + sa.Column('default_lang', sa.String(), nullable=False), + sa.Column('theme', sa.String(), nullable=False), + sa.Column('disabled_providers_json', sa.String(), nullable=False), + sa.Column('recommendations_filter', sa.String(), nullable=False), + sa.Column('releases_filter', sa.String(), nullable=False), + sa.Column('anime_enabled', sa.Boolean(), nullable=False), + sa.Column('series_enabled', sa.Boolean(), nullable=False), + sa.Column('download_dir', sa.String(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_index(op.f('ix_app_settings_id'), 'app_settings', ['id'], unique=False) + op.create_index(op.f('ix_app_settings_user_id'), 'app_settings', ['user_id'], unique=True) + + op.create_table( + 'favorites', + sa.Column('anime_id', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('url', sa.String(), nullable=False), + sa.Column('provider', sa.String(), nullable=False), + sa.Column('poster_url', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('metadata_json', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_favorites_anime_id'), 'favorites', ['anime_id'], unique=False) + op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False) + op.create_index(op.f('ix_favorites_title'), 'favorites', ['title'], unique=False) + op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'], unique=False) + + op.create_table( + 'refresh_tokens', + sa.Column('token_id', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('revoked_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_id') + ) + op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_token_id'), 'refresh_tokens', ['token_id'], unique=True) + op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False) + + op.create_table( + 'sonarr_config', + sa.Column('webhook_enabled', sa.Boolean(), nullable=False), + sa.Column('webhook_secret', sa.String(), nullable=True), + sa.Column('auto_download_enabled', sa.Boolean(), nullable=False), + sa.Column('default_language', sa.String(), nullable=False), + sa.Column('default_quality', sa.String(), nullable=True), + sa.Column('default_provider', sa.String(), nullable=False), + sa.Column('verify_hmac', sa.Boolean(), nullable=False), + sa.Column('log_webhooks', sa.Boolean(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sonarr_config_id'), 'sonarr_config', ['id'], unique=False) + + op.create_table( + 'sonarr_mappings', + sa.Column('sonarr_series_id', sa.Integer(), nullable=False), + sa.Column('sonarr_title', sa.String(), nullable=False), + sa.Column('anime_provider', sa.String(), nullable=False), + sa.Column('anime_url', sa.String(), nullable=False), + sa.Column('anime_title', sa.String(), nullable=False), + sa.Column('lang', sa.String(), nullable=False), + sa.Column('quality_preference', sa.String(), nullable=True), + sa.Column('auto_download', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('sonarr_series_id') + ) + op.create_index(op.f('ix_sonarr_mappings_id'), 'sonarr_mappings', ['id'], unique=False) + op.create_index(op.f('ix_sonarr_mappings_sonarr_series_id'), 'sonarr_mappings', ['sonarr_series_id'], unique=True) + op.create_index(op.f('ix_sonarr_mappings_user_id'), 'sonarr_mappings', ['user_id'], unique=False) + + op.create_table( + 'watchlist_items', + sa.Column('anime_title', sa.String(), nullable=False), + sa.Column('anime_url', sa.String(), nullable=False), + sa.Column('provider_id', sa.String(), nullable=False), + sa.Column('lang', sa.String(), nullable=False), + sa.Column('last_checked', sa.DateTime(), nullable=True), + sa.Column('last_episode_downloaded', sa.Integer(), nullable=False), + sa.Column('total_episodes', sa.Integer(), nullable=True), + sa.Column('auto_download', sa.Boolean(), nullable=False), + sa.Column('quality_preference', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('poster_image', sa.String(), nullable=True), + sa.Column('cover_image', sa.String(), nullable=True), + sa.Column('synopsis', sa.String(), nullable=True), + sa.Column('genres_json', sa.String(), nullable=True), + sa.Column('added_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_watchlist_items_anime_title'), 'watchlist_items', ['anime_title'], unique=False) + op.create_index(op.f('ix_watchlist_items_id'), 'watchlist_items', ['id'], unique=False) + op.create_index(op.f('ix_watchlist_items_user_id'), 'watchlist_items', ['user_id'], unique=False) + + op.create_table( + 'watchlist_settings', + sa.Column('check_interval_hours', sa.Integer(), nullable=False), + sa.Column('auto_download_enabled', sa.Boolean(), nullable=False), + sa.Column('max_concurrent_auto_downloads', sa.Integer(), nullable=False), + sa.Column('notify_on_new_episodes', sa.Boolean(), nullable=False), + sa.Column('include_completed_anime', sa.Boolean(), nullable=False), + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_watchlist_settings_id'), 'watchlist_settings', ['id'], unique=False) + op.create_index(op.f('ix_watchlist_settings_user_id'), 'watchlist_settings', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_watchlist_settings_user_id'), table_name='watchlist_settings') + op.drop_index(op.f('ix_watchlist_settings_id'), table_name='watchlist_settings') + op.drop_table('watchlist_settings') + op.drop_index(op.f('ix_watchlist_items_user_id'), table_name='watchlist_items') + op.drop_index(op.f('ix_watchlist_items_id'), table_name='watchlist_items') + op.drop_index(op.f('ix_watchlist_items_anime_title'), table_name='watchlist_items') + op.drop_table('watchlist_items') + op.drop_index(op.f('ix_sonarr_mappings_user_id'), table_name='sonarr_mappings') + op.drop_index(op.f('ix_sonarr_mappings_sonarr_series_id'), table_name='sonarr_mappings') + op.drop_index(op.f('ix_sonarr_mappings_id'), table_name='sonarr_mappings') + op.drop_table('sonarr_mappings') + op.drop_index(op.f('ix_sonarr_config_id'), table_name='sonarr_config') + op.drop_table('sonarr_config') + op.drop_index(op.f('ix_refresh_tokens_username'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_token_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites') + op.drop_index(op.f('ix_favorites_title'), table_name='favorites') + op.drop_index(op.f('ix_favorites_id'), table_name='favorites') + op.drop_index(op.f('ix_favorites_anime_id'), table_name='favorites') + op.drop_table('favorites') + op.drop_index(op.f('ix_app_settings_user_id'), table_name='app_settings') + op.drop_index(op.f('ix_app_settings_id'), table_name='app_settings') + op.drop_table('app_settings') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/alembic/versions/e0273f326a15_initial_migration.py b/alembic/versions/e0273f326a15_initial_migration.py deleted file mode 100644 index 391c491..0000000 --- a/alembic/versions/e0273f326a15_initial_migration.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Initial migration - -Revision ID: e0273f326a15 -Revises: -Create Date: 2026-03-24 17:05:50.046027 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'e0273f326a15' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic/versions/e88271d11851_add_watchlistsettingstable.py b/alembic/versions/e88271d11851_add_watchlistsettingstable.py deleted file mode 100644 index 757a3d3..0000000 --- a/alembic/versions/e88271d11851_add_watchlistsettingstable.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Add WatchlistSettingsTable - -Revision ID: e88271d11851 -Revises: e0273f326a15 -Create Date: 2026-03-24 17:07:10.189457 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'e88271d11851' -down_revision: Union[str, None] = 'e0273f326a15' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/app/auth.py b/app/auth.py index f7bc99b..cdd09ab 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,9 +1,7 @@ """User authentication and management system with SQLModel support""" -import os -import hashlib from datetime import datetime, timedelta -from typing import Optional, Dict, List +from typing import Optional from jose import jwt from passlib.context import CryptContext import logging @@ -11,7 +9,7 @@ from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials from sqlmodel import Session, select from app.database import engine -from app.models.auth import UserTable +from app.models.auth import UserTable, RefreshTokenTable from app.config import get_settings logger = logging.getLogger(__name__) @@ -189,33 +187,32 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable: raise HTTPException(status_code=401, detail="Invalid authentication credentials") -# Refresh tokens storage -REFRESH_TOKENS_FILE = "config/refresh_tokens.json" +def _get_refresh_token(token_id: str) -> Optional[RefreshTokenTable]: + """Get a refresh token from the database by token_id""" + with Session(engine) as session: + statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id) + return session.exec(statement).first() -def _load_refresh_tokens() -> Dict[str, dict]: - """Load refresh tokens from file""" - import json - - try: - if os.path.exists(REFRESH_TOKENS_FILE): - with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - logger.error(f"Error loading refresh tokens: {e}") - return {} +def _save_refresh_token(token: RefreshTokenTable): + """Save or update a refresh token in the database""" + with Session(engine) as session: + session.add(token) + session.commit() -def _save_refresh_tokens(tokens: Dict[str, dict]): - """Save refresh tokens to file""" - import json - - try: - os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True) - with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f: - json.dump(tokens, f, indent=2, ensure_ascii=False, default=str) - except Exception as e: - logger.error(f"Error saving refresh tokens: {e}") +def _revoke_refresh_token_db(token_id: str) -> bool: + """Revoke a refresh token in the database""" + with Session(engine) as session: + statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id) + db_token = session.exec(statement).first() + if not db_token: + return False + db_token.revoked = True + db_token.revoked_at = datetime.now() + session.add(db_token) + session.commit() + return True def _get_jwt_config() -> dict: @@ -267,15 +264,15 @@ def create_access_refresh_tokens(data: dict) -> tuple[str, str]: refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"] ) - # Store refresh token mapping - refresh_tokens = _load_refresh_tokens() - refresh_tokens[token_id] = { - "username": data["sub"], - "token_id": token_id, - "created_at": datetime.now().isoformat(), - "expires_at": refresh_expire.isoformat(), - } - _save_refresh_tokens(refresh_tokens) + # Store refresh token in database + db_token = RefreshTokenTable( + token_id=token_id, + username=data["sub"], + created_at=datetime.now(), + expires_at=refresh_expire, + revoked=False, + ) + _save_refresh_token(db_token) return access_token, refresh_token @@ -305,15 +302,18 @@ def verify_refresh_token(token: str) -> Optional[str]: if not username or not token_id: return None - # Check if token exists in storage - refresh_tokens = _load_refresh_tokens() - stored_token = refresh_tokens.get(token_id) + # Check if token exists in database + stored_token = _get_refresh_token(token_id) if not stored_token: return None # Verify token hasn't been revoked or expired - if stored_token.get("revoked"): + if stored_token.revoked: + return None + + # Also check expiration in database + if stored_token.expires_at and stored_token.expires_at < datetime.now(): return None return username @@ -341,14 +341,7 @@ def revoke_refresh_token(token: str) -> bool: if not token_id: return False - refresh_tokens = _load_refresh_tokens() - if token_id in refresh_tokens: - refresh_tokens[token_id]["revoked"] = True - refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat() - _save_refresh_tokens(refresh_tokens) - return True - - return False + return _revoke_refresh_token_db(token_id) except JWTError: return False diff --git a/app/database.py b/app/database.py index a11cbe0..6956495 100644 --- a/app/database.py +++ b/app/database.py @@ -18,7 +18,7 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) def create_db_and_tables(): """Create the database and tables based on the models""" # CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata - from app.models.auth import UserTable + from app.models.auth import UserTable, RefreshTokenTable from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable from app.models.favorites import FavoriteTable from app.models.sonarr import SonarrMappingTable, SonarrConfigTable diff --git a/app/downloaders/__init__.py b/app/downloaders/__init__.py index 079066a..a670ea1 100644 --- a/app/downloaders/__init__.py +++ b/app/downloaders/__init__.py @@ -17,7 +17,6 @@ from .anime_sites import ( BaseAnimeSite, get_anime_site, AnimeSamaDownloader, - NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader ) diff --git a/app/downloaders/anime_sites/__init__.py b/app/downloaders/anime_sites/__init__.py index 8b49c41..c711f00 100644 --- a/app/downloaders/anime_sites/__init__.py +++ b/app/downloaders/anime_sites/__init__.py @@ -2,7 +2,6 @@ from .base import BaseAnimeSite # Import all anime site downloaders from .animesama import AnimeSamaDownloader -from .nekosama import NekoSamaDownloader from .animeultime import AnimeUltimeDownloader from .vostfree import VostfreeDownloader from .frenchmanga import FrenchMangaDownloader @@ -10,7 +9,6 @@ from .frenchmanga import FrenchMangaDownloader __all__ = [ "BaseAnimeSite", "AnimeSamaDownloader", - "NekoSamaDownloader", "AnimeUltimeDownloader", "VostfreeDownloader", "FrenchMangaDownloader", @@ -22,7 +20,6 @@ def get_anime_site(url: str) -> BaseAnimeSite: sites = [ AnimeSamaDownloader(), AnimeUltimeDownloader(), - NekoSamaDownloader(), VostfreeDownloader(), FrenchMangaDownloader(), ] diff --git a/app/downloaders/anime_sites/animesama.py b/app/downloaders/anime_sites/animesama.py index a09da34..af66f5c 100644 --- a/app/downloaders/anime_sites/animesama.py +++ b/app/downloaders/anime_sites/animesama.py @@ -490,15 +490,16 @@ class AnimeSamaDownloader(BaseAnimeSite): part.replace("saison", "").replace("Saison", "") ) break - except: - pass + except Exception: + logger.debug("Could not parse season number from URL part") episode = "01" if season_num: return f"{anime_name} - S{season_num} - Episode {episode}.mp4" else: return f"{anime_name} - Episode {episode}.mp4" - except: + except Exception: + logger.debug("Could not generate filename, using default") return "Anime - Episode 01.Mp4" def _generate_anime_name(self, anime_url: str) -> str: @@ -511,7 +512,8 @@ class AnimeSamaDownloader(BaseAnimeSite): return parts[i + 1].replace("-", " ").title() # Fallback return "Anime" - except: + except Exception: + logger.debug("Could not extract anime name from URL") return "Anime" def _extract_season_number(self, anime_url: str) -> int | None: @@ -522,7 +524,8 @@ class AnimeSamaDownloader(BaseAnimeSite): if "saison" in part.lower(): return int(part.replace("saison", "").replace("Saison", "")) return None - except: + except Exception: + logger.debug("Could not extract season number from URL") return None async def _extract_from_lpayer( @@ -744,7 +747,8 @@ class AnimeSamaDownloader(BaseAnimeSite): if match: return match.group(1) - except: + except Exception: + logger.debug("Could not extract video URL from scripts") pass return None diff --git a/app/downloaders/anime_sites/nekosama.py b/app/downloaders/anime_sites/nekosama.py deleted file mode 100644 index a259321..0000000 --- a/app/downloaders/anime_sites/nekosama.py +++ /dev/null @@ -1,317 +0,0 @@ -from .base import BaseAnimeSite -from bs4 import BeautifulSoup -import re -from typing import Optional -from urllib.parse import urljoin - - -class NekoSamaDownloader(BaseAnimeSite): - """Downloader for neko-sama.org (anime streaming via Gupy) - - NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine. - It does NOT host video content - it provides metadata about where to watch legally. - This provider can search and get metadata but cannot provide direct download links. - """ - - BASE_DOMAINS = [ - "neko-sama.org", - "www.neko-sama.org", - "neko-sama.fr", - "nekosama.fr", - "www.gupy.fr", - "gupy.fr", - ] - - def __init__(self): - super().__init__() - self.id = "neko-sama" - - def can_handle(self, url: str) -> bool: - return any(domain in url.lower() for domain in self.BASE_DOMAINS) - - async def get_download_link( - self, url: str, target_filename: Optional[str] = None - ) -> tuple[str, str]: - """ - Extract download link from neko-sama URL. - - NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host. - This returns streaming platform information instead of direct video links. - """ - try: - # Check if this is a Gupy URL - if "gupy.fr" in url or "neko-sama.org" in url: - response = await self.client.get(url, follow_redirects=True) - soup = BeautifulSoup(response.text, "lxml") - - # Look for streaming platform links - streaming_links = [] - for link in soup.find_all("a", href=True): - href = link.get("href", "") - if "/out/" in href: - text = link.get_text(strip=True) - if text and "Regarder" in text: - streaming_links.append(f"{text}: {href}") - - if streaming_links: - title_elem = soup.find("h1") or soup.find("title") - title = ( - title_elem.get_text(strip=True).split("|")[0].strip() - if title_elem - else "Unknown" - ) - info = "Available streaming platforms:\n" + "\n".join( - streaming_links[:5] - ) - filename = target_filename or f"{title}_streaming_info.txt" - return info, filename - - raise Exception( - "No streaming links found - Gupy is a legal streaming search, not a video host" - ) - - # Legacy: try original method for other URLs - response = await self.client.get(url, follow_redirects=True) - soup = BeautifulSoup(response.text, "lxml") - - # Method 1: Look for iframes with video - iframes = soup.find_all("iframe") - for iframe in iframes: - src = iframe.get("src", "") - if src and any(p in src for p in ["video", "player", "stream"]): - if not src.startswith("http"): - src = urljoin(str(response.url), src) - filename = self._generate_filename(str(response.url)) - return src, filename - - # Method 2: Look for video tags - videos = soup.find_all("video") - for video in videos: - src = video.get("src") or video.get("data-src") - if src: - filename = self._generate_filename(str(response.url)) - return src, filename - - sources = video.find_all("source") - for source in sources: - src = source.get("src", "") - if src: - filename = self._generate_filename(str(response.url)) - return src, filename - - # Method 3: Look in scripts - scripts = soup.find_all("script") - for script in scripts: - if script.string: - patterns = [ - r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)', - r'"url":"([^"]+)"', - r'"video":"([^"]+)"', - ] - for pattern in patterns: - matches = re.findall(pattern, script.string) - for match in matches: - match = match.replace("\\/", "/") - if any(ext in match for ext in ["mp4", "m3u8"]): - filename = self._generate_filename(str(response.url)) - return match, filename - - raise Exception( - "Could not find video link - Neko-Sama/Gupy does not host video content" - ) - - except Exception as e: - raise Exception(f"Error extracting NekoSama link: {str(e)}") - - def _generate_filename(self, url: str) -> str: - parts = url.split("/") - anime_name = "anime" - episode = "1" - - for i, part in enumerate(parts): - if "episode" in part.lower(): - match = re.search(r"episode[-\s]*(\d+)", part, re.I) - if match: - episode = match.group(1) - - filename = f"{anime_name} - Episode {episode}.mp4" - return filename.title() - - async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]: - """Get list of episodes for an anime.""" - try: - response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, "lxml") - - episodes = [] - # Try to find episode links - episode_links = soup.find_all("a", href=re.compile(r"episode")) - - for link in episode_links: - href = link.get("href", "") - match = re.search(r"episode[-\s]*(\d+)", href, re.I) - if match: - episode_num = match.group(1) - if not href.startswith("http"): - href = urljoin(anime_url, href) - - episodes.append({"episode": episode_num, "url": href}) - - # Deduplicate and sort - seen = set() - unique_episodes = [] - for ep in episodes: - if ep["episode"] not in seen: - seen.add(ep["episode"]) - unique_episodes.append(ep) - - unique_episodes.sort(key=lambda x: int(x["episode"])) - return unique_episodes - - except Exception as e: - return [] - - async def get_anime_metadata(self, anime_url: str) -> dict: - """Extract rich metadata from anime page.""" - try: - print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}") - response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, "lxml") - - metadata = { - "synopsis": None, - "genres": [], - "rating": None, - "release_year": None, - "studio": None, - "poster_image": None, - "banner_image": None, - "total_episodes": None, - "status": None, - "alternative_titles": [], - } - - # Extract title and year from h1 - title_elem = soup.find("h1") - if title_elem: - title_text = title_elem.get_text(strip=True) - # Extract year from title like "Naruto (2002)" - year_match = re.search(r"\((\d{4})\)", title_text) - if year_match: - metadata["release_year"] = int(year_match.group(1)) - - # Extract synopsis - Gupy shows it as paragraphs - synopsis_elem = soup.find("p") - if synopsis_elem: - text = synopsis_elem.get_text(strip=True) - if len(text) > 50: - metadata["synopsis"] = text - - # Extract genres from meta tags or links - genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag")) - if genre_links: - genres = [] - for link in genre_links[:5]: - text = link.get_text(strip=True) - if text and "/" not in text and len(text) < 30: - genres.append(text) - metadata["genres"] = genres - - # Extract rating from percentage - rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%")) - if rating_elem: - match = re.search(r"(\d+(\.\d+)?)%", rating_elem) - if match: - rating = float(match.group(1)) / 10 - metadata["rating"] = f"{rating:.1f}/10" - - # Extract poster image - poster_elem = soup.find("img", src=re.compile(r"poster|poster")) - if poster_elem: - metadata["poster_image"] = poster_elem.get("src") - - # Extract episode count from page text - page_text = soup.get_text() - ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I) - if ep_match: - metadata["total_episodes"] = int(ep_match.group(1)) - - # Extract studio/director - director_elem = soup.find("a", href=re.compile(r"person|réalisé")) - if director_elem: - metadata["studio"] = director_elem.get_text(strip=True) - - print(f"[NEKO-SAMA] Extracted metadata: {metadata}") - return metadata - - except Exception as e: - print(f"[NEKO-SAMA] Error extracting metadata: {e}") - return {} - - async def search_anime( - self, query: str, lang: str = "vostfr", include_metadata: bool = False - ) -> list[dict]: - """Search for anime on neko-sama (uses Gupy backend).""" - try: - import time - from html import unescape - - start = time.time() - print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...") - - # Neko-Sama now uses Gupy - try the direct URL pattern - search_slug = query.lower().replace(" ", "-") - search_urls = [ - f"https://www.gupy.fr/series/{search_slug}/", - f"https://neko-sama.org/series/{search_slug}/", - ] - - results = [] - for search_url in search_urls: - response = await self.client.get(search_url, follow_redirects=True) - print(f"[NEKO-SAMA] Tried {search_url} -> {response.status_code}") - - if response.status_code == 200: - final_url = str(response.url) - print(f"[NEKO-SAMA] Found anime at {final_url}") - - # Extract title from page - soup = BeautifulSoup(response.text, "lxml") - title_elem = soup.find("h1") or soup.find("title") - title = ( - unescape(title_elem.get_text(strip=True)) - if title_elem - else query - ) - # Clean up title - title = title.split("|")[0].split("-")[0].strip() - - result = { - "title": title, - "url": final_url, - "cover_image": None, - "type": "direct", - "metadata": None, - } - - # Try to get poster - poster = soup.find("img", src=re.compile(r"poster")) - if poster: - result["cover_image"] = poster.get("src") - - if include_metadata: - metadata = await self.get_anime_metadata(final_url) - result["metadata"] = metadata - - results.append(result) - break - - elapsed = time.time() - start - print( - f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results" - ) - return results - - except Exception as e: - print(f"[NEKO-SAMA] Error: {str(e)}") - return [] diff --git a/app/downloaders/video_players/doodstream.py b/app/downloaders/video_players/doodstream.py index e2b860c..21d497b 100644 --- a/app/downloaders/video_players/doodstream.py +++ b/app/downloaders/video_players/doodstream.py @@ -68,7 +68,7 @@ class DoodStreamDownloader(BaseVideoPlayer): fname = self._extract_filename_from_headers(head_resp.headers) if fname: filename = fname - except: + except Exception: pass return download_url, filename diff --git a/app/downloaders/video_players/lpayer.py b/app/downloaders/video_players/lpayer.py index 10d2d61..3327ec9 100644 --- a/app/downloaders/video_players/lpayer.py +++ b/app/downloaders/video_players/lpayer.py @@ -102,7 +102,7 @@ class LpayerDownloader(BaseVideoPlayer): try: await page.mouse.click(640, 360) await asyncio.sleep(3) - except: + except Exception: pass # Try JavaScript extraction to find video URLs in DOM @@ -235,7 +235,7 @@ class LpayerDownloader(BaseVideoPlayer): if browser: try: await browser.close() - except: + except Exception: pass """Extract video URL using Playwright to render JavaScript""" try: diff --git a/app/downloaders/video_players/oneupload.py b/app/downloaders/video_players/oneupload.py index 1ce57cc..17f8f8b 100644 --- a/app/downloaders/video_players/oneupload.py +++ b/app/downloaders/video_players/oneupload.py @@ -124,7 +124,7 @@ class OneuploadDownloader(BaseVideoPlayer): await element.click() await asyncio.sleep(2) break - except: + except Exception: continue except Exception as e: print(f"[ONEUPLOAD] Play button interaction: {e}") diff --git a/app/downloaders/video_players/rapidfile.py b/app/downloaders/video_players/rapidfile.py index 15d9f7e..913c75d 100644 --- a/app/downloaders/video_players/rapidfile.py +++ b/app/downloaders/video_players/rapidfile.py @@ -62,7 +62,7 @@ class RapidFileDownloader(BaseVideoPlayer): filename = fname else: filename = download_url.split('/')[-1] or "rapidfile_download" - except: + except Exception: filename = download_url.split('/')[-1] or "rapidfile_download" return download_url, filename diff --git a/app/downloaders/video_players/smoothpre.py b/app/downloaders/video_players/smoothpre.py index 96e8356..5fb0319 100644 --- a/app/downloaders/video_players/smoothpre.py +++ b/app/downloaders/video_players/smoothpre.py @@ -118,7 +118,7 @@ class SmoothpreDownloader(BaseVideoPlayer): await element.click() await asyncio.sleep(2) break - except: + except Exception: continue except Exception as e: print(f"[SMOOTHPRE] Play button interaction: {e}") diff --git a/app/downloaders/video_players/unfichier.py b/app/downloaders/video_players/unfichier.py index 5b6553e..b1a1797 100644 --- a/app/downloaders/video_players/unfichier.py +++ b/app/downloaders/video_players/unfichier.py @@ -42,7 +42,7 @@ class UnFichierDownloader(BaseVideoPlayer): if not filename: filename = href.split('/')[-1] or "downloaded_file" return href, filename - except: + except Exception: continue raise Exception("Could not find download link on page") diff --git a/app/downloaders/video_players/vidmoly.py b/app/downloaders/video_players/vidmoly.py index 3d34314..95485e4 100644 --- a/app/downloaders/video_players/vidmoly.py +++ b/app/downloaders/video_players/vidmoly.py @@ -177,7 +177,7 @@ class VidMolyDownloader(BaseVideoPlayer): await element.click() await asyncio.sleep(3) break - except: + except Exception: continue except Exception as e: print(f"[VIDMOLY] Play button interaction: {e}") diff --git a/app/models/auth.py b/app/models/auth.py index d884e61..1262519 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -62,5 +62,24 @@ class UserInDB(User): """Schema for user stored in database (with hashed password)""" hashed_password: str + +class RefreshTokenTable(SQLModel, table=True): + """Database table for refresh tokens""" + __tablename__ = "refresh_tokens" + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + primary_key=True, + index=True, + nullable=False + ) + token_id: str = Field(index=True, unique=True) + username: str = Field(index=True) + created_at: datetime = Field(default_factory=datetime.now) + expires_at: Optional[datetime] = None + revoked: bool = Field(default=False) + revoked_at: Optional[datetime] = None + + # Import WatchlistItemTable here to resolve SQLModel Relationship mappings from .watchlist import WatchlistItemTable diff --git a/app/models/settings.py b/app/models/settings.py index c705c92..dfbe755 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -32,7 +32,7 @@ class AppSettingsBase(SQLModel): def disabled_providers(self) -> List[str]: try: return json.loads(self.disabled_providers_json or "[]") - except: + except json.JSONDecodeError: return [] @disabled_providers.setter diff --git a/app/models/sonarr.py b/app/models/sonarr.py index e603f44..55f7cf1 100644 --- a/app/models/sonarr.py +++ b/app/models/sonarr.py @@ -160,7 +160,7 @@ class SonarrMapping(BaseModel): """Mapping between Sonarr series and anime providers (API model)""" sonarr_series_id: int sonarr_title: str - anime_provider: str # 'anime-sama', 'neko-sama', etc. + anime_provider: str # 'anime-sama', 'anime-ultime', 'vostfree', 'french-manga', etc. anime_url: str anime_title: str lang: str = "vostfr" diff --git a/app/providers.py b/app/providers.py index 225768f..b5e6692 100644 --- a/app/providers.py +++ b/app/providers.py @@ -25,13 +25,6 @@ ANIME_PROVIDERS = { "icon": "▶️", "color": "#00ff88", }, - "neko-sama": { - "name": "Neko-Sama", - "domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"], - "url_pattern": "https://neko-sama.fr/anime/{slug}", - "icon": "🐱", - "color": "#ff6b6b", - }, "vostfree": { "name": "Vostfree", "domains": ["vostfree.tv", "www.vostfree.tv"], diff --git a/app/providers_manager.py b/app/providers_manager.py index 4352c0d..b872ef0 100644 --- a/app/providers_manager.py +++ b/app/providers_manager.py @@ -10,7 +10,6 @@ from datetime import datetime from app.downloaders.generic_scraper import GenericScraper from app.downloaders.anime_sites import ( AnimeSamaDownloader, - NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader, FrenchMangaDownloader, @@ -58,7 +57,6 @@ class ProvidersManager: """Load hardcoded Python providers""" provider_classes = [ ("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS), - ("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS), ("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS), ("vostfree", VostfreeDownloader, ANIME_PROVIDERS), ("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS), @@ -130,10 +128,23 @@ class ProvidersManager: return 200 <= response.status_code < 400 elif hasattr(scraper, "search_anime"): results = await scraper.search_anime("One Piece", lang="vostfr") - return len(results) > 0 + # Validate that results actually match the query + if not results: + return False + for r in results: + title = (r.get("title") or "").lower() + if "one" in title or "piece" in title: + return True + return False elif hasattr(scraper, "search"): results = await scraper.search("One Piece") - return len(results) > 0 + if not results: + return False + for r in results: + title = (r.get("title") or "").lower() + if "one" in title or "piece" in title: + return True + return False return False except Exception as e: logger.error( diff --git a/app/routers/router_anime.py b/app/routers/router_anime.py index 1e156c8..1e88dfb 100644 --- a/app/routers/router_anime.py +++ b/app/routers/router_anime.py @@ -29,7 +29,6 @@ from app.download_manager import DownloadManager from app.downloaders import ( AnimeSamaDownloader, AnimeUltimeDownloader, - NekoSamaDownloader, VostfreeDownloader, ZoneTelechargementDownloader, get_downloader, @@ -59,12 +58,10 @@ async def get_providers_health(): @router.post("/providers/health/check") -async def trigger_providers_health_check(background_tasks: BackgroundTasks): - """Trigger a manual health check of all providers in the background""" - from app.auto_download_scheduler import auto_download_scheduler - - background_tasks.add_task(auto_download_scheduler.trigger_health_check_now) - return {"status": "Health check triggered in background"} +async def trigger_providers_health_check(): + """Trigger a manual health check of all providers""" + await providers_manager.check_all_health() + return {"status": "ok", "providers": providers_manager.get_all_status()} def get_download_manager() -> DownloadManager: @@ -136,7 +133,6 @@ async def search_anime_unified( # Legacy providers (already included in providers_manager, but keep for fallback) legacy_downloaders = { "anime-ultime": AnimeUltimeDownloader(), - "neko-sama": NekoSamaDownloader(), "vostfree": VostfreeDownloader(), } for pid, dl in legacy_downloaders.items(): @@ -196,6 +192,12 @@ async def search_anime_unified( else: item_dict["_relevance_boost"] = 0.3 + # Filter out results with very low relevance + MIN_RELEVANCE_THRESHOLD = 0.5 + if item_dict["_relevance_boost"] < MIN_RELEVANCE_THRESHOLD: + logger.debug(f"Filtered low-relevance result '{title}' for query '{q}' (score: {item_dict['_relevance_boost']})") + continue + results[pid].append(item_dict) # Prepare enrichment task for top 15 results per provider diff --git a/app/sonarr_handler.py b/app/sonarr_handler.py index 3f3d32d..3240c66 100644 --- a/app/sonarr_handler.py +++ b/app/sonarr_handler.py @@ -17,7 +17,7 @@ from app.models.sonarr import ( SonarrDownloadRequest ) from app.models import DownloadRequest -from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader +from app.downloaders import get_downloader, AnimeSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader # Configure logging logger = logging.getLogger(__name__) @@ -205,7 +205,6 @@ class SonarrHandler: """Get downloader instance for provider""" providers = { "anime-sama": AnimeSamaDownloader(), - "neko-sama": NekoSamaDownloader(), "anime-ultime": AnimeUltimeDownloader(), "vostfree": VostfreeDownloader() } diff --git a/config/refresh_tokens.json b/config/refresh_tokens.json deleted file mode 100644 index eacff59..0000000 --- a/config/refresh_tokens.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU": { - "username": "testuser", - "token_id": "ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU", - "created_at": "2026-03-06T22:01:01.865697", - "expires_at": "2026-04-05T22:01:01.865619" - }, - "vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs": { - "username": "testuser", - "token_id": "vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs", - "created_at": "2026-03-06T22:03:55.154118", - "expires_at": "2026-04-05T22:03:55.154019" - }, - "fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o": { - "username": "testuser", - "token_id": "fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o", - "created_at": "2026-03-06T22:06:48.751392", - "expires_at": "2026-04-05T22:06:48.751237" - }, - "OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU": { - "username": "testuser", - "token_id": "OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU", - "created_at": "2026-03-06T22:06:48.753454", - "expires_at": "2026-04-05T22:06:48.753349" - }, - "pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY": { - "username": "testuser", - "token_id": "pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY", - "created_at": "2026-03-06T22:06:48.756403", - "expires_at": "2026-04-05T22:06:48.756301" - }, - "-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o": { - "username": "testuser", - "token_id": "-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o", - "created_at": "2026-03-06T22:06:48.757822", - "expires_at": "2026-04-05T22:06:48.757728" - }, - "x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g": { - "username": "testuser", - "token_id": "x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g", - "created_at": "2026-03-06T22:06:48.759219", - "expires_at": "2026-04-05T22:06:48.759121" - }, - "E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw": { - "username": "testuser", - "token_id": "E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw", - "created_at": "2026-03-06T22:07:03.414591", - "expires_at": "2026-04-05T22:07:03.414466" - }, - "XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8": { - "username": "testuser", - "token_id": "XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8", - "created_at": "2026-03-06T22:07:27.981118", - "expires_at": "2026-04-05T22:07:27.980974" - }, - "YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ": { - "username": "testuser", - "token_id": "YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ", - "created_at": "2026-03-06T22:07:27.982903", - "expires_at": "2026-04-05T22:07:27.982803" - }, - "OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek": { - "username": "testuser", - "token_id": "OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek", - "created_at": "2026-03-06T22:07:27.985521", - "expires_at": "2026-04-05T22:07:27.985410" - }, - "9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s": { - "username": "testuser", - "token_id": "9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s", - "created_at": "2026-03-06T22:07:27.986984", - "expires_at": "2026-04-05T22:07:27.986883" - }, - "vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI": { - "username": "testuser", - "token_id": "vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI", - "created_at": "2026-03-06T22:07:27.988625", - "expires_at": "2026-04-05T22:07:27.988525" - }, - "qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM": { - "username": "testuser", - "token_id": "qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM", - "created_at": "2026-03-06T22:07:33.163399", - "expires_at": "2026-04-05T22:07:33.163230" - }, - "8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8": { - "username": "testuser", - "token_id": "8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8", - "created_at": "2026-03-06T22:07:33.165736", - "expires_at": "2026-04-05T22:07:33.165608" - }, - "jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE": { - "username": "testuser", - "token_id": "jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE", - "created_at": "2026-03-06T22:07:33.168776", - "expires_at": "2026-04-05T22:07:33.168669" - }, - "XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA": { - "username": "testuser", - "token_id": "XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA", - "created_at": "2026-03-06T22:07:33.170429", - "expires_at": "2026-04-05T22:07:33.170321" - }, - "Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og": { - "username": "testuser", - "token_id": "Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og", - "created_at": "2026-03-06T22:07:33.172080", - "expires_at": "2026-04-05T22:07:33.171974" - }, - "N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY": { - "username": "testuser", - "token_id": "N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY", - "created_at": "2026-03-06T22:08:54.290837", - "expires_at": "2026-04-05T22:08:54.290674" - }, - "DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ": { - "username": "testuser", - "token_id": "DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ", - "created_at": "2026-03-06T22:08:54.292851", - "expires_at": "2026-04-05T22:08:54.292732" - }, - "MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4": { - "username": "testuser", - "token_id": "MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4", - "created_at": "2026-03-06T22:08:54.295788", - "expires_at": "2026-04-05T22:08:54.295675" - }, - "3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM": { - "username": "testuser", - "token_id": "3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM", - "created_at": "2026-03-06T22:08:54.297426", - "expires_at": "2026-04-05T22:08:54.297325" - }, - "_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0": { - "username": "testuser", - "token_id": "_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0", - "created_at": "2026-03-06T22:08:54.299268", - "expires_at": "2026-04-05T22:08:54.299159" - }, - "pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4": { - "username": "testuser", - "token_id": "pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4", - "created_at": "2026-03-06T22:09:24.318148", - "expires_at": "2026-04-05T22:09:24.317977" - }, - "3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc": { - "username": "testuser", - "token_id": "3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc", - "created_at": "2026-03-06T22:09:24.320197", - "expires_at": "2026-04-05T22:09:24.320080" - }, - "U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0": { - "username": "testuser", - "token_id": "U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0", - "created_at": "2026-03-06T22:09:24.323151", - "expires_at": "2026-04-05T22:09:24.323044" - }, - "ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70": { - "username": "testuser", - "token_id": "ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70", - "created_at": "2026-03-06T22:09:24.324867", - "expires_at": "2026-04-05T22:09:24.324760" - }, - "NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU": { - "username": "testuser", - "token_id": "NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU", - "created_at": "2026-03-06T22:09:24.326840", - "expires_at": "2026-04-05T22:09:24.326737" - }, - "OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4": { - "username": "testuser", - "token_id": "OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4", - "created_at": "2026-03-06T22:10:26.790594", - "expires_at": "2026-04-05T22:10:26.790416" - }, - "1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I": { - "username": "testuser", - "token_id": "1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I", - "created_at": "2026-03-06T22:10:26.792786", - "expires_at": "2026-04-05T22:10:26.792640" - }, - "okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w": { - "username": "testuser", - "token_id": "okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w", - "created_at": "2026-03-06T22:10:26.795866", - "expires_at": "2026-04-05T22:10:26.795737" - }, - "ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE": { - "username": "testuser", - "token_id": "ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE", - "created_at": "2026-03-06T22:10:26.797631", - "expires_at": "2026-04-05T22:10:26.797524" - }, - "CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE": { - "username": "testuser", - "token_id": "CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE", - "created_at": "2026-03-06T22:10:26.799655", - "expires_at": "2026-04-05T22:10:26.799536" - }, - "kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE": { - "username": "testuser", - "token_id": "kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE", - "created_at": "2026-03-06T22:27:21.684870", - "expires_at": "2026-04-05T22:27:21.684713" - }, - "X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg": { - "username": "testuser", - "token_id": "X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg", - "created_at": "2026-03-06T22:27:21.686951", - "expires_at": "2026-04-05T22:27:21.686838" - }, - "lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM": { - "username": "testuser", - "token_id": "lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM", - "created_at": "2026-03-06T22:27:21.689978", - "expires_at": "2026-04-05T22:27:21.689871" - }, - "CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4": { - "username": "testuser", - "token_id": "CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4", - "created_at": "2026-03-06T22:27:21.694564", - "expires_at": "2026-04-05T22:27:21.694451" - }, - "2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4": { - "username": "testuser", - "token_id": "2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4", - "created_at": "2026-03-06T22:27:21.696368", - "expires_at": "2026-04-05T22:27:21.696259" - }, - "innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w": { - "username": "testuser", - "token_id": "innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w", - "created_at": "2026-03-06T22:28:22.440825", - "expires_at": "2026-04-05T22:28:22.440584" - }, - "FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc": { - "username": "testuser", - "token_id": "FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc", - "created_at": "2026-03-06T22:28:22.443279", - "expires_at": "2026-04-05T22:28:22.443148" - }, - "xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc": { - "username": "testuser", - "token_id": "xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc", - "created_at": "2026-03-06T22:28:22.446772", - "expires_at": "2026-04-05T22:28:22.446637" - }, - "Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI": { - "username": "testuser", - "token_id": "Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI", - "created_at": "2026-03-06T22:28:22.448831", - "expires_at": "2026-04-05T22:28:22.448710" - }, - "cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U": { - "username": "testuser", - "token_id": "cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U", - "created_at": "2026-03-06T22:28:22.450873", - "expires_at": "2026-04-05T22:28:22.450755" - }, - "oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE": { - "username": "testuser", - "token_id": "oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE", - "created_at": "2026-03-06T22:43:41.536641", - "expires_at": "2026-04-05T22:43:41.536473" - }, - "8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw": { - "username": "testuser", - "token_id": "8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw", - "created_at": "2026-03-06T22:43:41.538970", - "expires_at": "2026-04-05T22:43:41.538842" - }, - "9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk": { - "username": "testuser", - "token_id": "9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk", - "created_at": "2026-03-06T22:43:41.542159", - "expires_at": "2026-04-05T22:43:41.542042" - }, - "-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU": { - "username": "testuser", - "token_id": "-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU", - "created_at": "2026-03-06T22:43:41.544148", - "expires_at": "2026-04-05T22:43:41.544030" - }, - "L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY": { - "username": "testuser", - "token_id": "L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY", - "created_at": "2026-03-06T22:43:41.546116", - "expires_at": "2026-04-05T22:43:41.545999" - }, - "Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0": { - "username": "testuser", - "token_id": "Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0", - "created_at": "2026-03-23T15:14:58.571086", - "expires_at": "2026-04-22T15:14:58.570921" - }, - "glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww": { - "username": "testuser", - "token_id": "glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww", - "created_at": "2026-03-23T15:14:58.573282", - "expires_at": "2026-04-22T15:14:58.573168" - }, - "3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA": { - "username": "testuser", - "token_id": "3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA", - "created_at": "2026-03-23T15:14:58.576669", - "expires_at": "2026-04-22T15:14:58.576537" - }, - "Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg": { - "username": "testuser", - "token_id": "Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg", - "created_at": "2026-03-23T15:14:58.578685", - "expires_at": "2026-04-22T15:14:58.578562" - }, - "8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY": { - "username": "testuser", - "token_id": "8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY", - "created_at": "2026-03-23T15:14:58.580654", - "expires_at": "2026-04-22T15:14:58.580531" - }, - "Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8": { - "username": "testuser", - "token_id": "Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8", - "created_at": "2026-03-23T15:34:35.684297", - "expires_at": "2026-04-22T15:34:35.684116" - }, - "BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ": { - "username": "testuser", - "token_id": "BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ", - "created_at": "2026-03-23T15:34:35.686743", - "expires_at": "2026-04-22T15:34:35.686606" - }, - "H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU": { - "username": "testuser", - "token_id": "H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU", - "created_at": "2026-03-23T15:34:35.690100", - "expires_at": "2026-04-22T15:34:35.689977" - }, - "9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM": { - "username": "testuser", - "token_id": "9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM", - "created_at": "2026-03-23T15:34:35.692293", - "expires_at": "2026-04-22T15:34:35.692176" - }, - "BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI": { - "username": "testuser", - "token_id": "BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI", - "created_at": "2026-03-23T15:34:35.694464", - "expires_at": "2026-04-22T15:34:35.694325" - }, - "wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34": { - "username": "testuser", - "token_id": "wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34", - "created_at": "2026-03-23T16:15:23.555117", - "expires_at": "2026-04-22T16:15:23.554918" - }, - "sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0": { - "username": "testuser", - "token_id": "sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0", - "created_at": "2026-03-23T16:15:23.557727", - "expires_at": "2026-04-22T16:15:23.557585" - }, - "ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s": { - "username": "testuser", - "token_id": "ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s", - "created_at": "2026-03-23T16:15:23.561170", - "expires_at": "2026-04-22T16:15:23.561048" - }, - "3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc": { - "username": "testuser", - "token_id": "3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc", - "created_at": "2026-03-23T16:15:23.563391", - "expires_at": "2026-04-22T16:15:23.563269" - }, - "-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE": { - "username": "testuser", - "token_id": "-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE", - "created_at": "2026-03-23T16:15:23.565588", - "expires_at": "2026-04-22T16:15:23.565458" - } -} \ No newline at end of file diff --git a/main.py b/main.py index d1cdf11..b2bae33 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ Main application file with startup configuration and middleware. All API routes have been migrated to app/routers/ for better maintainability. """ +import asyncio import logging import uuid from datetime import datetime @@ -42,6 +43,8 @@ app.add_middleware( "http://192.168.1.204", "http://192.168.1.200:3000", "http://192.168.1.200", + "http://192.168.5.127:3000", + "http://192.168.5.127", ], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], @@ -82,6 +85,11 @@ async def startup_event(): from app.auto_download_scheduler import auto_download_scheduler auto_download_scheduler.start() + + # Run initial provider health check in background + from app.providers_manager import providers_manager + + asyncio.create_task(providers_manager.check_all_health()) logger.info("Application started: Sonarr handler and scheduler initialized") diff --git a/package-lock.json b/package-lock.json index 34d8586..1c3abcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,605 +8,17 @@ "name": "ohm-streaming", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.58.2", - "jsdom": "^29.0.0", - "vitest": "^1.0.0" + "@playwright/test": "^1.60.0" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", - "dev": true, - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", - "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", - "dev": true, - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", - "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "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/@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/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -615,1035 +27,14 @@ "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", - "dev": true, - "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", - "dev": true, - "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", - "dev": true, - "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", - "dev": true, - "dependencies": { - "tinyspy": "^2.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", - "dev": true, - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "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/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true - }, - "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/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "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/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/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "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/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.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": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "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/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true - }, - "node_modules/jsdom": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", - "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", - "dev": true, - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.2", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.24.3", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", - "dev": true, - "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true - }, - "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/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mlly": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", - "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", - "dev": true, - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "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/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "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/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "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/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -1656,10 +47,11 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -1673,6 +65,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1680,556 +73,6 @@ "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "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/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/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-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "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/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/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "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/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true - }, - "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.26", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", - "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", - "dev": true, - "dependencies": { - "tldts-core": "^7.0.26" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.26", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", - "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", - "dev": true - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true - }, - "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", - "dev": true, - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", - "dev": true, - "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index e15af29..9b62954 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,6 @@ "test:watch": "vitest" }, "devDependencies": { - "@playwright/test": "^1.58.2", - "jsdom": "^29.0.0", - "vitest": "^1.0.0" + "@playwright/test": "^1.60.0" } } diff --git a/playwright.config.ts b/playwright.config.ts index 7552c6a..7484e0e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,50 +4,55 @@ import { defineConfig, devices } from '@playwright/test'; * @see https://playwright.dev/docs/test-configuration */ export default defineConfig({ - testDir: './tests/e2e', - + globalSetup: './tests/e2e/global-setup.ts', + /* Run tests in files in parallel */ fullyParallel: true, - + /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - + /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - + /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, - + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', - + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:3000', - + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - + /* Capture screenshot on failure */ screenshot: 'only-on-failure', - + /* Video recording on failure */ video: 'retain-on-failure', }, - + /* Configure projects for major browsers */ projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], }, ], - + /* Run your local dev server before starting the tests */ webServer: { - command: 'uvicorn main:app --host 0.0.0.0 --port 3000', + command: 'venv/bin/uvicorn main:app --host 0.0.0.0 --port 3000', url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, + reuseExistingServer: true, }, }); diff --git a/static/js/__tests__/auth-api.test.js b/static/js/__tests__/auth-api.test.js deleted file mode 100644 index 4db7a33..0000000 --- a/static/js/__tests__/auth-api.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; - -// Set up global window object for jsdom -global.window = global.window || {}; - -// Define skeleton functions for testing (same as in auth-api.js) -const API_BASE = '/api'; - -async function login(username, password) { - throw new Error('Not implemented yet'); -} - -async function register(username, password, email = null, full_name = null) { - throw new Error('Not implemented yet'); -} - -async function logout() { - throw new Error('Not implemented yet'); -} - -async function getMe(token) { - throw new Error('Not implemented yet'); -} - -// Set up window object -window.authApi = { - login, - register, - logout, - getMe, -}; - -describe('authApi', () => { - describe('login function', () => { - it('should be a function', () => { - expect(typeof window.authApi.login).toBe('function'); - }); - - it('should return a Promise', () => { - const result = window.authApi.login('test', 'test'); - expect(result).toBeInstanceOf(Promise); - }); - }); - - describe('register function', () => { - it('should be a function', () => { - expect(typeof window.authApi.register).toBe('function'); - }); - - it('should return a Promise', () => { - const result = window.authApi.register('testuser', 'password123', null, null); - expect(result).toBeInstanceOf(Promise); - }); - - it('should handle optional parameters', async () => { - try { - await window.authApi.register('test', 'password'); - } catch (e) { - expect(e.message).toBe('Not implemented yet'); - } - }); - }); - - describe('logout function', () => { - it('should be a function', () => { - expect(typeof window.authApi.logout).toBe('function'); - }); - - it('should return a Promise', () => { - const result = window.authApi.logout(); - expect(result).toBeInstanceOf(Promise); - }); - }); - - describe('getMe function', () => { - it('should be a function', () => { - expect(typeof window.authApi.getMe).toBe('function'); - }); - - it('should return a Promise', () => { - const result = window.authApi.getMe('fake-token'); - expect(result).toBeInstanceOf(Promise); - }); - }); -}); diff --git a/static/js/__tests__/auth-utils.test.js b/static/js/__tests__/auth-utils.test.js deleted file mode 100644 index 20715eb..0000000 --- a/static/js/__tests__/auth-utils.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -// Mock DOM elements for displayError tests -const mockDocument = () => { - const elements = {}; - global.document = { - getElementById: (id) => elements[id] || null, - }; - beforeEach(() => { - elements.authError = { - textContent: '', - classList: { - add: () => {}, - remove: () => {} - } - }; - elements.authSuccess = { - textContent: '', - classList: { - add: () => {}, - remove: () => {} - } - }; - }); -}; - -describe('safeJsonParse', () => { - // Import the function - we'll need to make it work with Vitest - // For now, we'll define it inline for testing - const safeJsonParse = (text, fallback = null) => { - try { - if (text === undefined || text === null || text === '') { - return fallback; - } - return JSON.parse(text); - } catch (error) { - return fallback; - } - }; - - it('should parse valid JSON string', () => { - const result = safeJsonParse('{"key":"value"}'); - expect(result).toEqual({ key: 'value' }); - }); - - it('should return fallback for invalid JSON', () => { - const result = safeJsonParse('invalid json'); - expect(result).toBeNull(); - }); - - it('should return custom fallback when provided', () => { - const result = safeJsonParse('invalid', 'custom fallback'); - expect(result).toBe('custom fallback'); - }); - - it('should return fallback for undefined input', () => { - const result = safeJsonParse(undefined); - expect(result).toBeNull(); - }); - - it('should return fallback for null input', () => { - const result = safeJsonParse(null); - expect(result).toBeNull(); - }); - - it('should return fallback for empty string', () => { - const result = safeJsonParse(''); - expect(result).toBeNull(); - }); - - it('should parse valid JSON array', () => { - const result = safeJsonParse('[1, 2, 3]'); - expect(result).toEqual([1, 2, 3]); - }); - - it('should parse nested JSON', () => { - const result = safeJsonParse('{"user":{"name":"John","age":30}}'); - expect(result).toEqual({ user: { name: 'John', age: 30 } }); - }); -}); diff --git a/static/js/__tests__/smoke.test.js b/static/js/__tests__/smoke.test.js deleted file mode 100644 index 39e2710..0000000 --- a/static/js/__tests__/smoke.test.js +++ /dev/null @@ -1,8 +0,0 @@ -// Smoke test to verify Vitest setup -import { describe, it, expect } from 'vitest'; - -describe('smoke', () => { - it('works', () => { - expect(true).toBe(true); - }); -}); diff --git a/templates/components/settings_section.html b/templates/components/settings_section.html index a63606f..806fb67 100644 --- a/templates/components/settings_section.html +++ b/templates/components/settings_section.html @@ -93,7 +93,8 @@

Disponibilite des Fournisseurs

-
diff --git a/templates/components/toast_container.html b/templates/components/toast_container.html index 4470a69..e2b0ff2 100644 --- a/templates/components/toast_container.html +++ b/templates/components/toast_container.html @@ -33,6 +33,10 @@ display: flex; flex-direction: column; gap: 10px; + pointer-events: none; +} +.toast { + pointer-events: auto; } .toast { min-width: 250px; diff --git a/test_watchlist.py b/test_watchlist.py index 27588b2..3f76666 100644 --- a/test_watchlist.py +++ b/test_watchlist.py @@ -41,7 +41,7 @@ async def test_watchlist_manager(): ) try: - item = watchlist_manager.create(test_user, item_data) + item = watchlist_manager.add(test_user, item_data) print(f" ✅ Item created: {item.id}") print(f" Title: {item.anime_title}") print(f" Status: {item.status}") @@ -127,8 +127,8 @@ async def test_scheduler(): print("\n2. Testing scheduler status...") try: - status = auto_download_scheduler.get_status() - print(f" ✅ Scheduler status: running={status['running']}") + running = auto_download_scheduler.is_running() + print(f" ✅ Scheduler status: running={running}") except Exception as e: print(f" ❌ Status failed: {e}") return False @@ -136,20 +136,18 @@ async def test_scheduler(): print("\n3. Testing scheduler start/stop...") try: # Start scheduler - await auto_download_scheduler.start() + auto_download_scheduler.start() print(" ✅ Scheduler started") - status = auto_download_scheduler.get_status() - if not status['running']: + if not auto_download_scheduler.is_running(): print(" ❌ Scheduler not running after start") return False # Stop scheduler - await auto_download_scheduler.stop() + auto_download_scheduler.stop() print(" ✅ Scheduler stopped") - status = auto_download_scheduler.get_status() - if status['running']: + if auto_download_scheduler.is_running(): print(" ❌ Scheduler still running after stop") return False diff --git a/test_watchlist_simple.py b/test_watchlist_simple.py index d2d691e..3186682 100644 --- a/test_watchlist_simple.py +++ b/test_watchlist_simple.py @@ -50,7 +50,7 @@ def test_watchlist_basics(): ) try: - item = watchlist_manager.create(test_user, item_data) + item = watchlist_manager.add(test_user, item_data) print(f" ✅ Item created: {item.id}") print(f" Title: {item.anime_title}") print(f" Status: {item.status}") @@ -178,7 +178,7 @@ async def test_scheduler(): print("🧪 TEST 3: Auto-Download Scheduler") print("="*60) - print("\n1. Testing scheduler start (async)...") + print("\n1. Testing scheduler start...") try: auto_download_scheduler.start() print(f" ✅ Scheduler started") diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..7f471ea --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,33 @@ +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'playwright/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + // Create user if not exists (global setup should have done it, but be safe) + const resp = await page.request.post('/api/auth/register', { + data: { + username: 'e2e_testuser', + password: 'TestPassword123!', + email: 'e2e@example.com', + full_name: 'E2E Test User', + }, + }); + if (!resp.ok() && resp.status() !== 400) { + console.warn('Register failed:', await resp.text()); + } + + // Login via UI + await page.goto('/login'); + await page.fill('#loginUsername', 'e2e_testuser'); + await page.fill('#loginPassword', 'TestPassword123!'); + + await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/api/auth/login')), + page.click('#loginSubmit'), + ]); + + await page.waitForURL('**/web**', { timeout: 10000 }); + + // Save storage state (localStorage + cookies) + await page.context().storageState({ path: authFile }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 9677ae4..3878134 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -1,119 +1,93 @@ import { test, expect } from '@playwright/test'; +import { TEST_USER, login } from './helpers'; test.describe('Auth Flow', () => { test('login success - redirects to home and stores token', async ({ page }) => { - await page.goto('/login'); - - // Fill login form - await page.fill('#loginUsername', 'testuser'); - await page.fill('#loginPassword', 'password123'); - - // Click login button - await page.click('#loginSubmit'); - - // Wait for redirect or success message - await page.waitForTimeout(2000); - - // Check if redirected or success message shown - const currentUrl = page.url(); - const successMessage = await page.locator('#authSuccess').textContent().catch(() => ''); - - // Either redirect happened or success message shown - expect(currentUrl.includes('/web') || successMessage.includes('réussie')).toBeTruthy(); + await login(page, TEST_USER.username, TEST_USER.password); + + // Verify redirect to /web + await expect(page).toHaveURL(/\/web/); + + // Verify token stored + const token = await page.evaluate(() => localStorage.getItem('auth_token')); + expect(token).toBeTruthy(); }); test('login with wrong credentials shows error', async ({ page }) => { await page.goto('/login'); - - // Fill login form with wrong credentials - await page.fill('#loginUsername', 'nonexistentuser'); + await page.fill('#loginUsername', 'nonexistentuser_xyz'); await page.fill('#loginPassword', 'wrongpassword'); - - // Click login button - await page.click('#loginSubmit'); - - // Wait for error - await page.waitForTimeout(2000); - - // Check error message is displayed - const errorVisible = await page.locator('#authError').isVisible().catch(() => false); - const errorText = await page.locator('#authError').textContent().catch(() => ''); - - // Error should be shown (and NOT be "[object Object]") - expect(errorVisible || errorText.length > 0).toBeTruthy(); - expect(errorText).not.toContain('[object Object]'); + + const [response] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/api/auth/login')), + page.click('#loginSubmit'), + ]); + + expect(response.status()).toBe(401); + + // Error message should be visible + const errorLocator = page.locator('#authError'); + await expect(errorLocator).toBeVisible(); + await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i); }); test('register new user shows success', async ({ page }) => { await page.goto('/login'); - - // Switch to register tab await page.click('text=Inscription'); - - // Fill register form with unique username - const uniqueUsername = 'testuser_' + Date.now(); + + const uniqueUsername = `testuser_${Date.now()}`; await page.fill('#registerUsername', uniqueUsername); await page.fill('#registerPassword', 'password123'); await page.fill('#registerPasswordConfirm', 'password123'); - - // Click register button - await page.click('#registerSubmit'); - - // Wait for success - await page.waitForTimeout(2000); - - // Check success message - const successVisible = await page.locator('#authSuccess').isVisible().catch(() => false); - const successText = await page.locator('#authSuccess').textContent().catch(() => ''); - - // Success should be shown - expect(successVisible || successText.includes('réussie')).toBeTruthy(); + + const [response] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/api/auth/register')), + page.click('#registerSubmit'), + ]); + + expect(response.status()).toBeLessThan(400); + + await expect(page.locator('#authSuccess')).toBeVisible(); + await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i); }); test('password mismatch shows validation error', async ({ page }) => { await page.goto('/login'); - - // Switch to register tab await page.click('text=Inscription'); - - // Fill register form with mismatching passwords + await page.fill('#registerUsername', 'testuser'); await page.fill('#registerPassword', 'password123'); await page.fill('#registerPasswordConfirm', 'differentpassword'); - - // Click register button await page.click('#registerSubmit'); - - // Wait for error - await page.waitForTimeout(1000); - - // Check error message - const errorText = await page.locator('#authError').textContent().catch(() => ''); - - // Should show password mismatch error - expect(errorText).toContain('correspondent'); + + await expect(page.locator('#authError')).toBeVisible(); + await expect(page.locator('#authError')).toContainText(/correspondent|match/i); }); test('login button shows loading state during request', async ({ page }) => { await page.goto('/login'); - - // Get button and check initial state const button = page.locator('#loginSubmit'); const initialText = await button.textContent(); - - // Fill form and click - await page.fill('#loginUsername', 'testuser'); - await page.fill('#loginPassword', 'password123'); - - // Click and immediately check loading state - await button.click(); - - // Check loading state (should change text or be disabled) - await page.waitForTimeout(100); - const buttonText = await button.textContent(); - const isDisabled = await button.isDisabled().catch(() => false); - - // Button should either show loading text or be disabled - expect(buttonText !== initialText || isDisabled).toBeTruthy(); + + await page.fill('#loginUsername', TEST_USER.username); + await page.fill('#loginPassword', TEST_USER.password); + + // Start the click but don't await it fully — we want to observe the loading state + const clickPromise = button.click(); + + // Poll briefly for loading state + let sawLoading = false; + for (let i = 0; i < 10; i++) { + const text = await button.textContent(); + const disabled = await button.isDisabled(); + if (text !== initialText || disabled) { + sawLoading = true; + break; + } + await page.waitForTimeout(50); + } + + await clickPromise; + expect(sawLoading).toBe(true); }); }); diff --git a/tests/e2e/downloads.spec.ts b/tests/e2e/downloads.spec.ts new file mode 100644 index 0000000..fbb617a --- /dev/null +++ b/tests/e2e/downloads.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from '@playwright/test'; +import { switchTab, waitForHtmx } from './helpers'; + +test.describe('Downloads', () => { + test('should display downloads tab', async ({ page }) => { + await page.goto('/web'); + await switchTab(page, 'Téléchargements'); + await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 }); + await expect(page.locator('#tab-downloads')).toBeVisible(); + }); +}); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..9c91c7f --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,29 @@ +/** + * Global setup for E2E tests. + * Creates a predictable test user so auth tests don't fail on missing accounts. + * Uses native fetch to avoid conflicts with vitest. + */ +export default async function globalSetup() { + const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000'; + + const testUser = { + username: 'e2e_testuser', + password: 'TestPassword123!', + email: 'e2e@example.com', + full_name: 'E2E Test User', + }; + + // Try to register the test user (ignore 400 if already exists) + const resp = await fetch(`${baseURL}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(testUser), + }); + + if (resp.ok || resp.status === 400) { + console.log(`[global-setup] Test user "${testUser.username}" ready`); + } else { + const body = await resp.text().catch(() => ''); + console.warn(`[global-setup] Register returned ${resp.status}: ${body}`); + } +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..acc3e61 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,81 @@ +import { Page, expect } from '@playwright/test'; + +export const TEST_USER = { + username: 'e2e_testuser', + password: 'TestPassword123!', +}; + +/** + * Log in via the UI login form. + */ +export async function login(page: Page, username: string, password: string) { + await page.goto('/login'); + await page.fill('#loginUsername', username); + await page.fill('#loginPassword', password); + + const [response] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/api/auth/login')), + page.click('#loginSubmit'), + ]); + + expect(response.status()).toBeLessThan(400); + + // Wait for success message or redirect + await Promise.race([ + page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 }), + page.waitForURL('**/web**', { timeout: 5000 }), + ]); +} + +/** + * Register a new unique user via the UI form. + */ +export async function register(page: Page, username: string, password: string) { + await page.goto('/login'); + await page.click('text=Inscription'); + + await page.fill('#registerUsername', username); + await page.fill('#registerPassword', password); + await page.fill('#registerPasswordConfirm', password); + + const [response] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/api/auth/register')), + page.click('#registerSubmit'), + ]); + + expect(response.status()).toBeLessThan(400); + + await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 }); +} + +/** + * Switch to a tab by name (Accueil, Anime, Série, Watchlist, etc.) + */ +export async function switchTab(page: Page, tabName: string) { + // Wait for tabs to be rendered + await page.locator('nav#mainTabs .tab').first().waitFor({ state: 'visible', timeout: 5000 }); + + const tab = page.locator('nav#mainTabs .tab', { hasText: new RegExp(tabName, 'i') }); + await tab.waitFor({ state: 'visible', timeout: 5000 }); + await tab.click(); + await expect(tab).toHaveClass(/active/); +} + +/** + * Wait for HTMX content to settle (no more hx-request in flight). + */ +export async function waitForHtmx(page: Page, timeout = 10000) { + await page.waitForFunction( + () => document.querySelectorAll('.htmx-request').length === 0, + { timeout } + ); +} + +/** + * Check that no unhandled JS errors occurred on the page. + */ +export function collectJsErrors(page: Page): string[] { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + return errors; +} diff --git a/tests/e2e/user_journey.spec.ts b/tests/e2e/user_journey.spec.ts index 804d0c7..dcdc81e 100644 --- a/tests/e2e/user_journey.spec.ts +++ b/tests/e2e/user_journey.spec.ts @@ -1,152 +1,90 @@ import { test, expect } from '@playwright/test'; +import { switchTab, waitForHtmx, collectJsErrors } from './helpers'; /** * User Journey E2E Tests * - * Simulates a complete user flow: register → login → browse → search → settings → logout. - * All tests are serial because they share browser state (auth token, navigation). - * - * FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector() + * Tests authenticated user flows. Auth is handled by auth.setup.ts + storageState. */ - test.describe('User Journey E2E', () => { - test.describe.configure({ mode: 'serial' }); + test('should browse homepage without JS errors', async ({ page }) => { + const jsErrors = collectJsErrors(page); + await page.goto('/web'); - const testData = { - username: `e2e_user_${Date.now()}`, - password: 'TestPass123!', - }; - - // Register a new user account via the UI form - test('should register a new user', async ({ page }) => { - await page.goto('/login'); - - // Switch to the register tab - await page.click('text=Inscription'); - - // Fill out the registration form - await page.fill('#registerUsername', testData.username); - await page.fill('#registerPassword', testData.password); - await page.fill('#registerPasswordConfirm', testData.password); - - // Submit and wait for the API response - const [response] = await Promise.all([ - page.waitForResponse((resp) => resp.url().includes('/api/auth/register')), - page.click('#registerSubmit'), - ]); - - // Registration should succeed (201 or 200) - expect(response.status()).toBeLessThan(400); - - // Verify the success message appears - await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 }); - const successText = await page.locator('#authSuccess').textContent(); - expect(successText).toMatch(/réussie|inscription/i); - }); - - // Login with the credentials registered in the previous test - test('should login with registered credentials', async ({ page }) => { - await page.goto('/login'); - - await page.fill('#loginUsername', testData.username); - await page.fill('#loginPassword', testData.password); - - // Submit and wait for the login API response - const [response] = await Promise.all([ - page.waitForResponse((resp) => resp.url().includes('/api/auth/login')), - page.click('#loginSubmit'), - ]); - - expect(response.status()).toBeLessThan(400); - - // Verify success message - await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 }); - const successText = await page.locator('#authSuccess').textContent(); - expect(successText).toMatch(/réussie/i); - - // Wait for redirect to /web - await page.waitForURL('**/web**', { timeout: 10000 }); - - // Verify the auth token is stored in localStorage - const token = await page.evaluate(() => localStorage.getItem('auth_token')); - expect(token).toBeTruthy(); - }); - - // Browse the homepage — verify layout loads without JS errors - test('should browse homepage without errors', async ({ page }) => { - // Collect JS page errors - const errors: string[] = []; - page.on('pageerror', (err) => errors.push(err.message)); - - // Ensure we are on /web (carried over from login) - if (!page.url().includes('/web')) { - await page.goto('/web'); - } - - // Wait for main content area to be visible - await page.locator('#main-content').waitFor({ state: 'visible', timeout: 10000 }); - - // Verify the header heading + // Main content should be visible + await expect(page.locator('#main-content')).toBeVisible(); await expect(page.locator('header h1')).toContainText('Ohm Stream'); - // Verify at least one navigation tab is visible + // At least one tab visible await expect(page.locator('.tab').first()).toBeVisible(); - // Verify the user info panel (logged-in state indicator) + // Authenticated user info should be visible await expect(page.locator('#userInfo')).toBeVisible(); - // No JavaScript errors should have been thrown - expect(errors).toHaveLength(0); + expect(jsErrors).toHaveLength(0); }); - // Search for an anime via the Anime tab — results may be empty but the UI must respond test('should search for anime', async ({ page }) => { - // Navigate to the Anime tab - await page.click('.tab:has-text("Anime")'); + // Mock the anime search API to return deterministic HTML + await page.route('/api/anime/search?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` +
+

Naruto Shippuden

+

Anime-Sama

+
+
+

Boruto: Naruto Next Generations

+

Neko-Sama

+
+ `, + }); + }); + + await page.goto('/web'); + await switchTab(page, 'Anime'); await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 }); - // Fill the search input — HTMX debounce triggers the request automatically await page.fill('#animeSearchInput', 'Naruto'); - // Wait for either results, an empty-state message, or the loading spinner to disappear - await Promise.race([ - page.locator('#animeSearchResults .sr-card').first().waitFor({ timeout: 15000 }), - page.locator('#animeSearchResults .sr-empty').first().waitFor({ timeout: 15000 }), - page.locator('#search-loading').waitFor({ state: 'detached', timeout: 15000 }), - ]); + // Click search button to trigger submit + await page.click('#tab-anime button[type="submit"]'); - // The search results container must be present regardless of result count + // Wait for results to appear + await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 }); + + // Results container should be visible and contain mocked data await expect(page.locator('#animeSearchResults')).toBeVisible(); + await expect(page.locator('#animeSearchResults')).toContainText('Naruto Shippuden'); }); - // Change a setting (language) and verify the PATCH response and toast notification test('should update settings', async ({ page }) => { - // Open the settings tab - await page.click('.tab:has-text("Paramètres")'); + await page.goto('/web'); + await switchTab(page, 'Paramètres'); - // Settings panel is loaded dynamically via HTMX — wait for the form + // Wait for settings form loaded via HTMX await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 }); - // Change the default language await page.selectOption('#default_lang', 'vf'); - // Submit the settings form and capture the PATCH response const [response] = await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH' ), - page.locator('form[hx-patch="/api/settings"] button[type="submit"]').click(), + page.locator('button:has-text("Enregistrer les preferences")').click(), ]); expect(response.status()).toBe(200); - // Verify a toast notification appears confirming the save - await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 }); + // Verify the setting was updated in the UI + await expect(page.locator('#default_lang')).toHaveValue('vf'); }); - // Logout — verify the API call succeeds, redirect happens, and token is cleared test('should logout successfully', async ({ page }) => { - // Click the logout button and wait for the API response + await page.goto('/web'); + const [response] = await Promise.all([ page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')), page.locator('#userInfo button:has-text("Déconnexion")').click(), @@ -154,7 +92,7 @@ test.describe('User Journey E2E', () => { expect(response.status()).toBeLessThan(400); - // Should be redirected back to the login page + // Should redirect to login await page.waitForURL('**/login**', { timeout: 10000 }); // The auth token must be cleared from localStorage diff --git a/tests/e2e/watchlist.spec.ts b/tests/e2e/watchlist.spec.ts new file mode 100644 index 0000000..cbbb122 --- /dev/null +++ b/tests/e2e/watchlist.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from '@playwright/test'; +import { switchTab, waitForHtmx } from './helpers'; + +test.describe('Watchlist', () => { + test('should display watchlist tab', async ({ page }) => { + await page.goto('/web'); + await switchTab(page, 'Watchlist'); + await page.locator('#tab-watchlist').waitFor({ state: 'visible', timeout: 5000 }); + await expect(page.locator('#tab-watchlist')).toBeVisible(); + }); +}); diff --git a/tests/test_api.py b/tests/test_api.py index e0d2a96..124b1f9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -426,7 +426,7 @@ class TestAPIFavorites: # Make sure it doesn't exist first try: client.delete("/api/favorites/test-toggle-add") - except: + except Exception: pass response = client.post( @@ -448,7 +448,7 @@ class TestAPIFavorites: # Make sure it doesn't exist first try: client.delete("/api/favorites/test-toggle-remove") - except: + except Exception: pass # Add first diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py index 3e86bce..046418d 100644 --- a/tests/test_download_manager.py +++ b/tests/test_download_manager.py @@ -354,7 +354,7 @@ class TestDownloadManagerErrorHandling: try: await manager.start_download(task.id) await asyncio.sleep(0.1) # Give it time to process - except: + except Exception: pass # The task should be in tasks dict diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 1866f31..0000000 --- a/vite.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - include: ['static/js/__tests__/**/*.test.js'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - reportsDirectory: 'htmlcov', - }, - }, -});