From e4c945e536ed69b78d13643170fb391fa000c899 Mon Sep 17 00:00:00 2001 From: Robert Wojciechowski Date: Wed, 25 Feb 2026 00:31:13 +0000 Subject: [PATCH] chore: commit all pending distributed changes --- .nvmrc | 1 + api/orchestration/media_sync.py | 4 + api/orchestration/prompt_transform.py | 6 +- conftest.py | 31 + docs/REFACTOR_PLAN_2.md | 1021 ------------------ package-lock.json | 1419 +++++++++++++++++++++++++ package.json | 12 + pyproject.toml | 4 + scripts/test-web.sh | 29 + tests/api/__init__.py | 1 - tests/api/test_distributed_queue.py | 9 + tests/api/test_media_sync.py | 229 ++++ tests/api/test_usdu_routes.py | 304 ++++++ tests/api/test_worker_routes.py | 4 + tests/conftest.py | 2 + tests/test_config.py | 293 +++++ tests/test_detection.py | 196 ++++ tests/test_job_timeout.py | 197 ++++ tests/test_payload_parsers.py | 208 ++++ tests/test_prompt_transform.py | 468 ++++++++ tests/test_static_mode.py | 253 +++++ vitest.config.js | 8 + web/main.js | 2 +- web/tests/executionUtils.test.js | 37 + web/tests/urlUtils.test.js | 242 +++++ web/ui.js | 1 - 26 files changed, 3955 insertions(+), 1026 deletions(-) create mode 100644 .nvmrc create mode 100644 conftest.py delete mode 100644 docs/REFACTOR_PLAN_2.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/test-web.sh delete mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_media_sync.py create mode 100644 tests/api/test_usdu_routes.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_detection.py create mode 100644 tests/test_job_timeout.py create mode 100644 tests/test_payload_parsers.py create mode 100644 tests/test_prompt_transform.py create mode 100644 tests/test_static_mode.py create mode 100644 vitest.config.js create mode 100644 web/tests/urlUtils.test.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/api/orchestration/media_sync.py b/api/orchestration/media_sync.py index 936675c..24223e1 100644 --- a/api/orchestration/media_sync.py +++ b/api/orchestration/media_sync.py @@ -34,6 +34,10 @@ def convert_paths_for_platform(obj, target_separator): is_absolute = trimmed.startswith("/") or trimmed.startswith("\\\\") has_protocol = bool(re.match(r"^\w+://", trimmed)) + # URLs are not local paths and should never be separator-normalized. + if has_protocol: + return trimmed + # Keep relative media-style paths in forward-slash form (Comfy-style annotated paths). if not has_drive and not is_absolute and not has_protocol and IMAGE_OR_VIDEO_RE.search(trimmed): return re.sub(r"[\\]+", "/", trimmed) diff --git a/api/orchestration/prompt_transform.py b/api/orchestration/prompt_transform.py index 8e3812a..119f2d1 100644 --- a/api/orchestration/prompt_transform.py +++ b/api/orchestration/prompt_transform.py @@ -140,7 +140,8 @@ def prune_prompt_for_worker(prompt_obj): if node is not None: pruned_prompt[node_id] = json.loads(json.dumps(node)) - next_id = _create_numeric_id_generator(pruned_prompt) + # Generate IDs from the original prompt so we never reuse IDs from pruned downstream nodes. + next_id = _create_numeric_id_generator(prompt_obj) for dist_id in distributed_ids: if dist_id not in pruned_prompt: continue @@ -187,7 +188,8 @@ def prepare_delegate_master_prompt(prompt_obj, collector_ids): f"Removed upstream reference '{input_name}' from node {node_id} for delegate-only master prompt." ) - next_id = _create_numeric_id_generator(pruned_prompt) + # Generate IDs from the original prompt to avoid ID collisions with pruned nodes. + next_id = _create_numeric_id_generator(prompt_obj) for collector_id in collector_ids: collector_entry = pruned_prompt.get(collector_id) if not collector_entry: diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..db98903 --- /dev/null +++ b/conftest.py @@ -0,0 +1,31 @@ +# conftest.py — project-level pytest configuration. +# +# Problem: custom_nodes/ComfyUI-Distributed/__init__.py uses relative imports +# (from .distributed import ...) that fail when pytest tries to import it as a +# standalone module during Package.setup() for the root package node. +# +# Fix: patch Package.setup() to skip the root-package's __init__.py import. +# All actual package context is provided by each test module via +# importlib.util.spec_from_file_location with synthetic stub packages. + +from _pytest.python import Package + +_orig_pkg_setup = Package.setup + + +def _patched_pkg_setup(self) -> None: + # Skip the root package setup — its __init__.py uses relative imports + # that require a parent package (ComfyUI's plugin loader) which is not + # available in the test environment. + if self.path == self.config.rootpath: + return + _orig_pkg_setup(self) + + +Package.setup = _patched_pkg_setup + +collect_ignore = [ + "__init__.py", + "distributed.py", + "distributed_upscale.py", +] diff --git a/docs/REFACTOR_PLAN_2.md b/docs/REFACTOR_PLAN_2.md deleted file mode 100644 index 5f464fe..0000000 --- a/docs/REFACTOR_PLAN_2.md +++ /dev/null @@ -1,1021 +0,0 @@ -# Refactor Plan 2 — ComfyUI-Distributed - -All paths relative to `custom_nodes/ComfyUI-Distributed/`. -Read every file listed under "Read first" before making any edits. -After every phase, run `python -c "import custom_nodes.ComfyUI-Distributed"` (or equivalent import check) to confirm nothing is broken. - ---- - -## Phase 1 — Correctness / Data Integrity (do these first) - -### Task 1.1 — Fix lock contention in USDU requeue path - -**Problem:** `_check_and_requeue_timed_out_workers` in `utils/usdu_managment.py` calls -`await probe_worker(...)` (a network request) while holding `distributed_tile_jobs_lock`. -Any concurrent heartbeat, submit_tiles, or request_image that also needs the lock is blocked -for the full probe timeout. - -**Read first:** `utils/usdu_managment.py` (find `_check_and_requeue_timed_out_workers`), -`utils/network.py` (confirm `probe_worker` signature). - -**Changes:** - -In `_check_and_requeue_timed_out_workers`: - -1. **Snapshot under lock, then release.** Collect the list of workers to probe while holding - the lock, then exit the lock context before any `await probe_worker(...)` call. - - Before (pseudocode): - ```python - async with prompt_server.distributed_tile_jobs_lock: - job_data = ... - for worker_id, last_seen in timed_out: - result = await probe_worker(url, timeout=...) # WRONG: network call under lock - if not result: - requeue(...) - ``` - - After: - ```python - # Step 1: snapshot under lock - async with prompt_server.distributed_tile_jobs_lock: - job_data = prompt_server.distributed_pending_tile_jobs.get(multi_job_id) - if not isinstance(job_data, BaseJobState): - return 0 - timed_out_snapshot = [ - (wid, ts) for wid, ts in job_data.worker_status.items() - if (now - float(ts)) > HEARTBEAT_TIMEOUT - ] - assigned_snapshot = dict(job_data.assigned_to_workers) - - # Step 2: probe outside lock - workers_to_requeue = [] - for worker_id, last_seen in timed_out_snapshot: - task_ids = assigned_snapshot.get(worker_id, []) - wrec = _find_worker_record(worker_id) - if wrec: - result = await probe_worker(build_worker_url(wrec), timeout=2.0) - if result is None: - workers_to_requeue.append((worker_id, task_ids)) - else: - workers_to_requeue.append((worker_id, task_ids)) - - # Step 3: re-acquire lock to apply requeue decisions - if not workers_to_requeue: - return 0 - async with prompt_server.distributed_tile_jobs_lock: - job_data = prompt_server.distributed_pending_tile_jobs.get(multi_job_id) - if not isinstance(job_data, BaseJobState): - return 0 - requeued = 0 - for worker_id, task_ids in workers_to_requeue: - for task_id in task_ids: - await job_data.pending_tasks.put(task_id) - requeued += 1 - job_data.assigned_to_workers.pop(worker_id, None) - job_data.worker_status.pop(worker_id, None) - return requeued - ``` - -2. Add a private helper `_find_worker_record(worker_id)` that loads config and returns the - worker dict (or `None`). This keeps the probe-outside-lock section clean. - -**Verify:** The function should now only hold the lock during pure dict reads/writes. -No `await` expressions should appear inside any `async with distributed_tile_jobs_lock:` block -in this function. - ---- - -### Task 1.2 — Fix non-idempotent client retries - -**Problem:** `web/apiClient.js` retries every request unconditionally. Mutating endpoints -(`/distributed/queue`, launch, stop, update, delete, startTunnel, stopTunnel) can execute -twice on a transient network error, causing duplicate jobs or double-start/stop. - -**Read first:** `web/apiClient.js` (full file). - -**Changes:** - -1. **Add a `retry` option to the internal `request` function.** Default `true` for safe reads, - `false` for all mutating calls. - - ```javascript - const request = async (endpoint, options = {}, { retries = TIMEOUTS.MAX_RETRIES, retry = true } = {}) => { - const maxAttempts = retry ? retries : 1; - let lastError; - let delay = TIMEOUTS.RETRY_DELAY; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const response = await fetch(...); - if (!response.ok) { ... } - return await response.json(); - } catch (error) { - lastError = error; - if (attempt < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, delay)); - delay *= 2; - } - } - } - throw lastError; - }; - ``` - -2. **Set `retry: false` for all mutating endpoints:** - - `launchWorker` — `request('/distributed/launch_worker', { method: 'POST', ... }, { retry: false })` - - `stopWorker` — same - - `queueDistributed` — same - - `updateWorker` — same - - `deleteWorker` — same - - `updateSetting` — same - - `updateMaster` — same - - `startTunnel` — same - - `stopTunnel` — same - - `dispatchToWorker` — same - -3. **Add idempotency key support for `queueDistributed`.** The caller already generates a - `trace_execution_id`; pass it as an `X-Idempotency-Key` header so a future backend can - deduplicate if desired. - - ```javascript - queueDistributed: (payload) => - request('/distributed/queue', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(payload.trace_execution_id - ? { 'X-Idempotency-Key': payload.trace_execution_id } - : {}), - }, - body: JSON.stringify(payload), - }, { retry: false }), - ``` - -4. **Leave `probeWorker`, `checkStatus`, `checkMultipleStatuses`, `getManagedWorkers`, - `getWorkerLog`, `getTunnelStatus`, `getNetworkInfo` as `retry: true`** — these are - read-only and safe to retry. - -**Verify:** `queueDistributed`, `launchWorker`, `stopWorker`, `startTunnel`, `stopTunnel` -all pass `{ retry: false }`. No mutating call uses the default retry loop. - ---- - -### Task 1.3 — Make config writes atomic and race-safe - -**Problem:** `utils/config.py` `save_config()` writes directly to the config file with -`json.dump(..., f)`. A crash mid-write corrupts the file. Concurrent requests from -`api/config_routes.py` do independent load–modify–save cycles with no locking, creating -a race condition. - -**Read first:** `utils/config.py` (full), `api/config_routes.py` (full). - -**Changes in `utils/config.py`:** - -1. Add a module-level `asyncio.Lock`: - ```python - import asyncio - _config_lock = asyncio.Lock() - ``` - -2. Rewrite `save_config` to write atomically using a temp file + `os.replace`: - ```python - def save_config(config): - config_path = _get_config_path() - tmp_path = config_path + '.tmp' - try: - with open(tmp_path, 'w') as f: - json.dump(config, f, indent=2) - os.replace(tmp_path, config_path) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise - ``` - -3. Expose a new async context manager `config_transaction()` for use by routes: - ```python - from contextlib import asynccontextmanager - - @asynccontextmanager - async def config_transaction(): - """Acquire config lock, yield loaded config, save on clean exit.""" - async with _config_lock: - config = load_config() - yield config - save_config(config) - ``` - -**Changes in `api/config_routes.py`:** - -4. Replace every `load_config()` / `save_config(config)` pair in route handlers with - `async with config_transaction() as config:`. Each handler becomes: - ```python - async with config_transaction() as config: - # mutate config in place - # no explicit save needed — context manager handles it - ``` - Apply to: `update_worker_endpoint`, `delete_worker_endpoint`, `update_setting_endpoint`, - `update_master_endpoint`, and the bulk `POST /distributed/config` handler. - -5. The read-only `GET /distributed/config` route does not need the lock — leave it using - `load_config()` directly. - -**Verify:** No `save_config` call outside of the `config_transaction` context manager in -route handlers. Grep for `save_config(` in `api/config_routes.py` and confirm zero results. - ---- - -## Phase 2 — Structural Splits - -### Task 2.1 — Split `utils/usdu_managment.py` god module - -**Problem:** `utils/usdu_managment.py` mixes job-state CRUD, timeout/requeue policy, -multipart payload parsing, conditioning utilities, and HTTP route registrations in one ~630-line file. - -**Read first:** `utils/usdu_managment.py` (full), `upscale/job_state.py`, `upscale/job_models.py`, -`api/worker_routes.py`, `upscale/worker_comms.py` (check `_send_heartbeat_to_master` import). - -**Target structure:** - -``` -upscale/ - job_store.py ← job init + ensure_tile_jobs_initialized + MAX_PAYLOAD_SIZE - job_timeout.py ← heartbeat check, requeue logic (_check_and_requeue_timed_out_workers) - payload_parsers.py ← _parse_tiles_from_form, any other multipart parsers -api/ - usdu_routes.py ← all HTTP route handlers from usdu_managment.py -utils/ - usdu_managment.py ← thin shim: re-exports for backward compat + _send_heartbeat_to_master -``` - -**Step-by-step:** - -1. **Create `upscale/job_store.py`.** - Move from `usdu_managment.py`: - - `ensure_tile_jobs_initialized()` - - `_init_job_queue(multi_job_id, mode, batch_size, num_tiles_per_image, batched_static)` - - `init_dynamic_job(...)` - - `init_static_job_batched(...)` - - `_drain_results_queue(multi_job_id)` - - The `MAX_PAYLOAD_SIZE` constant - Imports needed: `asyncio`, `server`, `upscale.job_models`, `utils.logging`. - -2. **Create `upscale/job_timeout.py`.** - Move from `usdu_managment.py` (after Task 1.1 changes are applied): - - `_check_and_requeue_timed_out_workers(multi_job_id, batch_size)` (already refactored in Task 1.1) - - `_find_worker_record(worker_id)` (new helper from Task 1.1) - Imports needed: `utils.network`, `utils.config`, `utils.constants`, `upscale.job_models`, - `upscale.job_store` (for `ensure_tile_jobs_initialized`). - -3. **Create `upscale/payload_parsers.py`.** - Move from `usdu_managment.py`: - - `_parse_tiles_from_form(request)` and any helper it uses - Imports needed: `aiohttp`, `PIL`, `utils.image`. - -4. **Create `api/usdu_routes.py`.** - Move from `usdu_managment.py` all `@server.PromptServer.instance.routes.*` decorated - functions: - - `heartbeat_endpoint` (`POST /distributed/heartbeat`) - - `submit_tiles_endpoint` (`POST /distributed/submit_tiles`) - - `submit_image_endpoint` (`POST /distributed/submit_image`) - - `request_image_endpoint` (`POST /distributed/request_image`) - - `job_status_endpoint` (`GET /distributed/job_status`) - Import from new modules above instead of `usdu_managment`. - -5. **Rewrite `utils/usdu_managment.py` as a thin shim.** - Keep only: - - `_send_heartbeat_to_master(...)` (used by `upscale/worker_comms.py`) - - `MAX_PAYLOAD_SIZE` re-export from `upscale.job_store` - - Re-exports of `_check_and_requeue_timed_out_workers` and `ensure_tile_jobs_initialized` - for any callers that import from here directly. - - ```python - # utils/usdu_managment.py — backward-compat shim - from ..upscale.job_store import ( - MAX_PAYLOAD_SIZE, - ensure_tile_jobs_initialized, - _init_job_queue, - init_dynamic_job, - init_static_job_batched, - ) - from ..upscale.job_timeout import _check_and_requeue_timed_out_workers - - async def _send_heartbeat_to_master(...): - ... # keep this here — it's worker-side only - ``` - -6. **Register `api/usdu_routes.py` in `__init__.py` or `distributed.py`.** - Find where `usdu_managment` is currently imported to trigger route registration, and - add an import of `api.usdu_routes` in the same place. - -7. **Update all import sites.** Grep for `from ..utils.usdu_managment import` and - `from .utils.usdu_managment import` across all files. Update each to import from the - appropriate new module. - -**Verify:** `python -m py_compile` on all modified files. Grep confirms no direct -`usdu_managment` imports remain except the shim itself and `worker_comms.py` -(which imports `_send_heartbeat_to_master`). - ---- - -### Task 2.2 — Decompose `CloudflareTunnelManager` - -**Problem:** `utils/cloudflare.py` is a 387-line class handling binary management, state -persistence, log streaming, process lifecycle, and status reporting. - -**Read first:** `utils/cloudflare.py` (full), `api/tunnel_routes.py`, `distributed.py` -(check where `CloudflareTunnelManager` is imported/instantiated). - -**Target structure:** - -``` -utils/ - cloudflare/ - __init__.py ← re-exports CloudflareTunnelManager for backward compat - binary.py ← binary discovery and download - state.py ← config persistence, previous-host restoration - process_reader.py ← thread-based output reader, URL capture, log buffer - tunnel.py ← CloudflareTunnelManager: start/stop lifecycle, status -``` - -**Step-by-step:** - -1. **Create `utils/cloudflare/` package.** - -2. **Create `utils/cloudflare/binary.py`.** - Move from `cloudflare.py`: - - `_get_cloudflared_dir()`, `_get_binary_path()` (or equivalent path helpers) - - `_get_platform_binary_name()` - - `_download_cloudflared()` / `ensure_binary()` — everything related to finding or - downloading the `cloudflared` binary. - Export a single `ensure_binary() -> str` function that returns the path to the binary. - -3. **Create `utils/cloudflare/state.py`.** - Move from `cloudflare.py`: - - `_save_tunnel_url_to_config(url)` / `_restore_previous_master_host()` / - `_clear_tunnel_state()` — all code that reads/writes the config file to persist or - restore tunnel URLs and master host settings. - This module imports from `utils.config`. - -4. **Create `utils/cloudflare/process_reader.py`.** - Move from `cloudflare.py`: - - The `_read_output` thread / `_OutputReader` thread class that reads `cloudflared` - stdout line-by-line, extracts the tunnel URL via regex, appends to `recent_logs`, - and sets the URL event. - Export: `ProcessReader` class (or equivalent) with `start(process)` / `stop()` / - `get_url()` / `get_recent_logs()` methods. Keep the `LOG_BUFFER_SIZE` constant here - (or move to `utils/constants.py` per Task 5.2). - -5. **Create `utils/cloudflare/tunnel.py`.** - This keeps the `CloudflareTunnelManager` class but now delegates to the modules above: - ```python - from .binary import ensure_binary - from .state import save_tunnel_url, restore_previous_host, clear_tunnel_state - from .process_reader import ProcessReader - - class CloudflareTunnelManager: - def __init__(self): ... - async def start(self): ... # calls ensure_binary, starts process, starts ProcessReader - async def stop(self): ... # stops ProcessReader, stops process, calls clear/restore - def get_status(self): ... - def get_recent_logs(self): ... - ``` - -6. **Create `utils/cloudflare/__init__.py`:** - ```python - from .tunnel import CloudflareTunnelManager - __all__ = ['CloudflareTunnelManager'] - ``` - -7. **Delete `utils/cloudflare.py`** (the original monolithic file) after confirming all - import sites resolve to `utils.cloudflare` (the package), which works identically due - to `__init__.py`. - -**Verify:** `from utils.cloudflare import CloudflareTunnelManager` resolves. All tunnel -routes still function. `TUNNEL_START_TIMEOUT` constant is accessible (either via -`utils.constants` after Task 5.2, or defined in `tunnel.py`). - ---- - -## Phase 3 — Reliability / Leak Fixes - -### Task 3.1 — Fix UI modal lifecycle leak - -**Problem:** `web/ui.js` adds a `keydown` listener when the log modal opens but never -removes it on close or backdrop-click, leaking one listener per open. - -**Read first:** `web/ui.js` (full — focus on log modal creation, open/close methods, -and the `keydown` listener). - -**Changes:** - -1. **Create `web/ui/logModal.js`** (file already has a `web/ui/` directory). - Extract the log modal HTML construction, open/close logic, and auto-refresh timer into - a module with explicit lifecycle: - - ```javascript - export function createLogModal() { - let _keydownHandler = null; - let _refreshTimer = null; - let _modalEl = null; - - function mount(container, { onClose, fetchLog }) { - // build modal DOM - _modalEl = buildModalDOM(); - container.appendChild(_modalEl); - - // store listener reference so we can remove it - _keydownHandler = (e) => { if (e.key === 'Escape') unmount(); }; - document.addEventListener('keydown', _keydownHandler); - - // backdrop click - _modalEl.querySelector('.modal-backdrop') - .addEventListener('click', unmount); - - // auto-refresh if checkbox checked - _refreshTimer = setInterval(() => { if (isAutoRefresh()) fetchLog(); }, 2000); - } - - function unmount() { - if (_keydownHandler) { - document.removeEventListener('keydown', _keydownHandler); - _keydownHandler = null; - } - if (_refreshTimer) { - clearInterval(_refreshTimer); - _refreshTimer = null; - } - _modalEl?.remove(); - _modalEl = null; - } - - return { mount, unmount }; - } - ``` - -2. **In `web/ui.js`**, replace the inline modal construction with: - ```javascript - import { createLogModal } from './ui/logModal.js'; - // ... - showLogModal(workerId) { - const modal = createLogModal(); - modal.mount(document.body, { - onClose: () => modal.unmount(), - fetchLog: () => this._fetchAndDisplayLog(workerId), - }); - } - ``` - -3. **Move any inline `style="..."` attributes from the modal DOM** into `distributed.css` - class rules (e.g., `.log-modal`, `.log-modal__body`, `.log-modal__backdrop`). - -**Verify:** Open log modal → close with Escape → open again. Confirm only one `keydown` -listener is registered at a time (check with DevTools → Event Listeners on `document`). - ---- - -## Phase 4 — Deduplication - -### Task 4.1 — Extract shared trace logging helpers - -**Problem:** Three copies of identical `_trace_prefix` / `_trace_debug` / `_trace_info` -helpers exist in `api/orchestration/dispatch.py:11-20`, -`api/orchestration/media_sync.py:24-33`, `api/queue_orchestration.py:32-41`. - -**Read first:** All three files (confirm the copies are byte-for-byte identical). - -**Changes:** - -1. **Create `utils/trace_logger.py`:** - ```python - from .logging import debug_log, log - - def trace_prefix(trace_execution_id: str) -> str: - return f"[Distributed][exec:{trace_execution_id}]" - - def trace_debug(trace_execution_id: str, message: str) -> None: - debug_log(f"{trace_prefix(trace_execution_id)} {message}") - - def trace_info(trace_execution_id: str, message: str) -> None: - log(f"{trace_prefix(trace_execution_id)} {message}") - ``` - -2. **In each of the three files**, delete the local definitions and add: - ```python - from ..utils.trace_logger import trace_prefix, trace_debug, trace_info - ``` - Then rename call sites: `_trace_prefix` → `trace_prefix`, etc. - -**Verify:** Grep for `def _trace_prefix` across the repo — should return zero results. - ---- - -### Task 4.2 — Consolidate positive int/float parsing - -**Problem:** Three near-identical implementations: -- `_parse_positive_int(value, default)` in `api/queue_orchestration.py:93-108` -- `_coerce_positive_int(value, default)` in `api/orchestration/dispatch.py:48-53` -- `validate_positive_int(value, field_name)` in `api/schemas.py:28-36` -Plus `_parse_positive_float` in `queue_orchestration.py`. - -**Read first:** `api/schemas.py`, `api/queue_orchestration.py`, `api/orchestration/dispatch.py`. - -**Changes:** - -1. **Add to `api/schemas.py`** (after existing `validate_positive_int`): - ```python - def parse_positive_int(value, default: int) -> int: - """Parse value as positive int, returning default on failure.""" - try: - parsed = int(value) - except (TypeError, ValueError): - return max(1, int(default)) - return max(1, parsed) - - def parse_positive_float(value, default: float) -> float: - """Parse value as positive float, returning default on failure.""" - try: - parsed = float(value) - except (TypeError, ValueError): - return max(0.0, float(default)) - return max(0.0, parsed) - ``` - -2. **In `api/queue_orchestration.py`:** - - Delete `_parse_positive_int` and `_parse_positive_float`. - - Add `from .schemas import parse_positive_int, parse_positive_float`. - - Replace all call sites. - -3. **In `api/orchestration/dispatch.py`:** - - Delete `_coerce_positive_int`. - - Add `from ..api.schemas import parse_positive_int`. - - Replace call sites. - -**Verify:** `grep -r "_parse_positive\|_coerce_positive"` returns zero results outside of -any test files. - ---- - -### Task 4.3 — Deduplicate conditioning slicing - -**Problem:** `upscale/modes/dynamic.py` contains conditioning slicing logic already -present in `upscale/tile_ops.py`. - -**Read first:** `upscale/modes/dynamic.py` (full), `upscale/tile_ops.py` (full). -Identify the exact duplicated slicing pattern in each file (look for code that slices -`positive` / `negative` conditioning tensors by batch index). - -**Changes:** - -1. **Identify** the shared logic. It will look like: - ```python - # Slicing positive/negative conditioning for a specific batch index - pos_sliced = [[t[:, idx:idx+1, ...] for t in cond_pair] for cond_pair in positive] - neg_sliced = [[t[:, idx:idx+1, ...] for t in cond_pair] for cond_pair in negative] - ``` - -2. **If the helper already exists in `tile_ops.py`**, find it and confirm its signature. - If not, extract the logic from `dynamic.py` into a new function in `tile_ops.py`: - ```python - def slice_conditioning(positive, negative, batch_idx: int): - """Return conditioning sliced to a single batch index.""" - ... - ``` - -3. **In `upscale/modes/dynamic.py`**, delete the local implementation and replace with: - ```python - from ..tile_ops import slice_conditioning - ``` - Update all call sites to use `slice_conditioning(positive, negative, idx)`. - -**Verify:** The slicing logic exists in exactly one place. `grep -n "idx:idx+1\|batch_idx:batch_idx"` -in `dynamic.py` returns zero results. - ---- - -### Task 4.4 — Unify worker probe/status logic in JavaScript - -**Problem:** `web/workerLifecycle.js` and `web/executionUtils.js` both implement probe + -parse flows. `executionUtils.js` already uses `extension.api.probeWorker()` but -`workerLifecycle.js` still has raw `fetch` calls for `checkMasterStatus` and -`checkWorkerStatus`. - -**Read first:** `web/workerLifecycle.js` (full), `web/executionUtils.js` (full), -`web/apiClient.js` (confirm `probeWorker` signature). - -**Changes:** - -1. **In `web/workerLifecycle.js`**, find `checkMasterStatus` and `checkWorkerStatus`. - Note: These two functions have legitimate reasons for raw `fetch`: - - `checkMasterStatus` uses a same-origin call (no CORS) to the local server - - `checkWorkerStatus` may use `AbortSignal.any()` to combine timeout + cancellation - - **Assessment before changing:** If `probeWorker` in `apiClient.js` supports an - `AbortSignal` parameter, migrate both to use `extension.api.probeWorker()`. - If it does not, add an optional `signal` parameter to `probeWorker`: - ```javascript - probeWorker: async (workerUrl, timeout, signal = null) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - const effectiveSignal = signal - ? AbortSignal.any([controller.signal, signal]) - : controller.signal; - try { - const response = await fetch(`${workerUrl}/prompt`, { signal: effectiveSignal }); - ... - } finally { - clearTimeout(timeoutId); - } - } - ``` - -2. **Create a shared UI mapper** in `web/workerUtils.js` (already exists): - ```javascript - export function applyProbeResultToWorkerDot(workerId, probeResult) { - const dot = document.getElementById(`status-${workerId}`); - if (!dot) return; - dot.classList.remove('worker-status--online', 'worker-status--offline', - 'worker-status--processing', 'worker-status--unknown', - 'status-pulsing'); - if (!probeResult || !probeResult.ok) { - dot.classList.add('worker-status--offline'); - dot.title = 'Offline'; - } else if (probeResult.queueRemaining > 0) { - dot.classList.add('worker-status--processing'); - dot.title = `Processing (${probeResult.queueRemaining} queued)`; - } else { - dot.classList.add('worker-status--online'); - dot.title = 'Online'; - } - } - ``` - -3. **Use `applyProbeResultToWorkerDot`** in both `workerLifecycle.js` and - `executionUtils.js` (the `performPreflightCheck` inactive-worker update block at - lines 915-922 of `executionUtils.js`). - -**Verify:** `grep -n "style\." web/workerLifecycle.js` returns zero results. DOM status -updates flow exclusively through class manipulation helpers. - ---- - -### Task 4.5 — Extract timeout handler duplication in result_collector - -**Problem:** `upscale/result_collector.py` lines 116-161 contain nearly identical -worker-status logging blocks for static (`TileJobState`) vs dynamic (`ImageJobState`) modes -inside the `asyncio.TimeoutError` handler. - -**Read first:** `upscale/result_collector.py` (full). - -**Changes:** - -1. **Extract a helper method** on `ResultCollectorMixin`: - ```python - def _log_worker_timeout_status(self, job_data, current_time: float, multi_job_id: str) -> None: - """Log which workers have timed out and for how long.""" - if not isinstance(job_data, BaseJobState): - return - worker_status = dict(job_data.worker_status) - for worker_id, last_seen in worker_status.items(): - elapsed = max(0.0, current_time - float(last_seen)) - log( - f"[USDU] Timeout: job={multi_job_id} worker={worker_id[:8]} " - f"elapsed={elapsed:.1f}s" - ) - ``` - -2. **In the `asyncio.TimeoutError` block**, replace both the static-mode and dynamic-mode - worker-status logging sections with a single call: - ```python - except asyncio.TimeoutError: - current_time = time.time() - self._log_worker_timeout_status(job_data_snapshot, current_time, multi_job_id) - if mode == 'dynamic': - if current_time - last_heartbeat_check >= HEARTBEAT_INTERVAL: - requeued = await self._check_and_requeue_timed_out_workers(...) - ... - ``` - The `job_data_snapshot` should be obtained under the lock before the `wait_for` call - (snapshot pattern from Task 1.1). - -**Verify:** The timeout handler block has one code path for status logging, not two. - ---- - -### Task 4.6 — Eliminate config update endpoint boilerplate - -**Problem:** `api/config_routes.py` `update_worker_endpoint`, `update_master_endpoint`, -and `update_setting_endpoint` all repeat the same `if "field" in data: target[field] = ...` -pattern with special handling for `None` (pop vs assign) and `host` normalization. - -**Read first:** `api/config_routes.py` (full, especially lines 107-275). - -**Changes:** - -1. **Define a field rule schema** near the top of `config_routes.py`: - ```python - # Each entry: (key, normalizer_or_None, remove_on_none: bool) - _WORKER_FIELDS = [ - ('enabled', None, False), - ('name', None, False), - ('host', normalize_host, True), # pop key when value is None - ('cuda_device', None, True), - ('extra_args', None, True), - ('type', None, False), - ] - _MASTER_FIELDS = [ - ('name', None, False), - ('host', normalize_host, True), - ] - ``` - -2. **Add a helper function:** - ```python - def _apply_field_patch(target: dict, data: dict, field_rules: list) -> None: - """Apply a partial update to target dict based on field rules.""" - for key, normalizer, remove_on_none in field_rules: - if key not in data: - continue - value = data[key] - if value is None and remove_on_none: - target.pop(key, None) - else: - target[key] = normalizer(value) if (normalizer and value is not None) else value - ``` - -3. **Replace the explicit if-blocks** in `update_worker_endpoint` with: - ```python - _apply_field_patch(worker, data, _WORKER_FIELDS) - ``` - And in `update_master_endpoint`: - ```python - _apply_field_patch(config['master'], data, _MASTER_FIELDS) - ``` - -**Verify:** `update_worker_endpoint` body no longer contains any `if "enabled" in data:` style -blocks. Behavior is identical — test with a PATCH that sets `host` to `None` (should remove -the key) and one that sets it to a URL string (should normalize it). - ---- - -## Phase 5 — Medium-Low - -### Task 5.1 — Centralize distributed state initialization - -**Problem:** The `ensure_distributed_state()` / job queue init pattern is repeated in -`api/queue_orchestration.py`, `distributed.py`, `api/job_routes.py` (prepare_job endpoint), -and `nodes/collector.py`. Each touches `prompt_server.distributed_pending_jobs` and the -associated lock independently. - -**Read first:** `api/queue_orchestration.py` (`ensure_distributed_state` function), -`distributed.py` (find where state attributes are set on `prompt_server`), -`api/job_routes.py` (`prepare_job_endpoint`, lines 131-146), -`nodes/collector.py` (lines 198-204). - -**Changes:** - -1. **Ensure `ensure_distributed_state()` in `api/queue_orchestration.py` is the sole place** - that creates `prompt_server.distributed_pending_jobs`, `distributed_jobs_lock`, and - any other top-level distributed state attributes. It should be idempotent: - ```python - def ensure_distributed_state(server_instance=None): - ps = server_instance or prompt_server - if not hasattr(ps, 'distributed_pending_jobs'): - ps.distributed_pending_jobs = {} - if not hasattr(ps, 'distributed_jobs_lock'): - ps.distributed_jobs_lock = asyncio.Lock() - ``` - -2. **In `api/job_routes.py` `prepare_job_endpoint`**, replace the inline init: - ```python - # Before: - async with prompt_server.distributed_jobs_lock: - if multi_job_id not in prompt_server.distributed_pending_jobs: - prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue() - ``` - with a call to `ensure_distributed_state()` at startup (it's idempotent) and keep the - per-job queue init in place (that's correct per-job logic, not state init). - -3. **In `nodes/collector.py`** (lines 198-204), the early queue creation is correct - behavior (race prevention) — leave it. But ensure `ensure_distributed_state()` has been - called before the node can ever run (it's called at startup in `queue_orchestration.py` - module import time). - -4. **In `distributed.py`**, remove any direct `prompt_server.distributed_* = ...` - assignments and replace with a call to `ensure_distributed_state()` at startup. - -**Verify:** Grep for `distributed_pending_jobs = {}` and `distributed_jobs_lock = ` — both -should appear only in `ensure_distributed_state`. All other sites just call that function. - ---- - -### Task 5.2 — Move all magic numbers to `utils/constants.py` - -**Problem:** Several timeout and size values are hard-coded in files rather than defined in -`utils/constants.py`. - -**Read first:** `utils/constants.py` (full), then check each file listed below. - -**Changes:** - -1. **In `utils/constants.py`**, add: - ```python - # Cloudflare tunnel - TUNNEL_START_TIMEOUT: float = float(os.environ.get("TUNNEL_START_TIMEOUT", "25")) - CLOUDFLARE_LOG_BUFFER_SIZE: int = 200 - - # USDU result collection - DYNAMIC_MODE_MAX_POLL_TIMEOUT: float = 10.0 - - # Static mode job poll loop - JOB_POLL_INTERVAL: float = 1.0 - JOB_POLL_MAX_ATTEMPTS: int = 20 - ``` - -2. **In `utils/cloudflare.py`** (or `utils/cloudflare/tunnel.py` after Task 2.2): - - Replace `TUNNEL_START_TIMEOUT = 25` (line ~30) with import from constants. - - Replace `> 200` log buffer check (line ~178) with `CLOUDFLARE_LOG_BUFFER_SIZE`. - -3. **In `upscale/result_collector.py`** (line ~65): - - Replace `min(10.0, timeout)` with `min(DYNAMIC_MODE_MAX_POLL_TIMEOUT, timeout)`. - - Add import: `from ..utils.constants import DYNAMIC_MODE_MAX_POLL_TIMEOUT`. - -4. **In `upscale/modes/static.py`**: - - Replace `time.sleep(1.0)` with `time.sleep(JOB_POLL_INTERVAL)`. - - Replace `max_attempts=20` with `max_attempts=JOB_POLL_MAX_ATTEMPTS`. - - Add import: `from ...utils.constants import JOB_POLL_INTERVAL, JOB_POLL_MAX_ATTEMPTS`. - -**Verify:** `grep -rn "= 25\b\|> 200\b\|10\.0\b\|max_attempts=20\b" utils/ upscale/modes/` -shows no magic number remnants in these files. - ---- - -## Phase 6 — Low / Cleanup - -### Task 6.1 — Remove dead code and import drift - -**Problem:** `api/worker_routes.py` imports helpers from `workers/detection.py` that -are not used within that file. `workers/detection.py` `get_comms_channel()` may be dormant. - -**Read first:** `api/worker_routes.py` (all imports at top of file), `workers/detection.py` -(full). Grep for every symbol imported in `worker_routes.py` to confirm usage. - -**Changes:** - -1. **In `api/worker_routes.py`**, identify every imported name from `workers.detection` - (and any other module). For each, search the file body for usage. Remove any import - line whose symbols are not referenced. - -2. **For `workers/detection.py` `get_comms_channel()`**: grep the full codebase for - `get_comms_channel`. If found only in `nodes/collector.py` imports but never called - (check `collector.py` body), remove the import in `collector.py` and the function - in `detection.py`. If it is called, leave it. - -3. **Also in `nodes/collector.py`** (line 20): `from ..workers.detection import is_local_worker, get_comms_channel`. - Check whether `is_local_worker` and `get_comms_channel` are referenced in the file body. - Remove unused ones. - -**Verify:** `python -m py_compile` on each modified file. No `ImportError` on module load. - ---- - -### Task 6.2 — Standardize error response format across API routes - -**Problem:** Some endpoints use `await handle_api_error(request, msg, status)`, -others call `web.json_response({"error": ...}, status=400)` directly, creating -inconsistent client-facing error shapes. - -**Read first:** `utils/network.py` (`handle_api_error` implementation), -`api/job_routes.py` (lines 276-288 — list-of-errors response), -`api/config_routes.py` (find all direct `web.json_response` error calls). - -**Changes:** - -1. **Extend `handle_api_error`** (if needed) to accept a list of error strings: - ```python - async def handle_api_error(request, error, status: int = 500): - if isinstance(error, list): - return web.json_response({"errors": error}, status=status) - msg = str(error) - debug_log(f"API error [{status}]: {msg}") - return web.json_response({"error": msg}, status=status) - ``` - -2. **In `api/job_routes.py`** (lines ~287-288): - ```python - # Before: - return web.json_response({"error": errors}, status=400) - # After: - return await handle_api_error(request, errors, 400) - ``` - -3. **In `api/config_routes.py`**, replace all `return web.json_response({"error": ...}, status=...)` - calls with `return await handle_api_error(request, ..., status)`. - -4. **In `api/worker_routes.py`**, check for any direct `web.json_response` error patterns - and standardize them. - -**Verify:** `grep -rn 'json_response.*"error"' api/` returns zero results (all error -responses now flow through `handle_api_error`). - ---- - -### Task 6.3 — Fix redundant server import alias - -**Problem:** `api/job_routes.py` lines 10-11 have both: -```python -import server -import server as _server -``` -Only one is needed. - -**Read first:** `api/job_routes.py` (check which alias is used throughout the file). - -**Changes:** - -1. Find all usages of `server` and `_server` in `api/job_routes.py`. -2. Pick one (the dominant one, or `server` for consistency with other files). -3. Remove the unused import and update any references to the removed alias. - ---- - -### Task 6.4 — Document mixin inheritance chain - -**Problem:** `ResultCollectorMixin`, `StaticModeMixin`, `DynamicModeMixin` in -`upscale/result_collector.py` and `upscale/modes/` have no documentation of the required -method interface, making them hard to extend. - -**Read first:** `upscale/result_collector.py`, `upscale/modes/static.py`, -`upscale/modes/dynamic.py`, `upscale/worker_comms.py`. - -**Changes:** - -1. **In `upscale/result_collector.py`**, add a docstring to `ResultCollectorMixin` - listing abstract methods / expected attributes: - ```python - class ResultCollectorMixin: - """ - Mixin for master-side result collection in USDU distributed jobs. - - Requires the consuming class to also mix in: - - JobStateMixin (upscale.job_state) for job state access - - WorkerCommsMixin (upscale.worker_comms) is NOT required here - - Expected to be used via multiple inheritance in DistributedUSDU node class. - """ - ``` - -2. **In `upscale/modes/static.py`** and `upscale/modes/dynamic.py`**, add docstrings - to `StaticModeMixin` and `DynamicModeMixin` listing what they call on `self` and what - they require from co-mixins. - -3. **No behavior changes** — documentation only. - ---- - -## Verification Checklist (run after all phases) - -```bash -# Python syntax check all modified files -find custom_nodes/ComfyUI-Distributed -name "*.py" | xargs python -m py_compile - -# Confirm no bare usdu_managment god-module imports remain -grep -r "from.*usdu_managment import" custom_nodes/ComfyUI-Distributed \ - --include="*.py" | grep -v "usdu_managment.py" - -# Confirm trace helpers are in one place -grep -rn "def _trace_prefix\|def _trace_debug\|def _trace_info" \ - custom_nodes/ComfyUI-Distributed --include="*.py" -# Expected: 0 results (all moved to utils/trace_logger.py as trace_prefix etc.) - -# Confirm no lock-held network calls remain -grep -A5 "distributed_tile_jobs_lock" custom_nodes/ComfyUI-Distributed/utils/usdu_managment.py \ - | grep "await probe_worker" -# Expected: 0 results - -# Confirm mutating JS calls don't retry -grep -A3 "queueDistributed\|launchWorker\|stopWorker\|startTunnel\|stopTunnel" \ - custom_nodes/ComfyUI-Distributed/web/apiClient.js | grep "retry: false" -# Expected: one hit per mutating call - -# Confirm no raw web.json_response error patterns remain -grep -rn 'web\.json_response.*"error"' custom_nodes/ComfyUI-Distributed/api/ --include="*.py" -# Expected: 0 results -``` - ---- - -## Notes for the implementing agent - -- **Do not change any HTTP endpoint paths or request/response JSON schemas.** All changes - are internal to Python modules and JS modules — the public API surface is frozen. -- **Do not change `upscale/job_models.py`** — the typed dataclasses are already correct. -- **Phase 1 tasks are prerequisites for Phase 2.** Specifically, Task 1.1 must be done - before Task 2.1 since the requeued function moves in 2.1. -- **Task 2.1 is the most complex.** Read all import sites before moving any code. Use - re-exports in the shim `usdu_managment.py` generously to avoid breaking callers. -- **Tasks within the same phase are independent** and can be done in any order or in - parallel. -- **After each task**, run `python -m py_compile` on all touched files before moving on. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f3b1ab0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1419 @@ +{ + "name": "comfyui-distributed-web-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "comfyui-distributed-web-tests", + "devDependencies": { + "vitest": "^2.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c83596 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "comfyui-distributed-web-tests", + "private": true, + "type": "module", + "scripts": { + "test:web": "bash ./scripts/test-web.sh", + "test:web:watch": "bash ./scripts/test-web.sh --watch" + }, + "devDependencies": { + "vitest": "^2.1.9" + } +} diff --git a/pyproject.toml b/pyproject.toml index 0448996..94ee24a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,7 @@ Repository = "https://github.com/robertvoy/ComfyUI-Distributed" PublisherId = "robertvoy" DisplayName = "ComfyUI-Distributed" Icon = "https://raw.githubusercontent.com/robertvoy/ComfyUI-Distributed/refs/heads/main/web/distributed-logo-icon.png" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/scripts/test-web.sh b/scripts/test-web.sh new file mode 100755 index 0000000..771201d --- /dev/null +++ b/scripts/test-web.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + . "${NVM_DIR}/nvm.sh" +fi + +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "[test-web] node/npm are not available." >&2 + echo "[test-web] Install nvm and Node, or ensure node/npm are on PATH." >&2 + exit 1 +fi + +if [[ -f "${REPO_ROOT}/.nvmrc" ]] && command -v nvm >/dev/null 2>&1; then + nvm use >/dev/null +fi + +cd "${REPO_ROOT}" + +if [[ "${1:-}" == "--watch" ]]; then + exec npx vitest web/tests +fi + +exec npx vitest run web/tests diff --git a/tests/api/__init__.py b/tests/api/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/tests/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/api/test_distributed_queue.py b/tests/api/test_distributed_queue.py index 42e1597..dc9ddee 100644 --- a/tests/api/test_distributed_queue.py +++ b/tests/api/test_distributed_queue.py @@ -74,7 +74,9 @@ def _load_job_routes_module(): sys.modules["server"] = server_module # torch stub (only needed to satisfy import) + created_torch_stub = False if "torch" not in sys.modules: + created_torch_stub = True torch_module = types.ModuleType("torch") torch_module.cuda = types.SimpleNamespace( is_available=lambda: False, @@ -84,7 +86,9 @@ def _load_job_routes_module(): sys.modules["torch"] = torch_module # PIL stub (only needed to satisfy import) + created_pil_stub = False if "PIL" not in sys.modules: + created_pil_stub = True pil_module = types.ModuleType("PIL") image_module = types.ModuleType("PIL.Image") pil_module.Image = image_module @@ -165,6 +169,11 @@ def _load_job_routes_module(): if created_aiohttp_stub: sys.modules.pop("aiohttp", None) + if created_torch_stub: + sys.modules.pop("torch", None) + if created_pil_stub: + sys.modules.pop("PIL.Image", None) + sys.modules.pop("PIL", None) return module diff --git a/tests/api/test_media_sync.py b/tests/api/test_media_sync.py new file mode 100644 index 0000000..f402f37 --- /dev/null +++ b/tests/api/test_media_sync.py @@ -0,0 +1,229 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +def _load_media_sync_module(): + module_path = Path(__file__).resolve().parents[2] / "api" / "orchestration" / "media_sync.py" + package_name = "dist_ms_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + api_pkg = types.ModuleType(f"{package_name}.api") + api_pkg.__path__ = [] + sys.modules[f"{package_name}.api"] = api_pkg + + orch_pkg = types.ModuleType(f"{package_name}.api.orchestration") + orch_pkg.__path__ = [] + sys.modules[f"{package_name}.api.orchestration"] = orch_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + logging_module.log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + network_module = types.ModuleType(f"{package_name}.utils.network") + network_module.build_worker_url = lambda worker, endpoint="": f"http://localhost{endpoint}" + + async def _fake_session(): + raise RuntimeError("network calls not used in pure-function tests") + + network_module.get_client_session = _fake_session + sys.modules[f"{package_name}.utils.network"] = network_module + + trace_module = types.ModuleType(f"{package_name}.utils.trace_logger") + trace_module.trace_debug = lambda *_args, **_kwargs: None + trace_module.trace_info = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.trace_logger"] = trace_module + + created_aiohttp_stub = False + if "aiohttp" not in sys.modules: + created_aiohttp_stub = True + aiohttp_module = types.ModuleType("aiohttp") + + class _ClientTimeout: + def __init__(self, total=None): + pass + + class _FormData: + def add_field(self, *args, **kwargs): + pass + + aiohttp_module.ClientTimeout = _ClientTimeout + aiohttp_module.FormData = _FormData + sys.modules["aiohttp"] = aiohttp_module + + spec = importlib.util.spec_from_file_location( + f"{package_name}.api.orchestration.media_sync", + module_path, + ) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + + if created_aiohttp_stub: + sys.modules.pop("aiohttp", None) + + return module + + +ms = _load_media_sync_module() + + +# --------------------------------------------------------------------------- +# convert_paths_for_platform +# --------------------------------------------------------------------------- + +class ConvertPathsForPlatformTests(unittest.TestCase): + def test_forward_slash_target_normalises_backslashes(self): + obj = {"ckpt_name": "C:\\Models\\model.safetensors"} + result = ms.convert_paths_for_platform(obj, "/") + self.assertEqual(result["ckpt_name"], "C:/Models/model.safetensors") + + def test_backslash_target_normalises_forward_slashes(self): + obj = {"ckpt_name": "/models/checkpoints/model.safetensors"} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertIn("\\", result["ckpt_name"]) + self.assertNotIn("/", result["ckpt_name"]) + + def test_relative_media_paths_always_stay_forward_slash(self): + """Relative image/video paths (Comfy annotated style) must not be backslash-ified.""" + obj = {"image": "subfolder/my_photo.png"} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertEqual(result["image"], "subfolder/my_photo.png") + + def test_annotated_relative_media_path_stays_forward_slash(self): + obj = {"image": "input/frame.jpg [abc123]"} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertIn("/", result["image"]) + self.assertNotIn("\\", result["image"].split("[")[0]) + + def test_non_filename_strings_are_untouched(self): + obj = {"prompt": "a beautiful cat", "count": 5} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertEqual(result["prompt"], "a beautiful cat") + self.assertEqual(result["count"], 5) + + def test_url_strings_are_untouched(self): + obj = {"url": "https://example.com/model.safetensors"} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertEqual(result["url"], "https://example.com/model.safetensors") + + def test_invalid_separator_returns_obj_unchanged(self): + obj = {"ckpt_name": "/models/model.safetensors"} + result = ms.convert_paths_for_platform(obj, "|") + self.assertEqual(result, obj) + + def test_nested_dict_is_processed_recursively(self): + obj = {"node": {"ckpt_name": "C:\\Models\\model.safetensors"}} + result = ms.convert_paths_for_platform(obj, "/") + self.assertEqual(result["node"]["ckpt_name"], "C:/Models/model.safetensors") + + def test_list_items_are_processed_recursively(self): + obj = [{"ckpt_name": "C:\\Models\\model.safetensors"}, "plain string"] + result = ms.convert_paths_for_platform(obj, "/") + self.assertEqual(result[0]["ckpt_name"], "C:/Models/model.safetensors") + self.assertEqual(result[1], "plain string") + + def test_non_string_scalar_values_are_untouched(self): + obj = {"seed": 42, "enabled": True, "ratio": 1.5} + result = ms.convert_paths_for_platform(obj, "/") + self.assertEqual(result["seed"], 42) + self.assertTrue(result["enabled"]) + + def test_absolute_unix_path_to_windows(self): + obj = {"lora": "/home/user/loras/my_lora.safetensors"} + result = ms.convert_paths_for_platform(obj, "\\") + self.assertNotIn("/", result["lora"]) + + def test_already_normalised_path_is_idempotent(self): + obj = {"ckpt": "C:/Models/model.safetensors"} + result = ms.convert_paths_for_platform(obj, "/") + self.assertEqual(result["ckpt"], "C:/Models/model.safetensors") + + +# --------------------------------------------------------------------------- +# _find_media_references +# --------------------------------------------------------------------------- + +class FindMediaReferencesTests(unittest.TestCase): + def test_finds_image_input(self): + prompt = {"1": {"class_type": "LoadImage", "inputs": {"image": "photo.png"}}} + refs = ms._find_media_references(prompt) + self.assertIn("photo.png", refs) + + def test_finds_video_input(self): + prompt = {"1": {"class_type": "LoadVideo", "inputs": {"video": "clip.mp4"}}} + refs = ms._find_media_references(prompt) + self.assertIn("clip.mp4", refs) + + def test_strips_annotation_suffix(self): + prompt = {"1": {"class_type": "LoadImage", "inputs": {"image": "photo.jpg [abc123]"}}} + refs = ms._find_media_references(prompt) + self.assertIn("photo.jpg", refs) + self.assertFalse(any("[" in r for r in refs)) + + def test_normalises_backslashes_in_path(self): + prompt = {"1": {"class_type": "LoadImage", "inputs": {"image": "sub\\img.png"}}} + refs = ms._find_media_references(prompt) + self.assertIn("sub/img.png", refs) + + def test_ignores_non_media_text_inputs(self): + prompt = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}}} + refs = ms._find_media_references(prompt) + self.assertEqual(refs, []) + + def test_ignores_node_link_values(self): + """Inputs that are [node_id, slot] lists should be ignored.""" + prompt = {"1": {"class_type": "Anything", "inputs": {"image": ["2", 0]}}} + refs = ms._find_media_references(prompt) + self.assertEqual(refs, []) + + def test_deduplicates_same_file_across_nodes(self): + prompt = { + "1": {"class_type": "LoadImage", "inputs": {"image": "cat.png"}}, + "2": {"class_type": "LoadImage", "inputs": {"image": "cat.png"}}, + } + refs = ms._find_media_references(prompt) + self.assertEqual(len(refs), 1) + + def test_returns_sorted_list(self): + prompt = { + "1": {"class_type": "LoadImage", "inputs": {"image": "z_image.png"}}, + "2": {"class_type": "LoadImage", "inputs": {"image": "a_image.jpg"}}, + } + refs = ms._find_media_references(prompt) + self.assertEqual(refs, sorted(refs)) + + def test_ignores_non_dict_nodes(self): + prompt = {"1": "not a node dict", "2": {"class_type": "LoadImage", "inputs": {"image": "img.png"}}} + refs = ms._find_media_references(prompt) + self.assertIn("img.png", refs) + + def test_empty_prompt_returns_empty_list(self): + self.assertEqual(ms._find_media_references({}), []) + + def test_multiple_media_types_all_found(self): + prompt = { + "1": {"class_type": "LoadImage", "inputs": {"image": "frame.png"}}, + "2": {"class_type": "LoadVideo", "inputs": {"video": "clip.mp4"}}, + } + refs = ms._find_media_references(prompt) + self.assertIn("frame.png", refs) + self.assertIn("clip.mp4", refs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/api/test_usdu_routes.py b/tests/api/test_usdu_routes.py new file mode 100644 index 0000000..4932663 --- /dev/null +++ b/tests/api/test_usdu_routes.py @@ -0,0 +1,304 @@ +import asyncio +import importlib.util +import io +import sys +import types +import unittest +from dataclasses import dataclass, field +from pathlib import Path + +from PIL import Image + + +class _FakeResponse: + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + +class _FakeRequest: + def __init__(self, json_payload=None, post_payload=None, headers=None, query=None): + self._json_payload = json_payload + self._post_payload = post_payload or {} + self.headers = headers or {} + self.query = query or {} + + async def json(self): + return self._json_payload + + async def post(self): + return self._post_payload + + +class _Routes: + def post(self, _path): + def _decorator(fn): + return fn + + return _decorator + + def get(self, _path): + def _decorator(fn): + return fn + + return _decorator + + +def _load_usdu_routes_module(): + module_path = Path(__file__).resolve().parents[2] / "api" / "usdu_routes.py" + package_name = "dist_api_usdu_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + api_pkg = types.ModuleType(f"{package_name}.api") + api_pkg.__path__ = [] + sys.modules[f"{package_name}.api"] = api_pkg + + upscale_pkg = types.ModuleType(f"{package_name}.upscale") + upscale_pkg.__path__ = [] + sys.modules[f"{package_name}.upscale"] = upscale_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + prompt_server_holder = { + "value": types.SimpleNamespace( + distributed_tile_jobs_lock=asyncio.Lock(), + distributed_pending_tile_jobs={}, + ) + } + + created_aiohttp_stub = False + if "aiohttp" not in sys.modules: + created_aiohttp_stub = True + aiohttp_module = types.ModuleType("aiohttp") + aiohttp_module.web = types.SimpleNamespace( + json_response=lambda payload, status=200: _FakeResponse(payload, status=status) + ) + sys.modules["aiohttp"] = aiohttp_module + + server_module = types.ModuleType("server") + server_module.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(routes=_Routes())) + sys.modules["server"] = server_module + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + network_module = types.ModuleType(f"{package_name}.utils.network") + + async def _handle_api_error(_request, error, status=500): + return _FakeResponse({"status": "error", "message": str(error)}, status=status) + + network_module.handle_api_error = _handle_api_error + sys.modules[f"{package_name}.utils.network"] = network_module + + job_store_module = types.ModuleType(f"{package_name}.upscale.job_store") + job_store_module.MAX_PAYLOAD_SIZE = 1024 + job_store_module.ensure_tile_jobs_initialized = lambda: prompt_server_holder["value"] + sys.modules[f"{package_name}.upscale.job_store"] = job_store_module + + job_models_module = types.ModuleType(f"{package_name}.upscale.job_models") + + class BaseJobState: + pass + + @dataclass + class TileJobState(BaseJobState): + multi_job_id: str + mode: str = field(default="static", init=False) + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + pending_tasks: asyncio.Queue = field(default_factory=asyncio.Queue) + completed_tasks: dict = field(default_factory=dict) + worker_status: dict = field(default_factory=dict) + assigned_to_workers: dict = field(default_factory=dict) + batch_size: int = 0 + num_tiles_per_image: int = 0 + batched_static: bool = False + + @dataclass + class ImageJobState(BaseJobState): + multi_job_id: str + mode: str = field(default="dynamic", init=False) + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + pending_images: asyncio.Queue = field(default_factory=asyncio.Queue) + completed_images: dict = field(default_factory=dict) + worker_status: dict = field(default_factory=dict) + assigned_to_workers: dict = field(default_factory=dict) + batch_size: int = 0 + num_tiles_per_image: int = 0 + batched_static: bool = False + + @property + def pending_tasks(self): + return self.pending_images + + @property + def completed_tasks(self): + return self.completed_images + + job_models_module.BaseJobState = BaseJobState + job_models_module.TileJobState = TileJobState + job_models_module.ImageJobState = ImageJobState + sys.modules[f"{package_name}.upscale.job_models"] = job_models_module + + parsers_module = types.ModuleType(f"{package_name}.upscale.payload_parsers") + parsers_module._parse_tiles_from_form = lambda _data: [] + sys.modules[f"{package_name}.upscale.payload_parsers"] = parsers_module + + spec = importlib.util.spec_from_file_location(f"{package_name}.api.usdu_routes", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + + if created_aiohttp_stub: + sys.modules.pop("aiohttp", None) + + module.web = types.SimpleNamespace( + json_response=lambda payload, status=200: _FakeResponse(payload, status=status) + ) + + module._prompt_server_holder = prompt_server_holder + module._TileJobState = TileJobState + module._ImageJobState = ImageJobState + return module + + +usdu_routes = _load_usdu_routes_module() + + +class _UploadField: + def __init__(self, data): + self.file = io.BytesIO(data) + + +def _tiny_png_bytes(): + image = Image.new("RGB", (1, 1), (255, 0, 0)) + buf = io.BytesIO() + image.save(buf, format="PNG") + return buf.getvalue() + + +class USDURoutesTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + usdu_routes._prompt_server_holder["value"] = types.SimpleNamespace( + distributed_tile_jobs_lock=asyncio.Lock(), + distributed_pending_tile_jobs={}, + ) + + async def test_heartbeat_updates_worker_status(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + job_data = usdu_routes._TileJobState("job-1") + prompt_server.distributed_pending_tile_jobs["job-1"] = job_data + + request = _FakeRequest(json_payload={"worker_id": "worker-a", "multi_job_id": "job-1"}) + response = await usdu_routes.heartbeat_endpoint(request) + + self.assertEqual(response.status, 200) + self.assertEqual(response.payload.get("status"), "success") + self.assertIn("worker-a", job_data.worker_status) + + async def test_heartbeat_missing_fields_returns_400(self): + request = _FakeRequest(json_payload={"worker_id": "worker-a"}) + response = await usdu_routes.heartbeat_endpoint(request) + + self.assertEqual(response.status, 400) + self.assertIn("missing", response.payload.get("message", "").lower()) + + async def test_request_image_dynamic_assigns_next_image(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + job_data = usdu_routes._ImageJobState("job-2") + await job_data.pending_images.put(7) + prompt_server.distributed_pending_tile_jobs["job-2"] = job_data + + request = _FakeRequest(json_payload={"worker_id": "worker-a", "multi_job_id": "job-2"}) + response = await usdu_routes.request_image_endpoint(request) + + self.assertEqual(response.status, 200) + self.assertEqual(response.payload.get("image_idx"), 7) + self.assertEqual(response.payload.get("estimated_remaining"), 0) + self.assertEqual(job_data.assigned_to_workers["worker-a"], [7]) + self.assertIn("worker-a", job_data.worker_status) + + async def test_request_image_static_assigns_tile_and_batched_flag(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + job_data = usdu_routes._TileJobState("job-3") + job_data.batched_static = True + await job_data.pending_tasks.put(4) + prompt_server.distributed_pending_tile_jobs["job-3"] = job_data + + request = _FakeRequest(json_payload={"worker_id": "worker-a", "multi_job_id": "job-3"}) + response = await usdu_routes.request_image_endpoint(request) + + self.assertEqual(response.status, 200) + self.assertEqual(response.payload.get("tile_idx"), 4) + self.assertTrue(response.payload.get("batched_static")) + self.assertEqual(job_data.assigned_to_workers["worker-a"], [4]) + + async def test_submit_tiles_completion_signal_enqueues_last_marker(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + job_data = usdu_routes._TileJobState("job-4") + prompt_server.distributed_pending_tile_jobs["job-4"] = job_data + + request = _FakeRequest( + post_payload={ + "multi_job_id": "job-4", + "worker_id": "worker-a", + "batch_size": "0", + "is_last": "true", + }, + headers={"content-length": "128"}, + ) + + response = await usdu_routes.submit_tiles_endpoint(request) + + self.assertEqual(response.status, 200) + queued = await job_data.queue.get() + self.assertEqual(queued["worker_id"], "worker-a") + self.assertTrue(queued["is_last"]) + self.assertEqual(queued["tiles"], []) + + async def test_submit_image_enqueues_processed_image_payload(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + job_data = usdu_routes._ImageJobState("job-5") + prompt_server.distributed_pending_tile_jobs["job-5"] = job_data + + request = _FakeRequest( + post_payload={ + "multi_job_id": "job-5", + "worker_id": "worker-a", + "image_idx": "2", + "full_image": _UploadField(_tiny_png_bytes()), + "is_last": "false", + }, + headers={"content-length": "256"}, + ) + + response = await usdu_routes.submit_image_endpoint(request) + + self.assertEqual(response.status, 200) + queued = await job_data.queue.get() + self.assertEqual(queued["worker_id"], "worker-a") + self.assertEqual(queued["image_idx"], 2) + self.assertIn("image", queued) + + async def test_job_status_endpoint_reports_ready(self): + prompt_server = usdu_routes._prompt_server_holder["value"] + prompt_server.distributed_pending_tile_jobs["job-6"] = usdu_routes._TileJobState("job-6") + + request = _FakeRequest(query={"multi_job_id": "job-6"}) + response = await usdu_routes.job_status_endpoint(request) + + self.assertEqual(response.status, 200) + self.assertTrue(response.payload.get("ready")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/api/test_worker_routes.py b/tests/api/test_worker_routes.py index 9a13861..03cdf4b 100644 --- a/tests/api/test_worker_routes.py +++ b/tests/api/test_worker_routes.py @@ -143,7 +143,9 @@ def _load_worker_routes_module(): server_module.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(routes=_Routes())) sys.modules["server"] = server_module + created_torch_stub = False if "torch" not in sys.modules: + created_torch_stub = True torch_module = types.ModuleType("torch") torch_module.cuda = types.SimpleNamespace( is_available=lambda: False, @@ -219,6 +221,8 @@ def _load_worker_routes_module(): if created_aiohttp_stub: sys.modules.pop("aiohttp", None) + if created_torch_stub: + sys.modules.pop("torch", None) return module diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af5d908 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,2 @@ +# This conftest.py marks the tests/ directory as the pytest collection root, +# preventing pytest from traversing into the parent package's __init__.py. diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f5eaa0e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,293 @@ +import importlib.util +import json +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +def _load_config_module(): + module_path = Path(__file__).resolve().parents[1] / "utils" / "config.py" + package_name = "dist_cfg_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + logging_module = types.ModuleType(f"{package_name}.logging") + logging_module.log = lambda *_args, **_kwargs: None + logging_module.debug_log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.logging"] = logging_module + + constants_module = types.ModuleType(f"{package_name}.constants") + constants_module.HEARTBEAT_TIMEOUT = 30 + sys.modules[f"{package_name}.constants"] = constants_module + + spec = importlib.util.spec_from_file_location(f"{package_name}.config", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +config = _load_config_module() + + +# --------------------------------------------------------------------------- +# _merge_with_defaults +# --------------------------------------------------------------------------- + +class MergeWithDefaultsTests(unittest.TestCase): + def test_non_dict_input_returns_defaults(self): + result = config._merge_with_defaults("not a dict", {"key": "default"}) + self.assertEqual(result, {"key": "default"}) + + def test_fills_missing_keys_with_defaults(self): + result = config._merge_with_defaults({}, {"a": 1, "b": 2}) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_loaded_value_overrides_default(self): + result = config._merge_with_defaults({"a": 99}, {"a": 1, "b": 2}) + self.assertEqual(result["a"], 99) + self.assertEqual(result["b"], 2) + + def test_nested_dict_merges_recursively(self): + defaults = {"settings": {"debug": False, "count": 5}} + loaded = {"settings": {"debug": True}} + result = config._merge_with_defaults(loaded, defaults) + self.assertTrue(result["settings"]["debug"]) + self.assertEqual(result["settings"]["count"], 5) + + def test_preserves_unknown_keys_for_forward_compatibility(self): + result = config._merge_with_defaults({"extra_key": "extra"}, {"a": 1}) + self.assertEqual(result["extra_key"], "extra") + + def test_none_loaded_value_overrides_default(self): + """Explicitly set None in config should override non-None default.""" + result = config._merge_with_defaults({"a": None}, {"a": "default"}) + self.assertIsNone(result["a"]) + + def test_non_dict_nested_loaded_value_replaces_dict_default(self): + """If loaded has a scalar where default has a dict, use the scalar.""" + defaults = {"settings": {"debug": False}} + loaded = {"settings": "flat_string"} + result = config._merge_with_defaults(loaded, defaults) + self.assertEqual(result["settings"], "flat_string") + + +# --------------------------------------------------------------------------- +# load_config +# --------------------------------------------------------------------------- + +class LoadConfigTests(unittest.TestCase): + def setUp(self): + config.invalidate_config_cache() + + def tearDown(self): + config.invalidate_config_cache() + + def test_returns_defaults_when_file_missing(self): + with patch.object(config, "CONFIG_FILE", "/nonexistent/path/config.json"): + cfg = config.load_config() + defaults = config.get_default_config() + self.assertEqual(cfg["settings"]["debug"], defaults["settings"]["debug"]) + self.assertIn("workers", cfg) + + def test_loads_valid_json_file(self): + data = { + "workers": [{"id": "w1"}], + "master": {"host": "test.host"}, + "settings": {}, + "tunnel": {}, + } + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(data, f) + tmp_path = f.name + try: + with patch.object(config, "CONFIG_FILE", tmp_path): + cfg = config.load_config() + self.assertEqual(cfg["master"]["host"], "test.host") + self.assertEqual(len(cfg["workers"]), 1) + finally: + os.unlink(tmp_path) + + def test_merges_loaded_file_with_defaults(self): + """Loaded file with partial settings should be filled in from defaults.""" + data = {"master": {"host": "h"}, "workers": [], "settings": {"debug": True}, "tunnel": {}} + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(data, f) + tmp_path = f.name + try: + with patch.object(config, "CONFIG_FILE", tmp_path): + cfg = config.load_config() + # debug was set to True + self.assertTrue(cfg["settings"]["debug"]) + # auto_launch_workers is a default key and should be present + self.assertIn("auto_launch_workers", cfg["settings"]) + finally: + os.unlink(tmp_path) + + def test_falls_back_to_defaults_on_invalid_json(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + f.write("{invalid json{{") + tmp_path = f.name + try: + with patch.object(config, "CONFIG_FILE", tmp_path): + cfg = config.load_config() + self.assertIn("settings", cfg) + self.assertIn("workers", cfg) + finally: + os.unlink(tmp_path) + + def test_second_call_returns_cached_object(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(config.get_default_config(), f) + tmp_path = f.name + try: + with patch.object(config, "CONFIG_FILE", tmp_path): + cfg1 = config.load_config() + cfg2 = config.load_config() + self.assertIs(cfg1, cfg2) + finally: + os.unlink(tmp_path) + + def test_invalidate_cache_forces_reload(self): + data = config.get_default_config() + data["master"]["host"] = "first" + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(data, f) + tmp_path = f.name + try: + with patch.object(config, "CONFIG_FILE", tmp_path): + cfg1 = config.load_config() + config.invalidate_config_cache() + data["master"]["host"] = "second" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(data, fh) + cfg2 = config.load_config() + self.assertEqual(cfg1["master"]["host"], "first") + self.assertEqual(cfg2["master"]["host"], "second") + finally: + os.unlink(tmp_path) + + +# --------------------------------------------------------------------------- +# save_config +# --------------------------------------------------------------------------- + +class SaveConfigTests(unittest.TestCase): + def setUp(self): + config.invalidate_config_cache() + + def tearDown(self): + config.invalidate_config_cache() + + def test_saves_and_reloads_correctly(self): + data = config.get_default_config() + data["master"]["host"] = "saved.host" + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = os.path.join(tmpdir, "config.json") + with patch.object(config, "CONFIG_FILE", tmp_path): + result = config.save_config(data) + self.assertTrue(result) + loaded = config.load_config() + self.assertEqual(loaded["master"]["host"], "saved.host") + + def test_returns_false_when_path_unwritable(self): + with patch.object(config, "CONFIG_FILE", "/nonexistent_dir/config.json"): + result = config.save_config({}) + self.assertFalse(result) + + def test_save_invalidates_cache(self): + """After saving, the cache should be cleared so next load re-reads.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = os.path.join(tmpdir, "config.json") + with patch.object(config, "CONFIG_FILE", tmp_path): + data = config.get_default_config() + config.save_config(data) + # Cache is now None; load_config should re-read + self.assertIsNone(config._config_cache) + + def test_written_file_is_valid_json(self): + data = config.get_default_config() + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = os.path.join(tmpdir, "config.json") + with patch.object(config, "CONFIG_FILE", tmp_path): + config.save_config(data) + with open(tmp_path, encoding="utf-8") as fh: + parsed = json.load(fh) + self.assertEqual(parsed["master"], data["master"]) + + +# --------------------------------------------------------------------------- +# get_worker_timeout_seconds +# --------------------------------------------------------------------------- + +class GetWorkerTimeoutSecondsTests(unittest.TestCase): + def test_returns_configured_value(self): + cfg = config.get_default_config() + cfg["settings"]["worker_timeout_seconds"] = 120 + with patch.object(config, "load_config", return_value=cfg): + self.assertEqual(config.get_worker_timeout_seconds(), 120) + + def test_clamps_zero_to_one(self): + cfg = config.get_default_config() + cfg["settings"]["worker_timeout_seconds"] = 0 + with patch.object(config, "load_config", return_value=cfg): + self.assertEqual(config.get_worker_timeout_seconds(), 1) + + def test_clamps_negative_to_one(self): + cfg = config.get_default_config() + cfg["settings"]["worker_timeout_seconds"] = -10 + with patch.object(config, "load_config", return_value=cfg): + self.assertEqual(config.get_worker_timeout_seconds(), 1) + + def test_falls_back_to_provided_default_when_key_missing(self): + cfg = config.get_default_config() + # worker_timeout_seconds is not present in default config + cfg["settings"].pop("worker_timeout_seconds", None) + with patch.object(config, "load_config", return_value=cfg): + result = config.get_worker_timeout_seconds(default=45) + self.assertEqual(result, 45) + + def test_fallback_also_clamped_to_one(self): + cfg = config.get_default_config() + cfg["settings"].pop("worker_timeout_seconds", None) + with patch.object(config, "load_config", return_value=cfg): + result = config.get_worker_timeout_seconds(default=0) + self.assertEqual(result, 1) + + +# --------------------------------------------------------------------------- +# is_master_delegate_only +# --------------------------------------------------------------------------- + +class IsMasterDelegateOnlyTests(unittest.TestCase): + def test_returns_false_by_default(self): + cfg = config.get_default_config() + with patch.object(config, "load_config", return_value=cfg): + self.assertFalse(config.is_master_delegate_only()) + + def test_returns_true_when_enabled(self): + cfg = config.get_default_config() + cfg["settings"]["master_delegate_only"] = True + with patch.object(config, "load_config", return_value=cfg): + self.assertTrue(config.is_master_delegate_only()) + + def test_returns_false_on_exception(self): + def _raise(): + raise RuntimeError("config exploded") + + with patch.object(config, "load_config", side_effect=RuntimeError("boom")): + self.assertFalse(config.is_master_delegate_only()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..ba6cd0e --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,196 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +def _load_detection_module(): + module_path = Path(__file__).resolve().parents[1] / "workers" / "detection.py" + package_name = "dist_det_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + workers_pkg = types.ModuleType(f"{package_name}.workers") + workers_pkg.__path__ = [] + sys.modules[f"{package_name}.workers"] = workers_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + network_module = types.ModuleType(f"{package_name}.utils.network") + network_module.normalize_host = lambda value: value + + async def _fake_session(): + raise RuntimeError("network calls not used in these tests") + + network_module.get_client_session = _fake_session + sys.modules[f"{package_name}.utils.network"] = network_module + + created_aiohttp_stub = False + if "aiohttp" not in sys.modules: + created_aiohttp_stub = True + aiohttp_module = types.ModuleType("aiohttp") + + class _ClientTimeout: + def __init__(self, total=None): + pass + + aiohttp_module.ClientTimeout = _ClientTimeout + sys.modules["aiohttp"] = aiohttp_module + + spec = importlib.util.spec_from_file_location( + f"{package_name}.workers.detection", + module_path, + ) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + + if created_aiohttp_stub: + sys.modules.pop("aiohttp", None) + + return module + + +detection = _load_detection_module() + + +# --------------------------------------------------------------------------- +# is_docker_environment +# --------------------------------------------------------------------------- + +class IsDockerEnvironmentTests(unittest.TestCase): + def test_true_when_dockerenv_file_exists(self): + with patch.object(detection.os.path, "exists", return_value=True), \ + patch.dict(detection.os.environ, {}, clear=True), \ + patch.object(detection.platform, "node", return_value="my-laptop"): + self.assertTrue(detection.is_docker_environment()) + + def test_true_when_docker_container_env_var_is_set(self): + with patch.object(detection.os.path, "exists", return_value=False), \ + patch.dict(detection.os.environ, {"DOCKER_CONTAINER": "1"}, clear=True), \ + patch.object(detection.platform, "node", return_value="my-laptop"): + self.assertTrue(detection.is_docker_environment()) + + def test_true_when_platform_node_contains_docker(self): + with patch.object(detection.os.path, "exists", return_value=False), \ + patch.dict(detection.os.environ, {}, clear=True), \ + patch.object(detection.platform, "node", return_value="my-docker-host"): + self.assertTrue(detection.is_docker_environment()) + + def test_false_when_none_of_the_signals_are_present(self): + with patch.object(detection.os.path, "exists", return_value=False), \ + patch.dict(detection.os.environ, {}, clear=True), \ + patch.object(detection.platform, "node", return_value="my-laptop"): + self.assertFalse(detection.is_docker_environment()) + + def test_docker_node_name_is_case_insensitive(self): + with patch.object(detection.os.path, "exists", return_value=False), \ + patch.dict(detection.os.environ, {}, clear=True), \ + patch.object(detection.platform, "node", return_value="My-Docker-Box"): + self.assertTrue(detection.is_docker_environment()) + + def test_docker_env_var_empty_string_is_falsy(self): + """An empty DOCKER_CONTAINER env var should NOT trigger docker detection.""" + with patch.object(detection.os.path, "exists", return_value=False), \ + patch.dict(detection.os.environ, {"DOCKER_CONTAINER": ""}, clear=True), \ + patch.object(detection.platform, "node", return_value="my-laptop"): + self.assertFalse(detection.is_docker_environment()) + + +# --------------------------------------------------------------------------- +# is_runpod_environment +# --------------------------------------------------------------------------- + +class IsRunpodEnvironmentTests(unittest.TestCase): + def test_true_when_runpod_pod_id_is_set(self): + with patch.dict(detection.os.environ, {"RUNPOD_POD_ID": "pod-abc"}, clear=True): + self.assertTrue(detection.is_runpod_environment()) + + def test_true_when_runpod_api_key_is_set(self): + with patch.dict(detection.os.environ, {"RUNPOD_API_KEY": "key-xyz"}, clear=True): + self.assertTrue(detection.is_runpod_environment()) + + def test_true_when_both_vars_are_set(self): + with patch.dict( + detection.os.environ, + {"RUNPOD_POD_ID": "pod-abc", "RUNPOD_API_KEY": "key-xyz"}, + clear=True, + ): + self.assertTrue(detection.is_runpod_environment()) + + def test_false_when_neither_var_is_set(self): + with patch.dict(detection.os.environ, {}, clear=True): + self.assertFalse(detection.is_runpod_environment()) + + def test_true_when_pod_id_is_empty_string(self): + """is not None check means even empty string counts as detected.""" + with patch.dict(detection.os.environ, {"RUNPOD_POD_ID": ""}, clear=True): + self.assertTrue(detection.is_runpod_environment()) + + +# --------------------------------------------------------------------------- +# is_local_worker (synchronous paths only) +# --------------------------------------------------------------------------- + +class IsLocalWorkerTests(unittest.IsolatedAsyncioTestCase): + async def test_true_for_localhost_host(self): + result = await detection.is_local_worker({"host": "localhost", "port": 8188}) + self.assertTrue(result) + + async def test_true_for_127_0_0_1(self): + result = await detection.is_local_worker({"host": "127.0.0.1", "port": 8188}) + self.assertTrue(result) + + async def test_true_for_0_0_0_0(self): + result = await detection.is_local_worker({"host": "0.0.0.0", "port": 8188}) + self.assertTrue(result) + + async def test_true_when_type_is_local(self): + result = await detection.is_local_worker({"type": "local", "host": "remote.example.com"}) + self.assertTrue(result) + + async def test_false_for_remote_host(self): + result = await detection.is_local_worker({"host": "remote.example.com", "port": 8188}) + self.assertFalse(result) + + async def test_true_when_no_host_key(self): + """Missing host defaults to 'localhost'.""" + result = await detection.is_local_worker({"port": 8188}) + self.assertTrue(result) + + +# --------------------------------------------------------------------------- +# get_machine_id +# --------------------------------------------------------------------------- + +class GetMachineIdTests(unittest.TestCase): + def test_returns_a_string(self): + result = detection.get_machine_id() + self.assertIsInstance(result, str) + + def test_returns_non_empty_string(self): + result = detection.get_machine_id() + self.assertTrue(len(result) > 0) + + def test_stable_across_calls(self): + r1 = detection.get_machine_id() + r2 = detection.get_machine_id() + self.assertEqual(r1, r2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_job_timeout.py b/tests/test_job_timeout.py new file mode 100644 index 0000000..451a0a3 --- /dev/null +++ b/tests/test_job_timeout.py @@ -0,0 +1,197 @@ +import asyncio +import importlib.util +import sys +import time +import types +import unittest +from dataclasses import dataclass, field +from pathlib import Path + + +def _load_job_timeout_module(): + module_path = Path(__file__).resolve().parents[1] / "upscale" / "job_timeout.py" + package_name = "dist_job_timeout_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + upscale_pkg = types.ModuleType(f"{package_name}.upscale") + upscale_pkg.__path__ = [] + sys.modules[f"{package_name}.upscale"] = upscale_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + config_holder = {"value": {"settings": {}, "workers": []}} + probe_holder = {"fn": None} + prompt_server_holder = { + "value": types.SimpleNamespace( + distributed_tile_jobs_lock=asyncio.Lock(), + distributed_pending_tile_jobs={}, + ) + } + + config_module = types.ModuleType(f"{package_name}.utils.config") + config_module.load_config = lambda: config_holder["value"] + sys.modules[f"{package_name}.utils.config"] = config_module + + constants_module = types.ModuleType(f"{package_name}.utils.constants") + constants_module.HEARTBEAT_TIMEOUT = 60 + sys.modules[f"{package_name}.utils.constants"] = constants_module + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + logging_module.log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + network_module = types.ModuleType(f"{package_name}.utils.network") + network_module.build_worker_url = lambda worker: f"http://{worker.get('host', '127.0.0.1')}:{worker.get('port', 8188)}" + + async def _probe_worker(url, timeout=2.0): + fn = probe_holder["fn"] + if fn is None: + return None + return await fn(url, timeout) + + network_module.probe_worker = _probe_worker + sys.modules[f"{package_name}.utils.network"] = network_module + + job_store_module = types.ModuleType(f"{package_name}.upscale.job_store") + job_store_module.ensure_tile_jobs_initialized = lambda: prompt_server_holder["value"] + sys.modules[f"{package_name}.upscale.job_store"] = job_store_module + + job_models_module = types.ModuleType(f"{package_name}.upscale.job_models") + + class BaseJobState: + pass + + @dataclass + class ImageJobState(BaseJobState): + multi_job_id: str + mode: str = field(default="dynamic", init=False) + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + pending_images: asyncio.Queue = field(default_factory=asyncio.Queue) + completed_images: dict = field(default_factory=dict) + worker_status: dict = field(default_factory=dict) + assigned_to_workers: dict = field(default_factory=dict) + batch_size: int = 0 + num_tiles_per_image: int = 0 + batched_static: bool = False + + @property + def pending_tasks(self): + return self.pending_images + + @property + def completed_tasks(self): + return self.completed_images + + @dataclass + class TileJobState(BaseJobState): + multi_job_id: str + mode: str = field(default="static", init=False) + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + pending_tasks: asyncio.Queue = field(default_factory=asyncio.Queue) + completed_tasks: dict = field(default_factory=dict) + worker_status: dict = field(default_factory=dict) + assigned_to_workers: dict = field(default_factory=dict) + batch_size: int = 0 + num_tiles_per_image: int = 0 + batched_static: bool = False + + job_models_module.BaseJobState = BaseJobState + job_models_module.ImageJobState = ImageJobState + job_models_module.TileJobState = TileJobState + sys.modules[f"{package_name}.upscale.job_models"] = job_models_module + + spec = importlib.util.spec_from_file_location(f"{package_name}.upscale.job_timeout", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + + module._config_holder = config_holder + module._probe_holder = probe_holder + module._prompt_server_holder = prompt_server_holder + module._ImageJobState = ImageJobState + module._TileJobState = TileJobState + return module + + +jt = _load_job_timeout_module() + + +class JobTimeoutRequeueTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + jt._prompt_server_holder["value"] = types.SimpleNamespace( + distributed_tile_jobs_lock=asyncio.Lock(), + distributed_pending_tile_jobs={}, + ) + jt._config_holder["value"] = { + "settings": {"worker_timeout_seconds": 5}, + "workers": [{"id": "worker-1", "host": "worker.local", "port": 8188}], + } + + async def test_requeues_only_incomplete_dynamic_tasks_for_timed_out_worker(self): + async def _offline_probe(_url, _timeout): + return None + + jt._probe_holder["fn"] = _offline_probe + prompt_server = jt._prompt_server_holder["value"] + job_data = jt._ImageJobState("job-1") + job_data.worker_status["worker-1"] = time.time() - 60.0 + job_data.assigned_to_workers["worker-1"] = [0, 1] + job_data.completed_images[1] = "done" + prompt_server.distributed_pending_tile_jobs["job-1"] = job_data + + requeued = await jt._check_and_requeue_timed_out_workers("job-1", total_tasks=2) + + self.assertEqual(requeued, 1) + self.assertEqual(await job_data.pending_images.get(), 0) + self.assertNotIn("worker-1", job_data.worker_status) + self.assertEqual(job_data.assigned_to_workers["worker-1"], []) + + async def test_busy_probe_graces_worker_and_skips_requeue(self): + async def _busy_probe(_url, _timeout): + return {"exec_info": {"queue_remaining": 3}} + + jt._probe_holder["fn"] = _busy_probe + prompt_server = jt._prompt_server_holder["value"] + job_data = jt._ImageJobState("job-2") + old_heartbeat = time.time() - 60.0 + job_data.worker_status["worker-1"] = old_heartbeat + job_data.assigned_to_workers["worker-1"] = [0] + prompt_server.distributed_pending_tile_jobs["job-2"] = job_data + + requeued = await jt._check_and_requeue_timed_out_workers("job-2", total_tasks=1) + + self.assertEqual(requeued, 0) + self.assertIn("worker-1", job_data.worker_status) + self.assertGreaterEqual(job_data.worker_status["worker-1"], old_heartbeat) + self.assertTrue(job_data.pending_images.empty()) + + async def test_completed_dynamic_task_is_not_requeued(self): + async def _offline_probe(_url, _timeout): + return None + + jt._probe_holder["fn"] = _offline_probe + prompt_server = jt._prompt_server_holder["value"] + job_data = jt._ImageJobState("job-3") + job_data.worker_status["worker-1"] = time.time() - 60.0 + job_data.assigned_to_workers["worker-1"] = [7] + job_data.completed_images[7] = "complete" + prompt_server.distributed_pending_tile_jobs["job-3"] = job_data + + requeued = await jt._check_and_requeue_timed_out_workers("job-3", total_tasks=1) + + self.assertEqual(requeued, 0) + self.assertTrue(job_data.pending_images.empty()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_payload_parsers.py b/tests/test_payload_parsers.py new file mode 100644 index 0000000..8043a0b --- /dev/null +++ b/tests/test_payload_parsers.py @@ -0,0 +1,208 @@ +import importlib.util +import io +import json +import sys +import types +import unittest +from pathlib import Path + +try: + from PIL import Image as PILImage + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + + +def _load_payload_parsers_module(): + # payload_parsers.py has no relative imports; only stdlib + PIL + module_path = Path(__file__).resolve().parents[1] / "upscale" / "payload_parsers.py" + spec = importlib.util.spec_from_file_location("upscale_payload_parsers", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +if PIL_AVAILABLE: + pp = _load_payload_parsers_module() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_png_bytes(width=64, height=64, color=(128, 64, 32)): + """Return raw PNG bytes for a solid-colour image.""" + img = PILImage.new("RGB", (width, height), color=color) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +class _MockFileField: + """Minimal multipart file-field stub.""" + + class _MockFile: + def __init__(self, data: bytes): + self._buf = io.BytesIO(data) + + def read(self) -> bytes: + return self._buf.read() + + def __init__(self, data: bytes): + self.file = self._MockFile(data) + + +def _make_form(n_tiles, *, padding=None, extra_meta=None, image_color=(128, 64, 32)): + """Build a minimal form-data dict with `n_tiles` tile entries.""" + image_bytes = _make_png_bytes(color=image_color) + metadata = [] + for i in range(n_tiles): + entry = { + "tile_idx": i, + "x": i * 64, + "y": 0, + "extracted_width": 64, + "extracted_height": 64, + } + if extra_meta and i < len(extra_meta): + entry.update(extra_meta[i]) + metadata.append(entry) + + form = {"tiles_metadata": json.dumps(metadata)} + if padding is not None: + form["padding"] = str(padding) + for i in range(n_tiles): + form[f"tile_{i}"] = _MockFileField(image_bytes) + return form + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@unittest.skipUnless(PIL_AVAILABLE, "PIL not installed") +class ParseTilesFromFormTests(unittest.TestCase): + + # --- happy paths --- + + def test_single_tile_returns_one_entry(self): + tiles = pp._parse_tiles_from_form(_make_form(1)) + self.assertEqual(len(tiles), 1) + + def test_multiple_tiles_all_returned(self): + tiles = pp._parse_tiles_from_form(_make_form(3)) + self.assertEqual(len(tiles), 3) + + def test_tile_image_is_pil_image(self): + tiles = pp._parse_tiles_from_form(_make_form(1)) + self.assertIsInstance(tiles[0]["image"], PILImage.Image) + + def test_tile_metadata_fields_are_parsed(self): + tiles = pp._parse_tiles_from_form(_make_form(1)) + tile = tiles[0] + self.assertEqual(tile["tile_idx"], 0) + self.assertEqual(tile["x"], 0) + self.assertEqual(tile["y"], 0) + self.assertEqual(tile["extracted_width"], 64) + self.assertEqual(tile["extracted_height"], 64) + + def test_padding_is_parsed_from_form(self): + tiles = pp._parse_tiles_from_form(_make_form(1, padding=16)) + self.assertEqual(tiles[0]["padding"], 16) + + def test_default_padding_is_zero(self): + form = _make_form(1) + form.pop("padding", None) + tiles = pp._parse_tiles_from_form(form) + self.assertEqual(tiles[0]["padding"], 0) + + def test_invalid_padding_string_falls_back_to_zero(self): + form = _make_form(1) + form["padding"] = "not_a_number" + tiles = pp._parse_tiles_from_form(form) + self.assertEqual(tiles[0]["padding"], 0) + + def test_optional_batch_idx_included_when_present(self): + extra = [{"batch_idx": 2}] + tiles = pp._parse_tiles_from_form(_make_form(1, extra_meta=extra)) + self.assertEqual(tiles[0]["batch_idx"], 2) + + def test_optional_global_idx_included_when_present(self): + extra = [{"global_idx": 5}] + tiles = pp._parse_tiles_from_form(_make_form(1, extra_meta=extra)) + self.assertEqual(tiles[0]["global_idx"], 5) + + def test_batch_idx_and_global_idx_absent_when_not_in_metadata(self): + tiles = pp._parse_tiles_from_form(_make_form(1)) + self.assertNotIn("batch_idx", tiles[0]) + self.assertNotIn("global_idx", tiles[0]) + + def test_tile_indices_match_metadata_order(self): + tiles = pp._parse_tiles_from_form(_make_form(3)) + for i, tile in enumerate(tiles): + self.assertEqual(tile["tile_idx"], i) + + def test_x_coordinates_reflect_metadata(self): + tiles = pp._parse_tiles_from_form(_make_form(3)) + self.assertEqual(tiles[1]["x"], 64) + self.assertEqual(tiles[2]["x"], 128) + + # --- error cases --- + + def test_missing_tiles_metadata_raises_value_error(self): + with self.assertRaises(ValueError, msg="Missing tiles_metadata"): + pp._parse_tiles_from_form({}) + + def test_invalid_json_metadata_raises_value_error(self): + form = {"tiles_metadata": "{not valid json}"} + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + def test_non_list_metadata_raises_value_error(self): + form = {"tiles_metadata": json.dumps({"not": "a list"})} + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + def test_missing_tile_file_field_raises_value_error(self): + form = { + "tiles_metadata": json.dumps([{"tile_idx": 0, "x": 0, "y": 0}]), + # tile_0 intentionally omitted + } + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + def test_tile_field_without_file_attr_raises_value_error(self): + form = { + "tiles_metadata": json.dumps([{"tile_idx": 0, "x": 0, "y": 0}]), + "tile_0": "plain string without .file", + } + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + def test_non_image_bytes_raises_value_error(self): + class _BadFileField: + class _BadFile: + def read(self): + return b"this is definitely not image data" + file = _BadFile() + + form = { + "tiles_metadata": json.dumps([{"tile_idx": 0, "x": 0, "y": 0}]), + "tile_0": _BadFileField(), + } + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + def test_invalid_metadata_value_type_raises_value_error(self): + """Non-integer metadata fields (x, y, etc.) should raise ValueError.""" + form = { + "tiles_metadata": json.dumps([{"tile_idx": 0, "x": "not_int", "y": 0}]), + "tile_0": _MockFileField(_make_png_bytes()), + } + with self.assertRaises(ValueError): + pp._parse_tiles_from_form(form) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_transform.py b/tests/test_prompt_transform.py new file mode 100644 index 0000000..9720a32 --- /dev/null +++ b/tests/test_prompt_transform.py @@ -0,0 +1,468 @@ +import importlib.util +import json +import sys +import types +import unittest +from pathlib import Path + + +def _load_prompt_transform_module(): + module_path = Path(__file__).resolve().parents[1] / "api" / "orchestration" / "prompt_transform.py" + package_name = "dist_pt_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + api_pkg = types.ModuleType(f"{package_name}.api") + api_pkg.__path__ = [] + sys.modules[f"{package_name}.api"] = api_pkg + + orch_pkg = types.ModuleType(f"{package_name}.api.orchestration") + orch_pkg.__path__ = [] + sys.modules[f"{package_name}.api.orchestration"] = orch_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + logging_module.log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + spec = importlib.util.spec_from_file_location( + f"{package_name}.api.orchestration.prompt_transform", + module_path, + ) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +pt = _load_prompt_transform_module() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _linear_prompt(): + """1 → 2 → 3 → 4(DistributedCollector) → 5(SaveImage)""" + return { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["1", 1]}}, + "3": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["2", 0]}}, + "4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}}, + "5": {"class_type": "SaveImage", "inputs": {"images": ["4", 0]}}, + } + + +def _collector_only_prompt(): + """1(Checkpoint) → 2(DistributedCollector) [no downstream from 2]""" + return { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "2": {"class_type": "DistributedCollector", "inputs": {"images": ["1", 0]}}, + } + + +def _delegate_prompt(): + """1 → 2 → 3(DistributedCollector) → 4(SaveImage)""" + return { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "2": {"class_type": "KSampler", "inputs": {"model": ["1", 0]}}, + "3": {"class_type": "DistributedCollector", "inputs": {"images": ["2", 0]}}, + "4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}}, + } + + +def _apply(prompt, participant_id, enabled_worker_ids=None, delegate_master=False): + if enabled_worker_ids is None: + enabled_worker_ids = ["worker-a", "worker-b"] + idx = pt.PromptIndex(prompt) + job_id_map = pt.generate_job_id_map(idx, "run") + return pt.apply_participant_overrides( + prompt, + participant_id=participant_id, + enabled_worker_ids=enabled_worker_ids, + job_id_map=job_id_map, + master_url="http://master.example.com", + delegate_master=delegate_master, + prompt_index=idx, + ) + + +# --------------------------------------------------------------------------- +# PromptIndex +# --------------------------------------------------------------------------- + +class PromptIndexTests(unittest.TestCase): + def test_nodes_by_class_groups_correctly(self): + prompt = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "2": {"class_type": "DistributedCollector", "inputs": {}}, + "3": {"class_type": "DistributedCollector", "inputs": {}}, + } + idx = pt.PromptIndex(prompt) + self.assertCountEqual(idx.nodes_for_class("DistributedCollector"), ["2", "3"]) + self.assertEqual(idx.nodes_for_class("CheckpointLoaderSimple"), ["1"]) + + def test_nodes_for_class_unknown_returns_empty(self): + idx = pt.PromptIndex({"1": {"class_type": "KSampler", "inputs": {}}}) + self.assertEqual(idx.nodes_for_class("Nonexistent"), []) + + def test_nodes_without_class_type_are_indexed_under_none(self): + prompt = {"1": {"inputs": {}}} + idx = pt.PromptIndex(prompt) + # Should not raise; nodes_for_class with None key or missing class_type + self.assertEqual(idx.nodes_for_class("KSampler"), []) + + def test_copy_prompt_is_a_deep_copy(self): + prompt = {"1": {"class_type": "KSampler", "inputs": {"seed": 42}}} + idx = pt.PromptIndex(prompt) + copy = idx.copy_prompt() + copy["1"]["inputs"]["seed"] = 999 + self.assertEqual(prompt["1"]["inputs"]["seed"], 42) + + def test_has_upstream_direct_connection(self): + """Node 4 reads directly from node 3 (KSampler).""" + idx = pt.PromptIndex(_linear_prompt()) + self.assertTrue(idx.has_upstream("4", "KSampler")) + + def test_has_upstream_transitive_connection(self): + """Node 4 → 3 → 2 → 1 (CheckpointLoaderSimple).""" + idx = pt.PromptIndex(_linear_prompt()) + self.assertTrue(idx.has_upstream("4", "CheckpointLoaderSimple")) + + def test_has_upstream_returns_false_when_no_path(self): + idx = pt.PromptIndex(_linear_prompt()) + # CheckpointLoaderSimple has no upstream nodes + self.assertFalse(idx.has_upstream("1", "DistributedCollector")) + + def test_has_upstream_result_is_cached(self): + idx = pt.PromptIndex(_linear_prompt()) + r1 = idx.has_upstream("4", "KSampler") + r2 = idx.has_upstream("4", "KSampler") + self.assertEqual(r1, r2) + self.assertIn(("4", "KSampler"), idx._upstream_cache) + + def test_has_upstream_does_not_infinite_loop_on_cycle(self): + """Cyclic references in inputs should not cause infinite recursion.""" + prompt = { + "1": {"class_type": "A", "inputs": {"x": ["2", 0]}}, + "2": {"class_type": "B", "inputs": {"x": ["1", 0]}}, + } + idx = pt.PromptIndex(prompt) + # Should terminate without error + result = idx.has_upstream("1", "NonExistent") + self.assertFalse(result) + + +# --------------------------------------------------------------------------- +# find_nodes_by_class +# --------------------------------------------------------------------------- + +class FindNodesByClassTests(unittest.TestCase): + def test_finds_matching_nodes(self): + prompt = { + "1": {"class_type": "KSampler", "inputs": {}}, + "2": {"class_type": "DistributedCollector", "inputs": {}}, + } + result = pt.find_nodes_by_class(prompt, "KSampler") + self.assertEqual(result, ["1"]) + + def test_returns_empty_when_no_match(self): + prompt = {"1": {"class_type": "KSampler", "inputs": {}}} + self.assertEqual(pt.find_nodes_by_class(prompt, "DistributedCollector"), []) + + def test_skips_non_dict_nodes(self): + prompt = {"1": "not a dict", "2": {"class_type": "KSampler", "inputs": {}}} + result = pt.find_nodes_by_class(prompt, "KSampler") + self.assertEqual(result, ["2"]) + + +# --------------------------------------------------------------------------- +# prune_prompt_for_worker +# --------------------------------------------------------------------------- + +class PrunePromptForWorkerTests(unittest.TestCase): + def test_no_distributed_nodes_returns_prompt_unchanged(self): + prompt = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + result = pt.prune_prompt_for_worker(prompt) + self.assertCountEqual(result.keys(), ["1", "2"]) + + def test_keeps_collector_and_upstream(self): + prompt = _linear_prompt() + result = pt.prune_prompt_for_worker(prompt) + for node_id in ("1", "2", "3", "4"): + self.assertIn(node_id, result) + + def test_removes_downstream_of_collector(self): + prompt = _linear_prompt() + result = pt.prune_prompt_for_worker(prompt) + self.assertNotIn("5", result) + + def test_injects_preview_image_when_downstream_exists(self): + prompt = _linear_prompt() + result = pt.prune_prompt_for_worker(prompt) + preview_nodes = [n for n in result.values() if n.get("class_type") == "PreviewImage"] + self.assertEqual(len(preview_nodes), 1) + self.assertEqual(preview_nodes[0]["inputs"]["images"], ["4", 0]) + + def test_no_preview_image_when_no_downstream(self): + result = pt.prune_prompt_for_worker(_collector_only_prompt()) + preview_nodes = [n for n in result.values() if n.get("class_type") == "PreviewImage"] + self.assertEqual(len(preview_nodes), 0) + + def test_unrelated_nodes_are_pruned(self): + prompt = { + "1": {"class_type": "DistributedCollector", "inputs": {}}, + "2": {"class_type": "UnrelatedNode", "inputs": {}}, # no connection to 1 + } + result = pt.prune_prompt_for_worker(prompt) + self.assertIn("1", result) + self.assertNotIn("2", result) + + def test_result_is_a_copy_not_same_object(self): + prompt = _linear_prompt() + result = pt.prune_prompt_for_worker(prompt) + # Mutating the result should not affect the original + original_keys = set(prompt.keys()) + result["NEW"] = {"class_type": "Test", "inputs": {}} + self.assertEqual(set(prompt.keys()), original_keys) + + def test_upscale_node_is_treated_as_distributed(self): + prompt = { + "1": {"class_type": "KSampler", "inputs": {}}, + "2": {"class_type": "UltimateSDUpscaleDistributed", "inputs": {"image": ["1", 0]}}, + "3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}}, + } + result = pt.prune_prompt_for_worker(prompt) + self.assertIn("1", result) + self.assertIn("2", result) + self.assertNotIn("3", result) + + +# --------------------------------------------------------------------------- +# prepare_delegate_master_prompt +# --------------------------------------------------------------------------- + +class PrepareDelegateMasterPromptTests(unittest.TestCase): + def test_keeps_collector_and_downstream(self): + prompt = _delegate_prompt() + result = pt.prepare_delegate_master_prompt(prompt, ["3"]) + self.assertIn("3", result) + self.assertIn("4", result) + self.assertNotIn("1", result) + self.assertNotIn("2", result) + + def test_removes_dangling_upstream_refs(self): + """Collector must not retain dangling refs to pruned upstream nodes.""" + prompt = _delegate_prompt() + result = pt.prepare_delegate_master_prompt(prompt, ["3"]) + collector_inputs = result["3"].get("inputs", {}) + # Original "images" pointed at node 2, which is pruned. + # It should now point at a newly injected placeholder node. + self.assertIn("images", collector_inputs) + source_id = str(collector_inputs["images"][0]) + self.assertNotEqual(source_id, "2") + self.assertIn(source_id, result) + self.assertEqual(result[source_id].get("class_type"), "DistributedEmptyImage") + + def test_injects_empty_image_placeholder(self): + prompt = _delegate_prompt() + result = pt.prepare_delegate_master_prompt(prompt, ["3"]) + empty_nodes = [(nid, n) for nid, n in result.items() if n.get("class_type") == "DistributedEmptyImage"] + self.assertEqual(len(empty_nodes), 1) + placeholder_id = empty_nodes[0][0] + self.assertEqual(result["3"]["inputs"]["images"], [placeholder_id, 0]) + + def test_one_placeholder_per_collector(self): + """Two collectors → two placeholders.""" + prompt = { + "1": {"class_type": "DistributedCollector", "inputs": {}}, + "2": {"class_type": "DistributedCollector", "inputs": {}}, + "3": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + result = pt.prepare_delegate_master_prompt(prompt, ["1", "2"]) + empty_nodes = [n for n in result.values() if n.get("class_type") == "DistributedEmptyImage"] + self.assertEqual(len(empty_nodes), 2) + + def test_result_is_independent_copy(self): + prompt = _delegate_prompt() + result = pt.prepare_delegate_master_prompt(prompt, ["3"]) + result["3"]["inputs"]["NEW"] = "injected" + # Original should be untouched + self.assertNotIn("NEW", prompt["3"].get("inputs", {})) + + +# --------------------------------------------------------------------------- +# generate_job_id_map +# --------------------------------------------------------------------------- + +class GenerateJobIdMapTests(unittest.TestCase): + def test_maps_collector_nodes(self): + prompt = { + "1": {"class_type": "DistributedCollector", "inputs": {}}, + "2": {"class_type": "KSampler", "inputs": {}}, + } + idx = pt.PromptIndex(prompt) + job_map = pt.generate_job_id_map(idx, "prefix") + self.assertEqual(job_map["1"], "prefix_1") + self.assertNotIn("2", job_map) + + def test_maps_upscale_nodes(self): + prompt = { + "5": {"class_type": "UltimateSDUpscaleDistributed", "inputs": {}}, + } + idx = pt.PromptIndex(prompt) + job_map = pt.generate_job_id_map(idx, "run") + self.assertEqual(job_map["5"], "run_5") + + def test_empty_prompt_returns_empty_map(self): + idx = pt.PromptIndex({}) + self.assertEqual(pt.generate_job_id_map(idx, "prefix"), {}) + + def test_stable_ids_across_calls(self): + prompt = {"1": {"class_type": "DistributedCollector", "inputs": {}}} + idx = pt.PromptIndex(prompt) + m1 = pt.generate_job_id_map(idx, "run") + m2 = pt.generate_job_id_map(idx, "run") + self.assertEqual(m1, m2) + + +# --------------------------------------------------------------------------- +# apply_participant_overrides – DistributedCollector +# --------------------------------------------------------------------------- + +class ApplyOverridesCollectorTests(unittest.TestCase): + def _collector_prompt(self): + return {"1": {"class_type": "DistributedCollector", "inputs": {}}} + + def test_worker_sets_is_worker_true(self): + result = _apply(self._collector_prompt(), "worker-a") + self.assertTrue(result["1"]["inputs"]["is_worker"]) + + def test_worker_sets_master_url(self): + result = _apply(self._collector_prompt(), "worker-a") + self.assertEqual(result["1"]["inputs"]["master_url"], "http://master.example.com") + + def test_worker_sets_worker_id(self): + result = _apply(self._collector_prompt(), "worker-a") + self.assertEqual(result["1"]["inputs"]["worker_id"], "worker-a") + + def test_worker_sets_delegate_only_false(self): + result = _apply(self._collector_prompt(), "worker-a") + self.assertFalse(result["1"]["inputs"]["delegate_only"]) + + def test_master_sets_is_worker_false(self): + result = _apply(self._collector_prompt(), "master") + self.assertFalse(result["1"]["inputs"]["is_worker"]) + + def test_master_clears_stale_master_url(self): + prompt = {"1": {"class_type": "DistributedCollector", "inputs": {"master_url": "stale"}}} + result = _apply(prompt, "master") + self.assertNotIn("master_url", result["1"]["inputs"]) + + def test_master_clears_stale_worker_id(self): + prompt = {"1": {"class_type": "DistributedCollector", "inputs": {"worker_id": "stale"}}} + result = _apply(prompt, "master") + self.assertNotIn("worker_id", result["1"]["inputs"]) + + def test_master_with_delegate_master_sets_delegate_only_true(self): + result = _apply(self._collector_prompt(), "master", delegate_master=True) + self.assertTrue(result["1"]["inputs"]["delegate_only"]) + + def test_master_without_delegate_master_sets_delegate_only_false(self): + result = _apply(self._collector_prompt(), "master", delegate_master=False) + self.assertFalse(result["1"]["inputs"]["delegate_only"]) + + def test_enabled_worker_ids_serialized_as_json(self): + enabled = ["worker-a", "worker-b"] + result = _apply(self._collector_prompt(), "master", enabled_worker_ids=enabled) + self.assertEqual(result["1"]["inputs"]["enabled_worker_ids"], json.dumps(enabled)) + + def test_multi_job_id_is_set_from_job_map(self): + prompt = {"1": {"class_type": "DistributedCollector", "inputs": {}}} + idx = pt.PromptIndex(prompt) + job_id_map = {"1": "run_abc_1"} + result = pt.apply_participant_overrides( + prompt, + participant_id="worker-a", + enabled_worker_ids=["worker-a"], + job_id_map=job_id_map, + master_url="http://master", + delegate_master=False, + prompt_index=idx, + ) + self.assertEqual(result["1"]["inputs"]["multi_job_id"], "run_abc_1") + + +# --------------------------------------------------------------------------- +# apply_participant_overrides – DistributedSeed +# --------------------------------------------------------------------------- + +class ApplyOverridesSeedTests(unittest.TestCase): + def _seed_prompt(self): + return {"1": {"class_type": "DistributedSeed", "inputs": {}}} + + def test_worker_sets_is_worker_true(self): + result = _apply(self._seed_prompt(), "worker-a") + self.assertTrue(result["1"]["inputs"]["is_worker"]) + + def test_worker_id_reflects_index_in_enabled_list(self): + result = _apply(self._seed_prompt(), "worker-b", enabled_worker_ids=["worker-a", "worker-b"]) + self.assertEqual(result["1"]["inputs"]["worker_id"], "worker_1") + + def test_master_sets_is_worker_false(self): + result = _apply(self._seed_prompt(), "master") + self.assertFalse(result["1"]["inputs"]["is_worker"]) + + def test_master_sets_empty_worker_id(self): + result = _apply(self._seed_prompt(), "master") + self.assertEqual(result["1"]["inputs"]["worker_id"], "") + + +# --------------------------------------------------------------------------- +# apply_participant_overrides – UltimateSDUpscaleDistributed +# --------------------------------------------------------------------------- + +class ApplyOverridesUpscaleTests(unittest.TestCase): + def _upscale_prompt(self): + return {"1": {"class_type": "UltimateSDUpscaleDistributed", "inputs": {}}} + + def test_worker_sets_is_worker_true(self): + result = _apply(self._upscale_prompt(), "worker-a") + self.assertTrue(result["1"]["inputs"]["is_worker"]) + + def test_worker_sets_master_url_and_worker_id(self): + result = _apply(self._upscale_prompt(), "worker-a") + self.assertEqual(result["1"]["inputs"]["master_url"], "http://master.example.com") + self.assertEqual(result["1"]["inputs"]["worker_id"], "worker-a") + + def test_master_clears_master_url_and_worker_id(self): + prompt = {"1": {"class_type": "UltimateSDUpscaleDistributed", "inputs": {"master_url": "x", "worker_id": "y"}}} + result = _apply(prompt, "master") + self.assertNotIn("master_url", result["1"]["inputs"]) + self.assertNotIn("worker_id", result["1"]["inputs"]) + + def test_collector_downstream_of_upscale_gets_pass_through(self): + """A DistributedCollector that is downstream of UltimateSDUpscaleDistributed → pass_through=True.""" + prompt = { + "1": {"class_type": "UltimateSDUpscaleDistributed", "inputs": {}}, + "2": {"class_type": "DistributedCollector", "inputs": {"images": ["1", 0]}}, + } + result = _apply(prompt, "worker-a", enabled_worker_ids=["worker-a"]) + self.assertTrue(result["2"]["inputs"].get("pass_through")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_static_mode.py b/tests/test_static_mode.py new file mode 100644 index 0000000..f69a865 --- /dev/null +++ b/tests/test_static_mode.py @@ -0,0 +1,253 @@ +import asyncio +import importlib.util +import sys +import types +import unittest +from pathlib import Path + +import torch + + +def _load_static_mode_module(): + module_path = Path(__file__).resolve().parents[1] / "upscale" / "modes" / "static.py" + package_name = "dist_static_mode_testpkg" + + for mod_name in list(sys.modules): + if mod_name == package_name or mod_name.startswith(f"{package_name}."): + del sys.modules[mod_name] + + root_pkg = types.ModuleType(package_name) + root_pkg.__path__ = [] + sys.modules[package_name] = root_pkg + + upscale_pkg = types.ModuleType(f"{package_name}.upscale") + upscale_pkg.__path__ = [] + sys.modules[f"{package_name}.upscale"] = upscale_pkg + + modes_pkg = types.ModuleType(f"{package_name}.upscale.modes") + modes_pkg.__path__ = [] + sys.modules[f"{package_name}.upscale.modes"] = modes_pkg + + utils_pkg = types.ModuleType(f"{package_name}.utils") + utils_pkg.__path__ = [] + sys.modules[f"{package_name}.utils"] = utils_pkg + + created_comfy_stub = False + if "comfy" not in sys.modules: + created_comfy_stub = True + comfy_module = types.ModuleType("comfy") + model_mgmt = types.ModuleType("comfy.model_management") + + class _InterruptProcessingException(Exception): + pass + + model_mgmt.processing_interrupted = lambda: False + model_mgmt.throw_exception_if_processing_interrupted = lambda: None + model_mgmt.InterruptProcessingException = _InterruptProcessingException + + comfy_module.model_management = model_mgmt + sys.modules["comfy"] = comfy_module + sys.modules["comfy.model_management"] = model_mgmt + + logging_module = types.ModuleType(f"{package_name}.utils.logging") + logging_module.debug_log = lambda *_args, **_kwargs: None + logging_module.log = lambda *_args, **_kwargs: None + sys.modules[f"{package_name}.utils.logging"] = logging_module + + image_module = types.ModuleType(f"{package_name}.utils.image") + from PIL import Image as PILImage + import numpy as np + + def _tensor_to_pil(img_tensor, batch_index=0): + return PILImage.fromarray((255 * img_tensor[batch_index].cpu().numpy()).astype(np.uint8)) + + def _pil_to_tensor(image): + arr = np.array(image).astype(np.float32) / 255.0 + return torch.from_numpy(arr).unsqueeze(0) + + image_module.tensor_to_pil = _tensor_to_pil + image_module.pil_to_tensor = _pil_to_tensor + sys.modules[f"{package_name}.utils.image"] = image_module + + async_helpers_module = types.ModuleType(f"{package_name}.utils.async_helpers") + + def _run_async_in_server_loop(coro, timeout=None): + if timeout is not None: + return asyncio.run(asyncio.wait_for(coro, timeout=timeout)) + return asyncio.run(coro) + + async_helpers_module.run_async_in_server_loop = _run_async_in_server_loop + sys.modules[f"{package_name}.utils.async_helpers"] = async_helpers_module + + config_module = types.ModuleType(f"{package_name}.utils.config") + config_module.get_worker_timeout_seconds = lambda: 60 + sys.modules[f"{package_name}.utils.config"] = config_module + + constants_module = types.ModuleType(f"{package_name}.utils.constants") + constants_module.HEARTBEAT_INTERVAL = 10.0 + constants_module.JOB_POLL_INTERVAL = 0.0 + constants_module.JOB_POLL_MAX_ATTEMPTS = 3 + constants_module.MAX_BATCH = 20 + constants_module.TILE_SEND_TIMEOUT = 1.0 + constants_module.TILE_WAIT_TIMEOUT = 1.0 + sys.modules[f"{package_name}.utils.constants"] = constants_module + + job_store_module = types.ModuleType(f"{package_name}.upscale.job_store") + + async def _noop(*_args, **_kwargs): + return None + + job_store_module.ensure_tile_jobs_initialized = lambda: types.SimpleNamespace( + distributed_tile_jobs_lock=asyncio.Lock(), + distributed_pending_tile_jobs={}, + ) + job_store_module.init_static_job_batched = _noop + job_store_module._mark_task_completed = _noop + job_store_module._cleanup_job = _noop + job_store_module._drain_results_queue = _noop + job_store_module._get_completed_count = _noop + sys.modules[f"{package_name}.upscale.job_store"] = job_store_module + + job_models_module = types.ModuleType(f"{package_name}.upscale.job_models") + + class _TileJobState: + pass + + job_models_module.TileJobState = _TileJobState + sys.modules[f"{package_name}.upscale.job_models"] = job_models_module + + spec = importlib.util.spec_from_file_location(f"{package_name}.upscale.modes.static", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + + if created_comfy_stub: + sys.modules.pop("comfy.model_management", None) + sys.modules.pop("comfy", None) + + return module + + +static_mode = _load_static_mode_module() + + +class _FakeStaticWorker(static_mode.StaticModeMixin): + def __init__(self): + self.sent_batches = [] + self.request_calls = 0 + self.heartbeat_calls = 0 + self.job_ready = True + self.tile_sequence = [(0, 0, True), (None, 0, True)] + + def round_to_multiple(self, value): + return value + + def calculate_tiles(self, _width, _height, _tile_width, _tile_height, _force_uniform_tiles): + return [(0, 0)] + + def _poll_job_ready(self, *_args, **_kwargs): + return self.job_ready + + async def _request_tile_from_master(self, *_args, **_kwargs): + self.request_calls += 1 + return self.tile_sequence.pop(0) + + async def _send_heartbeat_to_master(self, *_args, **_kwargs): + self.heartbeat_calls += 1 + + async def send_tiles_batch_to_master( + self, + processed_tiles, + _multi_job_id, + _master_url, + _padding, + _worker_id, + is_final_flush=False, + ): + self.sent_batches.append( + { + "tiles": list(processed_tiles), + "is_final_flush": bool(is_final_flush), + } + ) + + def _extract_and_process_tile(self, upscaled_image, *_args, **_kwargs): + batch_size = upscaled_image.shape[0] + processed_batch = torch.zeros((batch_size, 2, 2, 3), dtype=torch.float32) + return processed_batch, 0, 0, 2, 2 + + +def _call_worker_static(fake_worker): + image = torch.zeros((1, 4, 4, 3), dtype=torch.float32) + return fake_worker._process_worker_static_sync( + image, + model=None, + positive=None, + negative=None, + vae=None, + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + denoise=0.5, + tile_width=4, + tile_height=4, + padding=8, + mask_blur=4, + force_uniform_tiles=True, + tiled_decode=False, + multi_job_id="job-1", + master_url="http://master:8188", + worker_id="worker-1", + enabled_workers=["worker-1"], + ) + + +class StaticModeWorkerFlowTests(unittest.TestCase): + def test_worker_static_aborts_when_job_not_ready(self): + worker = _FakeStaticWorker() + worker.job_ready = False + + result = _call_worker_static(worker) + + self.assertEqual(result[0].shape[0], 1) + self.assertEqual(worker.request_calls, 0) + self.assertEqual(worker.heartbeat_calls, 0) + self.assertEqual(worker.sent_batches, []) + + def test_worker_static_requests_tiles_and_flushes_final_batch(self): + worker = _FakeStaticWorker() + + _call_worker_static(worker) + + self.assertEqual(worker.request_calls, 2) # one tile, then sentinel + self.assertEqual(worker.heartbeat_calls, 1) + self.assertEqual(len(worker.sent_batches), 1) + self.assertTrue(worker.sent_batches[0]["is_final_flush"]) + tiles = worker.sent_batches[0]["tiles"] + self.assertEqual(len(tiles), 1) + self.assertEqual(tiles[0]["tile_idx"], 0) + self.assertEqual(tiles[0]["global_idx"], 0) + self.assertEqual(tiles[0]["batch_idx"], 0) + + def test_flush_empty_final_still_sends_completion_signal(self): + worker = _FakeStaticWorker() + + returned = worker._flush_tiles_to_master( + [], + "job-1", + "http://master:8188", + 8, + "worker-1", + is_final_flush=True, + ) + + self.assertEqual(returned, []) + self.assertEqual(len(worker.sent_batches), 1) + self.assertEqual(worker.sent_batches[0]["tiles"], []) + self.assertTrue(worker.sent_batches[0]["is_final_flush"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..f4c071b --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["web/tests/**/*.test.js"], + environment: "node", + }, +}); diff --git a/web/main.js b/web/main.js index fe3e966..08ff795 100644 --- a/web/main.js +++ b/web/main.js @@ -192,7 +192,7 @@ class DistributedExtension { if (statusDot) { if (enabled) { // Enabled: Start with checking state and trigger check - this.ui.updateStatusDot(workerId, STATUS_COLORS.OFFLINE_RED, "Checking status...", true); + this.ui.updateStatusDot(workerId, STATUS_COLORS.OFFLINE_RED, "Checking status...", false); setTimeout(() => checkWorkerStatus(this, worker), TIMEOUTS.STATUS_CHECK_DELAY); } else { // Disabled: Set to gray diff --git a/web/tests/executionUtils.test.js b/web/tests/executionUtils.test.js index 0f7431a..bc02899 100644 --- a/web/tests/executionUtils.test.js +++ b/web/tests/executionUtils.test.js @@ -29,6 +29,16 @@ describe("execution decision helpers", () => { expect(prompt["2"].inputs.skip_dispatch).toBeUndefined(); }); + it("markSkipDispatch on empty prompt does not throw", () => { + expect(() => markSkipDispatch({})).not.toThrow(); + }); + + it("markSkipDispatch initialises missing inputs object before setting flag", () => { + const prompt = { "1": { class_type: NODE_CLASSES.DISTRIBUTED_QUEUE } }; + markSkipDispatch(prompt); + expect(prompt["1"].inputs.skip_dispatch).toBe(true); + }); + it("selectLeastBusyWorker round-robins among idle workers", () => { const extension = {}; const statuses = [ @@ -41,6 +51,27 @@ describe("execution decision helpers", () => { expect(selectLeastBusyWorker(extension, statuses)).toBe(statuses[0]); }); + it("selectLeastBusyWorker with a single idle worker always returns it", () => { + const extension = {}; + const statuses = [{ worker: { id: "w1" }, queueRemaining: 0 }]; + expect(selectLeastBusyWorker(extension, statuses)).toBe(statuses[0]); + expect(selectLeastBusyWorker(extension, statuses)).toBe(statuses[0]); + }); + + it("selectLeastBusyWorker round-robin wraps around correctly", () => { + const extension = {}; + const statuses = [ + { worker: { id: "w1" }, queueRemaining: 0 }, + { worker: { id: "w2" }, queueRemaining: 0 }, + { worker: { id: "w3" }, queueRemaining: 0 }, + ]; + const results = Array.from({ length: 4 }, () => selectLeastBusyWorker(extension, statuses)); + expect(results[0]).toBe(statuses[0]); + expect(results[1]).toBe(statuses[1]); + expect(results[2]).toBe(statuses[2]); + expect(results[3]).toBe(statuses[0]); // wraps around + }); + it("selectLeastBusyWorker chooses shortest queue when no idle workers", () => { const extension = {}; const statuses = [ @@ -52,6 +83,12 @@ describe("execution decision helpers", () => { expect(selectLeastBusyWorker(extension, statuses)).toBe(statuses[1]); }); + it("selectLeastBusyWorker returns only busy worker when queue length is 1", () => { + const extension = {}; + const statuses = [{ worker: { id: "w1" }, queueRemaining: 3 }]; + expect(selectLeastBusyWorker(extension, statuses)).toBe(statuses[0]); + }); + it("buildWorkerWebSocketUrl converts http/https to ws/wss", () => { expect(buildWorkerWebSocketUrl("http://worker.local:8188")).toBe( "ws://worker.local:8188/distributed/worker_ws" diff --git a/web/tests/urlUtils.test.js b/web/tests/urlUtils.test.js new file mode 100644 index 0000000..801a1ae --- /dev/null +++ b/web/tests/urlUtils.test.js @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + buildWorkerUrl, + buildWorkerWebSocketUrl, + getMasterUrl, + normalizeWorkerUrl, + parseHostInput, +} from "../urlUtils.js"; + + +// --------------------------------------------------------------------------- +// normalizeWorkerUrl +// --------------------------------------------------------------------------- + +describe("normalizeWorkerUrl", () => { + it("returns empty string for empty input", () => { + expect(normalizeWorkerUrl("")).toBe(""); + }); + + it("returns empty string for null input", () => { + expect(normalizeWorkerUrl(null)).toBe(""); + }); + + it("returns empty string for non-string input", () => { + expect(normalizeWorkerUrl(42)).toBe(""); + }); + + it("preserves https protocol", () => { + expect(normalizeWorkerUrl("https://example.com")).toBe("https://example.com"); + }); + + it("strips trailing slash", () => { + expect(normalizeWorkerUrl("http://example.com/")).toBe("http://example.com"); + }); + + it("prepends http when protocol is missing", () => { + const result = normalizeWorkerUrl("worker.local:8188"); + expect(result).toMatch(/^http:\/\//); + }); + + it("trims leading and trailing whitespace", () => { + expect(normalizeWorkerUrl(" http://example.com ")).toBe("http://example.com"); + }); +}); + + +// --------------------------------------------------------------------------- +// parseHostInput +// --------------------------------------------------------------------------- + +describe("parseHostInput", () => { + it("returns empty host and null port for null", () => { + expect(parseHostInput(null)).toEqual({ host: "", port: null }); + }); + + it("returns empty host and null port for empty string", () => { + expect(parseHostInput("")).toEqual({ host: "", port: null }); + }); + + it("parses hostname without port", () => { + const result = parseHostInput("worker.example.com"); + expect(result.host).toBe("worker.example.com"); + expect(result.port).toBeNull(); + }); + + it("parses hostname with port", () => { + const result = parseHostInput("worker.example.com:9000"); + expect(result.host).toBe("worker.example.com"); + expect(result.port).toBe(9000); + }); + + it("strips http:// protocol prefix", () => { + const result = parseHostInput("http://worker.example.com:8188"); + expect(result.host).toBe("worker.example.com"); + expect(result.port).toBe(8188); + }); + + it("strips https:// protocol prefix", () => { + const result = parseHostInput("https://worker.example.com"); + expect(result.host).toBe("worker.example.com"); + expect(result.port).toBeNull(); + }); + + it("ignores path after host:port", () => { + const result = parseHostInput("worker.example.com:8188/some/path"); + expect(result.host).toBe("worker.example.com"); + expect(result.port).toBe(8188); + }); +}); + + +// --------------------------------------------------------------------------- +// buildWorkerWebSocketUrl +// --------------------------------------------------------------------------- + +describe("buildWorkerWebSocketUrl", () => { + it("converts http to ws", () => { + expect(buildWorkerWebSocketUrl("http://worker.local:8188")).toBe( + "ws://worker.local:8188/distributed/worker_ws" + ); + }); + + it("converts https to wss", () => { + expect(buildWorkerWebSocketUrl("https://worker.example.com")).toBe( + "wss://worker.example.com/distributed/worker_ws" + ); + }); + + it("always appends /distributed/worker_ws", () => { + const url = buildWorkerWebSocketUrl("http://worker.local:8188"); + expect(url.endsWith("/distributed/worker_ws")).toBe(true); + }); +}); + + +// --------------------------------------------------------------------------- +// buildWorkerUrl (requires window.location stub) +// --------------------------------------------------------------------------- + +describe("buildWorkerUrl", () => { + let originalWindow; + + beforeEach(() => { + originalWindow = globalThis.window; + globalThis.window = { + location: { + hostname: "127.0.0.1", + protocol: "http:", + port: "8188", + origin: "http://127.0.0.1:8188", + }, + }; + }); + + afterEach(() => { + globalThis.window = originalWindow; + }); + + it("builds local worker URL using window hostname when no host set", () => { + const worker = { id: "w1", port: 8189 }; + expect(buildWorkerUrl(worker, "/prompt")).toBe("http://127.0.0.1:8189/prompt"); + }); + + it("builds remote worker URL using explicit host", () => { + const worker = { id: "w2", host: "worker.example.com", port: 9000 }; + expect(buildWorkerUrl(worker, "/prompt")).toBe("http://worker.example.com:9000/prompt"); + }); + + it("builds cloud worker URL with https when type=cloud", () => { + const worker = { id: "w3", host: "cloud.example.com", port: 443, type: "cloud" }; + expect(buildWorkerUrl(worker, "/prompt")).toBe("https://cloud.example.com/prompt"); + }); + + it("uses https for port 443 even without type=cloud", () => { + const worker = { id: "w4", host: "worker.example.com", port: 443 }; + const result = buildWorkerUrl(worker, ""); + expect(result.startsWith("https://")).toBe(true); + }); + + it("rewrites runpod proxy hostname for local port", () => { + globalThis.window = { + location: { + hostname: "podabc.proxy.runpod.net", + protocol: "https:", + port: "", + origin: "https://podabc.proxy.runpod.net", + }, + }; + const worker = { id: "w5", port: 8189 }; + expect(buildWorkerUrl(worker, "/prompt")).toBe( + "https://podabc-8189.proxy.runpod.net/prompt" + ); + }); + + it("returns URL without trailing slash when no endpoint given", () => { + const worker = { id: "w6", host: "worker.example.com", port: 8188 }; + const result = buildWorkerUrl(worker, ""); + expect(result.endsWith("/")).toBe(false); + }); +}); + + +// --------------------------------------------------------------------------- +// getMasterUrl +// --------------------------------------------------------------------------- + +describe("getMasterUrl", () => { + const _loc = (hostname, protocol = "http:", port = "8188") => ({ + hostname, + protocol, + port, + origin: `${protocol}//${hostname}${port ? `:${port}` : ""}`, + }); + + it("returns origin when master host not configured and hostname is non-localhost", () => { + const loc = _loc("192.168.1.10"); + const result = getMasterUrl({}, loc); + expect(result).toBe(loc.origin); + }); + + it("returns origin for localhost when master host not configured", () => { + const loc = _loc("127.0.0.1"); + const result = getMasterUrl({}, loc); + expect(result).toBe(loc.origin); + }); + + it("uses configured master host as-is when it includes http://", () => { + const config = { master: { host: "http://master.example.com" } }; + const result = getMasterUrl(config, _loc("127.0.0.1")); + expect(result).toBe("http://master.example.com"); + }); + + it("uses configured master host as-is when it includes https://", () => { + const config = { master: { host: "https://secure.master.com" } }; + const result = getMasterUrl(config, _loc("127.0.0.1")); + expect(result).toBe("https://secure.master.com"); + }); + + it("defaults to https for domain-name master hosts", () => { + const config = { master: { host: "master.example.com" } }; + const result = getMasterUrl(config, _loc("127.0.0.1")); + expect(result).toBe("https://master.example.com"); + }); + + it("does not force https for IP-address master hosts", () => { + const config = { master: { host: "192.168.1.100" } }; + const result = getMasterUrl(config, _loc("127.0.0.1")); + expect(result.startsWith("https://")).toBe(false); + }); + + it("does not force https for localhost master host", () => { + const config = { master: { host: "localhost" } }; + const result = getMasterUrl(config, _loc("127.0.0.1")); + expect(result.startsWith("https://")).toBe(false); + }); + + it("accepts null log parameter without throwing", () => { + const loc = _loc("127.0.0.1"); + expect(() => getMasterUrl({}, loc, null)).not.toThrow(); + }); +}); diff --git a/web/ui.js b/web/ui.js index 5dcce91..c16a6ac 100644 --- a/web/ui.js +++ b/web/ui.js @@ -55,7 +55,6 @@ const cardConfigs = { dynamic: true, initialColor: (data) => data.enabled ? STATUS_COLORS.OFFLINE_RED : STATUS_COLORS.DISABLED_GRAY, initialTitle: (data) => data.enabled ? "Checking status..." : "Disabled", - pulsing: (data) => data.enabled, id: (data) => `status-${data.id}` }, infoText: (data, extension) => {