From 5a611f8f4a41ed2148c829f9165cd5adcddfe4d8 Mon Sep 17 00:00:00 2001 From: hth Date: Fri, 15 May 2026 12:46:07 +0700 Subject: [PATCH 1/4] feat(dashboard): route ZooPost cloud APIs in dev --- dashboard/vite.config.ts | 46 +++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 07a340b5..af34bb12 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -1,16 +1,38 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' +import { loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -export default defineConfig({ - plugins: [react(), tailwindcss()], - server: { - port: 5173, - proxy: { - '/api': 'http://127.0.0.1:8100', - '/ws': { target: 'ws://127.0.0.1:8100', ws: true }, - '/health': 'http://127.0.0.1:8100', - } - }, - build: { outDir: 'dist' } +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const fbkitTarget = env.VITE_FBKIT_API_URL || 'http://127.0.0.1:8100' + const zoopostCloudTarget = env.VITE_ZOOPOST_CLOUD_API_URL || 'http://127.0.0.1:8200' + const zoopostCloudBearerToken = env.ZOOPOST_CLOUD_DEV_BEARER_TOKEN + const zoopostCloudApiProxy = zoopostCloudBearerToken + ? { target: zoopostCloudTarget, changeOrigin: true, headers: { Authorization: `Bearer ${zoopostCloudBearerToken}` } } + : { target: zoopostCloudTarget, changeOrigin: true } + + return { + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api/agent-installations': zoopostCloudApiProxy, + '/api/channels': zoopostCloudApiProxy, + '/api/content-items': zoopostCloudApiProxy, + '/api/media-assets': zoopostCloudApiProxy, + '/api/publish-jobs': zoopostCloudApiProxy, + '/api/live-arms': zoopostCloudApiProxy, + '/api/dashboard': zoopostCloudApiProxy, + '/agent-gateway': { target: zoopostCloudTarget, changeOrigin: true, ws: true }, + '/api': { target: fbkitTarget, changeOrigin: true }, + '/ws': { target: fbkitTarget.replace(/^http/, 'ws'), ws: true }, + '/health': { target: fbkitTarget, changeOrigin: true }, + }, + }, + build: { outDir: 'dist' }, + test: { + environment: 'jsdom', + }, + } }) From 5fe7c1ededa6c221b51b2e1db15ce0c54e3340dd Mon Sep 17 00:00:00 2001 From: hth Date: Fri, 15 May 2026 12:51:08 +0700 Subject: [PATCH 2/4] docs(dashboard): document ZooPost dev proxy routing --- docs/code-standards.md | 3 ++- docs/codebase-summary.md | 2 +- docs/system-architecture.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/code-standards.md b/docs/code-standards.md index de1ea98e..61b9cf91 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -59,7 +59,8 @@ Last updated: 2026-05-07 - Shared API helpers live in `dashboard/src/api/`. - Shared TypeScript types live in `dashboard/src/types/`. - Pages are route-level components in `dashboard/src/pages/` and are wired from `dashboard/src/App.tsx`. -- Dev proxy configuration belongs in `dashboard/vite.config.ts`. +- Dev proxy configuration belongs in `dashboard/vite.config.ts`; cloud-owned ZooPost API prefixes must stay ahead of the local FBKit `/api` fallback. +- Do not expose `ZOOPOST_CLOUD_DEV_BEARER_TOKEN` through `VITE_` variables or client code; it is a Vite-server-only dev proxy credential. ## Extension Standards diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 29e18899..1fee6b9f 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -228,7 +228,7 @@ The dashboard is a Vite React app in `dashboard/`. | Verified file | Behavior | |---|---| | `dashboard/package.json` | Scripts: `dev`, `build`, `lint`, `preview`; dependencies include React 19, React Router 7, Vite 8, Tailwind 4, lucide-react | -| `dashboard/vite.config.ts` | Dev server port `5173`; proxies `/api` and `/health` to `127.0.0.1:8100`; proxies `/ws` to WebSocket target `ws://127.0.0.1:8100` | +| `dashboard/vite.config.ts` | Dev server port `5173`; routes ZooPost Cloud prefixes (`/api/channels`, `/api/content-items`, `/api/media-assets`, `/api/publish-jobs`, `/api/live-arms`, `/api/dashboard`, `/api/agent-installations`) and `/agent-gateway` to `127.0.0.1:8200`; keeps FBKit fallback `/api`, `/health`, and `/ws` on `127.0.0.1:8100`; supports server-side-only `ZOOPOST_CLOUD_DEV_BEARER_TOKEN`; Vitest uses `jsdom` via the local Vite config | | `dashboard/src/App.tsx` | Routes: `/`, `/accounts`, `/tasks`, `/seeding`, `/spy`, `/logs` | | `dashboard/src/pages/DashboardPage.tsx` | Polls status/task/account/seeding/spy APIs and renders live event feed from dashboard WebSocket | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index dcd5c32f..772d289c 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -82,7 +82,7 @@ SQLite runs with WAL mode and foreign keys enabled in `get_db()`. |---|---|---| | Agent REST API | `http://127.0.0.1:8100` | `/health`, `/api/status`, and routers under `/api` | | Extension WebSocket | `ws://127.0.0.1:9222` | Used by Chrome extension background/content scripts | -| Dashboard dev server | `http://127.0.0.1:5173` | Vite dev server proxies `/api`, `/health`, and `/ws` | +| Dashboard dev server | `http://127.0.0.1:5173` | Vite dev server routes ZooPost Cloud prefixes to `127.0.0.1:8200`, keeps FBKit `/api` fallback plus `/health` and `/ws` on `127.0.0.1:8100`, and can inject `ZOOPOST_CLOUD_DEV_BEARER_TOKEN` server-side for cloud API smoke tests | | Dashboard WebSocket | `/ws/dashboard` on API server | Emits event-bus messages to the dashboard | ## Live Control Plane From d54277a6e6ecffc8f4e4b5001019971c7aa336c7 Mon Sep 17 00:00:00 2001 From: hth Date: Fri, 15 May 2026 13:02:20 +0700 Subject: [PATCH 3/4] feat(agent): add ZooPost dry-run dispatch adapter --- README.md | 2 + agent/config.py | 6 + agent/db/crud.py | 38 ++ agent/db/schema.py | 1 + agent/services/zoopost_cloud_agent.py | 322 +++++++++++ agent/worker/processor.py | 47 +- docs/codebase-summary.md | 14 +- docs/project-roadmap.md | 4 +- docs/rollout-gates.md | 2 +- docs/system-architecture.md | 6 +- .../phase-04-distributed-worker-readiness.md | 8 +- .../plan.md | 6 +- tests/unit/test_account_queue_quota.py | 148 +++++ tests/unit/test_rollout_gates_docs.py | 3 +- tests/unit/test_zoopost_adapter.py | 535 ++++++++++++++++++ 15 files changed, 1122 insertions(+), 20 deletions(-) create mode 100644 agent/services/zoopost_cloud_agent.py create mode 100644 tests/unit/test_zoopost_adapter.py diff --git a/README.md b/README.md index 0d33d7ab..507295b6 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ FBKit centralizes mutation safety in `agent/services/safety_gate.py`. | `WS_AUTH_ENABLED` | follows `API_AUTH_ENABLED` | Must be `true` before creating live arms or approving live tasks | | `FBKIT_NODE_ID` | `hostname:pid` | Optional worker identity for lease/status visibility; must be unique per worker process sharing one DB | | `LIVE_ACCOUNT_LEASE_TTL_SECONDS` | `900` | Live account lease TTL for live mutating tasks; clamped to `60`-`3600` seconds | +| `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` | `60` | Live account lease refresh interval while a live mutating task is processing; clamped to `5`-`300` seconds | Mutating task types include posting, messaging, liking, commenting, sharing, friend actions, group actions, page follow/unfollow, and video reup tasks. @@ -144,6 +145,7 @@ Additional protections: - live quota is reserved before live dispatch, skipped for dry-run tasks, and not reserved until live auth, arm, and extension guard readiness all pass - live quota reservation is date-scoped and idempotent for the same task retry; dry-run tasks remain exempt from live quota reservation - the worker uses a SQLite-backed live account lease to block same-account live mutating non-dry-run claims across workers sharing one DB; same-account dry-run/read-only work remains exempt +- the worker refreshes the matching SQLite live account lease during processing so long live mutating tasks do not expire while still running - the worker still reports process-local `active_live_account_ids` as telemetry/defense-in-depth, but the DB lease is the cross-worker guard - `FBClient` requires exact `fb_uid` routing when a task targets a specific Facebook account - `FBClient` marks sessions stale by heartbeat age and prefers the freshest duplicate session for exact `fb_uid` routing diff --git a/agent/config.py b/agent/config.py index a0981306..71d9918d 100644 --- a/agent/config.py +++ b/agent/config.py @@ -47,6 +47,12 @@ def _clamped_int(value: str | None, default: int, minimum: int, maximum: int) -> minimum=60, maximum=3600, ) +LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS = _clamped_int( + os.environ.get("LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS"), + default=60, + minimum=5, + maximum=300, +) # ─── Safety Gate ────────────────────────────────────────────── # Defaults protect personal accounts from accidental live mutations. diff --git a/agent/db/crud.py b/agent/db/crud.py index c5c22d13..10299cb6 100644 --- a/agent/db/crud.py +++ b/agent/db/crud.py @@ -415,6 +415,17 @@ async def get_task(task_id: str) -> dict | None: return _row_to_dict(await cur.fetchone()) +async def get_task_by_ref_id(ref_id: str) -> dict | None: + db = await get_db() + cur = await db.execute("SELECT * FROM task WHERE ref_id = ?", (ref_id,)) + return _row_to_dict(await cur.fetchone()) + + +async def rollback(): + db = await get_db() + await db.rollback() + + async def list_tasks(status: str = None, task_type: str = None, account_id: str = None) -> list[dict]: db = await get_db() conditions, params = [], [] @@ -672,6 +683,33 @@ async def release_live_account_lease(account_id: str, task_id: str, node_id: str return cur.rowcount == 1 +async def refresh_live_account_lease( + account_id: str, + task_id: str, + node_id: str, + ttl_seconds: int | None = None, +) -> dict | None: + """Extend the lease held by the matching account/task/node tuple.""" + if not account_id or not task_id or not node_id: + return None + db = await get_db() + now = utc_now_iso() + expires_at = _lease_expires_at(ttl_seconds) + cur = await db.execute( + "UPDATE live_account_lease SET heartbeat_at = ?, expires_at = ? " + "WHERE account_id = ? AND task_id = ? AND node_id = ? AND expires_at > ?", + (now, expires_at, account_id, task_id, node_id, now), + ) + await db.commit() + if cur.rowcount != 1: + return None + cur = await db.execute( + "SELECT * FROM live_account_lease WHERE account_id = ? AND task_id = ? AND node_id = ?", + (account_id, task_id, node_id), + ) + return _row_to_dict(await cur.fetchone()) + + async def list_active_live_account_leases() -> list[dict]: db = await get_db() cur = await db.execute( diff --git a/agent/db/schema.py b/agent/db/schema.py index 7d2561db..19e641a7 100644 --- a/agent/db/schema.py +++ b/agent/db/schema.py @@ -114,6 +114,7 @@ CREATE INDEX IF NOT EXISTS idx_task_priority ON task(priority DESC); CREATE INDEX IF NOT EXISTS idx_task_status_scheduled_priority ON task(status, scheduled_at, priority DESC); CREATE INDEX IF NOT EXISTS idx_task_account_status ON task(account_id, status); +CREATE UNIQUE INDEX IF NOT EXISTS idx_task_zoopost_ref ON task(ref_id) WHERE ref_id LIKE 'zoopost:%'; -- ─── FB Group ─────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS fb_group ( diff --git a/agent/services/zoopost_cloud_agent.py b/agent/services/zoopost_cloud_agent.py new file mode 100644 index 00000000..a2a940a1 --- /dev/null +++ b/agent/services/zoopost_cloud_agent.py @@ -0,0 +1,322 @@ +"""ZooPost cloud dispatch adapter for local FBKit tasks.""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass +from typing import Any + +import aiosqlite + +from agent.db import crud + +DISPATCH_REF_PREFIX = "zoopost:" +SERVER_OWNED_FIELDS = { + "_serverApproved", + "serverApproved", + "_liveArmId", + "liveArmId", + "live_arm_id", + "_quotaReserved", + "quotaReserved", + "approved", +} +MEDIA_PATH_FIELDS = {"path", "local_path", "localPath", "mediaPath", "mediaPaths", "filePath", "file_path"} +TASK_TYPE_MAP = { + "facebook.post_text": "POST_TEXT", + "facebook.post_image": "POST_IMAGE", + "facebook.post_video": "POST_VIDEO", + "facebook.post_link": "POST_LINK", + "facebook.reup_video": "REUP_VIDEO", +} +CHANNEL_TYPES = {"fanpage", "profile", "group"} +DEFAULT_CAPABILITIES = [{"name": "publish-dry-run"}] +TERMINAL_STATUS_MAP = { + "COMPLETED": "posted", + "FAILED": "failed", + "CANCELLED": "failed", +} + + +@dataclass +class GatewaySession: + session_id: str + session_generation: int + connection_id: str + sequence: int = 1 + + def next_sequence(self) -> int: + self.sequence += 1 + return self.sequence + + +async def open_gateway_session( + websocket, + credential: str, + connection_id: str, + connected_profiles: list[dict[str, Any]], + *, + capabilities: list[dict[str, Any]] | None = None, + live_guard_enabled: bool = False, +) -> GatewaySession: + await _send_json( + websocket, + { + "type": "agent_hello", + "messageId": _message_id("hello"), + "timestamp": _timestamp(), + "sequence": 1, + "credential": credential, + "connectionId": connection_id, + "capabilities": DEFAULT_CAPABILITIES if capabilities is None else capabilities, + "connectedProfiles": connected_profiles, + "liveGuardEnabled": live_guard_enabled, + }, + ) + ack = await _receive_json(websocket) + if ack.get("type") != "agent_hello_ack": + raise ValueError("cloud gateway rejected hello") + return GatewaySession( + session_id=ack["sessionId"], + session_generation=ack["sessionGeneration"], + connection_id=ack["connectionId"], + ) + + +async def poll_gateway_dispatches(websocket, session: GatewaySession, *, limit: int = 10) -> list[dict[str, Any]]: + await _send_json( + websocket, + { + "type": "agent_dispatch_poll", + "messageId": _message_id("poll"), + "sessionId": session.session_id, + "timestamp": _timestamp(), + "sequence": session.next_sequence(), + "limit": limit, + }, + ) + batch = await _receive_json(websocket) + if batch.get("type") != "agent_dispatch_batch": + raise ValueError("cloud gateway did not return dispatch batch") + results = [] + dispatches = batch.get("dispatches", []) + if not isinstance(dispatches, list): + raise ValueError("cloud gateway dispatch batch must be a list") + for dispatch in dispatches[:limit]: + dispatch_id = dispatch.get("dispatchId") + try: + if dispatch.get("type") != "dispatch_publish_target": + raise ValueError("unsupported cloud dispatch message") + results.append(await handle_dispatch(dispatch)) + except ValueError as exc: + if not isinstance(dispatch_id, str) or not dispatch_id.strip(): + raise + await _send_gateway_dispatch_failure(websocket, session, dispatch_id, exc) + results.append({"dispatchId": dispatch_id, "failed": True, "error": str(exc)}) + return results + + +async def send_gateway_task_result(websocket, session: GatewaySession, dispatch_id: str, task: dict[str, Any]) -> dict[str, Any]: + message = build_dispatch_result(dispatch_id, task) + message.update( + { + "messageId": _message_id("result"), + "sessionId": session.session_id, + "timestamp": _timestamp(), + "sequence": session.next_sequence(), + } + ) + await _send_json(websocket, message) + ack = await _receive_json(websocket) + if ack.get("type") != "agent_dispatch_result_ack": + raise ValueError("cloud gateway rejected dispatch result") + return ack + + +async def _send_gateway_dispatch_failure(websocket, session: GatewaySession, dispatch_id: str, error: Exception) -> dict[str, Any]: + await _send_json( + websocket, + { + "type": "agent_dispatch_result", + "messageId": _message_id("result"), + "sessionId": session.session_id, + "timestamp": _timestamp(), + "sequence": session.next_sequence(), + "dispatchId": dispatch_id, + "resultStatus": "failed", + "errorCode": "local_dispatch_validation_failed", + "errorMessage": str(error)[:1000], + }, + ) + ack = await _receive_json(websocket) + if ack.get("type") != "agent_dispatch_result_ack": + raise ValueError("cloud gateway rejected dispatch result") + return ack + + +async def handle_dispatch(dispatch: dict[str, Any]) -> dict[str, Any]: + dispatch_id = _required_text(dispatch, "dispatchId") + dispatch_ref = _dispatch_ref(dispatch_id) + existing_task = await crud.get_task_by_ref_id(dispatch_ref) + if existing_task: + return {"dispatchId": dispatch_id, "localTaskId": existing_task["id"], "duplicate": True} + + if dispatch.get("platform") != "facebook": + raise ValueError("only facebook dispatch is supported") + if dispatch.get("channelType") not in CHANNEL_TYPES: + raise ValueError("unsupported facebook channel type") + + task_type = TASK_TYPE_MAP.get(_required_text(dispatch, "platformTaskType")) + if not task_type: + raise ValueError("unsupported facebook task type") + + expected_fb_uid = _required_text(dispatch, "expectedFbUid") + account = await _get_account_by_fb_uid(expected_fb_uid) + if not account: + raise ValueError("expected facebook identity is not available locally") + + payload = _build_task_payload(dispatch, expected_fb_uid) + try: + task = await crud.create_task( + account["id"], + task_type, + payload=payload, + ref_id=dispatch_ref, + ) + except aiosqlite.IntegrityError: + await crud.rollback() + task = await crud.get_task_by_ref_id(dispatch_ref) + if task: + return {"dispatchId": dispatch_id, "localTaskId": task["id"], "duplicate": True} + raise + return {"dispatchId": dispatch_id, "localTaskId": task["id"], "duplicate": False} + + +async def _get_account_by_fb_uid(expected_fb_uid: str) -> dict[str, Any] | None: + for account in await crud.list_accounts(): + if account.get("fb_uid") == expected_fb_uid: + return account + return None + + +def _build_task_payload(dispatch: dict[str, Any], expected_fb_uid: str) -> dict[str, Any]: + raw_payload = _optional_field(dispatch, "payload", {}) + if not isinstance(raw_payload, dict): + raise ValueError("cloud dispatch payload must be an object") + payload = _strip_server_fields(dict(raw_payload)) + content = _optional_field(dispatch, "content", {}) + if not isinstance(content, dict): + raise ValueError("cloud dispatch content must be an object") + body = content.get("body", "") + if not isinstance(body, str): + raise ValueError("cloud dispatch content body must be text") + media = _optional_field(dispatch, "media", []) + _reject_worker_media_payload(payload) + _reject_filesystem_media_paths(media) + + payload.update( + { + "dryRun": True, + "content": body, + "expectedFbUid": expected_fb_uid, + } + ) + if media: + payload["zoopostMediaRefs"] = media + if dispatch.get("target"): + payload["target"] = dispatch["target"] + return payload + + +def _optional_field(data: dict[str, Any], field: str, default: Any) -> Any: + value = data.get(field, default) + return default if value is None else value + + +def _strip_server_fields(payload: dict[str, Any]) -> dict[str, Any]: + for field in SERVER_OWNED_FIELDS: + payload.pop(field, None) + return payload + + +def _reject_worker_media_payload(value: Any): + if isinstance(value, dict): + if MEDIA_PATH_FIELDS & set(value): + raise ValueError("cloud dispatch media must use opaque local media refs") + for nested in value.values(): + _reject_worker_media_payload(nested) + elif isinstance(value, list): + for item in value: + _reject_worker_media_payload(item) + + +def _reject_filesystem_media_paths(media: Any): + if media is None: + return + if not isinstance(media, list): + raise ValueError("cloud dispatch media must use opaque local media refs") + for item in media: + if isinstance(item, str): + raise ValueError("cloud dispatch media must use opaque local media refs") + if not isinstance(item, dict): + raise ValueError("cloud dispatch media must use opaque local media refs") + if MEDIA_PATH_FIELDS & set(item): + raise ValueError("cloud dispatch media must use opaque local media refs") + if not any(isinstance(item.get(field), str) and item[field].strip() for field in ("ref", "id", "localRef")): + raise ValueError("cloud dispatch media must use opaque local media refs") + + +def _required_text(data: dict[str, Any], field: str) -> str: + value = data.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"missing {field}") + return value + + +def _dispatch_ref(dispatch_id: str) -> str: + return f"{DISPATCH_REF_PREFIX}{dispatch_id}" + + +def build_dispatch_result(dispatch_id: str, task: dict[str, Any]) -> dict[str, Any]: + task_status = task.get("status") + if task_status not in TERMINAL_STATUS_MAP: + raise ValueError("task has no terminal dispatch result") + message = { + "type": "agent_dispatch_result", + "dispatchId": dispatch_id, + "resultStatus": TERMINAL_STATUS_MAP[task_status], + } + result = _task_result(task) + if result.get("externalPostId"): + message["externalPostId"] = result["externalPostId"] + if result.get("externalPostUrl"): + message["externalPostUrl"] = result["externalPostUrl"] + if task.get("error_message"): + message["errorMessage"] = task["error_message"] + return message + + +def _task_result(task: dict[str, Any]) -> dict[str, Any]: + try: + result = json.loads(task.get("result") or "{}") + except json.JSONDecodeError: + return {} + return result if isinstance(result, dict) else {} + + +async def _send_json(websocket, payload: dict[str, Any]): + await websocket.send(json.dumps(payload, separators=(",", ":"))) + + +async def _receive_json(websocket) -> dict[str, Any]: + return json.loads(await websocket.recv()) + + +def _message_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4()}" + + +def _timestamp() -> int: + return int(time.time()) diff --git a/agent/worker/processor.py b/agent/worker/processor.py index 83fcb4b6..f69850f7 100644 --- a/agent/worker/processor.py +++ b/agent/worker/processor.py @@ -111,8 +111,17 @@ def _bulk_recipients_from_payload(payload: dict) -> list[dict]: class WorkerController: """Controls the background task processor.""" - def __init__(self, node_id: str | None = None): + def __init__(self, node_id: str | None = None, live_lease_heartbeat_seconds: float | None = None): self.node_id = node_id or config.FBKIT_NODE_ID + requested_heartbeat_seconds = ( + live_lease_heartbeat_seconds + if live_lease_heartbeat_seconds is not None + else config.LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS + ) + self.live_lease_heartbeat_seconds = min( + requested_heartbeat_seconds, + max(5, config.LIVE_ACCOUNT_LEASE_TTL_SECONDS / 2), + ) self._shutdown = False self._active_count = 0 self._active_live_account_ids: set[str] = set() @@ -312,6 +321,31 @@ def _clear_live_account(self, account_id: str | None): if account_id: self._active_live_account_ids.discard(account_id) + async def _heartbeat_live_account_lease(self, live_lease: dict): + """Refresh a live account lease while a long live task is processing.""" + try: + while True: + await asyncio.sleep(self.live_lease_heartbeat_seconds) + refreshed = await crud.refresh_live_account_lease( + live_lease["account_id"], + live_lease["task_id"], + live_lease["node_id"], + config.LIVE_ACCOUNT_LEASE_TTL_SECONDS, + ) + if refreshed is None: + logger.warning( + "Live account lease heartbeat stopped for account=%s task=%s node=%s", + live_lease.get("account_id"), + live_lease.get("task_id"), + live_lease.get("node_id"), + ) + return + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error("Live account lease heartbeat failed: %s", exc) + return + async def _process_task( self, task: dict, @@ -325,8 +359,11 @@ async def _process_task( strategy = None strategy_id = None strategy_url = "*" + lease_heartbeat_task = None try: + if live_lease: + lease_heartbeat_task = asyncio.create_task(self._heartbeat_live_account_lease(live_lease)) session = get_session_manager() payload = json.loads(task.get("payload") or "{}") if task.get("payload") else {} payload = enforce_payload(task_type, payload) @@ -487,6 +524,14 @@ async def _process_task( asyncio.create_task(notifier.notify_task_failed(task, error_message)) finally: + if lease_heartbeat_task: + lease_heartbeat_task.cancel() + try: + await lease_heartbeat_task + except asyncio.CancelledError: + pass + except Exception as exc: + logger.error("Live account lease heartbeat cleanup observed failure: %s", exc) if live_lease: try: await crud.release_live_account_lease( diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 1fee6b9f..be6c39bb 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -127,6 +127,8 @@ Live quota reservation is skipped for dry-run tasks. Before reserving quota for `WorkerController` passes `node_id` and `LIVE_ACCOUNT_LEASE_TTL_SECONDS` into `crud.claim_next_pending_task(...)`. CRUD scans at most 500 ready pending tasks ordered by priority and creation time. Live mutating non-dry-run candidates must acquire or reclaim a row in `live_account_lease` before task claim; candidates blocked by another active lease are skipped. Same-account dry-run tasks and read-only tasks are not leased or blocked by the lease. If a claim race is lost after lease acquisition, the lease is released. +While a live mutating task is processing, `WorkerController` refreshes the matching account/task/node lease every `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` using `crud.refresh_live_account_lease(...)`. The effective heartbeat interval is clamped to at most half of `LIVE_ACCOUNT_LEASE_TTL_SECONDS` so misconfiguration cannot schedule the first heartbeat after lease expiry. Refresh updates `heartbeat_at` and extends `expires_at` only for the active matching lease; mismatched or expired leases are not refreshed and fail closed. + `WorkerController` still maintains process-local `_active_live_account_ids` around async processing for telemetry/defense-in-depth and exposes it through `/api/status`, but the SQLite lease is now the cross-worker same-account live guard for workers sharing one SQLite DB. The worker now waits for `FBClient.has_fresh_session` before claiming pending tasks. Stale-only sockets do not trigger queued work and therefore do not immediately fail tasks because an old browser session remained registered. @@ -163,12 +165,10 @@ Approval rejects malformed task payload JSON with `400` and writes an `APPROVE_T | `account_id` | Primary key and account scope for one active live mutating task | | `task_id` | Task that acquired the lease; release must match this task | | `node_id` | Worker identity for visibility and release ownership check | -| `acquired_at`, `heartbeat_at` | Acquisition metadata; heartbeat refresh is not yet implemented | +| `acquired_at`, `heartbeat_at` | Acquisition and latest refresh metadata | | `expires_at` | Crash-recovery expiry; active list returns rows with `expires_at > now` | -`crud.acquire_live_account_lease(account_id, task_id, node_id, ttl_seconds)` inserts or replaces only expired same-account leases. `ttl_seconds` is clamped to `60`-`3600`; default config is `LIVE_ACCOUNT_LEASE_TTL_SECONDS=900`. `crud.release_live_account_lease(account_id, task_id, node_id)` deletes only the matching account/task/node lease. `crud.list_active_live_account_leases()` powers read-only `/api/status` visibility. - -Residual caveat: no lease heartbeat refresh exists yet. Keep live tasks within the TTL, or prioritize heartbeat refresh before long live workflows. +`crud.acquire_live_account_lease(account_id, task_id, node_id, ttl_seconds)` inserts or replaces only expired same-account leases. `ttl_seconds` is clamped to `60`-`3600`; default config is `LIVE_ACCOUNT_LEASE_TTL_SECONDS=900`. `crud.refresh_live_account_lease(account_id, task_id, node_id, ttl_seconds)` extends only the matching active lease. `crud.release_live_account_lease(account_id, task_id, node_id)` deletes only the matching account/task/node lease. `crud.list_active_live_account_leases()` powers read-only `/api/status` visibility. ## Extension DOM-Action Guard @@ -211,6 +211,7 @@ Verified in `agent/config.py`: | `MAX_CONCURRENT_TASKS` | `1` | Worker concurrency limit | | `FBKIT_NODE_ID` | `hostname:pid` | Optional worker identity. Must be unique per worker process when multiple workers share one SQLite DB. | | `LIVE_ACCOUNT_LEASE_TTL_SECONDS` | `900` | Live account lease TTL for live mutating non-dry-run tasks; clamped to `60`-`3600` seconds | +| `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` | `60` | Refresh interval for matching live account leases while live mutating tasks are processing; clamped to `5`-`300` seconds | | `LIVE_ACTIONS_ENABLED` | `false` | Global switch for real mutating Facebook actions | | `DRY_RUN_DEFAULT` | `true` | Default dry-run value when live actions are allowed and no explicit flag is provided | | `APPROVAL_REQUIRED` | `true` | Requires payload approval before live mutation | @@ -227,10 +228,11 @@ The dashboard is a Vite React app in `dashboard/`. | Verified file | Behavior | |---|---| -| `dashboard/package.json` | Scripts: `dev`, `build`, `lint`, `preview`; dependencies include React 19, React Router 7, Vite 8, Tailwind 4, lucide-react | +| `dashboard/package.json` | Scripts: `dev`, `build`, `lint`, `test`, `preview`; runtime deps include React 19, React Router 7, and lucide-react; dev deps include Vite 8, Tailwind 4, Vitest, jsdom, and Testing Library for dashboard-local hook tests | | `dashboard/vite.config.ts` | Dev server port `5173`; routes ZooPost Cloud prefixes (`/api/channels`, `/api/content-items`, `/api/media-assets`, `/api/publish-jobs`, `/api/live-arms`, `/api/dashboard`, `/api/agent-installations`) and `/agent-gateway` to `127.0.0.1:8200`; keeps FBKit fallback `/api`, `/health`, and `/ws` on `127.0.0.1:8100`; supports server-side-only `ZOOPOST_CLOUD_DEV_BEARER_TOKEN`; Vitest uses `jsdom` via the local Vite config | | `dashboard/src/App.tsx` | Routes: `/`, `/accounts`, `/tasks`, `/seeding`, `/spy`, `/logs` | | `dashboard/src/pages/DashboardPage.tsx` | Polls status/task/account/seeding/spy APIs and renders live event feed from dashboard WebSocket | +| `dashboard/src/api/useWebSocket.test.ts` | Hook-level regression coverage for dashboard WebSocket reconnect, dual-consumer isolation, and unmount cleanup behavior with mocked `WebSocket` and fake timers | Dashboard session types include `profile_id`, `profile_name`, `last_seen_age_s`, `stale`, and `health`. `SafetyGateStatus` counts only fresh non-stale extension sessions as connected/logged in, so stale sessions no longer make the dashboard look live-ready. @@ -277,7 +279,7 @@ Do not use `POST /tasks/{task_id}/approve` as part of safe cleanup or dry-run va Latest reported validation for Phase 4 distributed worker readiness: `pytest tests\unit\test_account_queue_quota.py -q` passed with `22 passed in 4.80s`; `pytest tests\unit\test_safety_gate.py tests\unit\test_live_arming.py tests\unit\test_account_queue_quota.py -q` passed with `95 passed in 15.93s`; `pytest tests\unit -q` passed with `260 passed in 21.20s`; `python -m compileall agent` passed; `node --check extension\background.js` passed; dashboard `npm run build` passed. Final code review approved docs sync with no blockers. -Phase 4 is minimal readiness only. It does not add distributed orchestration, node assignment, queue federation, remote control, or live action enablement. Residual risks: no lease heartbeat refresh for long live workflows; `/api/status` exposes operational IDs/session metadata, so keep API local or enable API auth before non-local exposure; add a future multi-process SQLite contention integration test. +Phase 4 is minimal readiness only. It does not add distributed orchestration, node assignment, queue federation, remote control, or live action enablement. Residual risks: `/api/status` exposes operational IDs/session metadata, so keep API local or enable API auth before non-local exposure; add a future multi-process SQLite contention integration test. ## Rollout Gates diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 1d12ecfa..fe7cf284 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -52,14 +52,14 @@ FBKit has a working local-first architecture: FastAPI agent, SQLite task queue, - Document every confirmed change to Safety Gate behavior in [Codebase Summary](./codebase-summary.md). - Maintain the Phase 4 SQLite live account lease before relying on multiple workers sharing one DB; process-local `_active_live_account_ids` is only telemetry/defense-in-depth. - Treat stale extension sessions as offline for routing/readiness; do not count stale sockets as live-ready in status UI. -- Keep live workflows within `LIVE_ACCOUNT_LEASE_TTL_SECONDS` until heartbeat refresh is implemented. +- Keep `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` shorter than `LIVE_ACCOUNT_LEASE_TTL_SECONDS` for long live workflows; the worker clamps the effective interval to at most half of TTL. - Keep `/api/status` local or enable API auth before non-local exposure because it includes operational IDs/session metadata. ## Phase 4 Completion Evidence - DB-backed live account leases are implemented for live mutating non-dry-run tasks; dry-run/read-only tasks remain lease-exempt. - Worker exposes read-only `node_id`, `active_live_account_ids`, and `live_account_leases` under `/api/status`. -- New config: `FBKIT_NODE_ID` optional, default `hostname:pid`; `LIVE_ACCOUNT_LEASE_TTL_SECONDS` default `900`, clamped `60`-`3600`. +- New config: `FBKIT_NODE_ID` optional, default `hostname:pid`; `LIVE_ACCOUNT_LEASE_TTL_SECONDS` default `900`, clamped `60`-`3600`; `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` default `60`, clamped `5`-`300`. - Safety defaults unchanged: no live actions enabled by default; Safety Gate, live arm, API/WS auth, exact `fb_uid`, extension guard, and quota checks remain intact. - Verification: `pytest tests\unit\test_account_queue_quota.py -q` passed with `22 passed in 4.80s`; targeted safety/live/quota suite passed with `95 passed in 15.93s`; final full unit suite passed with `260 passed in 21.20s`; `python -m compileall agent`, `node --check extension\background.js`, and dashboard `npm run build` passed. - Final code review approved docs sync with no blockers. diff --git a/docs/rollout-gates.md b/docs/rollout-gates.md index d0e870e0..e6b3cff1 100644 --- a/docs/rollout-gates.md +++ b/docs/rollout-gates.md @@ -110,7 +110,7 @@ Required guardrails: - `FBKIT_NODE_ID` must be unique per worker process when multiple workers share one DB. - `LIVE_ACCOUNT_LEASE_TTL_SECONDS` must exceed expected live task duration. -- Live account lease heartbeat refresh is not implemented; keep live workflows within the lease TTL or add heartbeat refresh first. +- `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` should be shorter than the lease TTL; the worker clamps the effective heartbeat interval to at most half of `LIVE_ACCOUNT_LEASE_TTL_SECONDS`. - `/api/status` exposes operational metadata: node IDs, account IDs, session metadata, live arms, and live account leases. - Keep the API bound to localhost or enable `API_AUTH_ENABLED=true` before non-local exposure. diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 772d289c..80a7b106 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -119,7 +119,9 @@ The worker checks `FBClient.has_fresh_session` before claiming tasks. This keeps The SQLite MVP keeps queue selection in `agent/db/crud.py`. `claim_next_pending_task(...)` scans up to 500 ready pending tasks ordered by priority and creation time. Live mutating non-dry-run candidates require `acquire_live_account_lease(account_id, task_id, node_id, ttl_seconds)` before claim. If another active lease exists for that account, CRUD skips that live candidate and keeps scanning. Dry-run and read-only tasks are exempt from lease reads/writes. -`live_account_lease.account_id` is the primary key. `task_id` and `node_id` are ownership metadata used on release; `expires_at` allows expired lease reclaim after worker crash. `FBKIT_NODE_ID` defaults to `hostname:pid` and should be unique per worker process when multiple workers share one SQLite DB. `LIVE_ACCOUNT_LEASE_TTL_SECONDS` defaults to `900` and is clamped to `60`-`3600`. +`live_account_lease.account_id` is the primary key. `task_id` and `node_id` are ownership metadata used on release and heartbeat refresh; `expires_at` allows expired lease reclaim after worker crash. `FBKIT_NODE_ID` defaults to `hostname:pid` and should be unique per worker process when multiple workers share one SQLite DB. `LIVE_ACCOUNT_LEASE_TTL_SECONDS` defaults to `900` and is clamped to `60`-`3600`. + +While a live mutating task is processing, the worker refreshes the matching account/task/node lease every `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` seconds. The worker clamps the effective interval to at most half of `LIVE_ACCOUNT_LEASE_TTL_SECONDS`, updates `heartbeat_at`, and extends `expires_at` only if the lease still matches and is active; lost, expired, or mismatched leases are not refreshed. Worker `finally` releases the matching DB lease and clears process-local `_active_live_account_ids`. The process-local set remains status telemetry/defense-in-depth, not the cross-worker guard. @@ -127,7 +129,7 @@ Quota reservation is live-only. `_check_rate_limit()` requires live auth readine Account visibility is exposed at `GET /api/accounts/{account_id}/queue-summary`. The response comes from `crud.get_account_queue_summary(account_id)` and includes task counts by status, quota usage/limits, stale-counter-aware `used` values, and blocked reasons. This endpoint is the current source for per-account queue/quota diagnostics. -Limits: Phase 4 is distributed worker readiness only, not distributed orchestration/control plane. Lease heartbeat refresh is not implemented; keep live workflows within TTL or add heartbeat refresh before long live tasks. `/api/status` exposes operational IDs/session metadata, so keep API local or enable API auth before non-local exposure. A multi-process SQLite contention integration test remains future hardening. +Limits: Phase 4 is distributed worker readiness only, not distributed orchestration/control plane. `/api/status` exposes operational IDs/session metadata, so keep API local or enable API auth before non-local exposure. A multi-process SQLite contention integration test remains future hardening. ## Deployment Shape diff --git a/plans/260507-0000-safety-first-50-account-roadmap/phase-04-distributed-worker-readiness.md b/plans/260507-0000-safety-first-50-account-roadmap/phase-04-distributed-worker-readiness.md index 07c10df8..b28257f5 100644 --- a/plans/260507-0000-safety-first-50-account-roadmap/phase-04-distributed-worker-readiness.md +++ b/plans/260507-0000-safety-first-50-account-roadmap/phase-04-distributed-worker-readiness.md @@ -105,7 +105,7 @@ Lease TTL: - Add `LIVE_ACCOUNT_LEASE_TTL_SECONDS` in `agent/config.py`, default `900`. - Clamp minimum to `60` and maximum to `3600` in config or CRUD helper. -- Risk note: if browser dispatch can exceed TTL, add heartbeat refresh in a later phase. For this minimal phase, release in `finally` + conservative TTL is acceptable; tests cover expired reclaim. +- Risk note: browser dispatch that exceeds TTL is protected by worker lease heartbeat refresh while the task is processing; release in `finally` and expired reclaim remain the crash-recovery path. ## Data Flows @@ -320,7 +320,7 @@ Expected: fail because table/helpers do not exist. | Risk | Likelihood | Impact | Mitigation | |---|---:|---:|---| -| Lease expires while live task still running, another worker reclaims account | Medium | High | Default TTL conservative; release in finally; document future heartbeat if task runtime exceeds TTL; do not use this as permission to enable live actions. | +| Lease expires while live task still running, another worker reclaims account | Low | High | Worker refreshes matching active lease during processing; effective heartbeat interval is clamped to at most half of TTL; release in finally; do not use this as permission to enable live actions. | | Lease acquired then task claim fails, stale lease blocks account | Low | Medium | Claim code must release on `rowcount != 1`; targeted test. | | SQLite write contention with multiple workers | Medium | Medium | Short single-statement acquire/release; no long transactions; WAL already enabled. | | Dry-run accidentally blocked by lease | Medium | Medium | Explicit dry-run exemption tests. | @@ -402,11 +402,11 @@ Expected runtime smoke: status includes `worker.node_id`; live actions remain di ## Residual Risks -- Lease heartbeat refresh is not implemented. Keep live tasks within TTL or prioritize heartbeat refresh before long live workflows. +- Lease heartbeat refresh is implemented for matching account/task/node leases during task processing. The worker clamps the effective heartbeat interval to at most half of TTL and retains conservative TTL values for crash recovery. - `/api/status` exposes operational IDs/session metadata. Keep API local or enable API auth before non-local exposure. - Future hardening: multi-process SQLite contention integration test. ## Unresolved Questions -- What maximum live task duration should trigger heartbeat refresh work? +- What heartbeat interval and TTL values should be used for controlled live pilots? - Should future diagnostics include recently expired leases, or keep `/api/status` active leases only? diff --git a/plans/260507-0000-safety-first-50-account-roadmap/plan.md b/plans/260507-0000-safety-first-50-account-roadmap/plan.md index 45ed51f3..aa6265f0 100644 --- a/plans/260507-0000-safety-first-50-account-roadmap/plan.md +++ b/plans/260507-0000-safety-first-50-account-roadmap/plan.md @@ -115,11 +115,11 @@ Use this plan as the source of truth. Implement one phase at a time with TDD for - Dry-run/read-only tasks remain lease-exempt. - Worker keeps process-local `_active_live_account_ids` as telemetry/defense-in-depth; DB lease is now the cross-worker guard. - `/api/status` worker block exposes read-only `node_id`, `active_live_account_ids`, and `live_account_leases`. -- New config: `FBKIT_NODE_ID` optional, default `hostname:pid`; must be unique per worker process when multiple workers share one DB. `LIVE_ACCOUNT_LEASE_TTL_SECONDS` default `900`, clamped `60`-`3600`. +- New config: `FBKIT_NODE_ID` optional, default `hostname:pid`; must be unique per worker process when multiple workers share one DB. `LIVE_ACCOUNT_LEASE_TTL_SECONDS` default `900`, clamped `60`-`3600`; `LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS` default `60`, clamped `5`-`300`. - No live actions enabled by default. Safety Gate, live arm, API auth, WS auth, exact `fb_uid`, extension guard, and quota checks remain intact. - Verification: `pytest tests\unit\test_account_queue_quota.py -q` -> `22 passed in 4.80s`; `pytest tests\unit\test_safety_gate.py tests\unit\test_live_arming.py tests\unit\test_account_queue_quota.py -q` -> `95 passed in 15.93s`; final `pytest tests\unit -q` -> `260 passed in 21.20s`; `python -m compileall agent` passed; `node --check extension\background.js` passed; dashboard `npm run build` passed. - Final code review approved docs sync, no blockers. -- Residual risks: no lease heartbeat refresh; keep live tasks within TTL or prioritize heartbeat before long live workflows. `/api/status` exposes operational IDs/session metadata; keep API local or enable API auth before non-local exposure. Future hardening: multi-process SQLite contention integration test. +- Live account leases are refreshed during task processing by matching account/task/node before expiry; the worker clamps the effective heartbeat interval to at most half of TTL. Residual risks: `/api/status` exposes operational IDs/session metadata; keep API local or enable API auth before non-local exposure. Future hardening: multi-process SQLite contention integration test. ## Phase 5 Completion Evidence @@ -133,4 +133,4 @@ Use this plan as the source of truth. Implement one phase at a time with TDD for - Is the 50-account target for scheduled/rate-limited operation, or true simultaneous live action? - Should live mode be permanently restricted to dedicated test/business assets? - What is the maximum acceptable local machine resource budget for the multi-profile pilot? -- What maximum live task duration should drive lease heartbeat refresh design? +- What live task duration and heartbeat interval should be used for controlled live pilots? diff --git a/tests/unit/test_account_queue_quota.py b/tests/unit/test_account_queue_quota.py index ea9b5855..ab88e04a 100644 --- a/tests/unit/test_account_queue_quota.py +++ b/tests/unit/test_account_queue_quota.py @@ -1,4 +1,5 @@ import json +import asyncio from datetime import date, timedelta import pytest @@ -152,6 +153,78 @@ async def test_expired_live_account_lease_can_be_reclaimed(account_a): assert lease["node_id"] == "node-b" +@pytest.mark.asyncio +async def test_refresh_live_account_lease_extends_matching_active_lease(account_a): + task = await crud.create_task( + account_id=account_a["id"], + task_type="POST_TEXT", + payload=json.dumps({"content": "heartbeat", "dryRun": False}), + enforce_safety=False, + ) + await crud.acquire_live_account_lease(account_a["id"], task["id"], "node-a", 60) + db = await crud.get_db() + old_heartbeat = (utc_now() - timedelta(seconds=20)).replace(microsecond=0).isoformat() + old_expires = (utc_now() + timedelta(seconds=5)).replace(microsecond=0).isoformat() + await db.execute( + "UPDATE live_account_lease SET heartbeat_at = ?, expires_at = ? WHERE account_id = ?", + (old_heartbeat, old_expires, account_a["id"]), + ) + await db.commit() + + refreshed = await crud.refresh_live_account_lease(account_a["id"], task["id"], "node-a", 60) + + assert refreshed["heartbeat_at"] > old_heartbeat + assert refreshed["expires_at"] > old_expires + + +@pytest.mark.asyncio +async def test_refresh_live_account_lease_requires_matching_task_and_node(account_a): + task = await crud.create_task( + account_id=account_a["id"], + task_type="POST_TEXT", + payload=json.dumps({"content": "heartbeat", "dryRun": False}), + enforce_safety=False, + ) + lease = await crud.acquire_live_account_lease(account_a["id"], task["id"], "node-a", 60) + + wrong_task = await crud.refresh_live_account_lease(account_a["id"], "other-task", "node-a", 60) + wrong_node = await crud.refresh_live_account_lease(account_a["id"], task["id"], "node-b", 60) + + assert wrong_task is None + assert wrong_node is None + assert await crud.list_active_live_account_leases() == [lease] + + +@pytest.mark.asyncio +async def test_refresh_live_account_lease_rejects_expired_matching_lease(account_a): + task = await crud.create_task( + account_id=account_a["id"], + task_type="POST_TEXT", + payload=json.dumps({"content": "expired heartbeat", "dryRun": False}), + enforce_safety=False, + ) + await crud.acquire_live_account_lease(account_a["id"], task["id"], "node-a", 60) + db = await crud.get_db() + expired_at = (utc_now() - timedelta(seconds=1)).replace(microsecond=0).isoformat() + await db.execute( + "UPDATE live_account_lease SET expires_at = ? WHERE account_id = ?", + (expired_at, account_a["id"]), + ) + await db.commit() + + refreshed = await crud.refresh_live_account_lease(account_a["id"], task["id"], "node-a", 60) + + assert refreshed is None + + +def test_worker_clamps_live_lease_heartbeat_interval_below_ttl(monkeypatch): + monkeypatch.setattr("agent.config.LIVE_ACCOUNT_LEASE_TTL_SECONDS", 60, raising=False) + + worker = processor.WorkerController(live_lease_heartbeat_seconds=300) + + assert worker.live_lease_heartbeat_seconds == 30 + + @pytest.mark.asyncio async def test_claim_next_pending_task_skips_db_leased_same_account_live_task(account_a, account_b, monkeypatch): monkeypatch.setattr("agent.config.LIVE_ACTIONS_ENABLED", True, raising=False) @@ -377,6 +450,81 @@ async def fake_dispatch(*args, **kwargs): assert await crud.list_active_live_account_leases() == [] +@pytest.mark.asyncio +async def test_worker_refreshes_live_account_lease_during_processing(account_a, monkeypatch): + monkeypatch.setattr("agent.config.LIVE_ACTIONS_ENABLED", True, raising=False) + monkeypatch.setattr("agent.config.API_AUTH_ENABLED", True, raising=False) + monkeypatch.setattr("agent.config.WS_AUTH_ENABLED", True, raising=False) + monkeypatch.setattr("agent.config.APPROVAL_REQUIRED", True, raising=False) + monkeypatch.setattr("agent.config.DRY_RUN_DEFAULT", False, raising=False) + + class FakeClient: + def session_live_guard_enabled(self, fb_uid=None): + return True + + monkeypatch.setattr(processor, "get_fb_client", lambda: FakeClient()) + monkeypatch.setattr(processor, "action_delay", lambda: None) + arm = await crud.arm_live_actions(account_a["id"], ["POST_TEXT"], 300, created_by="unit-test") + task = await crud.create_task( + account_id=account_a["id"], + task_type="POST_TEXT", + payload=json.dumps({"content": "long", "dryRun": False, "_serverApproved": True, "_liveArmId": arm["id"]}), + enforce_safety=False, + ) + lease = await crud.acquire_live_account_lease(account_a["id"], task["id"], "node-a", 60) + refresh_calls = [] + original_refresh = crud.refresh_live_account_lease + + async def track_refresh(*args, **kwargs): + refresh_calls.append(args) + return await original_refresh(*args, **kwargs) + + monkeypatch.setattr(processor.crud, "refresh_live_account_lease", track_refresh) + worker = processor.WorkerController(node_id="node-a", live_lease_heartbeat_seconds=0.01) + + async def slow_dispatch(*args, **kwargs): + await asyncio.sleep(0.03) + return {"success": True} + + monkeypatch.setattr(worker, "_dispatch", slow_dispatch) + + await worker._process_task(task, live_account_id=account_a["id"], live_lease=lease) + + assert refresh_calls + assert refresh_calls[0][:3] == (account_a["id"], task["id"], "node-a") + + +@pytest.mark.asyncio +async def test_worker_cleanup_survives_live_account_lease_heartbeat_error(account_a, monkeypatch): + task = await crud.create_task( + account_id=account_a["id"], + task_type="POST_TEXT", + payload=json.dumps({"content": "heartbeat error", "dryRun": False}), + enforce_safety=False, + ) + lease = await crud.acquire_live_account_lease(account_a["id"], task["id"], "node-a", 60) + worker = processor.WorkerController(node_id="node-a", live_lease_heartbeat_seconds=0.01) + worker._active_count = 1 + worker._active_live_account_ids.add(account_a["id"]) + + async def raise_refresh(*args, **kwargs): + raise RuntimeError("heartbeat db unavailable") + + async def slow_dispatch(*args, **kwargs): + await asyncio.sleep(0.03) + return {"success": True} + + monkeypatch.setattr(processor.crud, "refresh_live_account_lease", raise_refresh) + monkeypatch.setattr(worker, "_dispatch", slow_dispatch) + monkeypatch.setattr(processor, "action_delay", lambda: None) + + await worker._process_task(task, live_account_id=account_a["id"], live_lease=lease) + + assert await crud.list_active_live_account_leases() == [] + assert account_a["id"] not in worker.active_live_account_ids + assert worker.active_count == 0 + + @pytest.mark.asyncio async def test_account_queue_summary_reports_queue_and_quota(account_a, monkeypatch): monkeypatch.setattr("agent.config.RATE_LIMIT_POSTS_PER_DAY", 20, raising=False) diff --git a/tests/unit/test_rollout_gates_docs.py b/tests/unit/test_rollout_gates_docs.py index 0e667c50..5c2ef2ca 100644 --- a/tests/unit/test_rollout_gates_docs.py +++ b/tests/unit/test_rollout_gates_docs.py @@ -45,7 +45,8 @@ def test_rollout_gates_doc_covers_phase4_operational_limits(): for required in [ "FBKIT_NODE_ID", "LIVE_ACCOUNT_LEASE_TTL_SECONDS", - "lease heartbeat refresh is not implemented", + "LIVE_ACCOUNT_LEASE_HEARTBEAT_SECONDS", + "effective heartbeat interval", "`/api/status` exposes operational metadata", ]: assert required in content diff --git a/tests/unit/test_zoopost_adapter.py b/tests/unit/test_zoopost_adapter.py new file mode 100644 index 00000000..3362e2d2 --- /dev/null +++ b/tests/unit/test_zoopost_adapter.py @@ -0,0 +1,535 @@ +import asyncio +import json + +import pytest + + +class FakeCloudSocket: + def __init__(self, incoming): + self.incoming = list(incoming) + self.sent = [] + + async def send(self, message): + self.sent.append(json.loads(message)) + + async def recv(self): + return json.dumps(self.incoming.pop(0)) + + +@pytest.fixture +async def db(tmp_path, monkeypatch): + db_path = str(tmp_path / "zoopost_adapter_test.db") + monkeypatch.setenv("DB_PATH", db_path) + monkeypatch.setenv("LIVE_ACTIONS_ENABLED", "false") + monkeypatch.setenv("DRY_RUN_DEFAULT", "true") + monkeypatch.setenv("APPROVAL_REQUIRED", "true") + + import agent.config as config + import agent.db.schema as schema_mod + + config.LIVE_ACTIONS_ENABLED = False + config.DRY_RUN_DEFAULT = True + config.APPROVAL_REQUIRED = True + schema_mod._db = None + schema_mod.DB_PATH = db_path + await schema_mod.init_db() + yield + await schema_mod.close_db() + + +@pytest.mark.asyncio +async def test_dry_run_dispatch_creates_fbkit_task(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + account = await crud.create_account("Page A", fb_uid="page-1") + result = await handle_dispatch( + { + "dispatchId": "dispatch-1", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Xin chao ZooPost"}, + } + ) + + assert result["dispatchId"] == "dispatch-1" + assert result["localTaskId"] + task = await crud.get_task(result["localTaskId"]) + payload = json.loads(task["payload"]) + assert task["account_id"] == account["id"] + assert task["task_type"] == "POST_TEXT" + assert task["ref_id"] == "zoopost:dispatch-1" + assert payload["dryRun"] is True + assert payload["content"] == "Xin chao ZooPost" + assert payload["expectedFbUid"] == "page-1" + + +@pytest.mark.asyncio +async def test_duplicate_dispatch_reuses_existing_local_task(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + dispatch = { + "dispatchId": "dispatch-dup", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "One task"}, + } + + first = await handle_dispatch(dispatch) + second = await handle_dispatch(dispatch) + tasks = await crud.list_tasks() + + assert first["localTaskId"] == second["localTaskId"] + assert second["duplicate"] is True + assert len(tasks) == 1 + + +@pytest.mark.asyncio +async def test_cloud_live_markers_are_stripped_and_forced_dry_run(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + result = await handle_dispatch( + { + "dispatchId": "dispatch-live-marker", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "dryRun": False, + "content": {"body": "Must stay safe"}, + "payload": { + "_serverApproved": True, + "_liveArmId": "cloud-arm", + "_quotaReserved": {"counter": "daily_posts"}, + "approved": True, + "liveArmId": "cloud-arm", + }, + } + ) + + task = await crud.get_task(result["localTaskId"]) + payload = json.loads(task["payload"]) + assert payload["dryRun"] is True + assert "_serverApproved" not in payload + assert "_liveArmId" not in payload + assert "_quotaReserved" not in payload + assert "approved" not in payload + assert "liveArmId" not in payload + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_dispatch_creates_one_local_task(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + dispatch = { + "dispatchId": "dispatch-race", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "One race task"}, + } + + first, second = await asyncio.gather(handle_dispatch(dispatch), handle_dispatch(dispatch)) + tasks = await crud.list_tasks() + + assert first["localTaskId"] == second["localTaskId"] + assert len(tasks) == 1 + + +@pytest.mark.parametrize( + "media", + [ + {"path": "C:/Users/me/Pictures/a.png"}, + ["C:/Users/me/Pictures/a.png"], + [{"path": "C:/Users/me/Pictures/a.png"}], + [{"localPath": "C:/Users/me/Pictures/a.png"}], + [{"file_path": "C:/Users/me/Pictures/a.png"}], + ], +) +@pytest.mark.asyncio +async def test_cloud_filesystem_paths_are_rejected(db, media): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + + with pytest.raises(ValueError, match="opaque local media refs"): + await handle_dispatch( + { + "dispatchId": "dispatch-path", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_image", + "expectedFbUid": "page-1", + "content": {"body": "Bad media"}, + "media": media, + } + ) + + +@pytest.mark.parametrize( + "payload", + [ + {"mediaPaths": ["C:/Users/me/Pictures/a.png"]}, + {"mediaPath": "C:/Users/me/Pictures/a.png"}, + {"nested": {"filePath": "C:/Users/me/Pictures/a.png"}}, + {"items": [{"local_path": "C:/Users/me/Pictures/a.png"}]}, + ], +) +@pytest.mark.asyncio +async def test_payload_media_paths_are_rejected(db, payload): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + + with pytest.raises(ValueError, match="opaque local media refs"): + await handle_dispatch( + { + "dispatchId": "dispatch-payload-path", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_image", + "expectedFbUid": "page-1", + "content": {"body": "Bad payload media"}, + "payload": payload, + } + ) + + +@pytest.mark.asyncio +async def test_opaque_media_refs_are_kept_as_non_executable_metadata(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + result = await handle_dispatch( + { + "dispatchId": "dispatch-media-ref", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_image", + "expectedFbUid": "page-1", + "content": {"body": "Media ref"}, + "media": [{"ref": "local-media-token"}], + } + ) + + task = await crud.get_task(result["localTaskId"]) + payload = json.loads(task["payload"]) + assert payload["zoopostMediaRefs"] == [{"ref": "local-media-token"}] + assert "mediaPaths" not in payload + + +@pytest.mark.asyncio +async def test_result_message_maps_fbkit_task_status(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import build_dispatch_result, handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + result = await handle_dispatch( + { + "dispatchId": "dispatch-result", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Result"}, + } + ) + task = await crud.update_task(result["localTaskId"], status="COMPLETED", result=json.dumps({"externalPostId": "dry-run-post"})) + + message = build_dispatch_result("dispatch-result", task) + + assert message["type"] == "agent_dispatch_result" + assert message["dispatchId"] == "dispatch-result" + assert message["resultStatus"] == "posted" + assert message["externalPostId"] == "dry-run-post" + + +@pytest.mark.asyncio +async def test_result_message_requires_terminal_task_status(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import build_dispatch_result, handle_dispatch + + await crud.create_account("Page A", fb_uid="page-1") + result = await handle_dispatch( + { + "dispatchId": "dispatch-pending-result", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Pending"}, + } + ) + task = await crud.get_task(result["localTaskId"]) + + with pytest.raises(ValueError, match="terminal dispatch result"): + build_dispatch_result("dispatch-pending-result", task) + + +@pytest.mark.asyncio +async def test_gateway_poll_processes_dispatch_batch(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import open_gateway_session, poll_gateway_dispatches + + await crud.create_account("Page A", fb_uid="page-1") + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + { + "type": "agent_dispatch_batch", + "messageId": "poll-2", + "dispatches": [ + { + "type": "dispatch_publish_target", + "dispatchId": "dispatch-gateway", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Gateway post"}, + "dryRun": True, + } + ], + }, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", [{"platform": "facebook", "channel_type": "fanpage", "external_id": "page-1"}]) + results = await poll_gateway_dispatches(socket, session, limit=5) + task = await crud.get_task(results[0]["localTaskId"]) + + assert socket.sent[0]["type"] == "agent_hello" + assert socket.sent[1]["type"] == "agent_dispatch_poll" + assert socket.sent[1]["sessionId"] == "session-1" + assert socket.sent[1]["limit"] == 5 + assert task["ref_id"] == "zoopost:dispatch-gateway" + + +@pytest.mark.asyncio +async def test_gateway_poll_caps_cloud_dispatch_batch_to_requested_limit(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import open_gateway_session, poll_gateway_dispatches + + await crud.create_account("Page A", fb_uid="page-1") + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + { + "type": "agent_dispatch_batch", + "messageId": "poll-2", + "dispatches": [ + { + "type": "dispatch_publish_target", + "dispatchId": "dispatch-limit-a", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "First"}, + "dryRun": True, + }, + { + "type": "dispatch_publish_target", + "dispatchId": "dispatch-limit-b", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Second"}, + "dryRun": True, + }, + ], + }, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", []) + results = await poll_gateway_dispatches(socket, session, limit=1) + tasks = await crud.list_tasks() + + assert [result["dispatchId"] for result in results] == ["dispatch-limit-a"] + assert len(tasks) == 1 + assert tasks[0]["ref_id"] == "zoopost:dispatch-limit-a" + +@pytest.mark.asyncio +async def test_gateway_poll_reports_local_dispatch_failure(db): + from agent.services.zoopost_cloud_agent import open_gateway_session, poll_gateway_dispatches + + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + { + "type": "agent_dispatch_batch", + "messageId": "poll-2", + "dispatches": [ + { + "type": "dispatch_publish_target", + "dispatchId": "dispatch-missing-account", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "missing-page", + "content": {"body": "No local account"}, + "dryRun": True, + } + ], + }, + {"type": "agent_dispatch_result_ack", "messageId": "result-3", "targetId": "target-1"}, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", []) + results = await poll_gateway_dispatches(socket, session) + failure_message = socket.sent[2] + + assert results[0]["dispatchId"] == "dispatch-missing-account" + assert results[0]["failed"] is True + assert failure_message["type"] == "agent_dispatch_result" + assert failure_message["dispatchId"] == "dispatch-missing-account" + assert failure_message["resultStatus"] == "failed" + assert failure_message["errorCode"] == "local_dispatch_validation_failed" + assert "expected facebook identity" in failure_message["errorMessage"] + + +@pytest.mark.asyncio +async def test_gateway_hello_preserves_explicit_empty_capabilities(db): + from agent.services.zoopost_cloud_agent import open_gateway_session + + socket = FakeCloudSocket( + [{"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}] + ) + + await open_gateway_session(socket, "credential", "conn-1", [], capabilities=[]) + + assert socket.sent[0]["capabilities"] == [] + + +@pytest.mark.asyncio +async def test_gateway_poll_reports_malformed_dispatch_content(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import open_gateway_session, poll_gateway_dispatches + + await crud.create_account("Page A", fb_uid="page-1") + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + { + "type": "agent_dispatch_batch", + "messageId": "poll-2", + "dispatches": [ + { + "type": "dispatch_publish_target", + "dispatchId": "dispatch-bad-content", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": ["not", "an", "object"], + "dryRun": True, + } + ], + }, + {"type": "agent_dispatch_result_ack", "messageId": "result-3", "targetId": "target-1"}, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", []) + results = await poll_gateway_dispatches(socket, session) + failure_message = socket.sent[2] + + assert results[0]["dispatchId"] == "dispatch-bad-content" + assert results[0]["failed"] is True + assert failure_message["resultStatus"] == "failed" + assert failure_message["errorCode"] == "local_dispatch_validation_failed" + assert "content" in failure_message["errorMessage"] + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("payload", [], "payload"), + ("content", "", "content"), + ("media", "", "media"), + ("content", {"body": ["not", "text"]}, "body"), + ], +) +@pytest.mark.asyncio +async def test_gateway_poll_reports_malformed_falsy_dispatch_fields(db, field, value, message): + from agent.db import crud + from agent.services.zoopost_cloud_agent import open_gateway_session, poll_gateway_dispatches + + await crud.create_account("Page A", fb_uid="page-1") + dispatch = { + "type": "dispatch_publish_target", + "dispatchId": f"dispatch-bad-{field}", + "platform": "facebook", + "channelType": "fanpage", + "platformTaskType": "facebook.post_text", + "expectedFbUid": "page-1", + "content": {"body": "Valid body"}, + "dryRun": True, + } + dispatch[field] = value + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + {"type": "agent_dispatch_batch", "messageId": "poll-2", "dispatches": [dispatch]}, + {"type": "agent_dispatch_result_ack", "messageId": "result-3", "targetId": "target-1"}, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", []) + results = await poll_gateway_dispatches(socket, session) + failure_message = socket.sent[2] + + assert results[0]["failed"] is True + assert failure_message["resultStatus"] == "failed" + assert failure_message["errorCode"] == "local_dispatch_validation_failed" + assert message in failure_message["errorMessage"] + + +@pytest.mark.asyncio +async def test_gateway_result_message_matches_cloud_contract(db): + from agent.db import crud + from agent.services.zoopost_cloud_agent import open_gateway_session, send_gateway_task_result + + account = await crud.create_account("Page A", fb_uid="page-1") + task = await crud.create_task( + account["id"], + "POST_TEXT", + payload={"dryRun": True, "content": "Done"}, + ref_id="zoopost:dispatch-result-send", + ) + task = await crud.update_task(task["id"], status="COMPLETED", result=json.dumps({"externalPostId": "post-1"})) + socket = FakeCloudSocket( + [ + {"type": "agent_hello_ack", "sessionId": "session-1", "sessionGeneration": 1, "connectionId": "conn-1"}, + {"type": "agent_dispatch_result_ack", "messageId": "result-2", "targetId": "target-1"}, + ] + ) + + session = await open_gateway_session(socket, "credential", "conn-1", []) + ack = await send_gateway_task_result(socket, session, "dispatch-result-send", task) + result_message = socket.sent[1] + + assert ack["type"] == "agent_dispatch_result_ack" + assert result_message["type"] == "agent_dispatch_result" + assert result_message["sessionId"] == "session-1" + assert result_message["dispatchId"] == "dispatch-result-send" + assert result_message["resultStatus"] == "posted" + assert result_message["externalPostId"] == "post-1" + assert "localTaskId" not in result_message From 4b6b619aa61d1374ef779f2bfc1df7bb51d2b1ae Mon Sep 17 00:00:00 2001 From: hth Date: Fri, 15 May 2026 13:06:54 +0700 Subject: [PATCH 4/4] fix(dashboard): prevent WebSocket reconnect leaks --- dashboard/package-lock.json | 1042 +++++++++++++++++++++++- dashboard/package.json | 6 +- dashboard/src/api/useWebSocket.test.ts | 210 +++++ dashboard/src/api/useWebSocket.ts | 24 +- dashboard/src/pages/LogsPage.tsx | 4 +- dashboard/src/pages/SeedingPage.tsx | 4 +- dashboard/src/pages/SpyPage.tsx | 8 +- dashboard/src/pages/TasksPage.tsx | 4 +- 8 files changed, 1292 insertions(+), 10 deletions(-) create mode 100644 dashboard/src/api/useWebSocket.test.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ced7d765..9b02b204 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/vite": "^4.2.2", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -24,12 +25,65 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.5" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -222,6 +276,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -270,6 +334,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -461,6 +678,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -854,6 +1089,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1126,6 +1368,55 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1137,6 +1428,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1502,6 +1819,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1542,6 +1972,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1565,6 +2006,27 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1585,6 +2047,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1661,6 +2133,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1740,6 +2222,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1747,6 +2243,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1765,6 +2275,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1772,6 +2289,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1782,6 +2310,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -1803,6 +2339,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2000,6 +2556,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2010,6 +2576,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2185,6 +2761,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2245,6 +2834,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2282,6 +2878,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2656,6 +3303,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2666,6 +3324,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2719,6 +3384,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2782,6 +3458,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2802,6 +3491,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2861,6 +3557,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2892,6 +3618,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", @@ -2930,6 +3664,16 @@ "react-dom": ">=18" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2981,6 +3725,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3026,6 +3783,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3036,6 +3800,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3062,6 +3840,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -3083,6 +3868,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3100,6 +3902,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3172,6 +4030,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3298,6 +4166,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3314,6 +4320,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3324,6 +4347,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index bf37393f..f22c3617 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -18,6 +19,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/vite": "^4.2.2", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -26,9 +28,11 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.5" } } diff --git a/dashboard/src/api/useWebSocket.test.ts b/dashboard/src/api/useWebSocket.test.ts new file mode 100644 index 00000000..3e10406a --- /dev/null +++ b/dashboard/src/api/useWebSocket.test.ts @@ -0,0 +1,210 @@ +import { act, cleanup, render, renderHook, screen } from '@testing-library/react' +import { createElement, Fragment } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useWebSocket } from './useWebSocket' + +type ActEnvironmentGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + +class MockWebSocket { + static instances: MockWebSocket[] = [] + + url: string + onopen: ((event: Event) => void) | null = null + onclose: ((event: CloseEvent) => void) | null = null + onerror: ((event: Event) => void) | null = null + onmessage: ((event: MessageEvent) => void) | null = null + + constructor(url: string) { + this.url = url + MockWebSocket.instances.push(this) + } + + emitOpen() { + this.onopen?.(new Event('open')) + } + + emitClose() { + this.onclose?.(new CloseEvent('close')) + } + + close() { + this.emitClose() + } +} + +function PrimarySocketProbe() { + const { isConnected } = useWebSocket() + + return createElement('span', { 'data-testid': 'primary-status' }, isConnected ? 'connected' : 'offline') +} + +function SecondarySocketProbe() { + const { isConnected } = useWebSocket() + + return createElement('span', { 'data-testid': 'secondary-status' }, isConnected ? 'connected' : 'offline') +} + +function DualSocketProbe({ showSecondary }: { showSecondary: boolean }) { + return createElement( + Fragment, + null, + createElement(PrimarySocketProbe), + showSecondary ? createElement(SecondarySocketProbe) : null, + ) +} + +describe('useWebSocket', () => { + beforeEach(() => { + ;(globalThis as ActEnvironmentGlobal).IS_REACT_ACT_ENVIRONMENT = true + MockWebSocket.instances = [] + vi.useFakeTimers() + vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket) + }) + + afterEach(() => { + cleanup() + expect(vi.getTimerCount()).toBe(0) + ;(globalThis as ActEnvironmentGlobal).IS_REACT_ACT_ENVIRONMENT = false + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('reconnects after the socket closes', async () => { + const { result } = renderHook(() => useWebSocket()) + + expect(MockWebSocket.instances).toHaveLength(1) + expect(MockWebSocket.instances[0].url).toMatch(/\/ws\/dashboard$/) + + act(() => { + MockWebSocket.instances[0].emitOpen() + }) + expect(result.current.isConnected).toBe(true) + + act(() => { + MockWebSocket.instances[0].emitClose() + }) + expect(result.current.isConnected).toBe(false) + + act(() => { + vi.advanceTimersByTime(999) + }) + expect(MockWebSocket.instances).toHaveLength(1) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(MockWebSocket.instances).toHaveLength(2) + + act(() => { + MockWebSocket.instances[1].emitOpen() + }) + expect(result.current.isConnected).toBe(true) + + act(() => { + MockWebSocket.instances[1].emitClose() + }) + expect(result.current.isConnected).toBe(false) + + act(() => { + vi.advanceTimersByTime(999) + }) + expect(MockWebSocket.instances).toHaveLength(2) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(MockWebSocket.instances).toHaveLength(3) + }) + + it('cancels a pending reconnect when the hook unmounts', () => { + const { unmount } = renderHook(() => useWebSocket()) + + act(() => { + MockWebSocket.instances[0].emitClose() + }) + + unmount() + + act(() => { + vi.runAllTimers() + }) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('ignores a queued reconnect callback after unmount', () => { + let reconnectCallback: (() => void) | null = null + + vi.spyOn(window, 'setTimeout').mockImplementation(((callback: TimerHandler) => { + reconnectCallback = callback as () => void + return 1 + }) as typeof window.setTimeout) + + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') + const { unmount } = renderHook(() => useWebSocket()) + + act(() => { + MockWebSocket.instances[0].emitClose() + }) + + expect(reconnectCallback).not.toBeNull() + + unmount() + + expect(clearTimeoutSpy).toHaveBeenCalledWith(1) + + act(() => { + reconnectCallback?.() + }) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('does not let an unmounted secondary consumer leak reconnects', () => { + const { rerender } = render(createElement(DualSocketProbe, { showSecondary: true })) + + expect(MockWebSocket.instances).toHaveLength(2) + + act(() => { + MockWebSocket.instances[0].emitOpen() + MockWebSocket.instances[1].emitOpen() + }) + + expect(screen.getByTestId('primary-status').textContent).toBe('connected') + expect(screen.getByTestId('secondary-status').textContent).toBe('connected') + + act(() => { + MockWebSocket.instances[1].emitClose() + }) + + expect(screen.getByTestId('primary-status').textContent).toBe('connected') + expect(screen.getByTestId('secondary-status').textContent).toBe('offline') + + rerender(createElement(DualSocketProbe, { showSecondary: false })) + + act(() => { + vi.runAllTimers() + }) + + expect(MockWebSocket.instances).toHaveLength(2) + expect(screen.getByTestId('primary-status').textContent).toBe('connected') + + act(() => { + MockWebSocket.instances[0].emitClose() + }) + + expect(screen.getByTestId('primary-status').textContent).toBe('offline') + + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(MockWebSocket.instances).toHaveLength(3) + + act(() => { + MockWebSocket.instances[2].emitOpen() + }) + + expect(screen.getByTestId('primary-status').textContent).toBe('connected') + }) +}) diff --git a/dashboard/src/api/useWebSocket.ts b/dashboard/src/api/useWebSocket.ts index 9e859652..d65bc1db 100644 --- a/dashboard/src/api/useWebSocket.ts +++ b/dashboard/src/api/useWebSocket.ts @@ -6,6 +6,9 @@ export function useWebSocket() { const [lastEvent, setLastEvent] = useState(null) const wsRef = useRef(null) const retriesRef = useRef(0) + const reconnectTimeoutRef = useRef(null) + const shouldReconnectRef = useRef(true) + const connectRef = useRef<() => void>(() => {}) const connect = useCallback(() => { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' @@ -21,23 +24,38 @@ export function useWebSocket() { try { const event: WSEvent = JSON.parse(e.data) setLastEvent(event) - } catch {} + } catch (error) { + void error + } } ws.onclose = () => { setIsConnected(false) wsRef.current = null + if (!shouldReconnectRef.current) return const delay = Math.min(1000 * 2 ** retriesRef.current, 30000) retriesRef.current++ - setTimeout(connect, delay) + reconnectTimeoutRef.current = window.setTimeout(() => { + if (!shouldReconnectRef.current) return + connectRef.current() + }, delay) } ws.onerror = () => ws.close() }, []) useEffect(() => { + shouldReconnectRef.current = true + connectRef.current = connect connect() - return () => { wsRef.current?.close() } + return () => { + shouldReconnectRef.current = false + if (reconnectTimeoutRef.current !== null) { + window.clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + wsRef.current?.close() + } }, [connect]) return { isConnected, lastEvent } diff --git a/dashboard/src/pages/LogsPage.tsx b/dashboard/src/pages/LogsPage.tsx index 9708027f..327e4d09 100644 --- a/dashboard/src/pages/LogsPage.tsx +++ b/dashboard/src/pages/LogsPage.tsx @@ -19,7 +19,9 @@ export default function LogsPage() { try { const data = await fetchAPI('/api/accounts/activity') setLogs(data) - } catch {} + } catch (error) { + void error + } finally { setLoading(false) } }, []) diff --git a/dashboard/src/pages/SeedingPage.tsx b/dashboard/src/pages/SeedingPage.tsx index b8004a0e..15793fe0 100644 --- a/dashboard/src/pages/SeedingPage.tsx +++ b/dashboard/src/pages/SeedingPage.tsx @@ -29,7 +29,9 @@ export default function SeedingPage() { try { const data = await fetchAPI('/api/seeding/campaigns') setCampaigns(data) - } catch {} + } catch (error) { + void error + } finally { setLoading(false) } }, []) diff --git a/dashboard/src/pages/SpyPage.tsx b/dashboard/src/pages/SpyPage.tsx index 08816b2e..203f6763 100644 --- a/dashboard/src/pages/SpyPage.tsx +++ b/dashboard/src/pages/SpyPage.tsx @@ -16,7 +16,9 @@ export default function SpyPage() { try { const data = await fetchAPI('/api/spy/targets') setTargets(data) - } catch {} + } catch (error) { + void error + } finally { setLoading(false) } }, []) @@ -25,7 +27,9 @@ export default function SpyPage() { try { const data = await fetchAPI(url) setAds(data) - } catch {} + } catch (error) { + void error + } }, []) useEffect(() => { loadTargets() }, [loadTargets]) diff --git a/dashboard/src/pages/TasksPage.tsx b/dashboard/src/pages/TasksPage.tsx index bb02be46..b3df3f61 100644 --- a/dashboard/src/pages/TasksPage.tsx +++ b/dashboard/src/pages/TasksPage.tsx @@ -24,7 +24,9 @@ export default function TasksPage() { const url = filter ? `/api/tasks?status=${filter}` : '/api/tasks' const data = await fetchAPI(url) setTasks(data) - } catch {} + } catch (error) { + void error + } finally { setLoading(false) } }, [filter])