diff --git a/.github/actions/e2e-ready/action.yml b/.github/actions/e2e-ready/action.yml index 62c7fa46..502e561c 100644 --- a/.github/actions/e2e-ready/action.yml +++ b/.github/actions/e2e-ready/action.yml @@ -20,7 +20,7 @@ runs: sudo k3s kubectl config view --raw > /home/runner/.kube/config sudo chmod 600 /home/runner/.kube/config export KUBECONFIG=/home/runner/.kube/config - timeout 90 bash -c 'until kubectl cluster-info 2>/dev/null; do sleep 3; done' + kubectl wait --for=condition=Ready node --all --timeout=90s kubectl create namespace integr8scode --dry-run=client -o yaml | kubectl apply -f - sed -E 's#https://(127\.0\.0\.1|0\.0\.0\.0):6443#https://host.docker.internal:6443#g' \ /home/runner/.kube/config > backend/kubeconfig.yaml @@ -32,9 +32,11 @@ runs: cp backend/config.test.toml backend/config.toml cp backend/secrets.example.toml backend/secrets.toml - - name: Pre-pull test runtime image into K3s + - name: Pre-pull test runtime images into K3s shell: bash - run: sudo k3s crictl pull docker.io/library/python:3.11-slim + run: | + sudo k3s crictl pull docker.io/library/python:3.11-slim + sudo k3s crictl pull docker.io/library/busybox:1.36 - name: Wait for image pull and infra shell: bash diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bac34726..f041b180 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,7 +40,7 @@ jobs: run: uv tool install mkdocs --with mkdocs-material --with mkdocs-mermaid2-plugin --with mkdocs-swagger-ui-tag - name: Install backend dependencies - run: cd backend && uv sync --frozen + run: cd backend && uv sync --frozen --no-dev - name: Set up config for OpenAPI generation run: cp backend/secrets.example.toml backend/secrets.toml diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 34070e65..35820dc5 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -24,7 +24,7 @@ jobs: run: | cd backend uv python install 3.12 - uv sync --frozen + uv sync --frozen --group lint --no-dev - name: Run mypy env: diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index c670ce34..c81bfec0 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -24,7 +24,7 @@ jobs: run: | cd backend uv python install 3.12 - uv sync --frozen + uv sync --frozen --group lint --no-dev - name: Run ruff run: | diff --git a/.github/workflows/stack-tests.yml b/.github/workflows/stack-tests.yml index eb8c0a80..0f76bc3e 100644 --- a/.github/workflows/stack-tests.yml +++ b/.github/workflows/stack-tests.yml @@ -50,7 +50,7 @@ jobs: run: | cd backend uv python install 3.12 - uv sync --frozen + uv sync --frozen --group test --no-dev - name: Set up config run: cp backend/secrets.example.toml backend/secrets.toml @@ -170,7 +170,9 @@ jobs: # ── Backend (depends on local base image) ─────────────── - name: Build backend image run: | - docker build -t integr8scode-backend:latest --build-context base=docker-image://integr8scode-base:latest -f ./backend/Dockerfile ./backend + docker build -t integr8scode-backend:latest \ + --build-context base=docker-image://integr8scode-base:latest \ + -f ./backend/Dockerfile ./backend # ── Utility images (GHA-cached, independent of base) ──────────── - name: Build cert-generator image @@ -248,11 +250,11 @@ jobs: timeout-minutes: 15 run: | docker compose exec -T backend \ - uv run pytest tests/e2e -v -rs \ + sh -c 'uv sync --group test --no-dev --frozen --no-install-project && pytest tests/e2e -v -rs \ --durations=0 \ --cov=app \ --cov-report=xml:coverage-e2e.xml \ - --cov-report=term + --cov-report=term' - name: Copy coverage if: always() diff --git a/.github/workflows/vulture.yml b/.github/workflows/vulture.yml index 9d4c1e56..ba288996 100644 --- a/.github/workflows/vulture.yml +++ b/.github/workflows/vulture.yml @@ -24,7 +24,7 @@ jobs: run: | cd backend uv python install 3.12 - uv sync --frozen + uv sync --frozen --group lint --no-dev - name: Run vulture run: | diff --git a/backend/.dockerignore b/backend/.dockerignore index 5a9bec3f..75f3e4d7 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -23,3 +23,18 @@ htmlcov/ # Local dev files *.log .DS_Store + +# Tests and docs (not needed in production image) +tests/ +docs/ +*.md + +# Secrets and test/override configs (not needed in image) +secrets.toml +secrets.*.toml +config.test.toml +config.*.toml +!config.toml + +# Dead code detection whitelist +vulture_whitelist.py diff --git a/backend/Dockerfile.base b/backend/Dockerfile.base index 51556eb7..93f07fd7 100644 --- a/backend/Dockerfile.base +++ b/backend/Dockerfile.base @@ -1,14 +1,15 @@ # Shared base image for all backend services # Contains: Python, system deps, uv, and all Python dependencies -FROM python:3.12-slim +# Multi-stage build: gcc + dev headers only in builder, not in final image + +FROM python:3.12-slim AS builder WORKDIR /app -# Install OS security patches + system dependencies needed by any service -RUN apt-get update && apt-get upgrade -y \ +# Install build-time dependencies (gcc + dev headers for C extensions) +RUN apt-get update \ && apt-get install -y --no-install-recommends \ gcc \ - curl \ libsnappy-dev \ liblzma-dev \ && rm -rf /var/lib/apt/lists/* @@ -16,11 +17,36 @@ RUN apt-get update && apt-get upgrade -y \ # Install uv (using Docker Hub mirror - ghcr.io has rate limiting issues) COPY --from=astral/uv:latest /uv /uvx /bin/ +# Pre-compile bytecode for faster startup; copy mode avoids symlink issues with cache mounts +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + # Copy dependency files COPY pyproject.toml uv.lock ./ -# Install Python dependencies (production only) -RUN uv sync --locked --no-dev --no-install-project +# Install Python dependencies with BuildKit cache mount for faster rebuilds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + +FROM python:3.12-slim + +WORKDIR /app + +# Install only runtime dependencies (shared libs, no -dev headers, no gcc) +RUN apt-get update && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + curl \ + libsnappy1v5 \ + liblzma5 \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv from builder (avoids second Docker Hub pull, guarantees version consistency) +COPY --from=builder /bin/uv /bin/uvx /bin/ + +# Copy pre-built virtual environment from builder stage +COPY --from=builder /app/.venv /app/.venv + +# Copy dependency files (needed for uv to recognize the project) +COPY pyproject.toml uv.lock ./ # Set paths: PYTHONPATH for imports, PATH for venv binaries (no uv run needed at runtime) ENV PYTHONPATH=/app diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test deleted file mode 100644 index 515d95fa..00000000 --- a/backend/Dockerfile.test +++ /dev/null @@ -1,28 +0,0 @@ -# Test runner container - lightweight, uses same network as services -FROM python:3.12-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Install uv -COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /uvx /bin/ - -# Copy dependency files -COPY pyproject.toml uv.lock ./ - -# Install Python dependencies (including dev deps for testing) -RUN uv sync --frozen - -# Copy application code -COPY . . - -# Set Python path -ENV PYTHONPATH=/app - -# Default command runs all tests -CMD ["uv", "run", "pytest", "-v", "--tb=short"] diff --git a/backend/app/api/routes/admin/events.py b/backend/app/api/routes/admin/events.py index 2198ad13..9edf953e 100644 --- a/backend/app/api/routes/admin/events.py +++ b/backend/app/api/routes/admin/events.py @@ -6,6 +6,7 @@ from dishka.integrations.fastapi import DishkaRoute from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse from app.api.dependencies import admin_user from app.domain.enums import EventType, ExportFormat @@ -24,6 +25,16 @@ ) from app.schemas_pydantic.common import ErrorResponse from app.services.admin import AdminEventsService +from app.services.sse import SSEService + + +class _SSEResponse(EventSourceResponse): + """Workaround: sse-starlette sets media_type only in __init__, not as a + class attribute. FastAPI reads the class attribute for OpenAPI generation, + so without this subclass every SSE endpoint shows application/json.""" + + media_type = "text/event-stream" + router = APIRouter( prefix="/admin/events", tags=["admin-events"], route_class=DishkaRoute, dependencies=[Depends(admin_user)] @@ -133,16 +144,25 @@ async def replay_events( @router.get( "/replay/{session_id}/status", - responses={404: {"model": ErrorResponse, "description": "Replay session not found"}}, + response_class=_SSEResponse, + responses={ + 200: {"model": EventReplayStatusResponse}, + 404: {"model": ErrorResponse, "description": "Replay session not found"}, + }, ) -async def get_replay_status(session_id: str, service: FromDishka[AdminEventsService]) -> EventReplayStatusResponse: - """Get the status and progress of a replay session.""" - status = await service.get_replay_status(session_id) +async def stream_replay_status( + session_id: str, + service: FromDishka[AdminEventsService], + sse_service: FromDishka[SSEService], +) -> EventSourceResponse: + """Stream the status and progress of a replay session via SSE.""" + status = await service.get_replay_sse_status(session_id) if not status: raise HTTPException(status_code=404, detail="Replay session not found") - return EventReplayStatusResponse.model_validate(status) + stream = await sse_service.create_replay_stream(status) + return EventSourceResponse(stream, ping=15) @router.delete("/{event_id}", responses={404: {"model": ErrorResponse, "description": "Event not found"}}) diff --git a/backend/app/api/routes/saga.py b/backend/app/api/routes/saga.py index 07b0b292..fd6ff431 100644 --- a/backend/app/api/routes/saga.py +++ b/backend/app/api/routes/saga.py @@ -63,11 +63,12 @@ async def list_sagas( user: Annotated[User, Depends(current_user)], saga_service: FromDishka[SagaService], state: Annotated[SagaState | None, Query(description="Filter by saga state")] = None, + execution_id: Annotated[str | None, Query(description="Filter by execution ID")] = None, limit: Annotated[int, Query(ge=1, le=1000)] = 100, skip: Annotated[int, Query(ge=0)] = 0, ) -> SagaListResponse: """List sagas accessible by the current user.""" - result = await saga_service.list_user_sagas(user, state, limit, skip) + result = await saga_service.list_user_sagas(user, state, execution_id=execution_id, limit=limit, skip=skip) return SagaListResponse.model_validate(result) diff --git a/backend/app/core/container.py b/backend/app/core/container.py index e85a2bc7..afbce68a 100644 --- a/backend/app/core/container.py +++ b/backend/app/core/container.py @@ -150,6 +150,7 @@ def create_event_replay_container(settings: Settings) -> AsyncContainer: RepositoryProvider(), MessagingProvider(), DLQProvider(), + SSEProvider(), EventReplayProvider(), context={Settings: settings}, ) diff --git a/backend/app/core/providers.py b/backend/app/core/providers.py index 05cdcab1..f01ffcf3 100644 --- a/backend/app/core/providers.py +++ b/backend/app/core/providers.py @@ -90,6 +90,7 @@ def get_broker( logger=logger, client_id=f"integr8scode-{settings.SERVICE_NAME}", request_timeout_ms=settings.KAFKA_REQUEST_TIMEOUT_MS, + graceful_timeout=settings.KAFKA_GRACEFUL_TIMEOUT, middlewares=(KafkaTelemetryMiddleware(),), ) logger.info("Kafka broker created") @@ -725,10 +726,12 @@ def get_event_replay_service( kafka_producer: UnifiedProducer, replay_metrics: ReplayMetrics, logger: structlog.stdlib.BoundLogger, + sse_bus: SSERedisBus, ) -> EventReplayService: return EventReplayService( repository=replay_repository, producer=kafka_producer, replay_metrics=replay_metrics, logger=logger, + sse_bus=sse_bus, ) diff --git a/backend/app/db/repositories/admin/admin_events_repository.py b/backend/app/db/repositories/admin/admin_events_repository.py index 56c0cba9..05729bce 100644 --- a/backend/app/db/repositories/admin/admin_events_repository.py +++ b/backend/app/db/repositories/admin/admin_events_repository.py @@ -27,7 +27,9 @@ ResourceUsageDomain, UserEventCount, ) +from app.domain.replay import ReplayError as DomainReplayError from app.domain.replay import ReplayFilter +from app.domain.sse import DomainReplaySSEPayload class AdminEventsRepository: @@ -233,6 +235,29 @@ async def update_replay_session(self, session_id: str, updates: ReplaySessionUpd async def get_replay_session_doc(self, session_id: str) -> ReplaySessionDocument | None: return await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session_id) + async def get_replay_session_sse_status(self, session_id: str) -> DomainReplaySSEPayload | None: + doc = await self.get_replay_session_doc(session_id) + if not doc: + return None + return DomainReplaySSEPayload( + session_id=doc.session_id, + status=doc.status, + total_events=doc.total_events, + replayed_events=doc.replayed_events, + failed_events=doc.failed_events, + skipped_events=doc.skipped_events, + replay_id=doc.replay_id, + created_at=doc.created_at, + started_at=doc.started_at, + completed_at=doc.completed_at, + errors=[ + e if isinstance(e, DomainReplayError) + else DomainReplayError(**e) if isinstance(e, dict) + else DomainReplayError(**dataclasses.asdict(e)) + for e in doc.errors + ], + ) + async def get_execution_results_for_filter( self, replay_filter: ReplayFilter, ) -> list[ExecutionResultSummary]: diff --git a/backend/app/domain/sse/__init__.py b/backend/app/domain/sse/__init__.py index 8e5239c9..737b115e 100644 --- a/backend/app/domain/sse/__init__.py +++ b/backend/app/domain/sse/__init__.py @@ -1,9 +1,11 @@ from .models import ( DomainNotificationSSEPayload, + DomainReplaySSEPayload, SSEExecutionEventData, ) __all__ = [ - "SSEExecutionEventData", "DomainNotificationSSEPayload", + "DomainReplaySSEPayload", + "SSEExecutionEventData", ] diff --git a/backend/app/domain/sse/models.py b/backend/app/domain/sse/models.py index becae9ec..0ffa3ea0 100644 --- a/backend/app/domain/sse/models.py +++ b/backend/app/domain/sse/models.py @@ -9,9 +9,11 @@ NotificationChannel, NotificationSeverity, NotificationStatus, + ReplayStatus, SSEControlEvent, ) from app.domain.execution.models import ExecutionResultDomain +from app.domain.replay import ReplayError @dataclass @@ -45,3 +47,20 @@ class DomainNotificationSSEPayload: tags: list[str] = field(default_factory=list) channel: NotificationChannel = NotificationChannel.IN_APP read_at: datetime | None = None + + +@dataclass +class DomainReplaySSEPayload: + """Replay session status payload for Redis transport and SSE wire.""" + + session_id: str + status: ReplayStatus + total_events: int + replayed_events: int + failed_events: int + skipped_events: int + replay_id: str + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + errors: list[ReplayError] = field(default_factory=list) diff --git a/backend/app/events/handlers.py b/backend/app/events/handlers.py index 0a2e732a..93e4d6f4 100644 --- a/backend/app/events/handlers.py +++ b/backend/app/events/handlers.py @@ -252,9 +252,11 @@ async def on_execution_cancelled( def register_sse_subscriber(broker: KafkaBroker, settings: Settings) -> None: + group_id = "sse-bridge-pool" + @broker.subscriber( *_SSE_EVENT_TYPES, - group_id="sse-bridge-pool", + group_id=group_id, ack_policy=AckPolicy.ACK_FIRST, auto_offset_reset="latest", max_workers=settings.SSE_CONSUMER_POOL_SIZE, @@ -264,7 +266,7 @@ async def on_sse_event( sse_bus: FromDishka[SSERedisBus], event_metrics: FromDishka[EventMetrics], ) -> None: - event_metrics.record_kafka_message_consumed(topic=body.event_type, consumer_group="sse-bridge-pool") + event_metrics.record_kafka_message_consumed(topic=body.event_type, consumer_group=group_id) execution_id = getattr(body, "execution_id", None) if execution_id: sse_data = SSEExecutionEventData(**{ @@ -274,9 +276,11 @@ async def on_sse_event( def register_notification_subscriber(broker: KafkaBroker) -> None: + group_id = "notification-service" + @broker.subscriber( EventType.EXECUTION_COMPLETED, - group_id="notification-service", + group_id=group_id, ack_policy=AckPolicy.ACK, max_poll_records=10, auto_offset_reset="latest", @@ -286,12 +290,12 @@ async def on_execution_completed( service: FromDishka[NotificationService], event_metrics: FromDishka[EventMetrics], ) -> None: - await _track_consumed(event_metrics, body, "notification-service", + await _track_consumed(event_metrics, body, group_id, service.handle_execution_completed(body)) @broker.subscriber( EventType.EXECUTION_FAILED, - group_id="notification-service", + group_id=group_id, ack_policy=AckPolicy.ACK, max_poll_records=10, auto_offset_reset="latest", @@ -301,12 +305,12 @@ async def on_execution_failed( service: FromDishka[NotificationService], event_metrics: FromDishka[EventMetrics], ) -> None: - await _track_consumed(event_metrics, body, "notification-service", + await _track_consumed(event_metrics, body, group_id, service.handle_execution_failed(body)) @broker.subscriber( EventType.EXECUTION_TIMEOUT, - group_id="notification-service", + group_id=group_id, ack_policy=AckPolicy.ACK, max_poll_records=10, auto_offset_reset="latest", @@ -316,7 +320,7 @@ async def on_execution_timeout( service: FromDishka[NotificationService], event_metrics: FromDishka[EventMetrics], ) -> None: - await _track_consumed(event_metrics, body, "notification-service", + await _track_consumed(event_metrics, body, group_id, service.handle_execution_timeout(body)) diff --git a/backend/app/services/admin/admin_events_service.py b/backend/app/services/admin/admin_events_service.py index 28d434ee..0c87bf68 100644 --- a/backend/app/services/admin/admin_events_service.py +++ b/backend/app/services/admin/admin_events_service.py @@ -21,6 +21,7 @@ ) from app.domain.exceptions import NotFoundError, ValidationError from app.domain.replay import ReplayConfig, ReplayError, ReplayFilter +from app.domain.sse import DomainReplaySSEPayload from app.services.event_replay import EventReplayService _status_detail_fields = set(ReplaySessionStatusDetail.__dataclass_fields__) @@ -211,6 +212,9 @@ async def prepare_or_schedule_replay( async def start_replay_session(self, session_id: str) -> None: await self._replay_service.start_session(session_id) + async def get_replay_sse_status(self, session_id: str) -> DomainReplaySSEPayload | None: + return await self._repo.get_replay_session_sse_status(session_id) + async def get_replay_status(self, session_id: str) -> ReplaySessionStatusDetail | None: doc = await self._repo.get_replay_session_doc(session_id) if not doc: diff --git a/backend/app/services/event_replay/replay_service.py b/backend/app/services/event_replay/replay_service.py index f54058cf..0cdf7344 100644 --- a/backend/app/services/event_replay/replay_service.py +++ b/backend/app/services/event_replay/replay_service.py @@ -25,7 +25,9 @@ ReplaySessionNotFoundError, ReplaySessionState, ) +from app.domain.sse import DomainReplaySSEPayload from app.events.core import UnifiedProducer +from app.services.sse.redis_bus import SSERedisBus class EventReplayService: @@ -35,6 +37,7 @@ def __init__( producer: UnifiedProducer, replay_metrics: ReplayMetrics, logger: structlog.stdlib.BoundLogger, + sse_bus: SSERedisBus, ) -> None: self._sessions: dict[str, ReplaySessionState] = {} self._schedulers: dict[str, AsyncIOScheduler] = {} @@ -46,6 +49,7 @@ def __init__( self.logger = logger self._file_locks: dict[str, asyncio.Lock] = {} self._metrics = replay_metrics + self._sse_bus = sse_bus async def create_session_from_config(self, config: ReplayConfig) -> ReplayOperationResult: try: @@ -100,6 +104,7 @@ async def start_session(self, session_id: str) -> ReplayOperationResult: self._metrics.record_status_change(session_id, previous_status, ReplayStatus.RUNNING) self._metrics.record_speed_multiplier(session.config.speed_multiplier, session.config.replay_type) await self._repository.update_session_status(session_id, ReplayStatus.RUNNING) + await self._publish_replay_status(session) return ReplayOperationResult( session_id=session_id, status=ReplayStatus.RUNNING, message="Replay session started" ) @@ -398,3 +403,23 @@ async def _update_session_in_db(self, session: ReplaySessionState) -> None: await self._repository.update_replay_session(session_id=session.session_id, updates=session_update) except Exception as e: self.logger.error(f"Failed to update session in database: {e}") + await self._publish_replay_status(session) + + async def _publish_replay_status(self, session: ReplaySessionState) -> None: + try: + payload = DomainReplaySSEPayload( + session_id=session.session_id, + status=session.status, + total_events=session.total_events, + replayed_events=session.replayed_events, + failed_events=session.failed_events, + skipped_events=session.skipped_events, + replay_id=session.replay_id, + created_at=session.created_at, + started_at=session.started_at, + completed_at=session.completed_at, + errors=session.errors, + ) + await self._sse_bus.publish_replay_status(session.session_id, payload) + except Exception as e: + self.logger.error("Failed to publish replay status to SSE", error=str(e)) diff --git a/backend/app/services/saga/saga_service.py b/backend/app/services/saga/saga_service.py index 66512d55..ae80856a 100644 --- a/backend/app/services/saga/saga_service.py +++ b/backend/app/services/saga/saga_service.py @@ -96,16 +96,25 @@ async def get_execution_sagas( return await self.saga_repo.get_sagas_by_execution(execution_id, state, limit=limit, skip=skip) async def list_user_sagas( - self, user: User, state: SagaState | None = None, limit: int = 100, skip: int = 0 + self, + user: User, + state: SagaState | None = None, + *, + execution_id: str | None = None, + limit: int = 100, + skip: int = 0, ) -> SagaListResult: - """List sagas accessible by user.""" + """List sagas accessible by user, optionally filtered by execution_id.""" + if execution_id: + if not await self.check_execution_access(execution_id, user): + raise SagaAccessDeniedError(execution_id, user.user_id) + return await self.saga_repo.get_sagas_by_execution(execution_id, state, limit=limit, skip=skip) + saga_filter = SagaFilter(state=state) # Non-admin users can only see their own sagas if user.role != UserRole.ADMIN: user_execution_ids = await self.saga_repo.get_user_execution_ids(user.user_id) - # If user has no executions, return empty result immediately - # (empty list would bypass the filter in repository) if not user_execution_ids: self.logger.debug( "User has no executions, returning empty saga list", diff --git a/backend/app/services/sse/redis_bus.py b/backend/app/services/sse/redis_bus.py index cdfc7b6a..7141f516 100644 --- a/backend/app/services/sse/redis_bus.py +++ b/backend/app/services/sse/redis_bus.py @@ -8,10 +8,11 @@ from pydantic import TypeAdapter from app.core.metrics import ConnectionMetrics -from app.domain.sse import DomainNotificationSSEPayload, SSEExecutionEventData +from app.domain.sse import DomainNotificationSSEPayload, DomainReplaySSEPayload, SSEExecutionEventData _sse_event_adapter = TypeAdapter(SSEExecutionEventData) _notif_payload_adapter = TypeAdapter(DomainNotificationSSEPayload) +_replay_adapter = TypeAdapter(DomainReplaySSEPayload) class SSERedisBus: @@ -24,12 +25,14 @@ def __init__( connection_metrics: ConnectionMetrics, exec_prefix: str = "sse:exec:", notif_prefix: str = "sse:notif:", + replay_prefix: str = "sse:replay:", ) -> None: self._redis = redis_client self.logger = logger self._metrics = connection_metrics self._exec_prefix = exec_prefix self._notif_prefix = notif_prefix + self._replay_prefix = replay_prefix def _exec_channel(self, execution_id: str) -> str: return f"{self._exec_prefix}{execution_id}" @@ -72,3 +75,24 @@ async def listen_notifications(self, user_id: str) -> AsyncGenerator[DomainNotif self._metrics.record_sse_connection_duration(duration, "notifications") self._metrics.decrement_sse_connections("notifications") self.logger.info("SSE notification stream closed", user_id=user_id) + + def _replay_channel(self, session_id: str) -> str: + return f"{self._replay_prefix}{session_id}" + + async def publish_replay_status(self, session_id: str, status: DomainReplaySSEPayload) -> None: + await self._redis.publish(self._replay_channel(session_id), _replay_adapter.dump_json(status)) + + async def listen_replay(self, session_id: str) -> AsyncGenerator[DomainReplaySSEPayload, None]: + start = datetime.now(timezone.utc) + self._metrics.increment_sse_connections("replay") + self.logger.info("SSE replay stream opened", session_id=session_id) + try: + async with self._redis.pubsub(ignore_subscribe_messages=True) as pubsub: + await pubsub.subscribe(self._replay_channel(session_id)) + async for message in pubsub.listen(): + yield _replay_adapter.validate_json(message["data"]) + finally: + duration = (datetime.now(timezone.utc) - start).total_seconds() + self._metrics.record_sse_connection_duration(duration, "replay") + self._metrics.decrement_sse_connections("replay") + self.logger.info("SSE replay stream closed", session_id=session_id) diff --git a/backend/app/services/sse/sse_service.py b/backend/app/services/sse/sse_service.py index 78a7b821..0058d597 100644 --- a/backend/app/services/sse/sse_service.py +++ b/backend/app/services/sse/sse_service.py @@ -6,15 +6,16 @@ from pydantic import TypeAdapter from app.db.repositories import ExecutionRepository -from app.domain.enums import EventType, SSEControlEvent, UserRole +from app.domain.enums import EventType, ReplayStatus, SSEControlEvent, UserRole from app.domain.exceptions import ForbiddenError from app.domain.execution import ExecutionNotFoundError from app.domain.execution.models import DomainExecution -from app.domain.sse import DomainNotificationSSEPayload, SSEExecutionEventData +from app.domain.sse import DomainNotificationSSEPayload, DomainReplaySSEPayload, SSEExecutionEventData from app.services.sse.redis_bus import SSERedisBus _exec_adapter = TypeAdapter(SSEExecutionEventData) _notif_adapter = TypeAdapter(DomainNotificationSSEPayload) +_replay_adapter = TypeAdapter(DomainReplaySSEPayload) _TERMINAL_TYPES: frozenset[EventType | SSEControlEvent] = frozenset({ EventType.RESULT_STORED, @@ -23,6 +24,12 @@ EventType.RESULT_FAILED, }) +_TERMINAL_REPLAY_STATUSES: frozenset[ReplayStatus] = frozenset({ + ReplayStatus.COMPLETED, + ReplayStatus.FAILED, + ReplayStatus.CANCELLED, +}) + class SSEService: """SSE service — transforms bus events and DB state into SSE wire format.""" @@ -75,3 +82,25 @@ async def _execution_pipeline( async def create_notification_stream(self, user_id: str) -> AsyncGenerator[dict[str, Any], None]: async for payload in self._bus.listen_notifications(user_id): yield {"event": "notification", "data": _notif_adapter.dump_json(payload).decode()} + + async def create_replay_stream( + self, initial_status: DomainReplaySSEPayload + ) -> AsyncGenerator[dict[str, Any], None]: + """Return the replay event stream generator. + + Caller (route) handles validation and initial DB fetch. + """ + return self._replay_pipeline(initial_status) + + async def _replay_pipeline( + self, initial_status: DomainReplaySSEPayload + ) -> AsyncGenerator[dict[str, Any], None]: + session_id = initial_status.session_id + yield {"data": _replay_adapter.dump_json(initial_status).decode()} + if initial_status.status in _TERMINAL_REPLAY_STATUSES: + return + async for status in self._bus.listen_replay(session_id): + self._logger.info("SSE replay event", session_id=session_id, status=status.status) + yield {"data": _replay_adapter.dump_json(status).decode()} + if status.status in _TERMINAL_REPLAY_STATUSES: + return diff --git a/backend/app/settings.py b/backend/app/settings.py index dcdea5ec..70425bd1 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -98,6 +98,7 @@ def __init__( KAFKA_MAX_POLL_INTERVAL_MS: int = 300000 KAFKA_MAX_POLL_RECORDS: int = 500 KAFKA_REQUEST_TIMEOUT_MS: int = 40000 + KAFKA_GRACEFUL_TIMEOUT: float = 15.0 # SSE Configuration SSE_CONSUMER_POOL_SIZE: int = 10 # Number of consumers in the partitioned pool diff --git a/backend/config.test.toml b/backend/config.test.toml index 2d259e56..4aa46cdd 100644 --- a/backend/config.test.toml +++ b/backend/config.test.toml @@ -29,13 +29,14 @@ EVENT_RETENTION_DAYS = 30 KAFKA_CONSUMER_GROUP_ID = "integr8scode-backend" KAFKA_AUTO_OFFSET_RESET = "earliest" KAFKA_ENABLE_AUTO_COMMIT = true -KAFKA_SESSION_TIMEOUT_MS = 10000 -KAFKA_HEARTBEAT_INTERVAL_MS = 3000 -KAFKA_REQUEST_TIMEOUT_MS = 15000 +KAFKA_SESSION_TIMEOUT_MS = 6000 +KAFKA_HEARTBEAT_INTERVAL_MS = 2000 +KAFKA_REQUEST_TIMEOUT_MS = 5000 KAFKA_MAX_POLL_RECORDS = 500 +KAFKA_GRACEFUL_TIMEOUT = 5.0 # SSE -SSE_CONSUMER_POOL_SIZE = 10 +SSE_CONSUMER_POOL_SIZE = 1 SSE_HEARTBEAT_INTERVAL = 30 LOG_LEVEL = "WARNING" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9f0fa172..b4efe3ae 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,8 +23,6 @@ dependencies = [ "click==8.3.1", "ConfigArgParse==1.7.1", "aiokafka==0.12.0", - "contourpy==1.3.3", - "cycler==0.12.1", "Deprecated==1.2.14", "dishka==1.7.2", "dnspython==2.8.0", @@ -32,7 +30,6 @@ dependencies = [ "email-validator==2.3.0", "exceptiongroup==1.2.2", "fastapi==0.128.0", - "fonttools==4.61.1", "frozenlist==1.7.0", "google-auth==2.47.0", "googleapis-common-protos==1.70.0", @@ -47,7 +44,6 @@ dependencies = [ "importlib-resources==6.5.2", "itsdangerous==2.2.0", "Jinja2==3.1.6", - "kiwisolver==1.4.9", "kubernetes_asyncio==34.3.3", "limits==5.6.0", "markdown-it-py==4.0.0", @@ -63,12 +59,10 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-http==1.39.1", "opentelemetry-exporter-prometheus==0.60b1", "opentelemetry-instrumentation==0.60b1", - "opentelemetry-instrumentation-asgi==0.60b1", "opentelemetry-instrumentation-fastapi==0.60b1", "opentelemetry-instrumentation-httpx==0.60b1", "opentelemetry-instrumentation-logging==0.60b1", "opentelemetry-instrumentation-pymongo==0.60b1", - "opentelemetry-instrumentation-redis==0.60b1", "opentelemetry-propagator-b3==1.39.1", "opentelemetry-proto==1.39.1", "opentelemetry-sdk==1.39.1", @@ -90,7 +84,6 @@ dependencies = [ "Pygments==2.19.2", "PyJWT==2.9.0", "pymongo==4.12.1", - "pyparsing==3.3.2", "python-dateutil==2.9.0.post0", "python-json-logger==2.0.7", "python-multipart==0.0.22", @@ -108,7 +101,6 @@ dependencies = [ "sortedcontainers==2.4.0", "sse-starlette==3.2.0", "starlette==0.49.1", - "tiktoken==0.11.0", "tomli==2.0.2", "typing_extensions==4.12.2", "urllib3==2.6.3", @@ -133,25 +125,35 @@ build-backend = "hatchling.build" packages = ["app", "workers"] [dependency-groups] -dev = [ - "async-asgi-testclient>=1.4.11", +test = [ + "async-asgi-testclient==1.4.11", "coverage==7.13.0", "hypothesis==6.151.6", "iniconfig==2.3.0", - "matplotlib==3.10.8", - "mypy==1.19.1", - "mypy_extensions==1.1.0", - "pipdeptree==2.23.4", "pluggy==1.5.0", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-cov==5.0.0", "pytest-env==1.2.0", + "pytest-timeout==2.3.1", "pytest-xdist==3.6.1", +] +lint = [ + "mypy==1.19.1", + "mypy_extensions==1.1.0", "ruff==0.14.10", "types-cachetools==6.2.0.20250827", "vulture==2.14", ] +load = [ + "matplotlib==3.10.8", +] +dev = [ + { include-group = "test" }, + { include-group = "lint" }, + { include-group = "load" }, + "pipdeptree==2.23.4", +] # Ruff configuration [tool.ruff] @@ -219,6 +221,8 @@ log_cli = false log_cli_level = "ERROR" log_level = "ERROR" addopts = "--tb=short -n auto --dist=loadfile" +timeout = 120 +timeout_method = "signal" # pytest-env: Sets env vars before test execution [tool.pytest_env] diff --git a/backend/tests/e2e/conftest.py b/backend/tests/e2e/conftest.py index a7e340f6..e55b06e0 100644 --- a/backend/tests/e2e/conftest.py +++ b/backend/tests/e2e/conftest.py @@ -1,148 +1,96 @@ import asyncio -import json -import logging import uuid -from collections.abc import AsyncGenerator, Callable -from contextlib import suppress +from collections.abc import Callable import pytest import pytest_asyncio -from aiokafka import AIOKafkaConsumer +import redis.asyncio as redis from app.db.docs.saga import SagaDocument from app.domain.enums import EventType, UserRole -from app.domain.events import DomainEvent, DomainEventAdapter, SagaStartedEvent +from app.domain.sse import SSEExecutionEventData from app.schemas_pydantic.execution import ExecutionRequest, ExecutionResponse from app.schemas_pydantic.notification import NotificationListResponse, NotificationResponse from app.schemas_pydantic.saga import SagaStatusResponse from app.schemas_pydantic.saved_script import SavedScriptCreateRequest from app.schemas_pydantic.user import UserCreate -from app.settings import Settings from httpx import AsyncClient +from pydantic import TypeAdapter -_logger = logging.getLogger("test.event_waiter") +_sse_adapter = TypeAdapter(SSEExecutionEventData) -# Event types that indicate execution result is stored in MongoDB RESULT_EVENT_TYPES = frozenset({EventType.RESULT_STORED, EventType.RESULT_FAILED}) -class EventWaiter: - """Async Kafka consumer that resolves futures when matching events arrive. +async def wait_for_sse_event( + redis_client: redis.Redis, + execution_id: str, + predicate: Callable[[SSEExecutionEventData], bool], + *, + timeout: float = 15.0, +) -> SSEExecutionEventData: + """Subscribe to execution's Redis SSE channel and await matching event. - Session-scoped: one consumer shared by all tests. Events are buffered so - a predicate registered after an event was consumed still matches it. + The SSE bridge publishes all execution lifecycle events to + sse:exec:{execution_id}. Pure event-driven — no polling. """ + channel = f"sse:exec:{execution_id}" + async with asyncio.timeout(timeout): + async with redis_client.pubsub(ignore_subscribe_messages=True) as pubsub: + await pubsub.subscribe(channel) + async for message in pubsub.listen(): + event = _sse_adapter.validate_json(message["data"]) + if predicate(event): + return event + raise AssertionError("unreachable") + + +async def wait_for_result( + redis_client: redis.Redis, + execution_id: str, + *, + timeout: float = 30.0, +) -> SSEExecutionEventData: + """Wait for RESULT_STORED or RESULT_FAILED.""" + return await wait_for_sse_event( + redis_client, execution_id, + lambda e: e.event_type in RESULT_EVENT_TYPES, + timeout=timeout, + ) + - def __init__(self, bootstrap_servers: str, topics: list[str]) -> None: - self._waiters: list[tuple[Callable[[DomainEvent], bool], asyncio.Future[DomainEvent]]] = [] - self._buffer: list[DomainEvent] = [] - self._consumer = AIOKafkaConsumer( - *topics, - bootstrap_servers=bootstrap_servers, - group_id=f"test-event-waiter-{uuid.uuid4().hex[:6]}", - auto_offset_reset="latest", - enable_auto_commit=True, - ) - self._task: asyncio.Task[None] | None = None - - async def start(self) -> None: - await self._consumer.start() - # Wait for partition assignment so no events are missed - while not self._consumer.assignment(): - await asyncio.sleep(0.05) - self._task = asyncio.create_task(self._consume_loop()) - - async def stop(self) -> None: - if self._task: - self._task.cancel() - with suppress(asyncio.CancelledError): - await self._task - await self._consumer.stop() - - async def _consume_loop(self) -> None: - async for msg in self._consumer: - try: - payload = json.loads(msg.value.decode()) - event = DomainEventAdapter.validate_python(payload) - except Exception: - continue - self._buffer.append(event) - for predicate, future in list(self._waiters): - if not future.done() and predicate(event): - future.set_result(event) - - async def wait_for( - self, - predicate: Callable[[DomainEvent], bool], - timeout: float = 15.0, - ) -> DomainEvent: - """Wait for a Kafka event matching predicate. No polling — pure async.""" - # Check buffer first (event may have arrived before this call) - for event in self._buffer: - if predicate(event): - return event - # Not in buffer — register waiter and await - future: asyncio.Future[DomainEvent] = asyncio.get_running_loop().create_future() - entry = (predicate, future) - self._waiters.append(entry) - try: - return await asyncio.wait_for(future, timeout=timeout) - finally: - if entry in self._waiters: - self._waiters.remove(entry) - - async def wait_for_result(self, execution_id: str, timeout: float = 30.0) -> DomainEvent: - """Wait for RESULT_STORED or RESULT_FAILED for *execution_id*.""" - return await self.wait_for( - lambda e: ( - e.event_type in RESULT_EVENT_TYPES - and e.execution_id == execution_id # type: ignore[union-attr] - ), - timeout=timeout, - ) - - async def wait_for_saga_command(self, execution_id: str, timeout: float = 15.0) -> DomainEvent: - """Wait for CREATE_POD_COMMAND for *execution_id*.""" - return await self.wait_for( - lambda e: ( - e.event_type == EventType.CREATE_POD_COMMAND - and e.execution_id == execution_id - ), - timeout=timeout, - ) - - async def wait_for_saga_started(self, execution_id: str, timeout: float = 15.0) -> SagaStartedEvent: - """Wait for SAGA_STARTED — saga document is guaranteed in MongoDB after this.""" - event = await self.wait_for( - lambda e: ( - e.event_type == EventType.SAGA_STARTED - and e.execution_id == execution_id - ), - timeout=timeout, - ) - assert isinstance(event, SagaStartedEvent) - return event - - async def wait_for_notification_created(self, execution_id: str, timeout: float = 15.0) -> DomainEvent: - """Wait for NOTIFICATION_CREATED — notification is guaranteed in MongoDB after this.""" - exec_tag = f"exec:{execution_id}" - return await self.wait_for( - lambda e: ( - e.event_type == EventType.NOTIFICATION_CREATED - and exec_tag in e.tags - ), - timeout=timeout, - ) - - -@pytest_asyncio.fixture(scope="session") -async def event_waiter(test_settings: Settings) -> AsyncGenerator[EventWaiter, None]: - """Session-scoped Kafka event waiter. Starts before any test produces events.""" - topics: list[str] = list(EventType) - waiter = EventWaiter(test_settings.KAFKA_BOOTSTRAP_SERVERS, topics) - await waiter.start() - _logger.info("EventWaiter started on %d topics", len(topics)) - yield waiter - await waiter.stop() +async def wait_for_pod_created( + redis_client: redis.Redis, + execution_id: str, + *, + timeout: float = 15.0, +) -> SSEExecutionEventData: + """Wait for POD_CREATED — implies saga started + command dispatched.""" + return await wait_for_sse_event( + redis_client, execution_id, + lambda e: e.event_type == EventType.POD_CREATED, + timeout=timeout, + ) + + +async def wait_for_notification( + redis_client: redis.Redis, + user_id: str, + *, + timeout: float = 30.0, +) -> None: + """Wait for a notification on the user's SSE channel. + + The notification service publishes to sse:notif:{user_id} only after + persisting to MongoDB, so receiving a message is a correct readiness + signal — unlike RESULT_STORED which comes from an independent consumer + group with no ordering guarantee. + """ + channel = f"sse:notif:{user_id}" + async with asyncio.timeout(timeout): + async with redis_client.pubsub(ignore_subscribe_messages=True) as pubsub: + await pubsub.subscribe(channel) + async for _message in pubsub.listen(): + return # first message = notification persisted @pytest.fixture @@ -233,21 +181,20 @@ async def created_execution_admin( @pytest_asyncio.fixture async def execution_with_saga( - event_waiter: EventWaiter, + redis_client: redis.Redis, created_execution: ExecutionResponse, ) -> tuple[ExecutionResponse, SagaStatusResponse]: - """Execution with saga guaranteed in MongoDB (via SAGA_STARTED event). + """Execution with saga guaranteed in MongoDB (via POD_CREATED event). - The saga orchestrator publishes SAGA_STARTED after persisting the saga - document to MongoDB. Once EventWaiter resolves the event, the document - is definitively in MongoDB. We query Beanie directly (same DB, no HTTP - round-trip) for a deterministic, sleep-free lookup. + POD_CREATED arrives after the saga orchestrator has persisted the saga + document and dispatched the create-pod command. We query Beanie directly + (same DB, no HTTP round-trip) for a deterministic, sleep-free lookup. """ - await event_waiter.wait_for_saga_started(created_execution.execution_id) + await wait_for_pod_created(redis_client, created_execution.execution_id) doc = await SagaDocument.find_one(SagaDocument.execution_id == created_execution.execution_id) assert doc is not None, ( - f"No saga document for {created_execution.execution_id} despite CREATE_POD_COMMAND received" + f"No saga document for {created_execution.execution_id} despite POD_CREATED received" ) saga = SagaStatusResponse.model_validate(doc, from_attributes=True) @@ -258,20 +205,24 @@ async def execution_with_saga( @pytest_asyncio.fixture async def execution_with_notification( test_user: AsyncClient, - event_waiter: EventWaiter, + redis_client: redis.Redis, created_execution: ExecutionResponse, ) -> tuple[ExecutionResponse, NotificationResponse]: - """Execution with notification guaranteed in MongoDB (via NOTIFICATION_CREATED event). + """Execution with notification guaranteed in MongoDB. - The notification service publishes NOTIFICATION_CREATED after persisting - the notification to MongoDB. Once EventWaiter resolves the event, the - document is definitively in MongoDB. + Waits on sse:notif:{user_id} — the notification service publishes there + only after persisting to MongoDB. Unlike RESULT_STORED (which comes from + an independent consumer group), this is a correct readiness signal. """ - await event_waiter.wait_for_notification_created(created_execution.execution_id) + me_resp = await test_user.get("/api/v1/auth/me") + assert me_resp.status_code == 200 + user_id = me_resp.json()["user_id"] + + await wait_for_notification(redis_client, user_id) + resp = await test_user.get("/api/v1/notifications", params={"limit": 10}) assert resp.status_code == 200 result = NotificationListResponse.model_validate(resp.json()) - assert result.notifications, "No notification despite NOTIFICATION_CREATED received" + assert result.notifications, "No notification after SSE delivery" notification = result.notifications[0] - assert created_execution.execution_id in (notification.subject + " ".join(notification.tags)) return created_execution, notification diff --git a/backend/tests/e2e/services/sse/test_partitioned_event_router.py b/backend/tests/e2e/services/sse/test_partitioned_event_router.py index 0ee48c3d..5e44d3d3 100644 --- a/backend/tests/e2e/services/sse/test_partitioned_event_router.py +++ b/backend/tests/e2e/services/sse/test_partitioned_event_router.py @@ -1,10 +1,10 @@ import asyncio -import structlog from unittest.mock import MagicMock from uuid import uuid4 import pytest import redis.asyncio as redis +import structlog from app.core.metrics import ConnectionMetrics from app.domain.enums import EventType from app.domain.sse import SSEExecutionEventData @@ -41,4 +41,4 @@ async def test_bus_routes_event_to_redis(redis_client: redis.Redis, test_setting await pub_task assert msg is not None - assert str(msg.event_type) == str(ev.event_type) + assert msg.event_type == ev.event_type diff --git a/backend/tests/e2e/test_admin_events_routes.py b/backend/tests/e2e/test_admin_events_routes.py index 0bad3ed7..c4020e8e 100644 --- a/backend/tests/e2e/test_admin_events_routes.py +++ b/backend/tests/e2e/test_admin_events_routes.py @@ -1,3 +1,4 @@ +import json as json_mod import uuid import pytest @@ -16,6 +17,8 @@ EventReplayStatusResponse, EventStatsResponse, ) +from app.services.admin import AdminEventsService +from app.services.sse import SSEService from dishka import AsyncContainer from httpx import AsyncClient @@ -363,11 +366,11 @@ async def test_replay_events_forbidden_for_regular_user( assert response.status_code == 403 -class TestGetReplayStatus: - """Tests for GET /api/v1/admin/events/replay/{session_id}/status.""" +class TestStreamReplayStatus: + """Tests for GET /api/v1/admin/events/replay/{session_id}/status (SSE).""" @pytest.mark.asyncio - async def test_get_replay_status_not_found( + async def test_stream_replay_status_not_found( self, test_admin: AsyncClient ) -> None: """Get nonexistent replay session returns 404.""" @@ -378,10 +381,10 @@ async def test_get_replay_status_not_found( assert response.status_code == 404 @pytest.mark.asyncio - async def test_get_replay_status_after_replay( - self, test_admin: AsyncClient, stored_event: DomainEvent + async def test_stream_replay_status_after_replay( + self, test_admin: AsyncClient, stored_event: DomainEvent, scope: AsyncContainer ) -> None: - """Get replay status after starting a replay.""" + """Replay creates a session whose SSE pipeline yields correct initial status.""" request = EventReplayRequest( aggregate_id=stored_event.aggregate_id, dry_run=False, @@ -394,24 +397,32 @@ async def test_get_replay_status_after_replay( replay_result = EventReplayResponse.model_validate(replay_response.json()) assert replay_result.session_id is not None - status_response = await test_admin.get( - f"/api/v1/admin/events/replay/{replay_result.session_id}/status" + # httpx ASGITransport cannot stream SSE (buffers entire body). + # Test the pipeline directly via DI — first yield is the initial status. + admin_service = await scope.get(AdminEventsService) + initial_status = await admin_service.get_replay_sse_status(replay_result.session_id) + assert initial_status is not None + + sse_service = await scope.get(SSEService) + stream = await sse_service.create_replay_stream(initial_status) + first_event = await anext(stream) + status = EventReplayStatusResponse.model_validate( + json_mod.loads(first_event["data"]) ) - - assert status_response.status_code == 200 - status = EventReplayStatusResponse.model_validate(status_response.json()) assert status.session_id == replay_result.session_id - # After scheduling a replay (dry_run=False), status is SCHEDULED or RUNNING if it started quickly - assert status.status in (ReplayStatus.SCHEDULED, ReplayStatus.RUNNING) + assert status.status in ( + ReplayStatus.SCHEDULED, ReplayStatus.CREATED, + ReplayStatus.RUNNING, ReplayStatus.COMPLETED, + ) assert status.total_events >= 1 assert status.replayed_events >= 0 assert status.progress_percentage >= 0.0 @pytest.mark.asyncio - async def test_get_replay_status_forbidden_for_regular_user( + async def test_stream_replay_status_forbidden_for_regular_user( self, test_user: AsyncClient ) -> None: - """Regular user cannot get replay status.""" + """Regular user cannot stream replay status.""" response = await test_user.get( "/api/v1/admin/events/replay/some-session/status" ) diff --git a/backend/tests/e2e/test_execution_routes.py b/backend/tests/e2e/test_execution_routes.py index d83bfd8c..e5b15546 100644 --- a/backend/tests/e2e/test_execution_routes.py +++ b/backend/tests/e2e/test_execution_routes.py @@ -8,6 +8,7 @@ import asyncio import pytest +import redis.asyncio as redis from app.domain.enums import EventType, ExecutionStatus from app.domain.events import ExecutionDomainEvent from app.schemas_pydantic.execution import ( @@ -24,7 +25,7 @@ from httpx import AsyncClient from pydantic import TypeAdapter -from tests.e2e.conftest import EventWaiter +from tests.e2e.conftest import wait_for_pod_created, wait_for_result pytestmark = [pytest.mark.e2e, pytest.mark.k8s] @@ -50,16 +51,16 @@ async def submit_and_wait( client: AsyncClient, - waiter: EventWaiter, + redis_client: redis.Redis, request: ExecutionRequest, *, timeout: float = 30.0, ) -> tuple[ExecutionResponse, ExecutionResult]: - """Submit script and wait for result via Kafka event — no polling.""" + """Submit script and wait for result via Redis pub/sub — no polling.""" resp = await client.post("/api/v1/execute", json=request.model_dump()) assert resp.status_code == 200 execution = ExecutionResponse.model_validate(resp.json()) - await waiter.wait_for_result(execution.execution_id, timeout=timeout) + await wait_for_result(redis_client, execution.execution_id, timeout=timeout) result_resp = await client.get(f"/api/v1/executions/{execution.execution_id}/result") assert result_resp.status_code == 200 return execution, ExecutionResult.model_validate(result_resp.json()) @@ -82,10 +83,10 @@ class TestExecutionHappyPath: @pytest.mark.asyncio async def test_execute_simple_script_completes( - self, test_user: AsyncClient, event_waiter: EventWaiter, simple_execution_request: ExecutionRequest + self, test_user: AsyncClient, redis_client: redis.Redis, simple_execution_request: ExecutionRequest ) -> None: """Simple script executes and completes successfully.""" - exec_response, result = await submit_and_wait(test_user, event_waiter, simple_execution_request) + exec_response, result = await submit_and_wait(test_user, redis_client, simple_execution_request) assert exec_response.execution_id assert result.status == ExecutionStatus.COMPLETED @@ -97,7 +98,7 @@ async def test_execute_simple_script_completes( assert result.exit_code == 0 @pytest.mark.asyncio - async def test_execute_multiline_output(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_execute_multiline_output(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Script with multiple print statements produces correct output.""" request = ExecutionRequest( script="print('Line 1')\nprint('Line 2')\nprint('Line 3')", @@ -105,14 +106,14 @@ async def test_execute_multiline_output(self, test_user: AsyncClient, event_wait lang_version="3.11", ) - _, result = await submit_and_wait(test_user, event_waiter, request) + _, result = await submit_and_wait(test_user, redis_client, request) assert result.status == ExecutionStatus.COMPLETED assert result.stdout is not None assert result.stdout.strip() == "Line 1\nLine 2\nLine 3" @pytest.mark.asyncio - async def test_execute_tracks_resource_usage(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_execute_tracks_resource_usage(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Execution tracks resource usage metrics.""" request = ExecutionRequest( script="import time; data = list(range(10000)); time.sleep(0.1); print('done')", @@ -120,7 +121,7 @@ async def test_execute_tracks_resource_usage(self, test_user: AsyncClient, event lang_version="3.11", ) - _, result = await submit_and_wait(test_user, event_waiter, request) + _, result = await submit_and_wait(test_user, redis_client, request) assert result.status == ExecutionStatus.COMPLETED assert result.resource_usage is not None @@ -131,7 +132,7 @@ async def test_execute_tracks_resource_usage(self, test_user: AsyncClient, event assert result.exit_code == 0 @pytest.mark.asyncio - async def test_execute_large_output(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_execute_large_output(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Script with large output completes successfully.""" request = ExecutionRequest( script="for i in range(500): print(f'Line {i}: ' + 'x' * 50)\nprint('END')", @@ -139,7 +140,7 @@ async def test_execute_large_output(self, test_user: AsyncClient, event_waiter: lang_version="3.11", ) - _, result = await submit_and_wait(test_user, event_waiter, request, timeout=120) + _, result = await submit_and_wait(test_user, redis_client, request, timeout=120) assert result.status == ExecutionStatus.COMPLETED assert result.stdout is not None @@ -153,7 +154,7 @@ class TestExecutionErrors: """Tests for execution error handling.""" @pytest.mark.asyncio - async def test_execute_syntax_error(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_execute_syntax_error(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Script with syntax error fails with proper error info.""" request = ExecutionRequest( script="def broken(\n pass", # Missing closing paren @@ -161,7 +162,7 @@ async def test_execute_syntax_error(self, test_user: AsyncClient, event_waiter: lang_version="3.11", ) - _, result = await submit_and_wait(test_user, event_waiter, request) + _, result = await submit_and_wait(test_user, redis_client, request) # Script errors result in COMPLETED status with non-zero exit code # FAILED is reserved for infrastructure/timeout failures @@ -171,7 +172,7 @@ async def test_execute_syntax_error(self, test_user: AsyncClient, event_waiter: assert result.exit_code != 0 @pytest.mark.asyncio - async def test_execute_runtime_error(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_execute_runtime_error(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Script with runtime error fails with traceback.""" request = ExecutionRequest( script="print('before')\nraise ValueError('test error')\nprint('after')", @@ -179,7 +180,7 @@ async def test_execute_runtime_error(self, test_user: AsyncClient, event_waiter: lang_version="3.11", ) - _, result = await submit_and_wait(test_user, event_waiter, request) + _, result = await submit_and_wait(test_user, redis_client, request) # Script errors result in COMPLETED status with non-zero exit code # FAILED is reserved for infrastructure/timeout failures @@ -197,7 +198,7 @@ class TestExecutionCancel: @pytest.mark.asyncio async def test_cancel_running_execution( - self, test_user: AsyncClient, event_waiter: EventWaiter, long_running_execution_request: ExecutionRequest + self, test_user: AsyncClient, redis_client: redis.Redis, long_running_execution_request: ExecutionRequest ) -> None: """Running execution can be cancelled.""" response = await test_user.post("/api/v1/execute", json=long_running_execution_request.model_dump()) @@ -205,8 +206,8 @@ async def test_cancel_running_execution( exec_response = ExecutionResponse.model_validate(response.json()) - # Wait for saga to start (pod creation command sent) instead of blind sleep - await event_waiter.wait_for_saga_command(exec_response.execution_id) + # Wait for pod creation (saga started + command dispatched) instead of blind sleep + await wait_for_pod_created(redis_client, exec_response.execution_id) cancel_req = CancelExecutionRequest(reason="Test cancellation") cancel_response = await test_user.post( @@ -222,11 +223,11 @@ async def test_cancel_running_execution( @pytest.mark.asyncio async def test_cancel_completed_execution_fails( - self, test_user: AsyncClient, event_waiter: EventWaiter + self, test_user: AsyncClient, redis_client: redis.Redis ) -> None: """Cannot cancel already completed execution.""" request = ExecutionRequest(script="print('quick')", lang="python", lang_version="3.11") - exec_response, _ = await submit_and_wait(test_user, event_waiter, request) + exec_response, _ = await submit_and_wait(test_user, redis_client, request) cancel_req = CancelExecutionRequest(reason="Too late") cancel_response = await test_user.post( @@ -243,11 +244,11 @@ class TestExecutionRetry: @pytest.mark.asyncio async def test_retry_completed_execution( - self, test_user: AsyncClient, event_waiter: EventWaiter + self, test_user: AsyncClient, redis_client: redis.Redis ) -> None: """Completed execution can be retried.""" request = ExecutionRequest(script="print('original')", lang="python", lang_version="3.11") - original, _ = await submit_and_wait(test_user, event_waiter, request) + original, _ = await submit_and_wait(test_user, redis_client, request) retry_response = await test_user.post( f"/api/v1/executions/{original.execution_id}/retry", @@ -258,7 +259,7 @@ async def test_retry_completed_execution( assert retried.execution_id != original.execution_id # Wait for retried execution to complete - await event_waiter.wait_for_result(retried.execution_id) + await wait_for_result(redis_client, retried.execution_id) result_resp = await test_user.get(f"/api/v1/executions/{retried.execution_id}/result") assert result_resp.status_code == 200 result = ExecutionResult.model_validate(result_resp.json()) @@ -285,11 +286,11 @@ async def test_retry_running_execution_fails( @pytest.mark.asyncio async def test_retry_other_users_execution_forbidden( - self, test_user: AsyncClient, another_user: AsyncClient, event_waiter: EventWaiter + self, test_user: AsyncClient, another_user: AsyncClient, redis_client: redis.Redis ) -> None: """Cannot retry another user's execution.""" request = ExecutionRequest(script="print('owned')", lang="python", lang_version="3.11") - original, _ = await submit_and_wait(test_user, event_waiter, request) + original, _ = await submit_and_wait(test_user, redis_client, request) retry_response = await another_user.post( f"/api/v1/executions/{original.execution_id}/retry", @@ -302,10 +303,10 @@ class TestExecutionEvents: """Tests for execution events.""" @pytest.mark.asyncio - async def test_get_execution_events(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_get_execution_events(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Get events for completed execution.""" request = ExecutionRequest(script="print('events test')", lang="python", lang_version="3.11") - exec_response, _ = await submit_and_wait(test_user, event_waiter, request) + exec_response, _ = await submit_and_wait(test_user, redis_client, request) events_response = await test_user.get(f"/api/v1/executions/{exec_response.execution_id}/events") assert events_response.status_code == 200 @@ -316,10 +317,10 @@ async def test_get_execution_events(self, test_user: AsyncClient, event_waiter: assert {EventType.EXECUTION_REQUESTED, EventType.EXECUTION_COMPLETED} <= event_types @pytest.mark.asyncio - async def test_get_events_filtered_by_type(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_get_events_filtered_by_type(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Filter events by event type.""" request = ExecutionRequest(script="print('filter test')", lang="python", lang_version="3.11") - exec_response, _ = await submit_and_wait(test_user, event_waiter, request) + exec_response, _ = await submit_and_wait(test_user, redis_client, request) events_response = await test_user.get( f"/api/v1/executions/{exec_response.execution_id}/events", @@ -351,11 +352,11 @@ class TestExecutionDelete: @pytest.mark.asyncio @pytest.mark.admin async def test_admin_delete_execution( - self, test_user: AsyncClient, test_admin: AsyncClient, event_waiter: EventWaiter + self, test_user: AsyncClient, test_admin: AsyncClient, redis_client: redis.Redis ) -> None: """Admin can delete an execution.""" request = ExecutionRequest(script="print('to delete')", lang="python", lang_version="3.11") - exec_response, _ = await submit_and_wait(test_user, event_waiter, request) + exec_response, _ = await submit_and_wait(test_user, redis_client, request) delete_response = await test_admin.delete(f"/api/v1/executions/{exec_response.execution_id}") assert delete_response.status_code == 200 @@ -393,10 +394,10 @@ class TestExecutionList: """Tests for execution listing.""" @pytest.mark.asyncio - async def test_get_user_executions(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_get_user_executions(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """User can list their executions.""" request = ExecutionRequest(script="print('list test')", lang="python", lang_version="3.11") - exec_response, _ = await submit_and_wait(test_user, event_waiter, request) + exec_response, _ = await submit_and_wait(test_user, redis_client, request) list_response = await test_user.get("/api/v1/user/executions", params={"limit": 10, "skip": 0}) assert list_response.status_code == 200 @@ -480,7 +481,7 @@ class TestExecutionConcurrency: @pytest.mark.asyncio @pytest.mark.xdist_group("execution_concurrency") - async def test_concurrent_executions(self, test_user: AsyncClient, event_waiter: EventWaiter) -> None: + async def test_concurrent_executions(self, test_user: AsyncClient, redis_client: redis.Redis) -> None: """Multiple concurrent executions work correctly.""" tasks = [] for i in range(3): @@ -498,8 +499,8 @@ async def test_concurrent_executions(self, test_user: AsyncClient, event_waiter: # All IDs should be unique assert len(execution_ids) == 3 - # Wait for all to complete — parallel futures, not sequential polling - await asyncio.gather(*(event_waiter.wait_for_result(eid) for eid in execution_ids)) + # Wait for all to complete — parallel subscriptions, not sequential polling + await asyncio.gather(*(wait_for_result(redis_client, eid) for eid in execution_ids)) for exec_id in execution_ids: result_resp = await test_user.get(f"/api/v1/executions/{exec_id}/result") assert result_resp.status_code == 200 diff --git a/backend/tests/e2e/test_k8s_worker_create_pod.py b/backend/tests/e2e/test_k8s_worker_create_pod.py index 0f2dd99c..93acf675 100644 --- a/backend/tests/e2e/test_k8s_worker_create_pod.py +++ b/backend/tests/e2e/test_k8s_worker_create_pod.py @@ -1,80 +1,35 @@ -import structlog -import uuid - import pytest -from app.core.metrics import EventMetrics -from app.domain.enums import QueuePriority -from app.domain.events import CreatePodCommandEvent, EventMetadata -from app.events.core import UnifiedProducer -from app.services.k8s_worker import KubernetesWorker -from app.settings import Settings -from dishka import AsyncContainer -from kubernetes_asyncio import client as k8s_client -from kubernetes_asyncio.client.rest import ApiException +import redis.asyncio as redis +from app.domain.enums import ExecutionStatus +from app.schemas_pydantic.execution import ExecutionRequest, ExecutionResult +from httpx import AsyncClient -pytestmark = [pytest.mark.e2e, pytest.mark.k8s] +from tests.e2e.conftest import wait_for_pod_created, wait_for_result -_test_logger = structlog.get_logger("test.k8s.worker_create_pod") +pytestmark = [pytest.mark.e2e, pytest.mark.k8s] @pytest.mark.asyncio async def test_worker_creates_configmap_and_pod( - scope: AsyncContainer, test_settings: Settings + test_user: AsyncClient, redis_client: redis.Redis ) -> None: - api_client: k8s_client.ApiClient = await scope.get(k8s_client.ApiClient) - producer: UnifiedProducer = await scope.get(UnifiedProducer) - event_metrics: EventMetrics = await scope.get(EventMetrics) - - worker = KubernetesWorker( - api_client=api_client, - producer=producer, - settings=test_settings, - logger=_test_logger, - event_metrics=event_metrics, - ) - - exec_id = uuid.uuid4().hex[:8] - cmd = CreatePodCommandEvent( - saga_id=uuid.uuid4().hex, - execution_id=exec_id, - script="echo hi", - language="python", - language_version="3.11", - runtime_image="busybox:1.36", - runtime_command=["echo", "done"], - runtime_filename="main.py", - timeout_seconds=60, - cpu_limit="100m", - memory_limit="128Mi", - cpu_request="50m", - memory_request="64Mi", - priority=QueuePriority.NORMAL, - metadata=EventMetadata(service_name="tests", service_version="1", user_id="u1"), - ) - - # Build and create ConfigMap + Pod - cm = worker.pod_builder.build_config_map( - command=cmd, - script_content=cmd.script, - entrypoint_content=await worker._get_entrypoint_script(), # noqa: SLF001 - ) - try: - await worker._create_config_map(cm) # noqa: SLF001 - except ApiException as e: - if e.status in (403, 404): - pytest.skip(f"Insufficient permissions or namespace not found: {e}") - raise - - pod = worker.pod_builder.build_pod_manifest(cmd) - await worker._create_pod(pod) # noqa: SLF001 - - # Verify resources exist - ns = test_settings.K8S_NAMESPACE - got_cm = await worker.v1.read_namespaced_config_map(name=f"script-{exec_id}", namespace=ns) - assert got_cm is not None - got_pod = await worker.v1.read_namespaced_pod(name=f"executor-{exec_id}", namespace=ns) - assert got_pod is not None - - # Cleanup - await worker.v1.delete_namespaced_pod(name=f"executor-{exec_id}", namespace=ns) - await worker.v1.delete_namespaced_config_map(name=f"script-{exec_id}", namespace=ns) + """Verify k8s-worker creates ConfigMap + Pod by running through the full pipeline.""" + request = ExecutionRequest(script="print('k8s-test')", lang="python", lang_version="3.11") + + resp = await test_user.post("/api/v1/execute", json=request.model_dump()) + assert resp.status_code == 200 + execution_id = resp.json()["execution_id"] + + # Saga dispatched CREATE_POD_COMMAND → k8s-worker created ConfigMap + Pod + await wait_for_pod_created(redis_client, execution_id) + + # Full pipeline completed: pod ran, result stored + await wait_for_result(redis_client, execution_id, timeout=30.0) + + result_resp = await test_user.get(f"/api/v1/executions/{execution_id}/result") + assert result_resp.status_code == 200 + result = ExecutionResult.model_validate(result_resp.json()) + assert result.status == ExecutionStatus.COMPLETED + assert result.exit_code == 0 + assert result.stdout is not None + assert "k8s-test" in result.stdout diff --git a/backend/tests/e2e/test_saga_routes.py b/backend/tests/e2e/test_saga_routes.py index 120fe9e7..410383c0 100644 --- a/backend/tests/e2e/test_saga_routes.py +++ b/backend/tests/e2e/test_saga_routes.py @@ -1,4 +1,6 @@ import pytest +import redis.asyncio as redis +from app.db.docs.saga import SagaDocument from app.domain.enums import SagaState from app.schemas_pydantic.execution import ExecutionRequest, ExecutionResponse from app.schemas_pydantic.saga import ( @@ -8,7 +10,7 @@ ) from httpx import AsyncClient -from tests.e2e.conftest import EventWaiter +from tests.e2e.conftest import wait_for_pod_created pytestmark = [pytest.mark.e2e, pytest.mark.kafka] @@ -191,7 +193,7 @@ class TestCancelSaga: async def test_cancel_saga( self, test_user: AsyncClient, - event_waiter: EventWaiter, + redis_client: redis.Redis, long_running_execution_request: ExecutionRequest, ) -> None: """Cancel a running saga.""" @@ -202,25 +204,22 @@ async def test_cancel_saga( execution = ExecutionResponse.model_validate(exec_response.json()) - # Get saga_id from SAGA_STARTED event (published after saga persisted) - started = await event_waiter.wait_for_saga_started(execution.execution_id) + # Wait for POD_CREATED — saga is persisted and orchestrator is idle + await wait_for_pod_created(redis_client, execution.execution_id) + doc = await SagaDocument.find_one(SagaDocument.execution_id == execution.execution_id) + assert doc is not None - # Wait for CREATE_POD_COMMAND — the orchestrator's last step. - # After this the orchestrator is idle, so cancel won't race with - # concurrent step-processing writes to the saga document. - await event_waiter.wait_for_saga_command(execution.execution_id) - - response = await test_user.post(f"/api/v1/sagas/{started.saga_id}/cancel") + response = await test_user.post(f"/api/v1/sagas/{doc.saga_id}/cancel") assert response.status_code == 200 result = SagaCancellationResponse.model_validate(response.json()) - assert result.saga_id == started.saga_id + assert result.saga_id == doc.saga_id assert result.success is True assert result.message is not None # cancel_saga sets state to CANCELLED synchronously in MongoDB # before returning the HTTP response (compensation also runs inline). - status_resp = await test_user.get(f"/api/v1/sagas/{started.saga_id}") + status_resp = await test_user.get(f"/api/v1/sagas/{doc.saga_id}") assert status_resp.status_code == 200 updated_saga = SagaStatusResponse.model_validate(status_resp.json()) assert updated_saga.state == SagaState.CANCELLED @@ -241,7 +240,7 @@ async def test_cancel_other_users_saga_forbidden( self, test_user: AsyncClient, another_user: AsyncClient, - event_waiter: EventWaiter, + redis_client: redis.Redis, long_running_execution_request: ExecutionRequest, ) -> None: """Cannot cancel another user's saga.""" @@ -251,9 +250,11 @@ async def test_cancel_other_users_saga_forbidden( assert exec_response.status_code == 200 execution = ExecutionResponse.model_validate(exec_response.json()) - started = await event_waiter.wait_for_saga_started(execution.execution_id) + await wait_for_pod_created(redis_client, execution.execution_id) + doc = await SagaDocument.find_one(SagaDocument.execution_id == execution.execution_id) + assert doc is not None - response = await another_user.post(f"/api/v1/sagas/{started.saga_id}/cancel") + response = await another_user.post(f"/api/v1/sagas/{doc.saga_id}/cancel") assert response.status_code == 403 diff --git a/backend/tests/e2e/test_sse_routes.py b/backend/tests/e2e/test_sse_routes.py index 84a88686..7c16aa2e 100644 --- a/backend/tests/e2e/test_sse_routes.py +++ b/backend/tests/e2e/test_sse_routes.py @@ -3,12 +3,13 @@ import pytest import pytest_asyncio +import redis.asyncio as redis from app.schemas_pydantic.execution import ExecutionRequest, ExecutionResponse from async_asgi_testclient import TestClient as SSETestClient from fastapi import FastAPI from httpx import AsyncClient -from tests.e2e.conftest import EventWaiter +from tests.e2e.conftest import wait_for_result pytestmark = [pytest.mark.e2e] @@ -83,7 +84,7 @@ async def test_notification_stream_returns_event_stream( sse_client: SSETestClient, test_user: AsyncClient, simple_execution_request: ExecutionRequest, - event_waiter: EventWaiter, + redis_client: redis.Redis, ) -> None: """Notification stream returns SSE content type when a notification arrives. @@ -91,7 +92,8 @@ async def test_notification_stream_returns_event_stream( execution stream). async-asgi-testclient blocks until the first http.response.body ASGI message. We trigger a real notification by creating an execution and waiting for its result — the notification - handler publishes to Redis before RESULT_STORED, unblocking the stream. + service is an independent Kafka consumer that publishes to the SSE + notification channel after persisting to MongoDB. """ async with sse_client: # Start stream in background — blocks until first body chunk @@ -110,7 +112,7 @@ async def test_notification_stream_returns_event_stream( ) assert resp.status_code == 200 execution = ExecutionResponse.model_validate(resp.json()) - await event_waiter.wait_for_result(execution.execution_id) + await wait_for_result(redis_client, execution.execution_id) # Notification published to Redis unblocks the SSE stream response = await asyncio.wait_for(stream_task, timeout=10.0) diff --git a/backend/tests/unit/services/sse/test_redis_bus.py b/backend/tests/unit/services/sse/test_redis_bus.py index 02431952..b839d248 100644 --- a/backend/tests/unit/services/sse/test_redis_bus.py +++ b/backend/tests/unit/services/sse/test_redis_bus.py @@ -10,8 +10,8 @@ import pytest import redis.asyncio as redis_async from app.core.metrics import ConnectionMetrics -from app.domain.enums import EventType, NotificationSeverity, NotificationStatus -from app.domain.sse import DomainNotificationSSEPayload, SSEExecutionEventData +from app.domain.enums import EventType, NotificationSeverity, NotificationStatus, ReplayStatus +from app.domain.sse import DomainNotificationSSEPayload, DomainReplaySSEPayload, SSEExecutionEventData from app.services.sse import SSERedisBus from app.services.sse.redis_bus import _sse_event_adapter @@ -102,9 +102,6 @@ async def test_publish_and_subscribe_round_trip() -> None: msg2 = await asyncio.wait_for(messages.__anext__(), timeout=2.0) assert msg2.event_type == EventType.EXECUTION_COMPLETED - # Close — aclose() on the generator triggers __aexit__ on the pubsub context - await messages.aclose() - assert r._pubsub.closed is True @pytest.mark.asyncio @@ -135,5 +132,33 @@ async def test_notifications_channels() -> None: assert "sse:notif:user-1" in r._pubsub.subscribed assert got.notification_id == "n1" - await messages.aclose() - assert r._pubsub.closed is True + + +@pytest.mark.asyncio +async def test_replay_publish_and_subscribe_round_trip() -> None: + r = _FakeRedis() + bus = SSERedisBus(cast(redis_async.Redis, r), logger=_test_logger, connection_metrics=MagicMock(spec=ConnectionMetrics)) + + status = DomainReplaySSEPayload( + session_id="sess-1", + status=ReplayStatus.RUNNING, + total_events=10, + replayed_events=3, + failed_events=0, + skipped_events=0, + replay_id="replay-1", + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + await bus.publish_replay_status("sess-1", status) + assert r.published, "nothing published" + ch, payload = r.published[-1] + assert ch.endswith("sess-1") + + await r._pubsub.push(ch, payload) + + messages = bus.listen_replay("sess-1") + got = await asyncio.wait_for(messages.__anext__(), timeout=2.0) + assert "sse:replay:sess-1" in r._pubsub.subscribed + assert got.session_id == "sess-1" + assert got.status == ReplayStatus.RUNNING + assert got.replayed_events == 3 diff --git a/backend/tests/unit/services/sse/test_sse_service.py b/backend/tests/unit/services/sse/test_sse_service.py index f4aa8121..b3a9177f 100644 --- a/backend/tests/unit/services/sse/test_sse_service.py +++ b/backend/tests/unit/services/sse/test_sse_service.py @@ -7,9 +7,9 @@ import pytest -from app.domain.enums import EventType, ExecutionStatus, NotificationSeverity, NotificationStatus, SSEControlEvent, UserRole +from app.domain.enums import EventType, ExecutionStatus, NotificationSeverity, NotificationStatus, ReplayStatus, SSEControlEvent, UserRole from app.domain.execution.models import DomainExecution, ExecutionResultDomain -from app.domain.sse import DomainNotificationSSEPayload, SSEExecutionEventData +from app.domain.sse import DomainNotificationSSEPayload, DomainReplaySSEPayload, SSEExecutionEventData from app.services.sse import SSEService pytestmark = pytest.mark.unit @@ -25,8 +25,10 @@ class _FakeBus: def __init__(self) -> None: self._exec_q: asyncio.Queue[SSEExecutionEventData | None] = asyncio.Queue() self._notif_q: asyncio.Queue[DomainNotificationSSEPayload | None] = asyncio.Queue() + self._replay_q: asyncio.Queue[DomainReplaySSEPayload | None] = asyncio.Queue() self.exec_closed = False self.notif_closed = False + self.replay_closed = False async def push_exec(self, event: SSEExecutionEventData | None) -> None: await self._exec_q.put(event) @@ -34,6 +36,9 @@ async def push_exec(self, event: SSEExecutionEventData | None) -> None: async def push_notif(self, payload: DomainNotificationSSEPayload | None) -> None: await self._notif_q.put(payload) + async def push_replay(self, status: DomainReplaySSEPayload | None) -> None: + await self._replay_q.put(status) + async def listen_execution(self, execution_id: str) -> AsyncGenerator[SSEExecutionEventData, None]: # noqa: ARG002 try: while True: @@ -54,6 +59,16 @@ async def listen_notifications(self, user_id: str) -> AsyncGenerator[DomainNotif finally: self.notif_closed = True + async def listen_replay(self, session_id: str) -> AsyncGenerator[DomainReplaySSEPayload, None]: # noqa: ARG002 + try: + while True: + item = await self._replay_q.get() + if item is None: + return + yield item + finally: + self.replay_closed = True + class _FakeExecRepo: """Fake ExecutionRepository with configurable return values.""" @@ -186,4 +201,92 @@ async def test_notification_stream_yields_notification_and_cleans_up() -> None: assert data["subject"] == "s" assert data["channel"] == "in_app" - await agen.aclose() + + +@pytest.mark.asyncio +async def test_replay_stream_yields_initial_then_live() -> None: + """Replay pipeline yields initial status from DB then streams live updates.""" + bus = _FakeBus() + svc = _make_service(bus) + + initial = DomainReplaySSEPayload( + session_id="sess-1", + status=ReplayStatus.RUNNING, + total_events=5, + replayed_events=0, + failed_events=0, + skipped_events=0, + replay_id="replay-1", + created_at=_NOW, + ) + + agen = await svc.create_replay_stream(initial) + + # First item is the initial status + first = await agen.__anext__() + data = _decode(first) + assert data["session_id"] == "sess-1" + assert data["status"] == "running" + assert data["replayed_events"] == 0 + + # Push a live update + await bus.push_replay(DomainReplaySSEPayload( + session_id="sess-1", + status=ReplayStatus.RUNNING, + total_events=5, + replayed_events=3, + failed_events=0, + skipped_events=0, + replay_id="replay-1", + created_at=_NOW, + )) + + second = await asyncio.wait_for(agen.__anext__(), timeout=2.0) + data2 = _decode(second) + assert data2["replayed_events"] == 3 + + # Push terminal status + await bus.push_replay(DomainReplaySSEPayload( + session_id="sess-1", + status=ReplayStatus.COMPLETED, + total_events=5, + replayed_events=5, + failed_events=0, + skipped_events=0, + replay_id="replay-1", + created_at=_NOW, + )) + + third = await asyncio.wait_for(agen.__anext__(), timeout=2.0) + data3 = _decode(third) + assert data3["status"] == "completed" + + with pytest.raises(StopAsyncIteration): + await agen.__anext__() + + +@pytest.mark.asyncio +async def test_replay_stream_terminal_initial_closes_immediately() -> None: + """If the initial replay status is terminal, the stream closes after yielding it.""" + bus = _FakeBus() + svc = _make_service(bus) + + initial = DomainReplaySSEPayload( + session_id="sess-2", + status=ReplayStatus.COMPLETED, + total_events=3, + replayed_events=3, + failed_events=0, + skipped_events=0, + replay_id="replay-2", + created_at=_NOW, + ) + + agen = await svc.create_replay_stream(initial) + + first = await agen.__anext__() + data = _decode(first) + assert data["status"] == "completed" + + with pytest.raises(StopAsyncIteration): + await agen.__anext__() diff --git a/backend/uv.lock b/backend/uv.lock index 3c799113..563bc408 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1062,8 +1062,6 @@ dependencies = [ { name = "charset-normalizer" }, { name = "click" }, { name = "configargparse" }, - { name = "contourpy" }, - { name = "cycler" }, { name = "deprecated" }, { name = "dishka" }, { name = "dnspython" }, @@ -1072,7 +1070,6 @@ dependencies = [ { name = "exceptiongroup" }, { name = "fastapi" }, { name = "faststream", extra = ["kafka"] }, - { name = "fonttools" }, { name = "frozenlist" }, { name = "google-auth" }, { name = "googleapis-common-protos" }, @@ -1087,7 +1084,6 @@ dependencies = [ { name = "importlib-resources" }, { name = "itsdangerous" }, { name = "jinja2" }, - { name = "kiwisolver" }, { name = "kubernetes-asyncio" }, { name = "limits" }, { name = "markdown-it-py" }, @@ -1104,12 +1100,10 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-pymongo" }, - { name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-propagator-b3" }, { name = "opentelemetry-proto" }, { name = "opentelemetry-sdk" }, @@ -1130,7 +1124,6 @@ dependencies = [ { name = "pygments" }, { name = "pyjwt" }, { name = "pymongo" }, - { name = "pyparsing" }, { name = "python-dateutil" }, { name = "python-json-logger" }, { name = "python-multipart" }, @@ -1149,7 +1142,6 @@ dependencies = [ { name = "sse-starlette" }, { name = "starlette" }, { name = "structlog" }, - { name = "tiktoken" }, { name = "tomli" }, { name = "typing-extensions" }, { name = "urllib3" }, @@ -1176,11 +1168,35 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, + { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "types-cachetools" }, { name = "vulture" }, ] +lint = [ + { name = "mypy" }, + { name = "mypy-extensions" }, + { name = "ruff" }, + { name = "types-cachetools" }, + { name = "vulture" }, +] +load = [ + { name = "matplotlib" }, +] +test = [ + { name = "async-asgi-testclient" }, + { name = "coverage" }, + { name = "hypothesis" }, + { name = "iniconfig" }, + { name = "pluggy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-env" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, +] [package.metadata] requires-dist = [ @@ -1206,8 +1222,6 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.4.0" }, { name = "click", specifier = "==8.3.1" }, { name = "configargparse", specifier = "==1.7.1" }, - { name = "contourpy", specifier = "==1.3.3" }, - { name = "cycler", specifier = "==0.12.1" }, { name = "deprecated", specifier = "==1.2.14" }, { name = "dishka", specifier = "==1.7.2" }, { name = "dnspython", specifier = "==2.8.0" }, @@ -1216,7 +1230,6 @@ requires-dist = [ { name = "exceptiongroup", specifier = "==1.2.2" }, { name = "fastapi", specifier = "==0.128.0" }, { name = "faststream", extras = ["kafka"], specifier = "==0.6.6" }, - { name = "fonttools", specifier = "==4.61.1" }, { name = "frozenlist", specifier = "==1.7.0" }, { name = "google-auth", specifier = "==2.47.0" }, { name = "googleapis-common-protos", specifier = "==1.70.0" }, @@ -1231,7 +1244,6 @@ requires-dist = [ { name = "importlib-resources", specifier = "==6.5.2" }, { name = "itsdangerous", specifier = "==2.2.0" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "kiwisolver", specifier = "==1.4.9" }, { name = "kubernetes-asyncio", specifier = "==34.3.3" }, { name = "limits", specifier = "==5.6.0" }, { name = "markdown-it-py", specifier = "==4.0.0" }, @@ -1248,12 +1260,10 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.39.1" }, { name = "opentelemetry-exporter-prometheus", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation", specifier = "==0.60b1" }, - { name = "opentelemetry-instrumentation-asgi", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-fastapi", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-httpx", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-logging", specifier = "==0.60b1" }, { name = "opentelemetry-instrumentation-pymongo", specifier = "==0.60b1" }, - { name = "opentelemetry-instrumentation-redis", specifier = "==0.60b1" }, { name = "opentelemetry-propagator-b3", specifier = "==1.39.1" }, { name = "opentelemetry-proto", specifier = "==1.39.1" }, { name = "opentelemetry-sdk", specifier = "==1.39.1" }, @@ -1274,7 +1284,6 @@ requires-dist = [ { name = "pygments", specifier = "==2.19.2" }, { name = "pyjwt", specifier = "==2.9.0" }, { name = "pymongo", specifier = "==4.12.1" }, - { name = "pyparsing", specifier = "==3.3.2" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-json-logger", specifier = "==2.0.7" }, { name = "python-multipart", specifier = "==0.0.22" }, @@ -1293,7 +1302,6 @@ requires-dist = [ { name = "sse-starlette", specifier = "==3.2.0" }, { name = "starlette", specifier = "==0.49.1" }, { name = "structlog", specifier = "==25.5.0" }, - { name = "tiktoken", specifier = "==0.11.0" }, { name = "tomli", specifier = "==2.0.2" }, { name = "typing-extensions", specifier = "==4.12.2" }, { name = "urllib3", specifier = "==2.6.3" }, @@ -1307,7 +1315,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "async-asgi-testclient", specifier = ">=1.4.11" }, + { name = "async-asgi-testclient", specifier = "==1.4.11" }, { name = "coverage", specifier = "==7.13.0" }, { name = "hypothesis", specifier = "==6.151.6" }, { name = "iniconfig", specifier = "==2.3.0" }, @@ -1320,11 +1328,33 @@ dev = [ { name = "pytest-asyncio", specifier = "==1.3.0" }, { name = "pytest-cov", specifier = "==5.0.0" }, { name = "pytest-env", specifier = "==1.2.0" }, + { name = "pytest-timeout", specifier = "==2.3.1" }, { name = "pytest-xdist", specifier = "==3.6.1" }, { name = "ruff", specifier = "==0.14.10" }, { name = "types-cachetools", specifier = "==6.2.0.20250827" }, { name = "vulture", specifier = "==2.14" }, ] +lint = [ + { name = "mypy", specifier = "==1.19.1" }, + { name = "mypy-extensions", specifier = "==1.1.0" }, + { name = "ruff", specifier = "==0.14.10" }, + { name = "types-cachetools", specifier = "==6.2.0.20250827" }, + { name = "vulture", specifier = "==2.14" }, +] +load = [{ name = "matplotlib", specifier = "==3.10.8" }] +test = [ + { name = "async-asgi-testclient", specifier = "==1.4.11" }, + { name = "coverage", specifier = "==7.13.0" }, + { name = "hypothesis", specifier = "==6.151.6" }, + { name = "iniconfig", specifier = "==2.3.0" }, + { name = "pluggy", specifier = "==1.5.0" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==1.3.0" }, + { name = "pytest-cov", specifier = "==5.0.0" }, + { name = "pytest-env", specifier = "==1.2.0" }, + { name = "pytest-timeout", specifier = "==2.3.1" }, + { name = "pytest-xdist", specifier = "==3.6.1" }, +] [[package]] name = "itsdangerous" @@ -2074,21 +2104,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/9d/9fe3ccbec82c20d7ae8d14af47f630fdc6066afda5b18743ceb13f4be997/opentelemetry_instrumentation_pymongo-0.60b1-py3-none-any.whl", hash = "sha256:179cff51e4b018fa92f6acb7aea1dfc5440364e66561db9d5ca0dc0227e0a6dc", size = 11419, upload-time = "2025-12-11T13:36:17.073Z" }, ] -[[package]] -name = "opentelemetry-instrumentation-redis" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/1e/225364fab4db793f6f5024ed9f3dd53774fd7c7c21fa242460234dcdf8d9/opentelemetry_instrumentation_redis-0.60b1.tar.gz", hash = "sha256:ecafa8f81c88917b59f0d842fb3d157f3a8edc71fb4b85bebca3bc19432ce7b8", size = 14774, upload-time = "2025-12-11T13:37:11.201Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/bd/d55d3b34fd49df08d9d9fa3701dff0051b216e2c7e9adaaa4ff6aa1de8d7/opentelemetry_instrumentation_redis-0.60b1-py3-none-any.whl", hash = "sha256:33bef0ff9af6f2d88de90c1cd7e25675c10a16d4f9ee5ae7592b28bb08b78139", size = 15502, upload-time = "2025-12-11T13:36:21.481Z" }, -] - [[package]] name = "opentelemetry-propagator-b3" version = "1.39.1" @@ -2633,6 +2648,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/98/822b924a4a3eb58aacba84444c7439fce32680592f394de26af9c76e2569/pytest_env-1.2.0-py3-none-any.whl", hash = "sha256:d7e5b7198f9b83c795377c09feefa45d56083834e60d04767efd64819fc9da00", size = 6251, upload-time = "2025-10-09T19:15:46.077Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/0d/04719abc7a4bdb3a7a1f968f24b0f5253d698c9cc94975330e9d3145befb/pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9", size = 17697, upload-time = "2024-03-07T21:04:01.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148, upload-time = "2024-03-07T21:03:58.764Z" }, +] + [[package]] name = "pytest-xdist" version = "3.6.1" @@ -2966,30 +2993,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] -[[package]] -name = "tiktoken" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/86/ad0155a37c4f310935d5ac0b1ccf9bdb635dcb906e0a9a26b616dd55825a/tiktoken-0.11.0.tar.gz", hash = "sha256:3c518641aee1c52247c2b97e74d8d07d780092af79d5911a6ab5e79359d9b06a", size = 37648, upload-time = "2025-08-08T23:58:08.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9e/eceddeffc169fc75fe0fd4f38471309f11cb1906f9b8aa39be4f5817df65/tiktoken-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fd9e6b23e860973cf9526544e220b223c60badf5b62e80a33509d6d40e6c8f5d", size = 1055199, upload-time = "2025-08-08T23:57:45.076Z" }, - { url = "https://files.pythonhosted.org/packages/4f/cf/5f02bfefffdc6b54e5094d2897bc80efd43050e5b09b576fd85936ee54bf/tiktoken-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76d53cee2da71ee2731c9caa747398762bda19d7f92665e882fef229cb0b5b", size = 996655, upload-time = "2025-08-08T23:57:46.304Z" }, - { url = "https://files.pythonhosted.org/packages/65/8e/c769b45ef379bc360c9978c4f6914c79fd432400a6733a8afc7ed7b0726a/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef72aab3ea240646e642413cb363b73869fed4e604dcfd69eec63dc54d603e8", size = 1128867, upload-time = "2025-08-08T23:57:47.438Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2d/4d77f6feb9292bfdd23d5813e442b3bba883f42d0ac78ef5fdc56873f756/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f929255c705efec7a28bf515e29dc74220b2f07544a8c81b8d69e8efc4578bd", size = 1183308, upload-time = "2025-08-08T23:57:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/7a/65/7ff0a65d3bb0fc5a1fb6cc71b03e0f6e71a68c5eea230d1ff1ba3fd6df49/tiktoken-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61f1d15822e4404953d499fd1dcc62817a12ae9fb1e4898033ec8fe3915fdf8e", size = 1244301, upload-time = "2025-08-08T23:57:49.642Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6e/5b71578799b72e5bdcef206a214c3ce860d999d579a3b56e74a6c8989ee2/tiktoken-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:45927a71ab6643dfd3ef57d515a5db3d199137adf551f66453be098502838b0f", size = 884282, upload-time = "2025-08-08T23:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/a9034bcee638716d9310443818d73c6387a6a96db93cbcb0819b77f5b206/tiktoken-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a5f3f25ffb152ee7fec78e90a5e5ea5b03b4ea240beed03305615847f7a6ace2", size = 1055339, upload-time = "2025-08-08T23:57:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/f1/91/9922b345f611b4e92581f234e64e9661e1c524875c8eadd513c4b2088472/tiktoken-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc6e9ad16a2a75b4c4be7208055a1f707c9510541d94d9cc31f7fbdc8db41d8", size = 997080, upload-time = "2025-08-08T23:57:53.442Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9d/49cd047c71336bc4b4af460ac213ec1c457da67712bde59b892e84f1859f/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a0517634d67a8a48fd4a4ad73930c3022629a85a217d256a6e9b8b47439d1e4", size = 1128501, upload-time = "2025-08-08T23:57:54.808Z" }, - { url = "https://files.pythonhosted.org/packages/52/d5/a0dcdb40dd2ea357e83cb36258967f0ae96f5dd40c722d6e382ceee6bba9/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fb4effe60574675118b73c6fbfd3b5868e5d7a1f570d6cc0d18724b09ecf318", size = 1182743, upload-time = "2025-08-08T23:57:56.307Z" }, - { url = "https://files.pythonhosted.org/packages/3b/17/a0fc51aefb66b7b5261ca1314afa83df0106b033f783f9a7bcbe8e741494/tiktoken-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94f984c9831fd32688aef4348803b0905d4ae9c432303087bae370dc1381a2b8", size = 1244057, upload-time = "2025-08-08T23:57:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/50/79/bcf350609f3a10f09fe4fc207f132085e497fdd3612f3925ab24d86a0ca0/tiktoken-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2177ffda31dec4023356a441793fed82f7af5291120751dee4d696414f54db0c", size = 883901, upload-time = "2025-08-08T23:57:59.359Z" }, -] - [[package]] name = "tomli" version = "2.0.2" diff --git a/deploy.sh b/deploy.sh index b2b1d904..076d1f77 100755 --- a/deploy.sh +++ b/deploy.sh @@ -248,7 +248,7 @@ cmd_test() { print_info "Running tests inside Docker..." if docker compose exec -T backend \ - uv run pytest tests/integration tests/unit -v --cov=app --cov-report=term; then + sh -c 'uv sync --group test --no-dev --frozen --no-install-project && pytest tests/integration tests/unit -v --cov=app --cov-report=term'; then print_success "All tests passed!" TEST_RESULT=0 else diff --git a/docs/architecture/frontend-build.md b/docs/architecture/frontend-build.md index 0c971399..ee7e3aa4 100644 --- a/docs/architecture/frontend-build.md +++ b/docs/architecture/frontend-build.md @@ -185,7 +185,7 @@ When backend endpoints change, update the backend and restart it, fetch the new ## Production build -The production build runs `npm run build`, which compiles TypeScript with source maps, processes Svelte components in production mode without dev warnings, extracts and minifies CSS, splits code into chunks, minifies JavaScript with Terser removing console.log calls, and outputs everything to `public/build/`. The Docker build copies `public/` to nginx, which serves static files and proxies `/api/` to the backend. +The production build runs `npm run build`, which compiles TypeScript (without source maps — source maps are only generated in development), processes Svelte components in production mode without dev warnings, extracts and minifies CSS, splits code into chunks, minifies JavaScript with Terser removing console.log calls, and outputs everything to `public/build/`. The Docker build copies `public/` to nginx, which serves static files and proxies `/api/` to the backend. ## Troubleshooting diff --git a/docs/operations/cicd.md b/docs/operations/cicd.md index 87e764c4..3255069f 100644 --- a/docs/operations/cicd.md +++ b/docs/operations/cicd.md @@ -103,7 +103,7 @@ graph TD end subgraph "Phase 2: Build" - C["Build & Push 5 Images to GHCR"] + C["Build & Push 4 Images to GHCR"] end subgraph "Phase 3: E2E (parallel runners)" @@ -145,8 +145,8 @@ are needed. All 4 images are scanned by Trivy and promoted to `latest` in the [Docker Scan & Promote](#docker-scan-promote) workflow. The base image is cached separately as a zstd-compressed tarball since its dependencies rarely change. The backend -image depends on it via `--build-context base=docker-image://integr8scode-base:latest`. Utility and frontend images -use GHA layer caching. +image uses `docker/build-push-action@v6` with GHA layer cache (`scope=backend`, `mode=max`), so intermediate layers +are reused when only application code changes. Utility and frontend images also use GHA layer caching. All 4 images are pushed to GHCR in parallel, with each push tracked by PID so individual failures are reported: diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json index 4b340d28..3b6ebb00 100644 --- a/docs/reference/openapi.json +++ b/docs/reference/openapi.json @@ -2069,9 +2069,9 @@ "tags": [ "admin-events" ], - "summary": "Get Replay Status", - "description": "Get the status and progress of a replay session.", - "operationId": "get_replay_status_api_v1_admin_events_replay__session_id__status_get", + "summary": "Stream Replay Status", + "description": "Stream the status and progress of a replay session via SSE.", + "operationId": "stream_replay_status_api_v1_admin_events_replay__session_id__status_get", "parameters": [ { "name": "session_id", @@ -2087,8 +2087,9 @@ "200": { "description": "Successful Response", "content": { - "application/json": { + "text/event-stream": { "schema": { + "type": "string", "$ref": "#/components/schemas/EventReplayStatusResponse" } } @@ -2097,7 +2098,7 @@ "404": { "description": "Replay session not found", "content": { - "application/json": { + "text/event-stream": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } @@ -3964,6 +3965,24 @@ }, "description": "Filter by saga state" }, + { + "name": "execution_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by execution ID", + "title": "Execution Id" + }, + "description": "Filter by execution ID" + }, { "name": "limit", "in": "query", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7dd34f0b..6e87f440 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "svelte-app", "version": "1.0.0", "dependencies": { - "@babel/runtime": "^7.28.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-go": "^6.0.1", @@ -21,11 +20,6 @@ "@codemirror/view": "^6.39.15", "@lucide/svelte": "^0.575.0", "@mateothegreat/svelte5-router": "^2.16.19", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-replace": "^6.0.1", - "@rollup/plugin-terser": "^0.4.4", "@uiw/codemirror-theme-bbedit": "^4.21.25", "@uiw/codemirror-theme-dracula": "^4.24.2", "@uiw/codemirror-theme-github": "^4.23.13", @@ -34,16 +28,7 @@ "ansi-to-html": "^0.7.2", "codemirror": "^6.0.1", "dompurify": "^3.2.0", - "dotenv": "^17.3.1", - "postcss": "^8.4.47", - "rollup": "^4.59.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-svelte": "^7.2.2", - "sirv-cli": "^3.0.1", "svelte": "^5.50.0", - "svelte-preprocess": "^6.0.3", "svelte-sonner": "^1.0.7" }, "devDependencies": { @@ -52,6 +37,11 @@ "@hey-api/openapi-ts": "^0.92.4", "@playwright/test": "^1.52.0", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", @@ -62,17 +52,26 @@ "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.17", + "dotenv": "^17.3.1", "eslint": "^10.0.1", "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.7.0", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", + "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "rollup": "^4.59.0", + "rollup-plugin-css-only": "^4.3.0", + "rollup-plugin-livereload": "^2.0.0", + "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-serve": "^3.0.0", + "rollup-plugin-svelte": "^7.2.2", + "sirv-cli": "^3.0.1", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^1.4.1", + "svelte-preprocess": "^6.0.3", "tailwindcss": "^4.1.13", "tslib": "^2.8.1", "typescript": "^5.7.2", @@ -83,7 +82,9 @@ "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@adobe/css-tools": { "version": "4.4.4", @@ -108,6 +109,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", @@ -121,6 +124,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", @@ -133,7 +138,9 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -218,6 +225,8 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "css-tree": "^3.0.0" }, @@ -371,6 +380,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -390,6 +401,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -413,6 +426,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" @@ -440,6 +455,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -461,7 +478,9 @@ "type": "opencollective", "url": "https://opencollective.com/csstools" } - ] + ], + "optional": true, + "peer": true }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -478,6 +497,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1046,6 +1067,8 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -1243,6 +1266,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1358,7 +1382,8 @@ "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true }, "node_modules/@rollup/plugin-alias": { "version": "6.0.0", @@ -1381,6 +1406,7 @@ "version": "29.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", @@ -1406,6 +1432,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.1.0" }, @@ -1425,6 +1452,7 @@ "version": "16.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", @@ -1448,6 +1476,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" @@ -1468,6 +1497,7 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", @@ -1515,6 +1545,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -1539,6 +1570,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -1551,6 +1583,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1563,6 +1596,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1575,6 +1609,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1587,6 +1622,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1599,6 +1635,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1611,6 +1648,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1623,6 +1661,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1635,6 +1674,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1647,6 +1687,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1659,6 +1700,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1671,6 +1713,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1683,6 +1726,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1695,6 +1739,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1707,6 +1752,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1719,6 +1765,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1731,6 +1778,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1743,6 +1791,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1755,6 +1804,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1767,6 +1817,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1779,6 +1830,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1791,6 +1843,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1803,6 +1856,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1815,6 +1869,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1827,6 +1882,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2259,6 +2315,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -2302,10 +2359,20 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true }, "node_modules/@types/trusted-types": { "version": "2.0.7", @@ -2313,6 +2380,21 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -2813,6 +2895,8 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -2855,6 +2939,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2883,6 +2968,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -2895,6 +2981,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -2969,6 +3056,7 @@ "version": "2.9.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz", "integrity": "sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==", + "dev": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2978,6 +3066,8 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -2986,6 +3076,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "engines": { "node": ">=8" }, @@ -3020,7 +3111,8 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true }, "node_modules/brace-expansion": { "version": "2.0.2", @@ -3035,6 +3127,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -3046,6 +3139,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3077,7 +3171,8 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true }, "node_modules/bundle-name": { "version": "4.1.0", @@ -3192,6 +3287,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -3203,6 +3299,7 @@ "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3231,6 +3328,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3246,6 +3344,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3300,6 +3399,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3310,7 +3410,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/color-support": { "version": "1.1.3", @@ -3324,12 +3425,14 @@ "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "engines": { "node": ">= 10" } @@ -3337,12 +3440,14 @@ "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true }, "node_modules/concat-with-sourcemaps": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, "dependencies": { "source-map": "^0.6.1" } @@ -3366,6 +3471,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", + "dev": true, "engines": { "node": ">=4" } @@ -3452,6 +3558,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14" }, @@ -3463,6 +3570,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -3479,6 +3587,8 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" @@ -3491,6 +3601,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, "engines": { "node": ">= 6" }, @@ -3508,6 +3619,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -3519,6 +3631,7 @@ "version": "5.1.15", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, "dependencies": { "cssnano-preset-default": "^5.2.14", "lilconfig": "^2.0.3", @@ -3539,6 +3652,7 @@ "version": "5.2.14", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, "dependencies": { "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", @@ -3581,6 +3695,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -3592,6 +3707,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { "node": ">= 6" } @@ -3600,6 +3716,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, "dependencies": { "css-tree": "^1.1.2" }, @@ -3611,6 +3728,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -3622,13 +3740,16 @@ "node_modules/csso/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/cssstyle": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^4.1.2", "@csstools/css-syntax-patches-for-csstree": "^1.0.26", @@ -3644,6 +3765,8 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -3673,7 +3796,9 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/deep-equal": { "version": "1.0.1", @@ -3691,6 +3816,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -3805,6 +3931,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -3818,6 +3945,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -3829,6 +3957,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, "dependencies": { "domelementtype": "^2.2.0" }, @@ -3851,6 +3980,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -3864,6 +3994,7 @@ "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, "engines": { "node": ">=12" }, @@ -3900,7 +4031,8 @@ "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==" + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -4013,6 +4145,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "engines": { "node": ">=6" } @@ -4345,7 +4478,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true }, "node_modules/esutils": { "version": "2.0.3", @@ -4368,7 +4502,8 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true }, "node_modules/expect-type": { "version": "1.3.0", @@ -4450,6 +4585,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -4478,6 +4614,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -4599,6 +4736,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -4612,6 +4750,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4620,6 +4759,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "dev": true, "dependencies": { "loader-utils": "^3.2.0" } @@ -4652,6 +4792,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, "engines": { "node": ">=8" }, @@ -4693,6 +4834,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -4730,10 +4872,70 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/happy-dom": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", + "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", + "dev": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/happy-dom/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4754,6 +4956,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -4766,6 +4969,8 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -4865,6 +5070,8 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4878,6 +5085,8 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -4905,12 +5114,14 @@ "node_modules/icss-replace-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", - "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==" + "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", + "dev": true }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -4931,6 +5142,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, "dependencies": { "import-from": "^3.0.0" }, @@ -4942,6 +5154,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, "dependencies": { "resolve-from": "^5.0.0" }, @@ -4986,6 +5199,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -4997,6 +5211,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -5026,6 +5241,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5034,6 +5250,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -5074,12 +5291,14 @@ "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -5088,7 +5307,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/is-promise": { "version": "4.0.0", @@ -5100,6 +5321,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, "dependencies": { "@types/estree": "*" } @@ -5193,6 +5415,8 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -5272,6 +5496,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, "engines": { "node": ">=6" } @@ -5653,6 +5878,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, "engines": { "node": ">=10" } @@ -5661,6 +5887,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dev": true, "dependencies": { "chokidar": "^3.5.0", "livereload-js": "^3.3.1", @@ -5677,12 +5904,14 @@ "node_modules/livereload-js": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", - "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==" + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", + "dev": true }, "node_modules/loader-utils": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, "engines": { "node": ">= 12.13.0" } @@ -5691,6 +5920,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", + "dev": true, "engines": { "node": ">=6" } @@ -5718,23 +5948,28 @@ "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true }, "node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -5801,7 +6036,9 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/media-typer": { "version": "1.1.0", @@ -5958,6 +6195,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, "engines": { "node": ">=4" } @@ -5966,6 +6204,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, "engines": { "node": ">=10" } @@ -5980,6 +6219,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -6017,7 +6257,8 @@ "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==" + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true }, "node_modules/nodemailer": { "version": "7.0.13", @@ -6032,6 +6273,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6040,6 +6282,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "engines": { "node": ">=10" }, @@ -6051,6 +6294,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -6179,12 +6423,14 @@ "node_modules/opts": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==" + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", + "dev": true }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, "engines": { "node": ">=4" } @@ -6223,6 +6469,7 @@ "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" @@ -6238,6 +6485,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, "dependencies": { "p-finally": "^1.0.0" }, @@ -6250,6 +6498,8 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -6262,6 +6512,8 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -6299,7 +6551,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-to-regexp": { "version": "8.3.0", @@ -6326,12 +6579,14 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "engines": { "node": ">=12" }, @@ -6343,6 +6598,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "dev": true, "engines": { "node": ">=10" }, @@ -6409,6 +6665,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6436,6 +6693,7 @@ "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" @@ -6448,6 +6706,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -6465,6 +6724,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -6480,6 +6740,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6491,6 +6752,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6502,6 +6764,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6513,6 +6776,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6540,6 +6804,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" @@ -6568,6 +6833,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { "node": ">= 6" } @@ -6576,6 +6842,7 @@ "version": "5.1.7", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^5.1.1" @@ -6591,6 +6858,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -6608,6 +6876,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6622,6 +6891,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, "dependencies": { "colord": "^2.9.1", "cssnano-utils": "^3.1.0", @@ -6638,6 +6908,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", @@ -6654,6 +6925,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -6668,6 +6940,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.3.1.tgz", "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", + "dev": true, "dependencies": { "generic-names": "^4.0.0", "icss-replace-symbols": "^1.1.0", @@ -6686,6 +6959,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -6697,6 +6971,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", @@ -6713,6 +6988,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6725,6 +7001,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -6739,6 +7016,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6751,6 +7029,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0" }, @@ -6765,6 +7044,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6776,6 +7056,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6790,6 +7071,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6804,6 +7086,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6818,6 +7101,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6832,6 +7116,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6846,6 +7131,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -6861,6 +7147,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, "dependencies": { "normalize-url": "^6.0.1", "postcss-value-parser": "^4.2.0" @@ -6876,6 +7163,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6890,6 +7178,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, "dependencies": { "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" @@ -6905,6 +7194,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" @@ -6920,6 +7210,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -6986,6 +7277,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6998,6 +7290,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^2.7.0" @@ -7013,6 +7306,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -7026,7 +7320,8 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true }, "node_modules/powershell-utils": { "version": "0.1.0", @@ -7079,6 +7374,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", "integrity": "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==", + "dev": true, "engines": { "node": ">=0.12" } @@ -7124,6 +7420,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -7172,6 +7469,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -7183,6 +7481,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -7208,6 +7507,8 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7222,6 +7523,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", @@ -7241,6 +7543,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "engines": { "node": ">=8" } @@ -7249,6 +7552,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, "engines": { "node": ">=10" } @@ -7257,6 +7561,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7300,6 +7605,7 @@ "version": "4.5.5", "resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.5.tgz", "integrity": "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ==", + "dev": true, "dependencies": { "@rollup/pluginutils": "5" }, @@ -7314,6 +7620,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz", "integrity": "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA==", + "dev": true, "dependencies": { "livereload": "^0.9.1" }, @@ -7325,6 +7632,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", "integrity": "sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==", + "dev": true, "dependencies": { "chalk": "^4.1.0", "concat-with-sourcemaps": "^1.1.0", @@ -7361,6 +7669,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.3.tgz", "integrity": "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^4.1.0", "resolve.exports": "^2.0.0" @@ -7377,6 +7686,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" @@ -7389,6 +7699,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -7400,6 +7711,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, "dependencies": { "estree-walker": "^0.6.1" } @@ -7407,7 +7719,8 @@ "node_modules/rollup-pluginutils/node_modules/estree-walker": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==" + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true }, "node_modules/router": { "version": "2.2.0", @@ -7456,6 +7769,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, "dependencies": { "mri": "^1.1.0" }, @@ -7467,6 +7781,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -7485,7 +7800,8 @@ "node_modules/safe-identifier": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", - "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==" + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -7498,6 +7814,8 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -7509,6 +7827,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", + "dev": true, "engines": { "node": ">=6" } @@ -7555,6 +7874,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -7699,6 +8019,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -7712,6 +8033,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", + "dev": true, "dependencies": { "console-clear": "^1.1.0", "get-port": "^5.1.1", @@ -7732,12 +8054,14 @@ "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "dev": true }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7746,6 +8070,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7754,6 +8079,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7763,7 +8089,8 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true }, "node_modules/stackback": { "version": "0.0.2", @@ -7789,7 +8116,8 @@ "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", - "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==" + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "dev": true }, "node_modules/strip-indent": { "version": "3.0.0", @@ -7806,7 +8134,8 @@ "node_modules/style-inject": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", - "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==" + "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", + "dev": true }, "node_modules/style-mod": { "version": "4.1.3", @@ -7817,6 +8146,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" @@ -7832,6 +8162,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7843,6 +8174,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -7984,6 +8316,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", "integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==", + "dev": true, "hasInstallScript": true, "engines": { "node": ">= 18.0.0" @@ -8057,6 +8390,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -8077,6 +8411,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -8088,13 +8423,16 @@ "node_modules/svgo/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/tailwindcss": { "version": "4.1.18", @@ -8119,6 +8457,7 @@ "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8135,7 +8474,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -8147,6 +8487,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", + "dev": true, "engines": { "node": ">=4" } @@ -8190,6 +8531,8 @@ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.0.19" }, @@ -8201,12 +8544,15 @@ "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -8227,6 +8573,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, "engines": { "node": ">=6" } @@ -8236,6 +8583,8 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -8248,6 +8597,8 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -8312,7 +8663,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8326,10 +8677,18 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8343,6 +8702,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8380,7 +8740,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/vary": { "version": "1.1.2", @@ -8566,6 +8927,8 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8578,6 +8941,8 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -8587,6 +8952,8 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -8596,6 +8963,8 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -8655,6 +9024,7 @@ "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, "engines": { "node": ">=8.3.0" }, @@ -8692,6 +9062,8 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -8700,7 +9072,9 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/yocto-queue": { "version": "0.1.0", diff --git a/frontend/package.json b/frontend/package.json index d5dc29f1..a99f69cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@babel/runtime": "^7.28.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-go": "^6.0.1", @@ -30,11 +29,6 @@ "@codemirror/view": "^6.39.15", "@lucide/svelte": "^0.575.0", "@mateothegreat/svelte5-router": "^2.16.19", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-replace": "^6.0.1", - "@rollup/plugin-terser": "^0.4.4", "@uiw/codemirror-theme-bbedit": "^4.21.25", "@uiw/codemirror-theme-dracula": "^4.24.2", "@uiw/codemirror-theme-github": "^4.23.13", @@ -43,16 +37,7 @@ "ansi-to-html": "^0.7.2", "codemirror": "^6.0.1", "dompurify": "^3.2.0", - "dotenv": "^17.3.1", - "postcss": "^8.4.47", - "rollup": "^4.59.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-svelte": "^7.2.2", - "sirv-cli": "^3.0.1", "svelte": "^5.50.0", - "svelte-preprocess": "^6.0.3", "svelte-sonner": "^1.0.7" }, "devDependencies": { @@ -61,6 +46,11 @@ "@hey-api/openapi-ts": "^0.92.4", "@playwright/test": "^1.52.0", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", @@ -71,17 +61,26 @@ "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.17", + "dotenv": "^17.3.1", "eslint": "^10.0.1", "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.7.0", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", + "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "rollup": "^4.59.0", + "rollup-plugin-css-only": "^4.3.0", + "rollup-plugin-livereload": "^2.0.0", + "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-serve": "^3.0.0", + "rollup-plugin-svelte": "^7.2.2", + "sirv-cli": "^3.0.1", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^1.4.1", + "svelte-preprocess": "^6.0.3", "tailwindcss": "^4.1.13", "tslib": "^2.8.1", "typescript": "^5.7.2", diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index c995cacc..cd128779 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -131,7 +131,7 @@ function startServer() { export default { input: 'src/main.ts', output: { - sourcemap: true, + sourcemap: !production, format: 'es', name: 'app', dir: 'public/build', @@ -178,7 +178,7 @@ export default { minimize: false, }), typescript({ - sourceMap: true, + sourceMap: !production, inlineSources: !production }), json(), diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index a14785ea..3392826d 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -7,6 +7,7 @@ */ import { vi, type Mock } from 'vitest'; +import userEvent from '@testing-library/user-event'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { ExecutionCompletedEvent, @@ -19,36 +20,9 @@ import type { EventType, } from '$lib/api'; -// ============================================================================ -// Mock Svelte Component Factory -// ============================================================================ +export type UserEventInstance = ReturnType; -/** - * Creates a mock Svelte 5 component with proper $$ structure. - * Use this for mocking child components in parent component tests. - * - * @param html - The HTML to render for this mock component - * @param testId - Optional data-testid attribute - */ -export function createMockSvelteComponent(html: string, testId?: string): { - default: { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; -} { - const htmlWithTestId = testId - ? html.replace('>', ` data-testid="${testId}">`) - : html; - - const MockComponent = function () { - return {}; - } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; - - MockComponent.render = () => ({ - html: htmlWithTestId, - css: { code: '', map: null }, - head: '', - }); - - return { default: MockComponent }; -} +export const user: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); // ============================================================================ // Mock Store Type (for use with vi.hoisted) @@ -116,55 +90,6 @@ export function createMockNamedComponents(components: Record): R return module; } -/** - * Creates a mock @lucide/svelte module with given icon names. - * All icons render as ``. - */ -export function createMockIconModule(...iconNames: string[]): Record { - return createMockNamedComponents( - Object.fromEntries(iconNames.map(name => [name, ''])) - ); -} - -/** - * Creates a mock svelte-sonner module with toast methods that delegate to addToast. - * Usage: `vi.mock('svelte-sonner', async () => (await import('...')).createToastMock(mocks.addToast))` - */ -export function createToastMock(addToast: (...args: unknown[]) => void) { - return { - toast: { - success: (...args: unknown[]) => addToast('success', ...args), - error: (...args: unknown[]) => addToast('error', ...args), - warning: (...args: unknown[]) => addToast('warning', ...args), - info: (...args: unknown[]) => addToast('info', ...args), - }, - }; -} - -/** - * Creates a mock @mateothegreat/svelte5-router module. - * If gotoFn is provided, goto calls are delegated to it for assertion tracking. - */ -export function createMockRouterModule(gotoFn?: (...args: unknown[]) => void) { - return { - goto: gotoFn ? (...args: unknown[]) => gotoFn(...args) : vi.fn(), - route: () => {}, - }; -} - -/** - * Creates a mock $utils/meta module with updateMetaTags and pageMeta. - */ -export function createMetaMock( - updateMetaTagsFn: (...args: unknown[]) => void, - pageMeta: Record, -) { - return { - updateMetaTags: (...args: unknown[]) => updateMetaTagsFn(...args), - pageMeta, - }; -} - // ============================================================================ // Test Data Factories // ============================================================================ diff --git a/frontend/src/components/__tests__/ErrorDisplay.test.ts b/frontend/src/components/__tests__/ErrorDisplay.test.ts index b9592528..c2ae5253 100644 --- a/frontend/src/components/__tests__/ErrorDisplay.test.ts +++ b/frontend/src/components/__tests__/ErrorDisplay.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import ErrorDisplay from '$components/ErrorDisplay.svelte'; describe('ErrorDisplay', () => { @@ -102,7 +102,6 @@ describe('ErrorDisplay', () => { }); it('reloads page when Reload button clicked', async () => { - const user = userEvent.setup(); render(ErrorDisplay, { props: { error: 'Error' } }); const reloadButton = screen.getByRole('button', { name: /Reload Page/i }); @@ -112,7 +111,6 @@ describe('ErrorDisplay', () => { }); it('navigates to home when Go to Home clicked', async () => { - const user = userEvent.setup(); render(ErrorDisplay, { props: { error: 'Error' } }); const homeButton = screen.getByRole('button', { name: /Go to Home/i }); diff --git a/frontend/src/components/__tests__/Header.test.ts b/frontend/src/components/__tests__/Header.test.ts index 322d3450..bdf55d37 100644 --- a/frontend/src/components/__tests__/Header.test.ts +++ b/frontend/src/components/__tests__/Header.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { suppressConsoleError } from '$test/test-utils'; +import { user, suppressConsoleError } from '$test/test-utils'; + +import * as router from '@mateothegreat/svelte5-router'; const mocks = vi.hoisted(() => ({ mockAuthStore: { @@ -20,10 +21,8 @@ const mocks = vi.hoisted(() => ({ value: 'auto' as string, }, mockToggleTheme: vi.fn(), - mockGoto: vi.fn(), })); -vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: mocks.mockGoto })); vi.mock('../../stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); @@ -31,9 +30,11 @@ vi.mock('../../stores/theme.svelte', () => ({ get themeStore() { return mocks.mockThemeStore; }, get toggleTheme() { return mocks.mockToggleTheme; }, })); -vi.mock('../NotificationCenter.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent( - '
NotificationCenter
', 'notification-center')); +vi.mock('../NotificationCenter.svelte', () => { + const M = function() { return {}; } as any; + M.render = () => ({ html: '
NotificationCenter
', css: { code: '', map: null }, head: '' }); + return { default: M }; +}); import Header from '$components/Header.svelte'; @@ -45,21 +46,17 @@ const setAuth = (isAuth: boolean, username: string | null = null, role: string | mocks.mockAuthStore.userEmail = email; }; -const openUserDropdown = async (_username: string) => { - const user = userEvent.setup(); - render(Header); - await user.click(screen.getByRole('button', { name: 'User menu' })); - return user; -}; +describe('Header', () => { + const openUserDropdown = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'User menu' })); + }; -const openMobileMenu = async () => { - const user = userEvent.setup(); - render(Header); - await user.click(screen.getByRole('button', { name: 'Open menu' })); - return { user }; -}; + const openMobileMenu = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'Open menu' })); + }; -describe('Header', () => { let originalInnerWidth: number; beforeEach(() => { @@ -67,7 +64,7 @@ describe('Header', () => { mocks.mockThemeStore.value = 'auto'; mocks.mockAuthStore.logout.mockReset(); mocks.mockToggleTheme.mockReset(); - mocks.mockGoto.mockReset(); + vi.spyOn(router, 'goto'); originalInnerWidth = window.innerWidth; Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); Object.defineProperty(window, 'matchMedia', { @@ -96,7 +93,6 @@ describe('Header', () => { describe('theme toggle', () => { it('renders and calls toggleTheme when clicked', async () => { - const user = userEvent.setup(); render(Header); const themeButton = screen.getByTitle('Toggle theme'); expect(themeButton).toBeInTheDocument(); @@ -131,7 +127,7 @@ describe('Header', () => { beforeEach(() => { setAuth(true, 'testuser', 'user', 'test@example.com'); }); it('shows username and opens dropdown with user info', async () => { - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getAllByText(/testuser/i).length).toBeGreaterThan(0); expect(screen.getByText('test@example.com')).toBeInTheDocument(); @@ -143,16 +139,16 @@ describe('Header', () => { it('shows "No email set" when email is null', async () => { mocks.mockAuthStore.userEmail = null; - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByText('No email set')).toBeInTheDocument(); }); }); it('logout calls logout and redirects', async () => { - const user = await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Logout/i })); expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -160,7 +156,7 @@ describe('Header', () => { beforeEach(() => { setAuth(true, 'admin', 'admin', 'admin@example.com'); }); it('shows Admin indicator and button in dropdown', async () => { - await openUserDropdown('admin'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByText('(Admin)')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); @@ -168,10 +164,10 @@ describe('Header', () => { }); it('Admin button navigates to admin panel', async () => { - const user = await openUserDropdown('admin'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^Admin$/ })); - await waitFor(() => { expect(mocks.mockGoto).toHaveBeenCalledWith('/admin/events'); }); + await waitFor(() => { expect(router.goto).toHaveBeenCalledWith('/admin/events'); }); }); }); @@ -215,7 +211,7 @@ describe('Header', () => { it('closes dropdown when clicking a menu item', async () => { const restoreConsole = suppressConsoleError(); setAuth(true, 'testuser', 'user'); - const user = await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('link', { name: /Settings/i })); await waitFor(() => { expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); }); @@ -224,7 +220,7 @@ describe('Header', () => { it('closes dropdown when clicking outside', async () => { setAuth(true, 'testuser', 'user'); - await openUserDropdown('testuser'); + await openUserDropdown(); await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); // Click outside the dropdown @@ -267,7 +263,7 @@ describe('Header', () => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); setAuth(true, 'mobileuser', 'user'); - const { user } = await openMobileMenu(); + await openMobileMenu(); await waitFor(() => { const mobileMenu = document.body.querySelector('.lg\\:hidden.absolute'); expect(mobileMenu?.textContent).toContain('Logout'); @@ -277,7 +273,7 @@ describe('Header', () => { await user.click(logoutButton); expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); }); diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index 4e1c0578..330ec43e 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user, type UserEventInstance } from '$test/test-utils'; // Types for mock notification state interface MockNotification { notification_id: string; @@ -77,20 +77,6 @@ const setNotifications = (notifications: MockNotification[]) => { mocks.mockNotificationStore.unreadCount = notifications.filter(n => n.status !== 'read').length; }; -const openDropdown = async () => { - const user = userEvent.setup(); - render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - return user; -}; - -const openDropdownWithContainer = async () => { - const user = userEvent.setup(); - const { container } = render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - return { user, container }; -}; - /** Mocks window.location.href for external URL testing */ const withMockedLocation = async (testFn: (mockHref: ReturnType) => Promise) => { const originalLocation = window.location; @@ -103,7 +89,7 @@ const withMockedLocation = async (testFn: (mockHref: ReturnType) = /** Interacts with a notification button via click or keyboard */ const interactWithButton = async ( - user: ReturnType, + user: UserEventInstance, button: HTMLElement, method: 'click' | 'keyboard' ) => { @@ -152,6 +138,17 @@ const interactionTestCases = [ // Tests describe('NotificationCenter', () => { + const openDropdown = async () => { + render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + }; + + const openDropdownWithContainer = async () => { + const { container } = render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + return { container }; + }; + beforeEach(() => { mocks.mockAuthStore.isAuthenticated = true; mocks.mockAuthStore.username = 'testuser'; @@ -206,7 +203,7 @@ describe('NotificationCenter', () => { }); it('navigates to /notifications when View all clicked', async () => { - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('View all notifications')); expect(mocks.mockGoto).toHaveBeenCalledWith('/notifications'); }); @@ -241,14 +238,14 @@ describe('NotificationCenter', () => { it('calls markAllAsRead when button clicked', async () => { setNotifications([createNotification()]); - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('Mark all as read')); expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); }); it('skips markAsRead for already-read notifications', async () => { setNotifications([createNotification({ subject: 'Read', status: 'read' })]); - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByRole('button', { name: /View notification: Read/i })); expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); }); @@ -293,7 +290,7 @@ describe('NotificationCenter', () => { it.each(interactionTestCases)('$method: navigates=$hasUrl', async ({ method, hasUrl, url }) => { const subject = `${method}-${hasUrl}`; setNotifications([createNotification({ subject, action_url: url })]); - const user = await openDropdown(); + await openDropdown(); const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${subject}`, 'i') }); await interactWithButton(user, button, method); @@ -302,15 +299,12 @@ describe('NotificationCenter', () => { }); it('ignores non-Enter keydown', async () => { - vi.useFakeTimers(); setNotifications([createNotification({ subject: 'Test', action_url: '/test' })]); - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(NotificationCenter); await user.click(screen.getByRole('button', { name: /Notifications/i })); screen.getByRole('button', { name: /View notification: Test/i }).focus(); await user.keyboard('{Tab}'); expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); - vi.useRealTimers(); }); }); @@ -319,7 +313,7 @@ describe('NotificationCenter', () => { await withMockedLocation(async (mockHref) => { const url = `https://example.com/${method}`; setNotifications([createNotification({ subject: method, action_url: url })]); - const user = await openDropdown(); + await openDropdown(); const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${method}`, 'i') }); await interactWithButton(user, button, method); expect(mockHref).toHaveBeenCalledWith(url); @@ -332,7 +326,7 @@ describe('NotificationCenter', () => { setNotifications([createNotification({ subject: 'Test' })]); render(NotificationCenter); expect(screen.getByRole('button', { name: /Notifications/i })).toHaveAttribute('aria-label', 'Notifications'); - await userEvent.click(screen.getByRole('button', { name: /Notifications/i })); + await user.click(screen.getByRole('button', { name: /Notifications/i })); await waitFor(() => { expect(screen.getByRole('button', { name: /View notification: Test/i })).toHaveAttribute('tabindex', '0'); }); @@ -341,14 +335,11 @@ describe('NotificationCenter', () => { describe('auto-mark as read', () => { it('marks notifications after 2s delay', async () => { - vi.useFakeTimers(); setNotifications([createNotification({ notification_id: '1' }), createNotification({ notification_id: '2' })]); - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(NotificationCenter); await user.click(screen.getByRole('button', { name: /Notifications/i })); await vi.advanceTimersByTimeAsync(2500); expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalled(); - vi.useRealTimers(); }); }); @@ -390,7 +381,7 @@ describe('NotificationCenter', () => { it('calls requestPermission when enable button clicked', async () => { mockNotificationPermission = 'default'; - const user = await openDropdown(); + await openDropdown(); await user.click(await screen.findByText('Enable desktop notifications')); expect(mockRequestPermission).toHaveBeenCalled(); }); diff --git a/frontend/src/components/__tests__/ProtectedRoute.test.ts b/frontend/src/components/__tests__/ProtectedRoute.test.ts index 81f4c7e6..7f6441ef 100644 --- a/frontend/src/components/__tests__/ProtectedRoute.test.ts +++ b/frontend/src/components/__tests__/ProtectedRoute.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; +import * as router from '@mateothegreat/svelte5-router'; + const mocks = vi.hoisted(() => ({ mockAuthStore: { isAuthenticated: null as boolean | null, @@ -11,18 +13,12 @@ const mocks = vi.hoisted(() => ({ csrfToken: null as string | null, waitForInit: vi.fn().mockResolvedValue(true), }, - mockGoto: vi.fn(), })); -vi.mock('@mateothegreat/svelte5-router', () => ({ goto: mocks.mockGoto })); vi.mock('../../stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); -vi.mock('../Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent( - '
Loading...
', 'spinner')); - import ProtectedRoute from '$components/ProtectedRoute.svelte'; describe('ProtectedRoute', () => { @@ -37,7 +33,7 @@ describe('ProtectedRoute', () => { mocks.mockAuthStore.userEmail = null; mocks.mockAuthStore.csrfToken = null; - mocks.mockGoto.mockReset(); + vi.spyOn(router, 'goto'); mocks.mockAuthStore.waitForInit.mockReset().mockResolvedValue(true); Object.defineProperty(window, 'location', { @@ -131,7 +127,7 @@ describe('ProtectedRoute', () => { // Give time for any potential redirect await new Promise(resolve => setTimeout(resolve, 50)); - expect(mocks.mockGoto).not.toHaveBeenCalled(); + expect(router.goto).not.toHaveBeenCalled(); }); it('does not save redirect path when authenticated', async () => { @@ -157,7 +153,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -165,7 +161,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { redirectTo: '/custom-login' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/custom-login'); + expect(router.goto).toHaveBeenCalledWith('/custom-login'); }); }); @@ -173,7 +169,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['redirectAfterLogin']).toBe('/protected-page?foo=bar#section'); @@ -193,7 +189,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['redirectAfterLogin']).toBeUndefined(); @@ -203,7 +199,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: 'Custom auth message' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('Custom auth message'); @@ -213,7 +209,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('Please log in to access this page'); @@ -227,7 +223,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { redirectTo: '/signin' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/signin'); + expect(router.goto).toHaveBeenCalledWith('/signin'); }); }); @@ -237,7 +233,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: 'You need to sign in first' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); expect(sessionStorageData['authMessage']).toBe('You need to sign in first'); @@ -282,7 +278,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); }); @@ -292,7 +288,7 @@ describe('ProtectedRoute', () => { render(ProtectedRoute, { props: { message: '' } }); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalled(); }); // Empty message should not be saved diff --git a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts index 57189b08..3374b9e1 100644 --- a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockEventDetail } from '$test/test-utils'; +import { createMockEventDetail, user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import EventDetailsModal from '../EventDetailsModal.svelte'; @@ -71,7 +66,6 @@ describe('EventDetailsModal', () => { }); it('calls onViewRelated with event_id when clicking a related event', async () => { - const user = userEvent.setup(); const { onViewRelated } = renderModal(); await user.click(screen.getByText('execution_started')); expect(onViewRelated).toHaveBeenCalledWith('rel-1'); @@ -85,14 +79,12 @@ describe('EventDetailsModal', () => { }); it('calls onReplay with event_id when Replay Event button is clicked', async () => { - const user = userEvent.setup(); const { onReplay } = renderModal(); await user.click(screen.getByRole('button', { name: 'Replay Event' })); expect(onReplay).toHaveBeenCalledWith('evt-1'); }); it('calls onClose when Close button in footer is clicked', async () => { - const user = userEvent.setup(); const { onClose } = renderModal(); await user.click(screen.getByRole('button', { name: 'Close' })); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts index 0e228210..32ad4838 100644 --- a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { EventFilter } from '$lib/api'; @@ -48,21 +48,18 @@ describe('EventFilters', () => { }); it('calls onApply when Apply button is clicked', async () => { - const user = userEvent.setup(); const { onApply } = renderFilters(); await user.click(screen.getByRole('button', { name: 'Apply' })); expect(onApply).toHaveBeenCalledOnce(); }); it('calls onClear when Clear All button is clicked', async () => { - const user = userEvent.setup(); const { onClear } = renderFilters(); await user.click(screen.getByRole('button', { name: 'Clear All' })); expect(onClear).toHaveBeenCalledOnce(); }); it('text inputs accept user input', async () => { - const user = userEvent.setup(); renderFilters(); const searchInput = screen.getByLabelText('Search') as HTMLInputElement; await user.type(searchInput, 'test query'); diff --git a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts index 64eb9d71..fed85a22 100644 --- a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockEvent, createMockEvents } from '$test/test-utils'; +import { createMockEvent, createMockEvents, user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('Eye', 'Play', 'Trash2')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import EventsTable from '../EventsTable.svelte'; @@ -87,7 +82,6 @@ describe('EventsTable', () => { describe('row click actions', () => { it('calls onViewDetails when clicking a table row', async () => { - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-click' })]; const { onViewDetails } = renderTable(events); const rows = screen.getAllByRole('button', { name: 'View event details' }); @@ -110,7 +104,6 @@ describe('EventsTable', () => { { title: 'Replay', callback: 'onReplay' as const }, { title: 'Delete', callback: 'onDelete' as const }, ])('$title button calls $callback with event_id without triggering row click', async ({ title, callback }) => { - const user = userEvent.setup(); const events = [createMockEvent({ event_id: 'evt-action' })]; const handlers = renderTable(events); const buttons = screen.getAllByTitle(title); @@ -121,7 +114,6 @@ describe('EventsTable', () => { }); it('calls onViewUser with user_id when clicking user link (stopPropagation)', async () => { - const user = userEvent.setup(); const event = createMockEvent({ metadata: { user_id: 'user-linked' } }); const { onViewUser, onViewDetails } = renderTable([event]); const userButtons = screen.getAllByTitle('View user overview'); diff --git a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts index 7ec7af16..4efe2b53 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('AlertTriangle', 'X')); import ReplayPreviewModal from '../ReplayPreviewModal.svelte'; @@ -101,7 +99,6 @@ describe('ReplayPreviewModal', () => { }); it('calls onConfirm with eventId and onClose when Proceed is clicked', async () => { - const user = userEvent.setup(); const { onConfirm, onClose } = renderModal(); await user.click(screen.getByRole('button', { name: 'Proceed with Replay' })); expect(onClose).toHaveBeenCalledOnce(); @@ -109,7 +106,6 @@ describe('ReplayPreviewModal', () => { }); it('calls onClose when Cancel button is clicked', async () => { - const user = userEvent.setup(); const { onClose, onConfirm } = renderModal(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts index 2e992a10..3e1dd0c9 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts @@ -1,10 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; import type { EventReplayStatusResponse } from '$lib/api'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); import ReplayProgressBanner from '../ReplayProgressBanner.svelte'; @@ -70,7 +68,6 @@ describe('ReplayProgressBanner', () => { }); it('calls onClose when close button is clicked', async () => { - const user = userEvent.setup(); const { onClose } = renderBanner(); await user.click(screen.getByTitle('Close')); expect(onClose).toHaveBeenCalledOnce(); diff --git a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts index e7bbed6e..32a7315c 100644 --- a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts @@ -2,12 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { createMockUserOverview } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('X')); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('
Loading...
', 'spinner')); -vi.mock('$components/EventTypeIcon.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('icon')); import UserOverviewModal from '../UserOverviewModal.svelte'; diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts index be9b50e8..c96b122f 100644 --- a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, within, fireEvent, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ChevronDown', 'ChevronRight')); import LanguageSelect from '../LanguageSelect.svelte'; @@ -20,14 +18,13 @@ function renderSelect(overrides: Partial = {}) { return { ...render(LanguageSelect, { props }), onselect: props.onselect }; } -async function openMenu() { - const user = userEvent.setup(); - const result = renderSelect(); - await user.click(screen.getByRole('button', { name: /Select language/i })); - return { user, ...result }; -} - describe('LanguageSelect', () => { + async function openMenu() { + const result = renderSelect(); + await user.click(screen.getByRole('button', { name: /Select language/i })); + return result; + } + describe('trigger button', () => { it('shows current language and version with aria-haspopup', () => { renderSelect(); @@ -54,7 +51,7 @@ describe('LanguageSelect', () => { }); it('closes menu on second click', async () => { - const { user } = await openMenu(); + await openMenu(); await user.click(screen.getByRole('button', { name: /Select language/i })); await waitFor(() => { expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); @@ -64,7 +61,7 @@ describe('LanguageSelect', () => { describe('version submenu', () => { it('shows all versions on language hover with correct aria-checked', async () => { - const { user } = await openMenu(); + await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /python/i })); const versionMenu = screen.getByRole('menu', { name: /python versions/i }); const versions = within(versionMenu).getAllByRole('menuitemradio'); @@ -74,7 +71,7 @@ describe('LanguageSelect', () => { }); it('calls onselect and closes menu on version click', async () => { - const { user, onselect } = await openMenu(); + const { onselect } = await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /node/i })); const nodeMenu = screen.getByRole('menu', { name: /node versions/i }); await user.click(within(nodeMenu).getByRole('menuitemradio', { name: '20' })); @@ -85,7 +82,7 @@ describe('LanguageSelect', () => { }); it('switches version submenu when hovering different language', async () => { - const { user } = await openMenu(); + await openMenu(); await user.hover(screen.getByRole('menuitem', { name: /python/i })); expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); @@ -102,7 +99,6 @@ describe('LanguageSelect', () => { { key: '{ArrowDown}', label: 'ArrowDown' }, { key: '{Enter}', label: 'Enter' }, ])('opens menu with $label on trigger', async ({ key }) => { - const user = userEvent.setup(); renderSelect(); screen.getByRole('button', { name: /Select language/i }).focus(); await user.keyboard(key); diff --git a/frontend/src/components/editor/__tests__/OutputPanel.test.ts b/frontend/src/components/editor/__tests__/OutputPanel.test.ts index d3f7cdb1..8e7d7077 100644 --- a/frontend/src/components/editor/__tests__/OutputPanel.test.ts +++ b/frontend/src/components/editor/__tests__/OutputPanel.test.ts @@ -1,20 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; import type { ExecutionResult } from '$lib/api'; import type { ExecutionPhase } from '$lib/editor'; -const mocks = vi.hoisted(() => ({ - addToast: vi.fn(), -})); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('AlertTriangle', 'FileText', 'Copy')); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('
Loading...
', 'spinner')); - import OutputPanel from '../OutputPanel.svelte'; function makeResult(overrides: Partial = {}): ExecutionResult { @@ -44,6 +34,8 @@ function renderIdle(overrides: Partial = {}) { describe('OutputPanel', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); }); it('shows heading and prompt text when idle with no result or error', () => { @@ -155,22 +147,20 @@ describe('OutputPanel', () => { { target: 'stderr', ariaLabel: 'Copy error text to clipboard', text: 'some error', toastLabel: 'Error text' }, { target: 'execution_id', ariaLabel: 'Click to copy execution ID', text: 'uuid-abc', toastLabel: 'Execution ID' }, ])('copies $target and shows success toast', async ({ target, ariaLabel, text, toastLabel }) => { - const user = userEvent.setup(); mockClipboard(); renderIdle({ result: makeResult({ [target]: text }), }); await user.click(screen.getByLabelText(ariaLabel)); expect(writeTextMock).toHaveBeenCalledWith(text); - expect(mocks.addToast).toHaveBeenCalledWith('success', `${toastLabel} copied to clipboard`); + expect(toast.success).toHaveBeenCalledWith(`${toastLabel} copied to clipboard`); }); it('shows error toast when clipboard write fails', async () => { - const user = userEvent.setup(); mockClipboard(false); renderIdle({ result: makeResult({ stdout: 'x' }) }); await user.click(screen.getByLabelText('Copy output to clipboard')); - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to copy output'); + expect(toast.error).toHaveBeenCalledWith('Failed to copy output'); }); }); diff --git a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts index 7bd4ef80..753cc3a0 100644 --- a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts +++ b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'MessageSquare', 'ChevronUp', 'ChevronDown', 'Cpu', 'MemoryStick', 'Clock', - )); +import { user } from '$test/test-utils'; + import ResourceLimits from '../ResourceLimits.svelte'; @@ -18,7 +15,6 @@ const LIMITS = { }; describe('ResourceLimits', () => { - it('renders nothing when limits is null', () => { const { container } = render(ResourceLimits, { props: { limits: null } }); expect(container.textContent?.trim()).toBe(''); @@ -32,7 +28,6 @@ describe('ResourceLimits', () => { }); it('expands panel on click showing all limit values', async () => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); await user.click(screen.getByRole('button', { name: /Resource Limits/i })); @@ -44,7 +39,6 @@ describe('ResourceLimits', () => { { label: 'Memory Limit', value: '256Mi' }, { label: 'Timeout', value: '30s' }, ])('shows $label = $value when expanded', async ({ label, value }) => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); await user.click(screen.getByRole('button', { name: /Resource Limits/i })); expect(screen.getByText(label)).toBeInTheDocument(); @@ -52,7 +46,6 @@ describe('ResourceLimits', () => { }); it('collapses panel on second click', async () => { - const user = userEvent.setup(); render(ResourceLimits, { props: { limits: LIMITS } }); const btn = screen.getByRole('button', { name: /Resource Limits/i }); diff --git a/frontend/src/components/editor/__tests__/SavedScripts.test.ts b/frontend/src/components/editor/__tests__/SavedScripts.test.ts index b3b18442..21869006 100644 --- a/frontend/src/components/editor/__tests__/SavedScripts.test.ts +++ b/frontend/src/components/editor/__tests__/SavedScripts.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('List', 'Trash2')); +import { user } from '$test/test-utils'; + import SavedScripts from '../SavedScripts.svelte'; import type { SavedScriptResponse } from '$lib/api'; @@ -28,14 +27,12 @@ function renderScripts(scripts: SavedScriptResponse[] = []) { return { ...result, onload, ondelete, onrefresh }; } -async function renderAndExpand(scripts: SavedScriptResponse[] = []) { - const user = userEvent.setup(); - const result = renderScripts(scripts); - await user.click(screen.getByRole('button', { name: /Show Saved Scripts/i })); - return { user, ...result }; -} - describe('SavedScripts', () => { + async function renderAndExpand(scripts: SavedScriptResponse[] = []) { + const result = renderScripts(scripts); + await user.click(screen.getByRole('button', { name: /Show Saved Scripts/i })); + return result; + } it('renders collapsed with heading, toggle button, and scripts hidden', () => { renderScripts(createScripts(2)); @@ -50,7 +47,6 @@ describe('SavedScripts', () => { expect(onrefresh).toHaveBeenCalledOnce(); onrefresh.mockClear(); - const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: /Hide Saved Scripts/i })); expect(onrefresh).not.toHaveBeenCalled(); }); @@ -79,7 +75,6 @@ describe('SavedScripts', () => { it('calls onload with full script object when clicking a script name', async () => { const scripts = createScripts(2); const { onload } = await renderAndExpand(scripts); - const user = userEvent.setup(); await user.click(screen.getByText('Script 2')); expect(onload).toHaveBeenCalledWith(scripts[1]); }); @@ -87,7 +82,6 @@ describe('SavedScripts', () => { it('calls ondelete with script id and does not trigger onload (stopPropagation)', async () => { const scripts = createScripts(1); const { onload, ondelete } = await renderAndExpand(scripts); - const user = userEvent.setup(); await user.click(screen.getByTitle('Delete Script 1')); expect(ondelete).toHaveBeenCalledWith('script-1'); expect(onload).not.toHaveBeenCalled(); diff --git a/frontend/src/lib/__tests__/formatters.test.ts b/frontend/src/lib/__tests__/formatters.test.ts index 6f289317..6f4a25d3 100644 --- a/frontend/src/lib/__tests__/formatters.test.ts +++ b/frontend/src/lib/__tests__/formatters.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { formatDate, formatTimestamp, @@ -98,10 +98,8 @@ describe('formatDurationBetween', () => { describe('formatRelativeTime', () => { beforeEach(() => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2025, 6, 15, 12, 0, 0)); }); - afterEach(() => vi.useRealTimers()); it.each([ [new Date(2025, 6, 15, 11, 59, 30), 'just now'], // 30s ago diff --git a/frontend/src/lib/admin/__tests__/autoRefresh.test.ts b/frontend/src/lib/admin/__tests__/autoRefresh.test.ts index a2e608c0..5e1b148b 100644 --- a/frontend/src/lib/admin/__tests__/autoRefresh.test.ts +++ b/frontend/src/lib/admin/__tests__/autoRefresh.test.ts @@ -1,17 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { effect_root } from 'svelte/internal/client'; -// Mock onDestroy — no component lifecycle in unit tests -vi.mock('svelte', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, onDestroy: vi.fn() }; -}); - const { createAutoRefresh } = await import('../autoRefresh.svelte'); describe('createAutoRefresh', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); function make(overrides: Record = {}) { const onRefresh = vi.fn(); @@ -19,7 +11,6 @@ describe('createAutoRefresh', () => { const teardown = effect_root(() => { ar = createAutoRefresh({ onRefresh, - autoCleanup: false, initialEnabled: false, ...overrides, }); diff --git a/frontend/src/lib/admin/autoRefresh.svelte.ts b/frontend/src/lib/admin/autoRefresh.svelte.ts index 1306a22e..1550dd16 100644 --- a/frontend/src/lib/admin/autoRefresh.svelte.ts +++ b/frontend/src/lib/admin/autoRefresh.svelte.ts @@ -3,8 +3,6 @@ * Manages interval-based data reloading with configurable rates */ -import { onDestroy } from 'svelte'; - export interface AutoRefreshState { enabled: boolean; rate: number; @@ -25,7 +23,6 @@ export interface AutoRefreshOptions { initialRate?: number; rateOptions?: RefreshRateOption[]; onRefresh: () => void | Promise; - autoCleanup?: boolean; } const DEFAULT_RATE_OPTIONS: RefreshRateOption[] = [ @@ -39,7 +36,6 @@ const DEFAULT_OPTIONS = { initialEnabled: true, initialRate: 5, rateOptions: DEFAULT_RATE_OPTIONS, - autoCleanup: true }; /** @@ -79,11 +75,6 @@ export function createAutoRefresh(options: AutoRefreshOptions): AutoRefreshState stop(); } - // Auto-cleanup on component destroy if enabled - if (opts.autoCleanup) { - onDestroy(cleanup); - } - // Watch for changes to enabled/rate and restart $effect(() => { void enabled; diff --git a/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts new file mode 100644 index 00000000..1cd6f159 --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + browseEventsApiV1AdminEventsBrowsePost: vi.fn(), + getEventStatsApiV1AdminEventsStatsGet: vi.fn(), + getEventDetailApiV1AdminEventsEventIdGet: vi.fn(), + replayEventsApiV1AdminEventsReplayPost: vi.fn(), + deleteEventApiV1AdminEventsEventIdDelete: vi.fn(), + getUserOverviewApiV1AdminUsersUserIdOverviewGet: vi.fn(), + unwrap: vi.fn((result: { data: unknown }) => result?.data), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), + toastSuccess: vi.fn(), + toastError: vi.fn(), + toastInfo: vi.fn(), + windowOpen: vi.fn(), + windowConfirm: vi.fn(), +})); + +vi.mock('$lib/api', () => ({ + browseEventsApiV1AdminEventsBrowsePost: (...args: unknown[]) => mocks.browseEventsApiV1AdminEventsBrowsePost(...args), + getEventStatsApiV1AdminEventsStatsGet: (...args: unknown[]) => mocks.getEventStatsApiV1AdminEventsStatsGet(...args), + getEventDetailApiV1AdminEventsEventIdGet: (...args: unknown[]) => mocks.getEventDetailApiV1AdminEventsEventIdGet(...args), + replayEventsApiV1AdminEventsReplayPost: (...args: unknown[]) => mocks.replayEventsApiV1AdminEventsReplayPost(...args), + deleteEventApiV1AdminEventsEventIdDelete: (...args: unknown[]) => mocks.deleteEventApiV1AdminEventsEventIdDelete(...args), + getUserOverviewApiV1AdminUsersUserIdOverviewGet: (...args: unknown[]) => mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrap: (result: { data: unknown }) => mocks.unwrap(result), + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +vi.mock('svelte-sonner', () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + error: (...args: unknown[]) => mocks.toastError(...args), + info: (...args: unknown[]) => mocks.toastInfo(...args), + warning: vi.fn(), + }, +})); + +type EventSourceHandler = ((event: MessageEvent) => void) | null; + +class MockEventSource { + url: string; + onmessage: EventSourceHandler = null; + onerror: ((event: Event) => void) | null = null; + closed = false; + static instances: MockEventSource[] = []; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + close(): void { + this.closed = true; + } + + simulateMessage(data: string): void { + if (this.onmessage) { + this.onmessage(new MessageEvent('message', { data })); + } + } + + simulateError(): void { + if (this.onerror) { + this.onerror(new Event('error')); + } + } +} + +const { createEventsStore } = await import('../eventsStore.svelte'); + +const createMockEvent = (overrides: Record = {}) => ({ + event_id: 'evt-1', + event_type: 'execution_completed', + event_version: '1', + timestamp: '2024-01-15T10:30:00Z', + aggregate_id: 'exec-456', + metadata: { service_name: 'test-service', service_version: '1.0.0', user_id: 'user-1' }, + execution_id: 'exec-456', + exit_code: 0, + stdout: 'hello', + ...overrides, +}); + +const createMockStats = () => ({ + total_events: 150, + error_rate: 2.5, + avg_processing_time: 1.23, + top_users: [], + events_by_type: [], + events_by_hour: [], +}); + +describe('EventsStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + MockEventSource.instances = []; + vi.stubGlobal('EventSource', MockEventSource); + vi.stubGlobal('open', mocks.windowOpen); + vi.stubGlobal('confirm', mocks.windowConfirm); + mocks.windowConfirm.mockReturnValue(true); + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ + data: { events: [], total: 0 }, + }); + mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ + data: null, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createEventsStore(); + }); + } + + afterEach(() => { + vi.unstubAllGlobals(); + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data', () => { + createStore(); + expect(store.events).toEqual([]); + expect(store.totalEvents).toBe(0); + expect(store.loading).toBe(false); + expect(store.stats).toBeNull(); + expect(store.filters).toEqual({}); + }); + }); + + describe('loadAll', () => { + it('loads events and stats', async () => { + const events = [createMockEvent()]; + const stats = createMockStats(); + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ + data: { events, total: 1 }, + }); + mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ + data: stats, + }); + + createStore(); + await store.loadAll(); + + expect(store.events).toEqual(events); + expect(store.totalEvents).toBe(1); + expect(store.stats).toEqual(stats); + }); + }); + + describe('loadEvents', () => { + it('passes pagination to API', async () => { + createStore(); + store.pagination.currentPage = 2; + await store.loadEvents(); + + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ skip: 10, limit: 10 }), + }), + ); + }); + + it('passes filters to API', async () => { + createStore(); + store.filters = { user_id: 'user-1', aggregate_id: 'agg-1' }; + await store.loadEvents(); + + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + filters: expect.objectContaining({ + user_id: 'user-1', + aggregate_id: 'agg-1', + }), + }), + }), + ); + }); + + it('handles empty response', async () => { + mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: null }); + + createStore(); + await store.loadEvents(); + + expect(store.events).toEqual([]); + expect(store.totalEvents).toBe(0); + }); + }); + + describe('loadEventDetail', () => { + it('returns event detail', async () => { + const detail = { event: createMockEvent(), related_events: [], timeline: [] }; + mocks.getEventDetailApiV1AdminEventsEventIdGet.mockResolvedValue({ data: detail }); + + createStore(); + const result = await store.loadEventDetail('evt-1'); + + expect(result).toEqual(detail); + expect(mocks.getEventDetailApiV1AdminEventsEventIdGet).toHaveBeenCalledWith({ + path: { event_id: 'evt-1' }, + }); + }); + }); + + describe('replayEvent', () => { + it('performs dry run and sets replayPreview', async () => { + const preview = [createMockEvent()]; + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, events_preview: preview }, + }); + + createStore(); + await store.replayEvent('evt-1', true); + + expect(store.replayPreview).toEqual({ + eventId: 'evt-1', + total_events: 1, + events_preview: preview, + }); + }); + + it('confirms and starts SSE stream for actual replay', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, session_id: 'session-1', replay_id: 'replay-1' }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + expect(mocks.windowConfirm).toHaveBeenCalled(); + expect(mocks.toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Replay scheduled')); + expect(store.activeReplaySession).toBeTruthy(); + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]!.url).toBe('/api/v1/admin/events/replay/session-1/status'); + }); + + it('updates activeReplaySession from SSE messages', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 5, session_id: 'session-1', replay_id: 'replay-1' }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + const es = MockEventSource.instances[0]!; + es.simulateMessage(JSON.stringify({ + session_id: 'session-1', + status: 'running', + total_events: 5, + replayed_events: 3, + failed_events: 0, + skipped_events: 0, + replay_id: 'replay-1', + created_at: '2024-01-01T00:00:00Z', + errors: [], + })); + + expect(store.activeReplaySession?.replayed_events).toBe(3); + expect(store.activeReplaySession?.progress_percentage).toBe(60); + }); + + it('disconnects SSE on terminal status', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 5, session_id: 'session-1', replay_id: 'replay-1' }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + const es = MockEventSource.instances[0]!; + es.simulateMessage(JSON.stringify({ + session_id: 'session-1', + status: 'completed', + total_events: 5, + replayed_events: 5, + failed_events: 0, + skipped_events: 0, + replay_id: 'replay-1', + created_at: '2024-01-01T00:00:00Z', + errors: [], + })); + + expect(mocks.toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Replay completed')); + expect(es.closed).toBe(true); + }); + + it('does not replay if confirm is cancelled', async () => { + mocks.windowConfirm.mockReturnValue(false); + + createStore(); + await store.replayEvent('evt-1', false); + + expect(mocks.replayEventsApiV1AdminEventsReplayPost).not.toHaveBeenCalled(); + }); + }); + + describe('deleteEvent', () => { + it('confirms and deletes event', async () => { + mocks.deleteEventApiV1AdminEventsEventIdDelete.mockResolvedValue({ data: {} }); + + createStore(); + await store.deleteEvent('evt-1'); + + expect(mocks.windowConfirm).toHaveBeenCalled(); + expect(mocks.deleteEventApiV1AdminEventsEventIdDelete).toHaveBeenCalledWith({ + path: { event_id: 'evt-1' }, + }); + expect(mocks.toastSuccess).toHaveBeenCalledWith('Event deleted successfully'); + }); + + it('does not delete if confirm is cancelled', async () => { + mocks.windowConfirm.mockReturnValue(false); + + createStore(); + await store.deleteEvent('evt-1'); + + expect(mocks.deleteEventApiV1AdminEventsEventIdDelete).not.toHaveBeenCalled(); + }); + }); + + describe('exportEvents', () => { + it('opens export URL for CSV', () => { + createStore(); + store.exportEvents('csv'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/admin/events/export/csv'), + '_blank', + ); + expect(mocks.toastInfo).toHaveBeenCalledWith(expect.stringContaining('CSV')); + }); + + it('opens export URL for JSON', () => { + createStore(); + store.exportEvents('json'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/admin/events/export/json'), + '_blank', + ); + }); + + it('includes filter params in export URL', () => { + createStore(); + store.filters = { user_id: 'user-1', aggregate_id: 'agg-1' }; + store.exportEvents('csv'); + + expect(mocks.windowOpen).toHaveBeenCalledWith( + expect.stringMatching(/user_id=user-1/), + '_blank', + ); + }); + }); + + describe('openUserOverview', () => { + it('loads user overview', async () => { + const overview = { user: { user_id: 'user-1' }, stats: {}, derived_counts: {} }; + mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet.mockResolvedValue({ + data: overview, + }); + + createStore(); + await store.openUserOverview('user-1'); + + expect(store.userOverview).toEqual(overview); + expect(store.userOverviewLoading).toBe(false); + }); + + it('skips empty userId', async () => { + createStore(); + await store.openUserOverview(''); + + expect(mocks.getUserOverviewApiV1AdminUsersUserIdOverviewGet).not.toHaveBeenCalled(); + }); + }); + + describe('clearFilters', () => { + it('resets filters and reloads', async () => { + createStore(); + store.filters = { user_id: 'test' }; + store.pagination.currentPage = 3; + + store.clearFilters(); + + expect(store.filters).toEqual({}); + expect(store.pagination.currentPage).toBe(1); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled(); + }); + }); + + describe('auto-refresh', () => { + it('fires loadAll on 30s interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalledTimes(2); + }); + + it('stops on cleanup', async () => { + createStore(); + await vi.advanceTimersByTimeAsync(30000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled(); + + const callsBefore = mocks.browseEventsApiV1AdminEventsBrowsePost.mock.calls.length; + store.mainRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(60000); + expect(mocks.browseEventsApiV1AdminEventsBrowsePost.mock.calls.length).toBe(callsBefore); + }); + }); + + describe('cleanup', () => { + it('cleans up SSE replay stream', async () => { + mocks.replayEventsApiV1AdminEventsReplayPost.mockResolvedValue({ + data: { total_events: 1, session_id: 'session-1', replay_id: 'replay-1' }, + }); + + createStore(); + await store.replayEvent('evt-1', false); + + const es = MockEventSource.instances[0]!; + expect(es.closed).toBe(false); + + store.cleanup(); + + expect(es.closed).toBe(true); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts new file mode 100644 index 00000000..dde36a3f --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + listExecutionsApiV1AdminExecutionsGet: vi.fn(), + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut: vi.fn(), + getQueueStatusApiV1AdminExecutionsQueueGet: vi.fn(), + unwrap: vi.fn((result: { data: unknown }) => result?.data), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), + toastSuccess: vi.fn(), +})); + +vi.mock('$lib/api', () => ({ + listExecutionsApiV1AdminExecutionsGet: (...args: unknown[]) => mocks.listExecutionsApiV1AdminExecutionsGet(...args), + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut: (...args: unknown[]) => mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut(...args), + getQueueStatusApiV1AdminExecutionsQueueGet: (...args: unknown[]) => mocks.getQueueStatusApiV1AdminExecutionsQueueGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrap: (result: { data: unknown }) => mocks.unwrap(result), + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +vi.mock('svelte-sonner', () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }, +})); + +const { createExecutionsStore } = await import('../executionsStore.svelte'); + +const createMockExecution = (overrides: Record = {}) => ({ + execution_id: 'exec-1', + script: 'print("hi")', + status: 'queued', + lang: 'python', + lang_version: '3.11', + priority: 'normal', + user_id: 'user-1', + created_at: '2024-01-15T10:30:00Z', + updated_at: null, + ...overrides, +}); + +const createMockQueueStatus = (overrides: Record = {}) => ({ + queue_depth: 5, + active_count: 2, + max_concurrent: 10, + by_priority: { normal: 3, high: 2 }, + ...overrides, +}); + +describe('ExecutionsStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ + data: { executions: [], total: 0, limit: 20, skip: 0, has_more: false }, + }); + mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ + data: createMockQueueStatus(), + }); + mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ + data: null, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createExecutionsStore(); + }); + } + + afterEach(() => { + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data', () => { + createStore(); + expect(store.executions).toEqual([]); + expect(store.total).toBe(0); + expect(store.loading).toBe(false); + expect(store.queueStatus).toBeNull(); + }); + + it('starts with default filters', () => { + createStore(); + expect(store.statusFilter).toBe('all'); + expect(store.priorityFilter).toBe('all'); + expect(store.userSearch).toBe(''); + }); + }); + + describe('loadData', () => { + it('loads executions and queue status', async () => { + const execs = [createMockExecution()]; + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ + data: { executions: execs, total: 1 }, + }); + mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ + data: createMockQueueStatus(), + }); + + createStore(); + await store.loadData(); + + expect(store.executions).toEqual(execs); + expect(store.total).toBe(1); + expect(store.queueStatus).toEqual(createMockQueueStatus()); + }); + + it('handles empty API response', async () => { + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ data: null }); + + createStore(); + await store.loadExecutions(); + + expect(store.executions).toEqual([]); + expect(store.total).toBe(0); + }); + }); + + describe('loadExecutions with filters', () => { + it('passes status filter to API', async () => { + createStore(); + store.statusFilter = 'running'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ status: 'running' }), + }), + ); + }); + + it('passes priority filter to API', async () => { + createStore(); + store.priorityFilter = 'high'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ priority: 'high' }), + }), + ); + }); + + it('passes user search to API', async () => { + createStore(); + store.userSearch = 'user-42'; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ user_id: 'user-42' }), + }), + ); + }); + + it('omits undefined filter values when "all"', async () => { + createStore(); + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + status: undefined, + priority: undefined, + user_id: undefined, + }), + }), + ); + }); + }); + + describe('updatePriority', () => { + it('calls API and reloads data', async () => { + createStore(); + await store.updatePriority('exec-1', 'high'); + + expect(mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut).toHaveBeenCalledWith({ + path: { execution_id: 'exec-1' }, + body: { priority: 'high' }, + }); + expect(mocks.toastSuccess).toHaveBeenCalledWith('Priority updated to high'); + }); + }); + + describe('resetFilters', () => { + it('resets all filters to defaults', () => { + createStore(); + store.statusFilter = 'running'; + store.priorityFilter = 'high'; + store.userSearch = 'test'; + + store.resetFilters(); + + expect(store.statusFilter).toBe('all'); + expect(store.priorityFilter).toBe('all'); + expect(store.userSearch).toBe(''); + }); + }); + + describe('auto-refresh', () => { + it('fires loadData on interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledTimes(2); + }); + + it('stops on cleanup', async () => { + createStore(); + // Verify interval is running + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled(); + + const callsBefore = mocks.listExecutionsApiV1AdminExecutionsGet.mock.calls.length; + store.autoRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(10000); + expect(mocks.listExecutionsApiV1AdminExecutionsGet.mock.calls.length).toBe(callsBefore); + }); + }); + + describe('pagination', () => { + it('passes pagination params to API', async () => { + createStore(); + store.pagination.currentPage = 2; + await store.loadExecutions(); + + expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + skip: 20, + limit: 20, + }), + }), + ); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts new file mode 100644 index 00000000..52212811 --- /dev/null +++ b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { effect_root } from 'svelte/internal/client'; + +const mocks = vi.hoisted(() => ({ + listSagasApiV1SagasGet: vi.fn(), + unwrapOr: vi.fn((result: { data: unknown }, fallback: unknown) => result?.data ?? fallback), +})); + +vi.mock('$lib/api', () => ({ + listSagasApiV1SagasGet: (...args: unknown[]) => mocks.listSagasApiV1SagasGet(...args), +})); + +vi.mock('$lib/api-interceptors', () => ({ + unwrapOr: (result: { data: unknown }, fallback: unknown) => mocks.unwrapOr(result, fallback), +})); + +const { createSagasStore } = await import('../sagasStore.svelte'); + +const createMockSaga = (overrides: Record = {}) => ({ + saga_id: 'saga-1', + saga_name: 'execution_saga', + execution_id: 'exec-123', + state: 'running', + current_step: 'create_pod', + completed_steps: ['validate_execution'], + compensated_steps: [], + retry_count: 0, + error_message: null, + context_data: {}, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:31:00Z', + completed_at: null, + ...overrides, +}); + +describe('SagasStore', () => { + let store: ReturnType; + let teardown: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas: [], total: 0 }, + }); + }); + + function createStore() { + teardown = effect_root(() => { + store = createSagasStore(); + }); + } + + afterEach(() => { + store?.cleanup(); + teardown?.(); + }); + + describe('initial state', () => { + it('starts with empty data and loading true', () => { + createStore(); + expect(store.sagas).toEqual([]); + expect(store.loading).toBe(true); + expect(store.totalItems).toBe(0); + }); + + it('starts with default filters', () => { + createStore(); + expect(store.stateFilter).toBe(''); + expect(store.executionIdFilter).toBe(''); + expect(store.searchQuery).toBe(''); + }); + }); + + describe('loadSagas', () => { + it('loads sagas from API', async () => { + const sagas = [createMockSaga()]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + await store.loadSagas(); + + expect(store.sagas).toEqual(sagas); + expect(store.totalItems).toBe(1); + expect(store.loading).toBe(false); + }); + + it('handles empty API response', async () => { + mocks.listSagasApiV1SagasGet.mockResolvedValue({ data: null }); + + createStore(); + await store.loadSagas(); + + expect(store.sagas).toEqual([]); + expect(store.totalItems).toBe(0); + }); + + it('passes state filter to API', async () => { + createStore(); + store.stateFilter = 'running'; + await store.loadSagas(); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ state: 'running' }), + }), + ); + }); + + it('passes pagination to API', async () => { + createStore(); + store.pagination.currentPage = 3; + await store.loadSagas(); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ skip: 20, limit: 10 }), + }), + ); + }); + }); + + describe('client-side filtering', () => { + it('passes execution_id filter as query param', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', execution_id: 'exec-abc' }), + ]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + store.executionIdFilter = 'exec-abc'; + await store.loadSagas(); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ execution_id: 'exec-abc' }), + }), + ); + expect(store.sagas).toEqual(sagas); + }); + + it('filters by search query', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', saga_name: 'alpha_saga' }), + createMockSaga({ saga_id: 's2', saga_name: 'beta_saga' }), + ]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 2 }, + }); + + createStore(); + store.searchQuery = 'alpha'; + await store.loadSagas(); + + expect(store.sagas).toHaveLength(1); + expect(store.sagas[0]!.saga_name).toBe('alpha_saga'); + }); + + it('hasClientFilters is true when filters active', () => { + createStore(); + expect(store.hasClientFilters).toBe(false); + + store.executionIdFilter = 'test'; + expect(store.hasClientFilters).toBe(true); + }); + }); + + describe('loadExecutionSagas', () => { + it('sets filter and delegates to loadSagas with execution_id query param', async () => { + const sagas = [createMockSaga({ execution_id: 'exec-target' })]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + await store.loadExecutionSagas('exec-target'); + + expect(store.executionIdFilter).toBe('exec-target'); + expect(store.pagination.currentPage).toBe(1); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ execution_id: 'exec-target' }), + }), + ); + expect(store.sagas).toEqual(sagas); + }); + }); + + describe('clearFilters', () => { + it('resets all filters and reloads', async () => { + createStore(); + store.stateFilter = 'failed'; + store.executionIdFilter = 'test'; + store.searchQuery = 'query'; + store.pagination.currentPage = 3; + + await store.clearFilters(); + + expect(store.stateFilter).toBe(''); + expect(store.executionIdFilter).toBe(''); + expect(store.searchQuery).toBe(''); + expect(store.pagination.currentPage).toBe(1); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled(); + }); + }); + + describe('auto-refresh', () => { + it('fires loadSagas on interval', async () => { + createStore(); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(2); + }); + + it('passes execution_id on auto-refresh when filter is set', async () => { + const sagas = [createMockSaga({ execution_id: 'exec-target' })]; + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + createStore(); + await store.loadExecutionSagas('exec-target'); + vi.clearAllMocks(); + + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 1 }, + }); + + await vi.advanceTimersByTimeAsync(5000); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ execution_id: 'exec-target' }), + }), + ); + }); + + it('stops on cleanup', async () => { + createStore(); + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled(); + + const callsBefore = mocks.listSagasApiV1SagasGet.mock.calls.length; + store.autoRefresh.enabled = false; + store.cleanup(); + + await vi.advanceTimersByTimeAsync(10000); + expect(mocks.listSagasApiV1SagasGet.mock.calls.length).toBe(callsBefore); + }); + }); +}); diff --git a/frontend/src/lib/admin/stores/eventsStore.svelte.ts b/frontend/src/lib/admin/stores/eventsStore.svelte.ts new file mode 100644 index 00000000..b7a9f707 --- /dev/null +++ b/frontend/src/lib/admin/stores/eventsStore.svelte.ts @@ -0,0 +1,218 @@ +import { + browseEventsApiV1AdminEventsBrowsePost, + getEventStatsApiV1AdminEventsStatsGet, + getEventDetailApiV1AdminEventsEventIdGet, + replayEventsApiV1AdminEventsReplayPost, + deleteEventApiV1AdminEventsEventIdDelete, + getUserOverviewApiV1AdminUsersUserIdOverviewGet, + type EventBrowseResponse, + type EventFilter, + type EventStatsResponse, + type EventDetailResponse, + type EventReplayStatusResponse, + type EventSummary, + type AdminUserOverview, +} from '$lib/api'; +import { unwrap, unwrapOr } from '$lib/api-interceptors'; +import { toast } from 'svelte-sonner'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; + +export type BrowsedEvent = EventBrowseResponse['events'][number]; + +class EventsStore { + events = $state([]); + loading = $state(false); + totalEvents = $state(0); + stats = $state(null); + filters = $state({}); + + activeReplaySession = $state(null); + replayPreview = $state<{ eventId: string; total_events: number; events_preview?: EventSummary[] } | null>(null); + private replayAbortController: AbortController | null = null; + + userOverview = $state(null); + userOverviewLoading = $state(false); + + pagination = createPaginationState({ initialPageSize: 10 }); + + mainRefresh = createAutoRefresh({ + onRefresh: () => this.loadAll(), + initialRate: 30, + initialEnabled: true, + }); + + async loadAll(): Promise { + await Promise.all([this.loadEvents(), this.loadStats()]); + } + + async loadEvents(): Promise { + this.loading = true; + const data = unwrapOr(await browseEventsApiV1AdminEventsBrowsePost({ + body: { + filters: { + ...this.filters, + start_time: this.filters.start_time ? new Date(this.filters.start_time).toISOString() : null, + end_time: this.filters.end_time ? new Date(this.filters.end_time).toISOString() : null + }, + skip: this.pagination.skip, + limit: this.pagination.pageSize + } + }), null); + this.loading = false; + this.events = data?.events ?? []; + this.totalEvents = data?.total || 0; + } + + async loadStats(): Promise { + this.stats = unwrapOr(await getEventStatsApiV1AdminEventsStatsGet({ query: { hours: 24 } }), null); + } + + async loadEventDetail(eventId: string): Promise { + return unwrapOr(await getEventDetailApiV1AdminEventsEventIdGet({ path: { event_id: eventId } }), null); + } + + async replayEvent(eventId: string, dryRun: boolean = true): Promise { + if (!dryRun && !confirm('Are you sure you want to replay this event? This will re-process the event through the system.')) { + return; + } + + const response = unwrap(await replayEventsApiV1AdminEventsReplayPost({ + body: { event_ids: [eventId], dry_run: dryRun } + })); + + if (dryRun) { + if (response.events_preview && response.events_preview.length > 0) { + this.replayPreview = { eventId, total_events: response.total_events, events_preview: response.events_preview }; + } else { + toast.info(`Dry run: ${response.total_events} events would be replayed`); + } + } else { + toast.success(`Replay scheduled! Tracking progress...`); + const sessionId = response.session_id; + if (sessionId) { + this.activeReplaySession = { + session_id: sessionId, + status: 'scheduled', + total_events: response.total_events, + replayed_events: 0, + progress_percentage: 0, + failed_events: 0, + skipped_events: 0, + replay_id: response.replay_id, + created_at: new Date().toISOString(), + started_at: null, + completed_at: null, + errors: null, + estimated_completion: null, + execution_results: null, + }; + this.connectReplayStream(sessionId); + } + } + } + + connectReplayStream(sessionId: string): void { + this.disconnectReplayStream(); + const controller = new AbortController(); + this.replayAbortController = controller; + this.#startReplayStream(sessionId, controller.signal); + } + + #startReplayStream(sessionId: string, signal: AbortSignal): void { + const eventSource = new EventSource(`/api/v1/admin/events/replay/${sessionId}/status`); + + signal.addEventListener('abort', () => { + eventSource.close(); + }); + + eventSource.onmessage = (event: MessageEvent) => { + if (signal.aborted) return; + try { + const payload = JSON.parse(event.data as string) as EventReplayStatusResponse; + const pct = payload.total_events > 0 + ? Math.round((payload.replayed_events / payload.total_events) * 100) + : 0; + this.activeReplaySession = { + ...this.activeReplaySession!, + ...payload, + progress_percentage: pct, + }; + + if (payload.status === 'completed' || payload.status === 'failed' || payload.status === 'cancelled') { + if (payload.status === 'completed') { + toast.success(`Replay completed! Processed ${payload.replayed_events} events successfully.`); + } else if (payload.status === 'failed') { + toast.error(`Replay failed: ${payload.errors?.[0]?.error || 'Unknown error'}`); + } + this.disconnectReplayStream(); + } + } catch { + // Ignore malformed SSE messages (e.g. pings) + } + }; + + eventSource.onerror = () => { + if (!signal.aborted) { + this.disconnectReplayStream(); + } + }; + } + + disconnectReplayStream(): void { + if (this.replayAbortController) { + this.replayAbortController.abort(); + this.replayAbortController = null; + } + } + + async deleteEvent(eventId: string): Promise { + if (!confirm('Are you sure you want to delete this event? This action cannot be undone.')) return; + unwrap(await deleteEventApiV1AdminEventsEventIdDelete({ path: { event_id: eventId } })); + toast.success('Event deleted successfully'); + await Promise.all([this.loadEvents(), this.loadStats()]); + } + + exportEvents(format: 'csv' | 'json' = 'csv'): void { + const params = new URLSearchParams(); + if (this.filters.event_types?.length) params.append('event_types', this.filters.event_types.join(',')); + if (this.filters.start_time) params.append('start_time', new Date(this.filters.start_time).toISOString()); + if (this.filters.end_time) params.append('end_time', new Date(this.filters.end_time).toISOString()); + if (this.filters.aggregate_id) params.append('aggregate_id', this.filters.aggregate_id); + if (this.filters.user_id) params.append('user_id', this.filters.user_id); + if (this.filters.service_name) params.append('service_name', this.filters.service_name); + + window.open(`/api/v1/admin/events/export/${format}?${params.toString()}`, '_blank'); + toast.info(`Starting ${format.toUpperCase()} export...`); + } + + async openUserOverview(userId: string): Promise { + if (!userId) return; + this.userOverview = null; + this.userOverviewLoading = true; + const data = unwrapOr(await getUserOverviewApiV1AdminUsersUserIdOverviewGet({ path: { user_id: userId } }), null); + this.userOverviewLoading = false; + if (!data) return; + this.userOverview = data; + } + + clearFilters(): void { + this.filters = {}; + this.pagination.currentPage = 1; + void this.loadEvents(); + } + + applyFilters(): void { + this.pagination.currentPage = 1; + void this.loadEvents(); + } + + cleanup(): void { + this.mainRefresh.cleanup(); + this.disconnectReplayStream(); + } +} + +export function createEventsStore(): EventsStore { + return new EventsStore(); +} diff --git a/frontend/src/lib/admin/stores/executionsStore.svelte.ts b/frontend/src/lib/admin/stores/executionsStore.svelte.ts new file mode 100644 index 00000000..1a9a7b42 --- /dev/null +++ b/frontend/src/lib/admin/stores/executionsStore.svelte.ts @@ -0,0 +1,81 @@ +import { + listExecutionsApiV1AdminExecutionsGet, + updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut, + getQueueStatusApiV1AdminExecutionsQueueGet, + type AdminExecutionResponse, + type QueueStatusResponse, + type QueuePriority, + type ExecutionStatus, +} from '$lib/api'; +import { unwrap, unwrapOr } from '$lib/api-interceptors'; +import { toast } from 'svelte-sonner'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; + +class ExecutionsStore { + executions = $state([]); + total = $state(0); + loading = $state(false); + queueStatus = $state(null); + + statusFilter = $state<'all' | ExecutionStatus>('all'); + priorityFilter = $state<'all' | QueuePriority>('all'); + userSearch = $state(''); + + pagination = createPaginationState({ initialPageSize: 20 }); + autoRefresh = createAutoRefresh({ + onRefresh: () => this.loadData(), + initialRate: 5, + initialEnabled: true, + }); + + async loadData(): Promise { + await Promise.all([this.loadExecutions(), this.loadQueueStatus()]); + } + + async loadExecutions(): Promise { + this.loading = true; + const data = unwrapOr(await listExecutionsApiV1AdminExecutionsGet({ + query: { + limit: this.pagination.pageSize, + skip: this.pagination.skip, + status: this.statusFilter !== 'all' ? this.statusFilter : undefined, + priority: this.priorityFilter !== 'all' ? this.priorityFilter : undefined, + user_id: this.userSearch || undefined, + }, + }), null); + this.executions = data?.executions || []; + this.total = data?.total || 0; + this.loading = false; + } + + async loadQueueStatus(): Promise { + const data = unwrapOr(await getQueueStatusApiV1AdminExecutionsQueueGet({}), null); + if (data) { + this.queueStatus = data; + } + } + + async updatePriority(executionId: string, newPriority: QueuePriority): Promise { + unwrap(await updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut({ + path: { execution_id: executionId }, + body: { priority: newPriority }, + })); + toast.success(`Priority updated to ${newPriority}`); + await this.loadData(); + } + + resetFilters(): void { + this.statusFilter = 'all'; + this.priorityFilter = 'all'; + this.userSearch = ''; + } + + cleanup(): void { + this.autoRefresh.cleanup(); + } +} + +export function createExecutionsStore(): ExecutionsStore { + return new ExecutionsStore(); +} diff --git a/frontend/src/lib/admin/stores/sagasStore.svelte.ts b/frontend/src/lib/admin/stores/sagasStore.svelte.ts new file mode 100644 index 00000000..23b08b9f --- /dev/null +++ b/frontend/src/lib/admin/stores/sagasStore.svelte.ts @@ -0,0 +1,78 @@ +import { + listSagasApiV1SagasGet, + type SagaStatusResponse, +} from '$lib/api'; +import { unwrapOr } from '$lib/api-interceptors'; +import { createAutoRefresh } from '../autoRefresh.svelte'; +import { createPaginationState } from '../pagination.svelte'; +import { type SagaStateFilter } from '$lib/admin/sagas'; + +class SagasStore { + sagas = $state([]); + loading = $state(true); + totalItems = $state(0); + serverReturnedCount = $state(0); + + stateFilter = $state(''); + executionIdFilter = $state(''); + searchQuery = $state(''); + + hasClientFilters = $derived(Boolean(this.executionIdFilter || this.searchQuery)); + + pagination = createPaginationState({ initialPageSize: 10 }); + autoRefresh = createAutoRefresh({ + onRefresh: () => this.loadSagas(), + initialRate: 5, + initialEnabled: true, + }); + + async loadSagas(): Promise { + this.loading = true; + const data = unwrapOr(await listSagasApiV1SagasGet({ + query: { + state: this.stateFilter || undefined, + execution_id: this.executionIdFilter || undefined, + limit: this.pagination.pageSize, + skip: this.pagination.skip, + } + }), null); + this.loading = false; + + let result = data?.sagas || []; + this.totalItems = data?.total || 0; + this.serverReturnedCount = result.length; + + if (this.searchQuery) { + const q = this.searchQuery.toLowerCase(); + result = result.filter(s => + s.saga_id.toLowerCase().includes(q) || + s.saga_name.toLowerCase().includes(q) || + s.execution_id.toLowerCase().includes(q) || + s.error_message?.toLowerCase().includes(q) + ); + } + this.sagas = result; + } + + async loadExecutionSagas(executionId: string): Promise { + this.executionIdFilter = executionId; + this.pagination.currentPage = 1; + await this.loadSagas(); + } + + clearFilters(): void { + this.stateFilter = ''; + this.executionIdFilter = ''; + this.searchQuery = ''; + this.pagination.currentPage = 1; + void this.loadSagas(); + } + + cleanup(): void { + this.autoRefresh.cleanup(); + } +} + +export function createSagasStore(): SagasStore { + return new SagasStore(); +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 0319c383..5f75af23 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { browseEventsApiV1AdminEventsBrowsePost, cancelExecutionApiV1ExecutionsExecutionIdCancelPost, cancelReplaySessionApiV1ReplaySessionsSessionIdCancelPost, cancelSagaApiV1SagasSagaIdCancelPost, cleanupOldSessionsApiV1ReplayCleanupPost, createExecutionApiV1ExecutePost, createReplaySessionApiV1ReplaySessionsPost, createSavedScriptApiV1ScriptsPost, createUserApiV1AdminUsersPost, deleteEventApiV1AdminEventsEventIdDelete, deleteExecutionApiV1ExecutionsExecutionIdDelete, deleteNotificationApiV1NotificationsNotificationIdDelete, deleteSavedScriptApiV1ScriptsScriptIdDelete, deleteUserApiV1AdminUsersUserIdDelete, executionEventsApiV1EventsExecutionsExecutionIdGet, exportEventsApiV1AdminEventsExportExportFormatGet, getCurrentUserProfileApiV1AuthMeGet, getDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGet, getEventDetailApiV1AdminEventsEventIdGet, getEventStatsApiV1AdminEventsStatsGet, getExampleScriptsApiV1ExampleScriptsGet, getExecutionEventsApiV1ExecutionsExecutionIdEventsGet, getExecutionSagasApiV1SagasExecutionExecutionIdGet, getK8sResourceLimitsApiV1K8sLimitsGet, getNotificationsApiV1NotificationsGet, getQueueStatusApiV1AdminExecutionsQueueGet, getReplaySessionApiV1ReplaySessionsSessionIdGet, getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet, getResultApiV1ExecutionsExecutionIdResultGet, getSagaStatusApiV1SagasSagaIdGet, getSavedScriptApiV1ScriptsScriptIdGet, getSettingsHistoryApiV1UserSettingsHistoryGet, getSubscriptionsApiV1NotificationsSubscriptionsGet, getSystemSettingsApiV1AdminSettingsGet, getUnreadCountApiV1NotificationsUnreadCountGet, getUserApiV1AdminUsersUserIdGet, getUserExecutionsApiV1UserExecutionsGet, getUserOverviewApiV1AdminUsersUserIdOverviewGet, getUserRateLimitsApiV1AdminRateLimitsUserIdGet, getUserSettingsApiV1UserSettingsGet, listExecutionsApiV1AdminExecutionsGet, listReplaySessionsApiV1ReplaySessionsGet, listSagasApiV1SagasGet, listSavedScriptsApiV1ScriptsGet, listUsersApiV1AdminUsersGet, livenessApiV1HealthLiveGet, loginApiV1AuthLoginPost, logoutApiV1AuthLogoutPost, markAllReadApiV1NotificationsMarkAllReadPost, markNotificationReadApiV1NotificationsNotificationIdReadPut, notificationStreamApiV1EventsNotificationsStreamGet, type Options, pauseReplaySessionApiV1ReplaySessionsSessionIdPausePost, registerApiV1AuthRegisterPost, replayEventsApiV1AdminEventsReplayPost, resetSystemSettingsApiV1AdminSettingsResetPost, resetUserPasswordApiV1AdminUsersUserIdResetPasswordPost, resetUserRateLimitsApiV1AdminRateLimitsUserIdResetPost, restoreSettingsApiV1UserSettingsRestorePost, resumeReplaySessionApiV1ReplaySessionsSessionIdResumePost, retryExecutionApiV1ExecutionsExecutionIdRetryPost, startReplaySessionApiV1ReplaySessionsSessionIdStartPost, unlockUserApiV1AdminUsersUserIdUnlockPost, updateCustomSettingApiV1UserSettingsCustomKeyPut, updateEditorSettingsApiV1UserSettingsEditorPut, updateNotificationSettingsApiV1UserSettingsNotificationsPut, updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut, updateSavedScriptApiV1ScriptsScriptIdPut, updateSubscriptionApiV1NotificationsSubscriptionsChannelPut, updateSystemSettingsApiV1AdminSettingsPut, updateThemeApiV1UserSettingsThemePut, updateUserApiV1AdminUsersUserIdPut, updateUserRateLimitsApiV1AdminRateLimitsUserIdPut, updateUserSettingsApiV1UserSettingsPut } from './sdk.gen'; -export type { AdminExecutionListResponse, AdminExecutionResponse, AdminUserOverview, AllocateResourcesCommandEvent, AuthFailedEvent, BodyLoginApiV1AuthLoginPost, BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostError, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponse, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionsExecutionIdCancelPostData, CancelExecutionApiV1ExecutionsExecutionIdCancelPostError, CancelExecutionApiV1ExecutionsExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponse, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponses, CancelExecutionRequest, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostError, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponse, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelResponse, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostError, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponse, CancelSagaApiV1SagasSagaIdCancelPostResponses, CancelStatus, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostError, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponse, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CleanupResponse, ClientOptions, ContainerStatusInfo, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostError, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponse, CreateExecutionApiV1ExecutePostResponses, CreatePodCommandEvent, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostError, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponse, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostError, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponse, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostError, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponse, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteError, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponse, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteExecutionApiV1ExecutionsExecutionIdDeleteData, DeleteExecutionApiV1ExecutionsExecutionIdDeleteError, DeleteExecutionApiV1ExecutionsExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponse, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteError, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponse, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteNotificationResponse, DeletePodCommandEvent, DeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteError, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteError, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponse, DeleteUserApiV1AdminUsersUserIdDeleteResponses, DeleteUserResponse, DerivedCounts, DlqMessageDiscardedEvent, DlqMessageReceivedEvent, DlqMessageRetriedEvent, EditorSettingsInput, EditorSettingsOutput, EndpointGroup, EndpointUsageStats, Environment, ErrorResponse, EventBrowseRequest, EventBrowseResponse, EventDeleteResponse, EventDetailResponse, EventFilter, EventMetadata, EventReplayRequest, EventReplayResponse, EventReplayStatusResponse, EventReplayStatusResponseWritable, EventStatistics, EventStatsResponse, EventSummary, EventType, EventTypeCount, ExampleScripts, ExecutionAcceptedEvent, ExecutionCancelledEvent, ExecutionCompletedEvent, ExecutionErrorType, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetError, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponse, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExecutionFailedEvent, ExecutionListResponse, ExecutionQueuedEvent, ExecutionRequest, ExecutionRequestedEvent, ExecutionResponse, ExecutionResult, ExecutionRunningEvent, ExecutionStartedEvent, ExecutionStatus, ExecutionTimeoutEvent, ExportEventsApiV1AdminEventsExportExportFormatGetData, ExportEventsApiV1AdminEventsExportExportFormatGetError, ExportEventsApiV1AdminEventsExportExportFormatGetErrors, ExportEventsApiV1AdminEventsExportExportFormatGetResponses, ExportFormat, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponse, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetData, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponse, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetError, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponse, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetError, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponse, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponse, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetError, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponse, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponse, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetError, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponse, GetNotificationsApiV1NotificationsGetResponses, GetQueueStatusApiV1AdminExecutionsQueueGetData, GetQueueStatusApiV1AdminExecutionsQueueGetError, GetQueueStatusApiV1AdminExecutionsQueueGetErrors, GetQueueStatusApiV1AdminExecutionsQueueGetResponse, GetQueueStatusApiV1AdminExecutionsQueueGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetError, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponse, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, GetResultApiV1ExecutionsExecutionIdResultGetData, GetResultApiV1ExecutionsExecutionIdResultGetError, GetResultApiV1ExecutionsExecutionIdResultGetErrors, GetResultApiV1ExecutionsExecutionIdResultGetResponse, GetResultApiV1ExecutionsExecutionIdResultGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetError, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponse, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetError, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponse, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetError, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponse, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponse, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetError, GetSystemSettingsApiV1AdminSettingsGetErrors, GetSystemSettingsApiV1AdminSettingsGetResponse, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponse, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetError, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponse, GetUserApiV1AdminUsersUserIdGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetError, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponse, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetError, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponse, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetData, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetError, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetErrors, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponse, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponse, GetUserSettingsApiV1UserSettingsGetResponses, HourlyEventCount, HttpValidationError, LanguageInfo, ListExecutionsApiV1AdminExecutionsGetData, ListExecutionsApiV1AdminExecutionsGetError, ListExecutionsApiV1AdminExecutionsGetErrors, ListExecutionsApiV1AdminExecutionsGetResponse, ListExecutionsApiV1AdminExecutionsGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetError, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponse, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetError, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponse, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponse, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetError, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponse, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponse, LivenessApiV1HealthLiveGetResponses, LivenessResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginMethod, LoginResponse, LogLevel, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponse, LogoutApiV1AuthLogoutPostResponses, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponse, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutError, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponse, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, MessageResponse, NotificationAllReadEvent, NotificationChannel, NotificationClickedEvent, NotificationCreatedEvent, NotificationDeliveredEvent, NotificationFailedEvent, NotificationListResponse, NotificationPreferencesUpdatedEvent, NotificationReadEvent, NotificationResponse, NotificationSentEvent, NotificationSettingsInput, NotificationSettingsOutput, NotificationSeverity, NotificationStatus, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponse, NotificationStreamApiV1EventsNotificationsStreamGetResponses, NotificationSubscription, PasswordResetRequest, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostError, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponse, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, PodCreatedEvent, PodDeletedEvent, PodFailedEvent, PodRunningEvent, PodScheduledEvent, PodSucceededEvent, PodTerminatedEvent, PriorityUpdateRequest, QueuePriority, QueueStatusResponse, QuotaExceededEvent, RateLimitAlgorithm, RateLimitExceededEvent, RateLimitRuleRequest, RateLimitRuleResponse, RateLimitSummary, RateLimitUpdateRequest, RateLimitUpdateResponse, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostError, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponse, RegisterApiV1AuthRegisterPostResponses, ReleaseResourcesCommandEvent, ReplayConfigSchema, ReplayError, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostError, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponse, ReplayEventsApiV1AdminEventsReplayPostResponses, ReplayFilter, ReplayFilterSchema, ReplayRequest, ReplayResponse, ReplaySession, ReplayStatus, ReplayTarget, ReplayType, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostError, ResetSystemSettingsApiV1AdminSettingsResetPostErrors, ResetSystemSettingsApiV1AdminSettingsResetPostResponse, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostError, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponse, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostData, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostError, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostErrors, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponse, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponses, ResourceLimitExceededEvent, ResourceLimits, ResourceUsage, ResourceUsageDomain, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostError, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponse, RestoreSettingsApiV1UserSettingsRestorePostResponses, RestoreSettingsRequest, ResultFailedEvent, ResultStoredEvent, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostError, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponse, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryExecutionApiV1ExecutionsExecutionIdRetryPostData, RetryExecutionApiV1ExecutionsExecutionIdRetryPostError, RetryExecutionApiV1ExecutionsExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponse, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponses, SagaCancellationResponse, SagaCancelledEvent, SagaCompensatedEvent, SagaCompensatingEvent, SagaCompletedEvent, SagaFailedEvent, SagaListResponse, SagaStartedEvent, SagaState, SagaStatusResponse, SavedScriptCreateRequest, SavedScriptListResponse, SavedScriptResponse, SavedScriptUpdate, ScriptDeletedEvent, ScriptSavedEvent, ScriptSharedEvent, SecurityViolationEvent, ServiceEventCount, ServiceRecoveredEvent, ServiceUnhealthyEvent, SessionConfigSummary, SessionSummary, SessionSummaryWritable, SettingsHistoryEntry, SettingsHistoryResponse, SseControlEvent, SseExecutionEventSchema, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostError, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, StorageType, SubscriptionsResponse, SubscriptionUpdate, SystemErrorEvent, SystemSettingsSchemaInput, SystemSettingsSchemaOutput, Theme, ThemeUpdateRequest, UnlockResponse, UnlockUserApiV1AdminUsersUserIdUnlockPostData, UnlockUserApiV1AdminUsersUserIdUnlockPostError, UnlockUserApiV1AdminUsersUserIdUnlockPostErrors, UnlockUserApiV1AdminUsersUserIdUnlockPostResponse, UnlockUserApiV1AdminUsersUserIdUnlockPostResponses, UnreadCountResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutError, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutError, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponse, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutError, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponse, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutData, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutError, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutErrors, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponse, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutError, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponse, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutError, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponse, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutError, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponse, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutError, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponse, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutError, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponse, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutData, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutError, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutErrors, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponse, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutError, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponse, UpdateUserSettingsApiV1UserSettingsPutResponses, UserCreate, UserDeletedEvent, UserEventCount, UserListResponse, UserLoggedInEvent, UserLoggedOutEvent, UserLoginEvent, UserRateLimitConfigResponse, UserRateLimitsResponse, UserRegisteredEvent, UserResponse, UserRole, UserSettings, UserSettingsUpdate, UserSettingsUpdatedEvent, UserUpdate, UserUpdatedEvent, ValidationError } from './types.gen'; +export { browseEventsApiV1AdminEventsBrowsePost, cancelExecutionApiV1ExecutionsExecutionIdCancelPost, cancelReplaySessionApiV1ReplaySessionsSessionIdCancelPost, cancelSagaApiV1SagasSagaIdCancelPost, cleanupOldSessionsApiV1ReplayCleanupPost, createExecutionApiV1ExecutePost, createReplaySessionApiV1ReplaySessionsPost, createSavedScriptApiV1ScriptsPost, createUserApiV1AdminUsersPost, deleteEventApiV1AdminEventsEventIdDelete, deleteExecutionApiV1ExecutionsExecutionIdDelete, deleteNotificationApiV1NotificationsNotificationIdDelete, deleteSavedScriptApiV1ScriptsScriptIdDelete, deleteUserApiV1AdminUsersUserIdDelete, executionEventsApiV1EventsExecutionsExecutionIdGet, exportEventsApiV1AdminEventsExportExportFormatGet, getCurrentUserProfileApiV1AuthMeGet, getDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGet, getEventDetailApiV1AdminEventsEventIdGet, getEventStatsApiV1AdminEventsStatsGet, getExampleScriptsApiV1ExampleScriptsGet, getExecutionEventsApiV1ExecutionsExecutionIdEventsGet, getExecutionSagasApiV1SagasExecutionExecutionIdGet, getK8sResourceLimitsApiV1K8sLimitsGet, getNotificationsApiV1NotificationsGet, getQueueStatusApiV1AdminExecutionsQueueGet, getReplaySessionApiV1ReplaySessionsSessionIdGet, getResultApiV1ExecutionsExecutionIdResultGet, getSagaStatusApiV1SagasSagaIdGet, getSavedScriptApiV1ScriptsScriptIdGet, getSettingsHistoryApiV1UserSettingsHistoryGet, getSubscriptionsApiV1NotificationsSubscriptionsGet, getSystemSettingsApiV1AdminSettingsGet, getUnreadCountApiV1NotificationsUnreadCountGet, getUserApiV1AdminUsersUserIdGet, getUserExecutionsApiV1UserExecutionsGet, getUserOverviewApiV1AdminUsersUserIdOverviewGet, getUserRateLimitsApiV1AdminRateLimitsUserIdGet, getUserSettingsApiV1UserSettingsGet, listExecutionsApiV1AdminExecutionsGet, listReplaySessionsApiV1ReplaySessionsGet, listSagasApiV1SagasGet, listSavedScriptsApiV1ScriptsGet, listUsersApiV1AdminUsersGet, livenessApiV1HealthLiveGet, loginApiV1AuthLoginPost, logoutApiV1AuthLogoutPost, markAllReadApiV1NotificationsMarkAllReadPost, markNotificationReadApiV1NotificationsNotificationIdReadPut, notificationStreamApiV1EventsNotificationsStreamGet, type Options, pauseReplaySessionApiV1ReplaySessionsSessionIdPausePost, registerApiV1AuthRegisterPost, replayEventsApiV1AdminEventsReplayPost, resetSystemSettingsApiV1AdminSettingsResetPost, resetUserPasswordApiV1AdminUsersUserIdResetPasswordPost, resetUserRateLimitsApiV1AdminRateLimitsUserIdResetPost, restoreSettingsApiV1UserSettingsRestorePost, resumeReplaySessionApiV1ReplaySessionsSessionIdResumePost, retryExecutionApiV1ExecutionsExecutionIdRetryPost, startReplaySessionApiV1ReplaySessionsSessionIdStartPost, streamReplayStatusApiV1AdminEventsReplaySessionIdStatusGet, unlockUserApiV1AdminUsersUserIdUnlockPost, updateCustomSettingApiV1UserSettingsCustomKeyPut, updateEditorSettingsApiV1UserSettingsEditorPut, updateNotificationSettingsApiV1UserSettingsNotificationsPut, updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut, updateSavedScriptApiV1ScriptsScriptIdPut, updateSubscriptionApiV1NotificationsSubscriptionsChannelPut, updateSystemSettingsApiV1AdminSettingsPut, updateThemeApiV1UserSettingsThemePut, updateUserApiV1AdminUsersUserIdPut, updateUserRateLimitsApiV1AdminRateLimitsUserIdPut, updateUserSettingsApiV1UserSettingsPut } from './sdk.gen'; +export type { AdminExecutionListResponse, AdminExecutionResponse, AdminUserOverview, AllocateResourcesCommandEvent, AuthFailedEvent, BodyLoginApiV1AuthLoginPost, BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostError, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponse, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionsExecutionIdCancelPostData, CancelExecutionApiV1ExecutionsExecutionIdCancelPostError, CancelExecutionApiV1ExecutionsExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponse, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponses, CancelExecutionRequest, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostError, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponse, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelResponse, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostError, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponse, CancelSagaApiV1SagasSagaIdCancelPostResponses, CancelStatus, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostError, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponse, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CleanupResponse, ClientOptions, ContainerStatusInfo, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostError, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponse, CreateExecutionApiV1ExecutePostResponses, CreatePodCommandEvent, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostError, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponse, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostError, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponse, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostError, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponse, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteError, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponse, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteExecutionApiV1ExecutionsExecutionIdDeleteData, DeleteExecutionApiV1ExecutionsExecutionIdDeleteError, DeleteExecutionApiV1ExecutionsExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponse, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteError, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponse, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteNotificationResponse, DeletePodCommandEvent, DeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteError, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteError, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponse, DeleteUserApiV1AdminUsersUserIdDeleteResponses, DeleteUserResponse, DerivedCounts, DlqMessageDiscardedEvent, DlqMessageReceivedEvent, DlqMessageRetriedEvent, EditorSettingsInput, EditorSettingsOutput, EndpointGroup, EndpointUsageStats, Environment, ErrorResponse, EventBrowseRequest, EventBrowseResponse, EventDeleteResponse, EventDetailResponse, EventFilter, EventMetadata, EventReplayRequest, EventReplayResponse, EventReplayStatusResponse, EventReplayStatusResponseWritable, EventStatistics, EventStatsResponse, EventSummary, EventType, EventTypeCount, ExampleScripts, ExecutionAcceptedEvent, ExecutionCancelledEvent, ExecutionCompletedEvent, ExecutionErrorType, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetError, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponse, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExecutionFailedEvent, ExecutionListResponse, ExecutionQueuedEvent, ExecutionRequest, ExecutionRequestedEvent, ExecutionResponse, ExecutionResult, ExecutionRunningEvent, ExecutionStartedEvent, ExecutionStatus, ExecutionTimeoutEvent, ExportEventsApiV1AdminEventsExportExportFormatGetData, ExportEventsApiV1AdminEventsExportExportFormatGetError, ExportEventsApiV1AdminEventsExportExportFormatGetErrors, ExportEventsApiV1AdminEventsExportExportFormatGetResponses, ExportFormat, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponse, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetData, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponse, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetError, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponse, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetError, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponse, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponse, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetError, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponse, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponse, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetError, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponse, GetNotificationsApiV1NotificationsGetResponses, GetQueueStatusApiV1AdminExecutionsQueueGetData, GetQueueStatusApiV1AdminExecutionsQueueGetError, GetQueueStatusApiV1AdminExecutionsQueueGetErrors, GetQueueStatusApiV1AdminExecutionsQueueGetResponse, GetQueueStatusApiV1AdminExecutionsQueueGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetError, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponse, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetResultApiV1ExecutionsExecutionIdResultGetData, GetResultApiV1ExecutionsExecutionIdResultGetError, GetResultApiV1ExecutionsExecutionIdResultGetErrors, GetResultApiV1ExecutionsExecutionIdResultGetResponse, GetResultApiV1ExecutionsExecutionIdResultGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetError, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponse, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetError, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponse, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetError, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponse, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponse, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetError, GetSystemSettingsApiV1AdminSettingsGetErrors, GetSystemSettingsApiV1AdminSettingsGetResponse, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponse, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetError, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponse, GetUserApiV1AdminUsersUserIdGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetError, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponse, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetError, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponse, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetData, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetError, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetErrors, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponse, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponse, GetUserSettingsApiV1UserSettingsGetResponses, HourlyEventCount, HttpValidationError, LanguageInfo, ListExecutionsApiV1AdminExecutionsGetData, ListExecutionsApiV1AdminExecutionsGetError, ListExecutionsApiV1AdminExecutionsGetErrors, ListExecutionsApiV1AdminExecutionsGetResponse, ListExecutionsApiV1AdminExecutionsGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetError, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponse, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetError, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponse, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponse, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetError, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponse, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponse, LivenessApiV1HealthLiveGetResponses, LivenessResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginMethod, LoginResponse, LogLevel, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponse, LogoutApiV1AuthLogoutPostResponses, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponse, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutError, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponse, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, MessageResponse, NotificationAllReadEvent, NotificationChannel, NotificationClickedEvent, NotificationCreatedEvent, NotificationDeliveredEvent, NotificationFailedEvent, NotificationListResponse, NotificationPreferencesUpdatedEvent, NotificationReadEvent, NotificationResponse, NotificationSentEvent, NotificationSettingsInput, NotificationSettingsOutput, NotificationSeverity, NotificationStatus, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponse, NotificationStreamApiV1EventsNotificationsStreamGetResponses, NotificationSubscription, PasswordResetRequest, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostError, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponse, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, PodCreatedEvent, PodDeletedEvent, PodFailedEvent, PodRunningEvent, PodScheduledEvent, PodSucceededEvent, PodTerminatedEvent, PriorityUpdateRequest, QueuePriority, QueueStatusResponse, QuotaExceededEvent, RateLimitAlgorithm, RateLimitExceededEvent, RateLimitRuleRequest, RateLimitRuleResponse, RateLimitSummary, RateLimitUpdateRequest, RateLimitUpdateResponse, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostError, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponse, RegisterApiV1AuthRegisterPostResponses, ReleaseResourcesCommandEvent, ReplayConfigSchema, ReplayError, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostError, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponse, ReplayEventsApiV1AdminEventsReplayPostResponses, ReplayFilter, ReplayFilterSchema, ReplayRequest, ReplayResponse, ReplaySession, ReplayStatus, ReplayTarget, ReplayType, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostError, ResetSystemSettingsApiV1AdminSettingsResetPostErrors, ResetSystemSettingsApiV1AdminSettingsResetPostResponse, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostError, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponse, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostData, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostError, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostErrors, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponse, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponses, ResourceLimitExceededEvent, ResourceLimits, ResourceUsage, ResourceUsageDomain, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostError, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponse, RestoreSettingsApiV1UserSettingsRestorePostResponses, RestoreSettingsRequest, ResultFailedEvent, ResultStoredEvent, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostError, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponse, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryExecutionApiV1ExecutionsExecutionIdRetryPostData, RetryExecutionApiV1ExecutionsExecutionIdRetryPostError, RetryExecutionApiV1ExecutionsExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponse, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponses, SagaCancellationResponse, SagaCancelledEvent, SagaCompensatedEvent, SagaCompensatingEvent, SagaCompletedEvent, SagaFailedEvent, SagaListResponse, SagaStartedEvent, SagaState, SagaStatusResponse, SavedScriptCreateRequest, SavedScriptListResponse, SavedScriptResponse, SavedScriptUpdate, ScriptDeletedEvent, ScriptSavedEvent, ScriptSharedEvent, SecurityViolationEvent, ServiceEventCount, ServiceRecoveredEvent, ServiceUnhealthyEvent, SessionConfigSummary, SessionSummary, SessionSummaryWritable, SettingsHistoryEntry, SettingsHistoryResponse, SseControlEvent, SseExecutionEventSchema, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostError, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, StorageType, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, SubscriptionsResponse, SubscriptionUpdate, SystemErrorEvent, SystemSettingsSchemaInput, SystemSettingsSchemaOutput, Theme, ThemeUpdateRequest, UnlockResponse, UnlockUserApiV1AdminUsersUserIdUnlockPostData, UnlockUserApiV1AdminUsersUserIdUnlockPostError, UnlockUserApiV1AdminUsersUserIdUnlockPostErrors, UnlockUserApiV1AdminUsersUserIdUnlockPostResponse, UnlockUserApiV1AdminUsersUserIdUnlockPostResponses, UnreadCountResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutError, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutError, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponse, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutError, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponse, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutData, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutError, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutErrors, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponse, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutError, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponse, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutError, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponse, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutError, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponse, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutError, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponse, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutError, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponse, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutData, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutError, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutErrors, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponse, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutError, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponse, UpdateUserSettingsApiV1UserSettingsPutResponses, UserCreate, UserDeletedEvent, UserEventCount, UserListResponse, UserLoggedInEvent, UserLoggedOutEvent, UserLoginEvent, UserRateLimitConfigResponse, UserRateLimitsResponse, UserRegisteredEvent, UserResponse, UserRole, UserSettings, UserSettingsUpdate, UserSettingsUpdatedEvent, UserUpdate, UserUpdatedEvent, ValidationError } from './types.gen'; diff --git a/frontend/src/lib/api/sdk.gen.ts b/frontend/src/lib/api/sdk.gen.ts index 6da260a0..42ced065 100644 --- a/frontend/src/lib/api/sdk.gen.ts +++ b/frontend/src/lib/api/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionsExecutionIdCancelPostData, CancelExecutionApiV1ExecutionsExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponses, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponses, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponses, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteExecutionApiV1ExecutionsExecutionIdDeleteData, DeleteExecutionApiV1ExecutionsExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponses, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExportEventsApiV1AdminEventsExportExportFormatGetData, ExportEventsApiV1AdminEventsExportExportFormatGetErrors, ExportEventsApiV1AdminEventsExportExportFormatGetResponses, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetData, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponses, GetQueueStatusApiV1AdminExecutionsQueueGetData, GetQueueStatusApiV1AdminExecutionsQueueGetErrors, GetQueueStatusApiV1AdminExecutionsQueueGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, GetResultApiV1ExecutionsExecutionIdResultGetData, GetResultApiV1ExecutionsExecutionIdResultGetErrors, GetResultApiV1ExecutionsExecutionIdResultGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetErrors, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetData, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetErrors, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponses, ListExecutionsApiV1AdminExecutionsGetData, ListExecutionsApiV1AdminExecutionsGetErrors, ListExecutionsApiV1AdminExecutionsGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponses, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponses, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponses, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponses, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponses, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostErrors, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostData, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostErrors, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponses, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponses, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryExecutionApiV1ExecutionsExecutionIdRetryPostData, RetryExecutionApiV1ExecutionsExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponses, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, UnlockUserApiV1AdminUsersUserIdUnlockPostData, UnlockUserApiV1AdminUsersUserIdUnlockPostErrors, UnlockUserApiV1AdminUsersUserIdUnlockPostResponses, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutData, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutErrors, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutData, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutErrors, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponses } from './types.gen'; +import type { BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionsExecutionIdCancelPostData, CancelExecutionApiV1ExecutionsExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionsExecutionIdCancelPostResponses, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponses, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponses, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteExecutionApiV1ExecutionsExecutionIdDeleteData, DeleteExecutionApiV1ExecutionsExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionsExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponses, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExportEventsApiV1AdminEventsExportExportFormatGetData, ExportEventsApiV1AdminEventsExportExportFormatGetErrors, ExportEventsApiV1AdminEventsExportExportFormatGetResponses, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetData, GetDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponses, GetQueueStatusApiV1AdminExecutionsQueueGetData, GetQueueStatusApiV1AdminExecutionsQueueGetErrors, GetQueueStatusApiV1AdminExecutionsQueueGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetResultApiV1ExecutionsExecutionIdResultGetData, GetResultApiV1ExecutionsExecutionIdResultGetErrors, GetResultApiV1ExecutionsExecutionIdResultGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetErrors, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetData, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetErrors, GetUserRateLimitsApiV1AdminRateLimitsUserIdGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponses, ListExecutionsApiV1AdminExecutionsGetData, ListExecutionsApiV1AdminExecutionsGetErrors, ListExecutionsApiV1AdminExecutionsGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponses, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponses, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponses, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponses, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponses, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostErrors, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostData, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostErrors, ResetUserRateLimitsApiV1AdminRateLimitsUserIdResetPostResponses, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponses, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryExecutionApiV1ExecutionsExecutionIdRetryPostData, RetryExecutionApiV1ExecutionsExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionsExecutionIdRetryPostResponses, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, UnlockUserApiV1AdminUsersUserIdUnlockPostData, UnlockUserApiV1AdminUsersUserIdUnlockPostErrors, UnlockUserApiV1AdminUsersUserIdUnlockPostResponses, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutData, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutErrors, UpdatePriorityApiV1AdminExecutionsExecutionIdPriorityPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutData, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutErrors, UpdateUserRateLimitsApiV1AdminRateLimitsUserIdPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponses } from './types.gen'; export type Options = Options2 & { /** @@ -328,11 +328,11 @@ export const replayEventsApiV1AdminEventsReplayPost = (options: Options) => (options.client ?? client).get({ url: '/api/v1/admin/events/replay/{session_id}/status', ...options }); +export const streamReplayStatusApiV1AdminEventsReplaySessionIdStatusGet = (options: Options) => (options.client ?? client).sse.get({ url: '/api/v1/admin/events/replay/{session_id}/status', ...options }); /** * List Executions diff --git a/frontend/src/lib/api/types.gen.ts b/frontend/src/lib/api/types.gen.ts index 66f8f87f..486b0f96 100644 --- a/frontend/src/lib/api/types.gen.ts +++ b/frontend/src/lib/api/types.gen.ts @@ -6801,7 +6801,7 @@ export type ReplayEventsApiV1AdminEventsReplayPostResponses = { export type ReplayEventsApiV1AdminEventsReplayPostResponse = ReplayEventsApiV1AdminEventsReplayPostResponses[keyof ReplayEventsApiV1AdminEventsReplayPostResponses]; -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData = { +export type StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData = { body?: never; path: { /** @@ -6813,7 +6813,7 @@ export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData = { url: '/api/v1/admin/events/replay/{session_id}/status'; }; -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors = { +export type StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors = { /** * Replay session not found */ @@ -6824,16 +6824,16 @@ export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors = { 422: HttpValidationError; }; -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors]; +export type StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError = StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors[keyof StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors]; -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses = { +export type StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses = { /** * Successful Response */ 200: EventReplayStatusResponse; }; -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses]; +export type StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse = StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses[keyof StreamReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses]; export type ListExecutionsApiV1AdminExecutionsGetData = { body?: never; @@ -7921,6 +7921,12 @@ export type ListSagasApiV1SagasGetData = { * Filter by saga state */ state?: SagaState | null; + /** + * Execution Id + * + * Filter by execution ID + */ + execution_id?: string | null; /** * Limit */ diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index ddde5781..5475d12e 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as meta from '$utils/meta'; +import Editor from '$routes/Editor.svelte'; function createMockLimits() { return { @@ -20,7 +23,6 @@ const mocks = vi.hoisted(() => ({ createSavedScriptApiV1ScriptsPost: vi.fn(), updateSavedScriptApiV1ScriptsScriptIdPut: vi.fn(), deleteSavedScriptApiV1ScriptsScriptIdDelete: vi.fn(), - addToast: vi.fn(), mockConfirm: vi.fn(), mockExecutionState: { phase: 'idle' as string, @@ -44,7 +46,6 @@ const mocks = vi.hoisted(() => ({ mockUnwrapOr: vi.fn((result: { data?: unknown; error?: unknown }, fallback: unknown) => { return result.error ? fallback : result.data ?? fallback; }), - mockUpdateMetaTags: vi.fn(), })); vi.mock('$lib/api', () => ({ @@ -65,29 +66,13 @@ vi.mock('$stores/userSettings.svelte', () => ({ }, })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/api-interceptors', () => ({ unwrap: (...args: unknown[]) => (mocks.mockUnwrap as (...a: unknown[]) => unknown)(...args), unwrapOr: (...args: unknown[]) => (mocks.mockUnwrapOr as (...a: unknown[]) => unknown)(...args), })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { editor: { title: 'Code Editor', description: 'Editor desc' } })); - vi.mock('$lib/editor', () => ({ createExecutionState: () => mocks.mockExecutionState })); -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'CirclePlay', 'Settings', 'Lightbulb', - 'FilePlus', 'Upload', 'Download', 'Save', - 'List', 'Trash2')); - vi.mock('$components/editor', async () => { const utils = await import('$test/test-utils'); const components = utils.createMockNamedComponents({ @@ -114,11 +99,14 @@ vi.mock('$components/editor', async () => { }); describe('Editor', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.mockAuthStore.isAuthenticated = true; mocks.mockAuthStore.verifyAuth.mockResolvedValue(true); @@ -143,8 +131,7 @@ describe('Editor', () => { afterEach(() => vi.unstubAllGlobals()); - async function renderEditor() { - const { default: Editor } = await import('$routes/Editor.svelte'); + function renderEditor() { return render(Editor); } @@ -184,7 +171,7 @@ describe('Editor', () => { it('calls updateMetaTags with editor meta', async () => { await renderEditor(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Code Editor', 'Editor desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Code Editor', expect.stringContaining('Online code editor')); }); }); }); @@ -219,7 +206,7 @@ describe('Editor', () => { }), }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script saved successfully.'); }); it('falls back to create when update returns 404', async () => { @@ -249,7 +236,7 @@ describe('Editor', () => { expect(screen.getByTitle(/Load Existing Script/)).toBeInTheDocument(); }); await user.click(screen.getByTitle(/Load Existing Script/)); - expect(mocks.addToast).toHaveBeenCalledWith('info', 'Loaded script: Existing Script'); + expect(toast.info).toHaveBeenCalledWith('Loaded script: Existing Script'); // Options closed after loadScript, reopen and click Save await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); @@ -274,7 +261,7 @@ describe('Editor', () => { }), }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script saved successfully.'); }); }); @@ -305,7 +292,7 @@ describe('Editor', () => { path: { script_id: 'script-99' }, }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script deleted successfully.'); + expect(toast.success).toHaveBeenCalledWith('Script deleted successfully.'); }); }); @@ -334,7 +321,7 @@ describe('Editor', () => { await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); await user.click(screen.getByRole('button', { name: 'New' })); expect(mocks.mockExecutionState.reset).toHaveBeenCalled(); - expect(mocks.addToast).toHaveBeenCalledWith('info', 'New script started.'); + expect(toast.info).toHaveBeenCalledWith('New script started.'); }); }); }); diff --git a/frontend/src/routes/__tests__/Home.test.ts b/frontend/src/routes/__tests__/Home.test.ts index d5aa13ac..3c21010e 100644 --- a/frontend/src/routes/__tests__/Home.test.ts +++ b/frontend/src/routes/__tests__/Home.test.ts @@ -1,26 +1,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -const mocks = vi.hoisted(() => ({ - mockUpdateMetaTags: vi.fn(), -})); +import * as meta from '$utils/meta'; +import Home from '$routes/Home.svelte'; -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule()); - -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { home: { title: 'Home', description: 'Home desc' } })); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('Zap', 'ShieldCheck', 'Clock')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Home', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderHome() { - const { default: Home } = await import('$routes/Home.svelte'); + function renderHome() { return render(Home); } @@ -58,7 +49,7 @@ describe('Home', () => { it('calls updateMetaTags with home meta on mount', async () => { await renderHome(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Home', 'Home desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Home', expect.stringContaining('Integr8sCode')); }); }); }); diff --git a/frontend/src/routes/__tests__/Login.test.ts b/frontend/src/routes/__tests__/Login.test.ts index cc9544bc..e7e94342 100644 --- a/frontend/src/routes/__tests__/Login.test.ts +++ b/frontend/src/routes/__tests__/Login.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as router from '@mateothegreat/svelte5-router'; +import * as meta from '$utils/meta'; +import Login from '$routes/Login.svelte'; + const mocks = vi.hoisted(() => ({ mockLogin: vi.fn(), - mockGoto: vi.fn(), - addToast: vi.fn(), mockLoadUserSettings: vi.fn(), mockGetErrorMessage: vi.fn((err: unknown, fallback?: string) => fallback || String(err)), - mockUpdateMetaTags: vi.fn(), mockAuthStore: { login: vi.fn(), isAuthenticated: false, @@ -19,12 +21,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('$stores/auth.svelte', () => ({ authStore: mocks.mockAuthStore })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule(mocks.mockGoto)); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/user-settings', () => ({ loadUserSettings: mocks.mockLoadUserSettings, })); @@ -33,24 +29,21 @@ vi.mock('$lib/api-interceptors', () => ({ getErrorMessage: mocks.mockGetErrorMessage, })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { login: { title: 'Login', description: 'Login desc' } })); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Login', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.mockAuthStore.login = vi.fn().mockResolvedValue(true); mocks.mockLoadUserSettings.mockResolvedValue(undefined); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderLogin() { - const { default: Login } = await import('$routes/Login.svelte'); + function renderLogin() { return render(Login); } @@ -67,7 +60,7 @@ describe('Login', () => { sessionStorage.setItem('authMessage', 'Please log in to continue'); await renderLogin(); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('info', 'Please log in to continue'); + expect(toast.info).toHaveBeenCalledWith('Please log in to continue'); }); expect(sessionStorage.getItem('authMessage')).toBeNull(); }); @@ -82,8 +75,8 @@ describe('Login', () => { expect(mocks.mockAuthStore.login).toHaveBeenCalledWith('testuser', 'pass1234'); }); expect(mocks.mockLoadUserSettings).toHaveBeenCalled(); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Login successful! Welcome back.'); - expect(mocks.mockGoto).toHaveBeenCalledWith('/editor'); + expect(toast.success).toHaveBeenCalledWith('Login successful! Welcome back.'); + expect(router.goto).toHaveBeenCalledWith('/editor'); }); it.each([ @@ -101,7 +94,7 @@ describe('Login', () => { await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { - expect(mocks.mockGoto).toHaveBeenCalledWith(expectedNav); + expect(router.goto).toHaveBeenCalledWith(expectedNav); }); expect(sessionStorage.getItem('redirectAfterLogin')).toBeNull(); }); @@ -118,7 +111,7 @@ describe('Login', () => { await waitFor(() => { expect(screen.getByText('Login failed. Please check your credentials.')).toBeInTheDocument(); }); - expect(mocks.addToast).not.toHaveBeenCalledWith('error', expect.anything()); + expect(toast.error).not.toHaveBeenCalled(); }); it('disables button and shows "Logging in..." during loading', async () => { @@ -146,7 +139,7 @@ describe('Login', () => { it('calls updateMetaTags on mount', async () => { await renderLogin(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Login', 'Login desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Login', expect.stringContaining('Sign in to Integr8sCode')); }); }); }); diff --git a/frontend/src/routes/__tests__/Notifications.test.ts b/frontend/src/routes/__tests__/Notifications.test.ts index ee0b8bf9..8058dd92 100644 --- a/frontend/src/routes/__tests__/Notifications.test.ts +++ b/frontend/src/routes/__tests__/Notifications.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { createMockNotification, createMockNotifications } from '$test/test-utils'; +import { createMockNotification, createMockNotifications, user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import Notifications from '$routes/Notifications.svelte'; const mocks = vi.hoisted(() => ({ - addToast: vi.fn(), mockNotificationStore: { notifications: [] as ReturnType[], unreadCount: 0, @@ -19,34 +19,23 @@ const mocks = vi.hoisted(() => ({ vi.mock('$stores/notificationStore.svelte', () => ({ notificationStore: mocks.mockNotificationStore })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule( - 'Bell', 'Trash2', 'Clock', 'CircleCheck', 'AlertCircle', 'Info')); - vi.mock('$lib/api', () => ({})); describe('Notifications', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.mockNotificationStore.notifications = []; mocks.mockNotificationStore.unreadCount = 0; mocks.mockNotificationStore.loading = false; mocks.mockNotificationStore.load.mockResolvedValue([]); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); mocks.mockNotificationStore.markAsRead.mockResolvedValue(true); mocks.mockNotificationStore.markAllAsRead.mockResolvedValue(true); mocks.mockNotificationStore.delete.mockResolvedValue(true); }); - async function renderNotifications() { - const { default: Notifications } = await import('$routes/Notifications.svelte'); + function renderNotifications() { return render(Notifications); } @@ -176,7 +165,7 @@ describe('Notifications', () => { await user.click(btn); expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('success', 'All notifications marked as read'); + expect(toast.success).toHaveBeenCalledWith('All notifications marked as read'); }); }); @@ -191,7 +180,7 @@ describe('Notifications', () => { ); await user.click(btn); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to mark all as read'); + expect(toast.error).toHaveBeenCalledWith('Failed to mark all as read'); }); }); }); @@ -207,7 +196,7 @@ describe('Notifications', () => { await user.click(deleteBtn); await waitFor(() => { expect(mocks.mockNotificationStore.delete).toHaveBeenCalledWith('del-1'); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Notification deleted'); + expect(toast.success).toHaveBeenCalledWith('Notification deleted'); }); }); @@ -221,7 +210,7 @@ describe('Notifications', () => { const deleteBtn = await screen.findByRole('button', { name: 'Delete notification' }); await user.click(deleteBtn); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to delete notification'); + expect(toast.error).toHaveBeenCalledWith('Failed to delete notification'); }); }); diff --git a/frontend/src/routes/__tests__/Privacy.test.ts b/frontend/src/routes/__tests__/Privacy.test.ts index 2e84fa0d..6c3e07b9 100644 --- a/frontend/src/routes/__tests__/Privacy.test.ts +++ b/frontend/src/routes/__tests__/Privacy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import Privacy from '$routes/Privacy.svelte'; const mocks = vi.hoisted(() => ({ scrollTo: vi.fn(), @@ -14,8 +15,7 @@ describe('Privacy', () => { vi.clearAllMocks(); }); - async function renderPrivacy() { - const { default: Privacy } = await import('$routes/Privacy.svelte'); + function renderPrivacy() { return render(Privacy); } diff --git a/frontend/src/routes/__tests__/Register.test.ts b/frontend/src/routes/__tests__/Register.test.ts index 500c14f5..f7229545 100644 --- a/frontend/src/routes/__tests__/Register.test.ts +++ b/frontend/src/routes/__tests__/Register.test.ts @@ -1,45 +1,38 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import * as router from '@mateothegreat/svelte5-router'; +import * as meta from '$utils/meta'; +import Register from '$routes/Register.svelte'; + const mocks = vi.hoisted(() => ({ registerApiV1AuthRegisterPost: vi.fn(), - mockGoto: vi.fn(), - addToast: vi.fn(), mockGetErrorMessage: vi.fn((_err: unknown, fallback?: string) => fallback || 'Unknown error'), - mockUpdateMetaTags: vi.fn(), })); vi.mock('$lib/api', () => ({ registerApiV1AuthRegisterPost: mocks.registerApiV1AuthRegisterPost, })); -vi.mock('@mateothegreat/svelte5-router', async () => - (await import('$test/test-utils')).createMockRouterModule(mocks.mockGoto)); - -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - vi.mock('$lib/api-interceptors', () => ({ getErrorMessage: mocks.mockGetErrorMessage, })); -vi.mock('$utils/meta', async () => - (await import('$test/test-utils')).createMetaMock( - mocks.mockUpdateMetaTags, { register: { title: 'Register', description: 'Register desc' } })); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: vi.fn() })); describe('Register', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); mocks.registerApiV1AuthRegisterPost.mockResolvedValue({ data: {}, error: undefined }); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); + vi.spyOn(meta, 'updateMetaTags'); }); - async function renderRegister() { - const { default: Register } = await import('$routes/Register.svelte'); + function renderRegister() { return render(Register); } @@ -80,7 +73,7 @@ describe('Register', () => { await waitFor(() => { expect(screen.getByText(expectedError)).toBeInTheDocument(); }); - expect(mocks.addToast).toHaveBeenCalledWith(toastType, expectedError); + expect(toast[toastType as keyof typeof toast]).toHaveBeenCalledWith(expectedError); expect(mocks.registerApiV1AuthRegisterPost).not.toHaveBeenCalled(); }); @@ -97,8 +90,8 @@ describe('Register', () => { body: { username: 'newuser', email: 'new@email.com', password: 'securepass' }, }); }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Registration successful! Please log in.'); - expect(mocks.mockGoto).toHaveBeenCalledWith('/login'); + expect(toast.success).toHaveBeenCalledWith('Registration successful! Please log in.'); + expect(router.goto).toHaveBeenCalledWith('/login'); }); it('shows error in DOM on API error (no duplicate toast)', async () => { @@ -118,7 +111,7 @@ describe('Register', () => { await waitFor(() => { expect(screen.getByText('Registration failed. Please try again.')).toBeInTheDocument(); }); - expect(mocks.addToast).not.toHaveBeenCalledWith('error', expect.anything()); + expect(toast.error).not.toHaveBeenCalled(); }); it('disables button and shows "Registering..." during loading', async () => { @@ -148,7 +141,7 @@ describe('Register', () => { it('calls updateMetaTags on mount', async () => { await renderRegister(); await waitFor(() => { - expect(mocks.mockUpdateMetaTags).toHaveBeenCalledWith('Register', 'Register desc'); + expect(meta.updateMetaTags).toHaveBeenCalledWith('Register', expect.stringContaining('Create a free Integr8sCode account')); }); }); }); diff --git a/frontend/src/routes/__tests__/Settings.test.ts b/frontend/src/routes/__tests__/Settings.test.ts index c1501d79..7024ecc7 100644 --- a/frontend/src/routes/__tests__/Settings.test.ts +++ b/frontend/src/routes/__tests__/Settings.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; +import { user } from '$test/test-utils'; +import { toast } from 'svelte-sonner'; +import Settings from '$routes/Settings.svelte'; function createMockSettings() { return { @@ -27,7 +29,6 @@ const mocks = vi.hoisted(() => ({ updateUserSettingsApiV1UserSettingsPut: vi.fn(), restoreSettingsApiV1UserSettingsRestorePost: vi.fn(), getSettingsHistoryApiV1UserSettingsHistoryGet: vi.fn(), - addToast: vi.fn(), mockSetTheme: vi.fn(), mockSetUserSettings: vi.fn(), mockConfirm: vi.fn(), @@ -53,20 +54,13 @@ vi.mock('$stores/userSettings.svelte', () => ({ userSettingsStore: { settings: null, editorSettings: {} }, })); -vi.mock('svelte-sonner', async () => - (await import('$test/test-utils')).createToastMock(mocks.addToast)); - -vi.mock('$components/Spinner.svelte', async () => - (await import('$test/test-utils')).createMockSvelteComponent('Loading', 'spinner')); - -vi.mock('@lucide/svelte', async () => - (await import('$test/test-utils')).createMockIconModule('ChevronDown')); - describe('Settings', () => { - const user = userEvent.setup(); - beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(toast, 'success'); + vi.spyOn(toast, 'error'); + vi.spyOn(toast, 'warning'); + vi.spyOn(toast, 'info'); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.mockAuthStore.isAuthenticated = true; mocks.getUserSettingsApiV1UserSettingsGet.mockResolvedValue({ @@ -81,8 +75,7 @@ describe('Settings', () => { afterEach(() => vi.unstubAllGlobals()); - async function renderSettings() { - const { default: Settings } = await import('$routes/Settings.svelte'); + function renderSettings() { return render(Settings); } @@ -152,7 +145,7 @@ describe('Settings', () => { }); await user.click(screen.getByRole('button', { name: /save settings/i })); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('info', 'No changes to save'); + expect(toast.info).toHaveBeenCalledWith('No changes to save'); }); expect(mocks.updateUserSettingsApiV1UserSettingsPut).not.toHaveBeenCalled(); }); @@ -186,7 +179,7 @@ describe('Settings', () => { const callArgs = mocks.updateUserSettingsApiV1UserSettingsPut.mock.calls[0]![0]; expect(callArgs.body).toHaveProperty('editor'); await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings saved successfully'); + expect(toast.success).toHaveBeenCalledWith('Settings saved successfully'); }); expect(mocks.mockSetUserSettings).toHaveBeenCalled(); }); @@ -274,7 +267,7 @@ describe('Settings', () => { }); }); expect(mocks.mockSetTheme).toHaveBeenCalledWith('light'); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Settings restored successfully'); + expect(toast.success).toHaveBeenCalledWith('Settings restored successfully'); }); it('does not call API when confirm is cancelled', async () => { diff --git a/frontend/src/routes/admin/AdminEvents.svelte b/frontend/src/routes/admin/AdminEvents.svelte index 3f46c2f4..502f92f6 100644 --- a/frontend/src/routes/admin/AdminEvents.svelte +++ b/frontend/src/routes/admin/AdminEvents.svelte @@ -1,25 +1,6 @@ @@ -263,9 +93,9 @@ - {#if hasActiveFilters(filters)} + {#if hasActiveFilters(store.filters)} - {getActiveFilterCount(filters)} + {getActiveFilterCount(store.filters)} {/if} @@ -285,14 +115,14 @@ {#if showExportMenu}
- - activeReplaySession = null} /> + store.activeReplaySession = null} /> - + - {#if !showFilters && hasActiveFilters(filters)} + {#if !showFilters && hasActiveFilters(store.filters)}
Active filters: - {#each getActiveFilterSummary(filters) as filter} + {#each getActiveFilterSummary(store.filters) as filter} {filter} {/each}
store.deleteEvent(id)} + onViewUser={handleUserOverview} /> - {#if totalEvents > 0} + {#if store.totalEvents > 0}
@@ -361,8 +191,8 @@ + + +
- +
@@ -188,12 +121,12 @@

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

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

No executions found

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